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) }