montana/macOS/MontanaPresence/JunonaView.swift

680 lines
25 KiB
Swift

import SwiftUI
// MARK: - Models
struct ChatMessage: Identifiable, Codable {
let id: UUID
let role: MessageRole
let content: String
let timestamp: Date
var aiModel: String?
enum MessageRole: String, Codable {
case user
case assistant
}
init(role: MessageRole, content: String, timestamp: Date, aiModel: String? = nil) {
self.id = UUID()
self.role = role
self.content = content
self.timestamp = timestamp
self.aiModel = aiModel
}
}
struct JunonaSession: Identifiable, Codable {
let id: UUID
var title: String
let createdAt: Date
var messages: [ChatMessage]
init(title: String, messages: [ChatMessage] = []) {
self.id = UUID()
self.title = title
self.createdAt = Date()
self.messages = messages
}
}
// MARK: - Chat Session Store (persistent)
class ChatSessionStore: ObservableObject {
static let shared = ChatSessionStore()
@Published var sessions: [JunonaSession] = []
@Published var currentSessionId: UUID?
@Published var pendingLoadId: UUID?
private let storageKey = "junona_sessions_v1"
init() { load() }
func load() {
guard let data = UserDefaults.standard.data(forKey: storageKey),
let decoded = try? JSONDecoder().decode([JunonaSession].self, from: data) else { return }
sessions = decoded
}
func save() {
guard let data = try? JSONEncoder().encode(sessions) else { return }
UserDefaults.standard.set(data, forKey: storageKey)
}
func createNewSession() -> UUID {
let session = JunonaSession(title: "Новый чат")
sessions.insert(session, at: 0)
currentSessionId = session.id
pendingLoadId = session.id
save()
return session.id
}
func ensureSession() {
if currentSessionId == nil {
_ = createNewSession()
}
}
func updateMessages(_ messages: [ChatMessage]) {
guard let id = currentSessionId,
let idx = sessions.firstIndex(where: { $0.id == id }) else { return }
sessions[idx].messages = messages
if let first = messages.first(where: { $0.role == .user }) {
let t = String(first.content.prefix(35))
sessions[idx].title = t + (first.content.count > 35 ? "..." : "")
}
save()
}
func selectSession(_ id: UUID) {
currentSessionId = id
pendingLoadId = id
}
func deleteSession(_ id: UUID) {
sessions.removeAll { $0.id == id }
if currentSessionId == id {
currentSessionId = nil
pendingLoadId = nil
}
save()
}
func messagesFor(_ id: UUID) -> [ChatMessage] {
sessions.first(where: { $0.id == id })?.messages ?? []
}
}
// MARK: - Junona View
/// Junona AI Agent Montana Protocol Assistant
/// Google Gemini AI
struct JunonaView: View {
@EnvironmentObject var engine: PresenceEngine
@ObservedObject private var sessionStore = ChatSessionStore.shared
@State private var messages: [ChatMessage] = []
@State private var inputText = ""
@State private var isLoading = false
@State private var showSidebar = false
@AppStorage("junonaDarkMode") private var isDarkMode = true
// Clock
@State private var secondsAngle: Double = 0
@State private var clockTimer: Timer?
var body: some View {
ZStack(alignment: .leading) {
VStack(spacing: 0) {
header
Rectangle()
.fill(Color(red: 0.83, green: 0.69, blue: 0.22).opacity(0.2))
.frame(height: 0.5)
if messages.isEmpty {
GeometryReader { geo in
ScrollView {
welcomeMessage
.frame(minHeight: geo.size.height)
}
}
} else {
ScrollViewReader { proxy in
ScrollView {
LazyVStack(alignment: .leading, spacing: 12) {
ForEach(messages) { message in
MessageBubble(message: message)
.id(message.id)
}
}
.padding()
}
.onChange(of: messages.count) {
if let lastMessage = messages.last {
withAnimation {
proxy.scrollTo(lastMessage.id, anchor: .bottom)
}
}
}
}
}
Rectangle()
.fill(Color(red: 0.83, green: 0.69, blue: 0.22).opacity(0.2))
.frame(height: 0.5)
inputArea
}
.background(isDarkMode ? Color(red: 0.04, green: 0.04, blue: 0.04) : Color(NSColor.windowBackgroundColor))
if showSidebar {
Color.black.opacity(0.3)
.ignoresSafeArea()
.onTapGesture {
withAnimation { showSidebar = false }
}
SharedSidebar(isVisible: $showSidebar)
.transition(.move(edge: .leading))
}
}
.animation(.easeInOut(duration: 0.3), value: showSidebar)
.onAppear {
// Load current session if exists
if let id = sessionStore.currentSessionId {
messages = sessionStore.messagesFor(id)
}
}
.onReceive(sessionStore.$pendingLoadId) { loadId in
if let loadId {
messages = sessionStore.messagesFor(loadId)
sessionStore.pendingLoadId = nil
}
}
}
// MARK: - Header
private var header: some View {
HStack(spacing: 12) {
Button(action: {
withAnimation { showSidebar.toggle() }
}) {
Image(systemName: "line.3.horizontal")
.font(.system(size: 20))
.foregroundColor(.primary)
}
.buttonStyle(.plain)
if let logoPath = Bundle.main.path(forResource: "JunonaLogo", ofType: "jpg"),
let nsImage = NSImage(contentsOfFile: logoPath) {
Image(nsImage: nsImage)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 40, height: 40)
.clipShape(Circle())
.overlay(
Circle()
.stroke(
LinearGradient(
colors: [
Color(red: 0.83, green: 0.69, blue: 0.22),
Color(red: 0.94, green: 0.82, blue: 0.38)
],
startPoint: .topLeading,
endPoint: .bottomTrailing
),
lineWidth: 2
)
)
} else {
Circle()
.fill(
LinearGradient(
colors: [
Color(red: 0.0, green: 0.83, blue: 1.0),
Color(red: 0.48, green: 0.18, blue: 1.0)
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.frame(width: 40, height: 40)
.overlay(
Text("\u{042E}")
.font(.system(size: 20, weight: .bold))
.foregroundColor(.white)
)
}
VStack(alignment: .leading, spacing: 2) {
Text("\u{042E}\u{043D}\u{043E}\u{043D}\u{0430}")
.font(.headline)
if isLoading {
Text("\u{043F}\u{0435}\u{0447}\u{0430}\u{0442}\u{0430}\u{0435}\u{0442}...")
.font(.caption)
.foregroundColor(Color(red: 0.29, green: 0.56, blue: 0.89))
} else {
Text("\u{0432} \u{0441}\u{0435}\u{0442}\u{0438}")
.font(.caption)
.foregroundColor(Color(red: 0.29, green: 0.56, blue: 0.89))
}
}
Spacer()
Button(action: { isDarkMode.toggle() }) {
Image(systemName: isDarkMode ? "moon.fill" : "sun.max.fill")
.font(.system(size: 16))
.foregroundColor(isDarkMode ? Color(red: 0.83, green: 0.69, blue: 0.22) : .orange)
}
.buttonStyle(.plain)
}
.padding()
}
// MARK: - Welcome Message
private var welcomeMessage: some View {
VStack(spacing: 16) {
Spacer()
VStack(spacing: 8) {
HStack(alignment: .firstTextBaseline, spacing: 12) {
let formatter = NumberFormatter()
let _ = {
formatter.numberStyle = .decimal
formatter.groupingSeparator = ","
}()
let balanceStr = formatter.string(from: NSNumber(value: engine.displayBalance)) ?? "\(engine.displayBalance)"
Text(balanceStr)
.font(.system(size: 32, weight: .bold, design: .monospaced))
.foregroundColor(Color(red: 0.83, green: 0.69, blue: 0.22))
Text("\u{0248}")
.font(.system(size: 32, weight: .bold))
.foregroundColor(Color(red: 0.94, green: 0.82, blue: 0.38))
}
Text("\u{2248} \(formatCurrency(engine.balanceRUB))\u{20BD}")
.font(.system(size: 18, weight: .medium, design: .monospaced))
.foregroundColor(.secondary)
}
// Junona clock
ZStack {
if let junonaPath = Bundle.main.path(forResource: "JunonaLogo", ofType: "jpg"),
let junonaImage = NSImage(contentsOfFile: junonaPath) {
Image(nsImage: junonaImage)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 140, height: 140)
.clipShape(Circle())
.overlay(
Circle()
.stroke(
LinearGradient(
colors: [
Color(red: 0.83, green: 0.69, blue: 0.22),
Color(red: 0.94, green: 0.82, blue: 0.38)
],
startPoint: .topLeading,
endPoint: .bottomTrailing
),
lineWidth: 3
)
)
.shadow(color: Color(red: 0.83, green: 0.69, blue: 0.22).opacity(0.3), radius: 20, x: 0, y: 10)
}
Circle()
.fill(
LinearGradient(
colors: [
Color(red: 0.83, green: 0.69, blue: 0.22),
Color(red: 0.94, green: 0.82, blue: 0.38)
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.frame(width: 12, height: 12)
.offset(
x: 74 * cos((secondsAngle - 90) * .pi / 180),
y: 74 * sin((secondsAngle - 90) * .pi / 180)
)
.shadow(color: Color(red: 0.94, green: 0.82, blue: 0.38), radius: 8)
}
.frame(width: 164, height: 164)
.onAppear { startClock() }
.onDisappear { stopClock() }
Spacer().frame(height: 8)
Spacer()
}
.frame(maxWidth: .infinity)
.padding()
}
// MARK: - Input Area
private var inputArea: some View {
VStack(spacing: 4) {
HStack(spacing: 12) {
TextField("\u{0427}\u{0442}\u{043E} \u{0438}\u{043C}\u{0435}\u{043D}\u{043D}\u{043E} \u{0432}\u{044B} \u{0445}\u{043E}\u{0442}\u{0438}\u{0442}\u{0435} \u{0443}\u{0437}\u{043D}\u{0430}\u{0442}\u{044C}?", text: $inputText, axis: .vertical)
.textFieldStyle(.plain)
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(isDarkMode ? Color.white.opacity(0.08) : Color.white)
.cornerRadius(20)
.overlay(
RoundedRectangle(cornerRadius: 20)
.stroke(Color(red: 0.83, green: 0.69, blue: 0.22).opacity(0.3), lineWidth: 1)
)
.lineLimit(1...5)
.onSubmit { sendMessage() }
Button(action: sendMessage) {
Image(systemName: "arrow.up.circle.fill")
.font(.system(size: 24))
.foregroundColor(inputText.isEmpty ? .gray : Color(red: 0.83, green: 0.69, blue: 0.22))
}
.buttonStyle(.plain)
.disabled(inputText.isEmpty || isLoading)
}
VStack(spacing: 4) {
Text("\u{0423}\u{043F}\u{0440}\u{0430}\u{0432}\u{043B}\u{044F}\u{0435}\u{0442} \u{041F}\u{0440}\u{043E}\u{0442}\u{043E}\u{043A}\u{043E}\u{043B}\u{043E}\u{043C} \u{041C}\u{043E}\u{043D}\u{0442}\u{0430}\u{043D}\u{0430}")
.font(.caption2)
.foregroundColor(.secondary.opacity(0.7))
.frame(maxWidth: .infinity, alignment: .center)
Text("\u{041C}\u{043E}\u{043D}\u{0442}\u{0430}\u{043D}\u{0430} v\(appVersion)")
.font(.system(size: 10, design: .monospaced))
.foregroundColor(.secondary.opacity(0.5))
.frame(maxWidth: .infinity, alignment: .center)
}
.padding(.top, 2)
}
.padding()
}
// MARK: - Actions
private func sendMessage() {
guard !inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
let sanitized = sanitizeInput(inputText)
guard !sanitized.isEmpty else {
messages.append(ChatMessage(
role: .assistant,
content: "\u{26A0}\u{FE0F} \u{041F}\u{043E}\u{0436}\u{0430}\u{043B}\u{0443}\u{0439}\u{0441}\u{0442}\u{0430}, \u{0432}\u{0432}\u{0435}\u{0434}\u{0438} \u{043A}\u{043E}\u{0440}\u{0440}\u{0435}\u{043A}\u{0442}\u{043D}\u{044B}\u{0439} \u{0432}\u{043E}\u{043F}\u{0440}\u{043E}\u{0441}",
timestamp: Date()
))
return
}
// Ensure session exists
sessionStore.ensureSession()
let userMessage = ChatMessage(role: .user, content: sanitized, timestamp: Date())
messages.append(userMessage)
let question = sanitized
inputText = ""
isLoading = true
// Save after adding user message
sessionStore.updateMessages(messages)
Task {
do {
let responses = try await callMontanaAPI(question: question)
await MainActor.run {
for resp in responses {
messages.append(ChatMessage(
role: .assistant,
content: resp.text,
timestamp: Date(),
aiModel: resp.model
))
}
isLoading = false
sessionStore.updateMessages(messages)
}
} catch {
await MainActor.run {
messages.append(ChatMessage(
role: .assistant,
content: "\u{274C} \u{041D}\u{0435} \u{0443}\u{0434}\u{0430}\u{043B}\u{043E}\u{0441}\u{044C} \u{043F}\u{043E}\u{043B}\u{0443}\u{0447}\u{0438}\u{0442}\u{044C} \u{043E}\u{0442}\u{0432}\u{0435}\u{0442}. \u{041F}\u{0440}\u{043E}\u{0432}\u{0435}\u{0440}\u{044C} \u{043F}\u{043E}\u{0434}\u{043A}\u{043B}\u{044E}\u{0447}\u{0435}\u{043D}\u{0438}\u{0435} \u{043A} \u{0438}\u{043D}\u{0442}\u{0435}\u{0440}\u{043D}\u{0435}\u{0442}\u{0443} \u{0438} \u{043F}\u{043E}\u{043F}\u{0440}\u{043E}\u{0431}\u{0443}\u{0439} \u{043F}\u{043E}\u{0437}\u{0436}\u{0435}.",
timestamp: Date()
))
isLoading = false
sessionStore.updateMessages(messages)
}
}
}
}
private struct AIResponse {
let model: String
let text: String
}
private func callMontanaAPI(question: String) async throws -> [AIResponse] {
let url = URL(string: "https://efir.org/api/chat")!
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.timeoutInterval = 60
let previousMessages = Array(messages.dropLast().suffix(20))
let history: [[String: String]] = previousMessages.compactMap { msg in
guard !msg.content.isEmpty else { return nil }
return [
"role": msg.role == .user ? "user" : "model",
"content": msg.content
]
}
let body: [String: Any] = ["message": question, "history": history]
request.httpBody = try JSONSerialization.data(withJSONObject: body)
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 60
let session = URLSession(configuration: config)
let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
throw NSError(domain: "Junona", code: -1, userInfo: [
NSLocalizedDescriptionKey: "Server error"
])
}
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
throw NSError(domain: "Junona", code: -2, userInfo: [
NSLocalizedDescriptionKey: "Invalid response"
])
}
if let resp = json["response"] as? String {
let m = json["model"] as? String ?? "\u{042E}\u{043D}\u{043E}\u{043D}\u{0430}"
return [AIResponse(model: m, text: resp)]
}
if let answer = json["answer"] as? String {
return [AIResponse(model: "\u{042E}\u{043D}\u{043E}\u{043D}\u{0430}", text: answer)]
}
throw NSError(domain: "Junona", code: -2, userInfo: [
NSLocalizedDescriptionKey: "No response"
])
}
private func sanitizeInput(_ input: String) -> String {
let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines)
let limited = String(trimmed.prefix(500))
let allowed = CharacterSet.alphanumerics
.union(.whitespaces)
.union(.punctuationCharacters)
.union(CharacterSet(charactersIn: "?!.,;:-\u{2014}\u{2013}\u{2014}()[]{}\"'\u{00AB}\u{00BB}\u{2116}@#$%^&*+=<>/\\"))
return String(limited.unicodeScalars.filter { allowed.contains($0) })
}
// MARK: - Clock
private func startClock() {
let currentSecond = Calendar.current.component(.second, from: Date())
secondsAngle = Double(currentSecond) * 6
clockTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [self] _ in
let second = Calendar.current.component(.second, from: Date())
withAnimation(.linear(duration: 1.0)) {
secondsAngle = Double(second) * 6
}
}
}
private func stopClock() {
clockTimer?.invalidate()
clockTimer = nil
}
// MARK: - Helpers
private func formatCurrency(_ value: Double) -> String {
if value < 1 {
return String(format: "%.2f", value)
} else if value < 100 {
return String(format: "%.1f", value)
} else {
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
formatter.maximumFractionDigits = 0
formatter.groupingSeparator = ","
return formatter.string(from: NSNumber(value: value)) ?? String(format: "%.0f", value)
}
}
private var appVersion: String {
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "3.15.0"
}
}
// MARK: - Message Bubble
struct MessageBubble: View {
let message: ChatMessage
private let goldGradient = LinearGradient(
colors: [
Color(red: 0.83, green: 0.69, blue: 0.22),
Color(red: 0.94, green: 0.82, blue: 0.38)
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
var body: some View {
HStack(alignment: .bottom, spacing: 6) {
if message.role == .assistant {
junonaAvatar
} else {
Spacer(minLength: 50)
}
VStack(alignment: .trailing, spacing: 2) {
Text(message.content)
.font(.system(size: 14))
.fixedSize(horizontal: false, vertical: true)
HStack(spacing: 3) {
if let model = message.aiModel {
Text(model)
}
Text(message.timestamp, style: .time)
}
.font(.system(size: 10))
.foregroundColor(.secondary.opacity(0.7))
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(
message.role == .user ?
Color(red: 0.83, green: 0.69, blue: 0.22).opacity(0.2) :
Color(red: 0.83, green: 0.69, blue: 0.22).opacity(0.08)
)
.foregroundColor(.primary)
.clipShape(
message.role == .user ?
UnevenRoundedRectangle(topLeadingRadius: 18, bottomLeadingRadius: 18, bottomTrailingRadius: 4, topTrailingRadius: 18) :
UnevenRoundedRectangle(topLeadingRadius: 18, bottomLeadingRadius: 4, bottomTrailingRadius: 18, topTrailingRadius: 18)
)
if message.role == .user {
userAvatar
} else {
Spacer(minLength: 50)
}
}
}
private var junonaAvatar: some View {
Group {
if let logoPath = Bundle.main.path(forResource: "JunonaLogo", ofType: "jpg"),
let nsImage = NSImage(contentsOfFile: logoPath) {
Image(nsImage: nsImage)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 32, height: 32)
.clipShape(Circle())
.overlay(Circle().stroke(goldGradient, lineWidth: 1))
} else {
Circle()
.fill(goldGradient)
.frame(width: 32, height: 32)
.overlay(
Text("\u{042E}")
.font(.system(size: 14, weight: .bold))
.foregroundColor(.white)
)
}
}
}
private var userAvatar: some View {
Group {
if let coinPath = Bundle.main.path(forResource: "TimeCoin", ofType: "png"),
let nsImage = NSImage(contentsOfFile: coinPath) {
Image(nsImage: nsImage)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 32, height: 32)
.clipShape(Circle())
.overlay(Circle().stroke(goldGradient, lineWidth: 1))
} else {
Circle()
.fill(goldGradient)
.frame(width: 32, height: 32)
.overlay(
Text("\u{0248}")
.font(.system(size: 14, weight: .bold))
.foregroundColor(.white)
)
}
}
}
}
// MARK: - Preview
#Preview {
JunonaView()
.environmentObject(PresenceEngine.shared)
.frame(width: 600, height: 500)
}