680 lines
25 KiB
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)
|
||
|
|
}
|