phase6: full UI — chat with streaming/thinking/stats, library, settings, quick chat, compare, prompt library, personas, export, menu bar extra
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Showing 25 changed files with +4,523 and −23
deleted
Sources/ZyquoLocal/App/ContentView.swift
+0 −22
@@ -1,22 +0,0 @@ | ||
| 1 | −// | |
| 2 | −// ContentView.swift | |
| 3 | −// Zyquo Local | |
| 4 | −// | |
| 5 | −// Author: Simon-Pierre Boucher | |
| 6 | −// Mail: contact@spboucher.ai | |
| 7 | −// | |
| 8 | − | |
| 9 | −import SwiftUI | |
| 10 | − | |
| 11 | −/// Placeholder root view — replaced by the full NavigationSplitView in Phase 6. | |
| 12 | −struct ContentView: View { | |
| 13 | − var body: some View { | |
| 14 | − VStack(spacing: 12) { | |
| 15 | − Text("Zyquo Local") | |
| 16 | − .font(.system(size: 20, weight: .semibold)) | |
| 17 | − Text("Phase 1 skeleton — inference engine and UI arrive in later phases.") | |
| 18 | − .foregroundStyle(.secondary) | |
| 19 | − } | |
| 20 | − .frame(maxWidth: .infinity, maxHeight: .infinity) | |
| 21 | − } | |
| 22 | −} | |
modified
Sources/ZyquoLocal/App/ZyquoLocalApp.swift
+109 −1
@@ -10,17 +10,125 @@ import SwiftUI | ||
| 10 | 10 | |
| 11 | 11 | struct ZyquoLocalApp: App { |
| 12 | 12 | @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate |
| 13 | + @State private var app = AppModel() | |
| 14 | + @Environment(\.openWindow) private var openWindow | |
| 13 | 15 | |
| 14 | 16 | var body: some Scene { |
| 15 | 17 | WindowGroup { |
| 16 | 18 | if HardwareGate.isAppleSilicon { |
| 17 | − ContentView() | |
| 19 | + RootView() | |
| 20 | + .environment(app) | |
| 18 | 21 | .frame(minWidth: 980, minHeight: 640) |
| 22 | + .onAppear { | |
| 23 | + QuickChatPanelController.shared.setup(app: app) | |
| 24 | + } | |
| 19 | 25 | } else { |
| 20 | 26 | UnsupportedHardwareView() |
| 21 | 27 | } |
| 22 | 28 | } |
| 23 | 29 | .defaultSize(width: 1240, height: 800) |
| 30 | + .windowStyle(.hiddenTitleBar) | |
| 31 | + .windowToolbarStyle(.unifiedCompact) | |
| 32 | + .commands { | |
| 33 | + CommandGroup(replacing: .newItem) { | |
| 34 | + Button("New Chat") { app.newConversation() } | |
| 35 | + .keyboardShortcut("n", modifiers: .command) | |
| 36 | + } | |
| 37 | + CommandMenu("Model") { | |
| 38 | + Button("Model Switcher…") { | |
| 39 | + NotificationCenter.default.post(name: .zyquoOpenModelSwitcher, object: nil) | |
| 40 | + } | |
| 41 | + .keyboardShortcut("k", modifiers: .command) | |
| 42 | + Button("Library") { app.route = .library } | |
| 43 | + .keyboardShortcut("l", modifiers: .command) | |
| 44 | + Divider() | |
| 45 | + Button("Compare Models…") { openWindow(id: "compare") } | |
| 46 | + Button("Quick Chat") { QuickChatPanelController.shared.toggle() } | |
| 47 | + .keyboardShortcut(.space, modifiers: .option) | |
| 48 | + Divider() | |
| 49 | + Button("Unload Model") { Task { await app.unloadModel() } } | |
| 50 | + .disabled(app.loadedModelID == nil) | |
| 51 | + } | |
| 52 | + CommandGroup(after: .textEditing) { | |
| 53 | + Button("Search Chats") { | |
| 54 | + NotificationCenter.default.post(name: .zyquoFocusSearch, object: nil) | |
| 55 | + } | |
| 56 | + .keyboardShortcut("f", modifiers: .command) | |
| 57 | + } | |
| 58 | + CommandGroup(replacing: .importExport) { | |
| 59 | + Button("Export Conversation…") { | |
| 60 | + NotificationCenter.default.post(name: .zyquoExportConversation, object: nil) | |
| 61 | + } | |
| 62 | + .keyboardShortcut("e", modifiers: [.command, .shift]) | |
| 63 | + } | |
| 64 | + } | |
| 65 | + | |
| 66 | + Window("Compare Models", id: "compare") { | |
| 67 | + CompareView() | |
| 68 | + .environment(app) | |
| 69 | + } | |
| 70 | + .defaultSize(width: 1100, height: 700) | |
| 71 | + | |
| 72 | + Settings { | |
| 73 | + SettingsView() | |
| 74 | + .environment(app) | |
| 75 | + } | |
| 76 | + | |
| 77 | + MenuBarExtra(isInserted: menuBarBinding) { | |
| 78 | + MenuBarExtraContent() | |
| 79 | + .environment(app) | |
| 80 | + } label: { | |
| 81 | + Image(nsImage: menuBarIcon) | |
| 82 | + } | |
| 83 | + .menuBarExtraStyle(.menu) | |
| 84 | + } | |
| 85 | + | |
| 86 | + private var menuBarBinding: Binding<Bool> { | |
| 87 | + Binding( | |
| 88 | + get: { app.settings.menuBarExtraEnabled }, | |
| 89 | + set: { app.settings.menuBarExtraEnabled = $0 } | |
| 90 | + ) | |
| 91 | + } | |
| 92 | + | |
| 93 | + private var menuBarIcon: NSImage { | |
| 94 | + if let url = Bundle.main.url(forResource: "MenuBarIcon", withExtension: "png"), | |
| 95 | + let image = NSImage(contentsOf: url) | |
| 96 | + { | |
| 97 | + image.isTemplate = true | |
| 98 | + image.size = NSSize(width: 18, height: 18) | |
| 99 | + return image | |
| 100 | + } | |
| 101 | + let fallback = NSImage( | |
| 102 | + systemSymbolName: "bolt.circle", accessibilityDescription: "Zyquo Local")! | |
| 103 | + fallback.isTemplate = true | |
| 104 | + return fallback | |
| 105 | + } | |
| 106 | +} | |
| 107 | + | |
| 108 | +/// Menu bar extra: state at a glance + quick actions. | |
| 109 | +struct MenuBarExtraContent: View { | |
| 110 | + @Environment(AppModel.self) private var app | |
| 111 | + @Environment(\.openWindow) private var openWindow | |
| 112 | + | |
| 113 | + var body: some View { | |
| 114 | + Group { | |
| 115 | + if let repoID = app.loadedModelID { | |
| 116 | + Text("Loaded: \(shortModelName(repoID))") | |
| 117 | + } else { | |
| 118 | + Text("No model loaded") | |
| 119 | + } | |
| 120 | + if app.downloads.activeCount > 0 { | |
| 121 | + Text("Downloading \(app.downloads.activeCount) model\(app.downloads.activeCount == 1 ? "" : "s") · \(Int(app.downloads.overallFraction * 100))%") | |
| 122 | + } | |
| 123 | + Divider() | |
| 124 | + Button("Quick Chat ⌥Space") { QuickChatPanelController.shared.toggle() } | |
| 125 | + Button("Open Zyquo Local") { | |
| 126 | + NSApp.activate(ignoringOtherApps: true) | |
| 127 | + NSApp.windows.first { $0.canBecomeMain }?.makeKeyAndOrderFront(nil) | |
| 128 | + } | |
| 129 | + Divider() | |
| 130 | + Button("Quit Zyquo Local") { NSApp.terminate(nil) } | |
| 131 | + } | |
| 24 | 132 | } |
| 25 | 133 | } |
| 26 | 134 | |
added
Sources/ZyquoLocal/Services/AppSettings.swift
+101 −0
@@ -0,0 +1,101 @@ | ||
| 1 | +// | |
| 2 | +// AppSettings.swift | |
| 3 | +// Zyquo Local | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import Foundation | |
| 10 | +import Observation | |
| 11 | + | |
| 12 | +/// App-wide settings, persisted as JSON. Appearance lives in ThemeStore. | |
| 13 | +@MainActor | |
| 14 | +@Observable | |
| 15 | +final class AppSettings { | |
| 16 | + /// Model loaded automatically on launch (repo ID), nil = none. | |
| 17 | + var defaultModelID: String? { | |
| 18 | + didSet { persist() } | |
| 19 | + } | |
| 20 | + | |
| 21 | + /// Keep the model loaded when the window closes / app is in background. | |
| 22 | + var keepModelLoaded: Bool { | |
| 23 | + didSet { persist() } | |
| 24 | + } | |
| 25 | + | |
| 26 | + /// Hugging Face token for gated models. Sent only to huggingface.co. | |
| 27 | + var hfToken: String { | |
| 28 | + didSet { persist() } | |
| 29 | + } | |
| 30 | + | |
| 31 | + /// Verify file sizes after download completes. | |
| 32 | + var autoVerifyDownloads: Bool { | |
| 33 | + didSet { persist() } | |
| 34 | + } | |
| 35 | + | |
| 36 | + /// Default generation parameters for new conversations. | |
| 37 | + var defaultParams: GenerationParams { | |
| 38 | + didSet { persist() } | |
| 39 | + } | |
| 40 | + | |
| 41 | + /// MLX GPU buffer-cache limit in MB (0 = MLX default). | |
| 42 | + var gpuCacheLimitMB: Int { | |
| 43 | + didSet { persist() } | |
| 44 | + } | |
| 45 | + | |
| 46 | + /// Hard cap applied on top of the model's context window (0 = model max). | |
| 47 | + var contextLengthCap: Int { | |
| 48 | + didSet { persist() } | |
| 49 | + } | |
| 50 | + | |
| 51 | + /// Global default system prompt for new conversations. | |
| 52 | + var defaultSystemPrompt: String { | |
| 53 | + didSet { persist() } | |
| 54 | + } | |
| 55 | + | |
| 56 | + var menuBarExtraEnabled: Bool { | |
| 57 | + didSet { persist() } | |
| 58 | + } | |
| 59 | + | |
| 60 | + private struct Persisted: Codable { | |
| 61 | + var defaultModelID: String? | |
| 62 | + var keepModelLoaded: Bool | |
| 63 | + var hfToken: String | |
| 64 | + var autoVerifyDownloads: Bool | |
| 65 | + var defaultParams: GenerationParams | |
| 66 | + var gpuCacheLimitMB: Int | |
| 67 | + var contextLengthCap: Int | |
| 68 | + var defaultSystemPrompt: String | |
| 69 | + var menuBarExtraEnabled: Bool | |
| 70 | + } | |
| 71 | + | |
| 72 | + init() { | |
| 73 | + let stored = PersistenceService.loadDocument(Persisted.self, named: "settings") | |
| 74 | + defaultModelID = stored?.defaultModelID | |
| 75 | + keepModelLoaded = stored?.keepModelLoaded ?? true | |
| 76 | + hfToken = stored?.hfToken ?? "" | |
| 77 | + autoVerifyDownloads = stored?.autoVerifyDownloads ?? true | |
| 78 | + defaultParams = stored?.defaultParams ?? GenerationParams() | |
| 79 | + gpuCacheLimitMB = stored?.gpuCacheLimitMB ?? 0 | |
| 80 | + contextLengthCap = stored?.contextLengthCap ?? 0 | |
| 81 | + defaultSystemPrompt = stored?.defaultSystemPrompt ?? "" | |
| 82 | + menuBarExtraEnabled = stored?.menuBarExtraEnabled ?? true | |
| 83 | + } | |
| 84 | + | |
| 85 | + private func persist() { | |
| 86 | + PersistenceService.saveDocument( | |
| 87 | + Persisted( | |
| 88 | + defaultModelID: defaultModelID, | |
| 89 | + keepModelLoaded: keepModelLoaded, | |
| 90 | + hfToken: hfToken, | |
| 91 | + autoVerifyDownloads: autoVerifyDownloads, | |
| 92 | + defaultParams: defaultParams, | |
| 93 | + gpuCacheLimitMB: gpuCacheLimitMB, | |
| 94 | + contextLengthCap: contextLengthCap, | |
| 95 | + defaultSystemPrompt: defaultSystemPrompt, | |
| 96 | + menuBarExtraEnabled: menuBarExtraEnabled | |
| 97 | + ), | |
| 98 | + named: "settings" | |
| 99 | + ) | |
| 100 | + } | |
| 101 | +} | |
added
Sources/ZyquoLocal/Services/ExportService.swift
+121 −0
@@ -0,0 +1,121 @@ | ||
| 1 | +// | |
| 2 | +// ExportService.swift | |
| 3 | +// Zyquo Local | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import AppKit | |
| 10 | +import Foundation | |
| 11 | +import UniformTypeIdentifiers | |
| 12 | + | |
| 13 | +/// Conversation export: Markdown and PDF via save panels. | |
| 14 | +@MainActor | |
| 15 | +enum ExportService { | |
| 16 | + static func markdown(for conversation: Conversation) -> String { | |
| 17 | + var lines: [String] = [] | |
| 18 | + lines.append("# \(conversation.title)") | |
| 19 | + lines.append("") | |
| 20 | + lines.append("_Exported from Zyquo Local · \(conversation.updatedAt.formatted(date: .abbreviated, time: .shortened))_") | |
| 21 | + if let modelID = conversation.modelID { | |
| 22 | + lines.append("_Model: \(modelID)_") | |
| 23 | + } | |
| 24 | + lines.append("") | |
| 25 | + if let system = conversation.systemPrompt, !system.isEmpty { | |
| 26 | + lines.append("> **System:** \(system)") | |
| 27 | + lines.append("") | |
| 28 | + } | |
| 29 | + for message in conversation.messages where message.role != .system { | |
| 30 | + lines.append(message.role == .user ? "## You" : "## Assistant") | |
| 31 | + lines.append("") | |
| 32 | + if let thinking = message.thinking, !thinking.isEmpty { | |
| 33 | + lines.append("<details><summary>Thinking</summary>") | |
| 34 | + lines.append("") | |
| 35 | + lines.append(thinking) | |
| 36 | + lines.append("") | |
| 37 | + lines.append("</details>") | |
| 38 | + lines.append("") | |
| 39 | + } | |
| 40 | + lines.append(message.content) | |
| 41 | + if let stats = message.stats { | |
| 42 | + lines.append("") | |
| 43 | + lines.append(String( | |
| 44 | + format: "_%.1f tok/s · %d tokens · %.1fs to first token_", | |
| 45 | + stats.tokensPerSecond, stats.generationTokenCount, stats.timeToFirstToken)) | |
| 46 | + } | |
| 47 | + lines.append("") | |
| 48 | + } | |
| 49 | + return lines.joined(separator: "\n") | |
| 50 | + } | |
| 51 | + | |
| 52 | + static func exportMarkdown(_ conversation: Conversation) { | |
| 53 | + let panel = NSSavePanel() | |
| 54 | + panel.allowedContentTypes = [.init(filenameExtension: "md") ?? .plainText] | |
| 55 | + panel.nameFieldStringValue = sanitizedFileName(conversation.title) + ".md" | |
| 56 | + guard panel.runModal() == .OK, let url = panel.url else { return } | |
| 57 | + try? markdown(for: conversation).write(to: url, atomically: true, encoding: .utf8) | |
| 58 | + } | |
| 59 | + | |
| 60 | + static func exportPDF(_ conversation: Conversation) { | |
| 61 | + let panel = NSSavePanel() | |
| 62 | + panel.allowedContentTypes = [.pdf] | |
| 63 | + panel.nameFieldStringValue = sanitizedFileName(conversation.title) + ".pdf" | |
| 64 | + guard panel.runModal() == .OK, let url = panel.url else { return } | |
| 65 | + writePDF(conversation, to: url) | |
| 66 | + } | |
| 67 | + | |
| 68 | + /// Simple paginated PDF from an attributed rendition of the transcript. | |
| 69 | + private static func writePDF(_ conversation: Conversation, to url: URL) { | |
| 70 | + let pageRect = CGRect(x: 0, y: 0, width: 612, height: 792) // US Letter | |
| 71 | + let inset: CGFloat = 54 | |
| 72 | + let contentRect = pageRect.insetBy(dx: inset, dy: inset) | |
| 73 | + | |
| 74 | + let text = NSMutableAttributedString() | |
| 75 | + let titleFont = NSFont.systemFont(ofSize: 18, weight: .semibold) | |
| 76 | + let bodyFont = NSFont.systemFont(ofSize: 11) | |
| 77 | + let roleFont = NSFont.systemFont(ofSize: 11, weight: .semibold) | |
| 78 | + let metaFont = NSFont.systemFont(ofSize: 9) | |
| 79 | + | |
| 80 | + text.append(NSAttributedString( | |
| 81 | + string: conversation.title + "\n", | |
| 82 | + attributes: [.font: titleFont])) | |
| 83 | + text.append(NSAttributedString( | |
| 84 | + string: "Exported from Zyquo Local · \(conversation.updatedAt.formatted())\n\n", | |
| 85 | + attributes: [.font: metaFont, .foregroundColor: NSColor.secondaryLabelColor])) | |
| 86 | + | |
| 87 | + for message in conversation.messages where message.role != .system { | |
| 88 | + text.append(NSAttributedString( | |
| 89 | + string: (message.role == .user ? "You" : "Assistant") + "\n", | |
| 90 | + attributes: [.font: roleFont])) | |
| 91 | + text.append(NSAttributedString( | |
| 92 | + string: message.content + "\n\n", | |
| 93 | + attributes: [.font: bodyFont])) | |
| 94 | + } | |
| 95 | + | |
| 96 | + var mediaBox = pageRect | |
| 97 | + guard let consumer = CGDataConsumer(url: url as CFURL), | |
| 98 | + let context = CGContext(consumer: consumer, mediaBox: &mediaBox, nil) | |
| 99 | + else { return } | |
| 100 | + | |
| 101 | + let framesetter = CTFramesetterCreateWithAttributedString(text) | |
| 102 | + var location = 0 | |
| 103 | + while location < text.length { | |
| 104 | + context.beginPDFPage(nil) | |
| 105 | + let path = CGPath(rect: contentRect, transform: nil) | |
| 106 | + let frame = CTFramesetterCreateFrame( | |
| 107 | + framesetter, CFRange(location: location, length: 0), path, nil) | |
| 108 | + CTFrameDraw(frame, context) | |
| 109 | + let visible = CTFrameGetVisibleStringRange(frame) | |
| 110 | + location += max(visible.length, 1) | |
| 111 | + context.endPDFPage() | |
| 112 | + } | |
| 113 | + context.closePDF() | |
| 114 | + } | |
| 115 | + | |
| 116 | + private static func sanitizedFileName(_ name: String) -> String { | |
| 117 | + let invalid = CharacterSet(charactersIn: "/\\:?%*|\"<>") | |
| 118 | + let cleaned = name.components(separatedBy: invalid).joined(separator: "-") | |
| 119 | + return cleaned.isEmpty ? "Conversation" : cleaned | |
| 120 | + } | |
| 121 | +} | |
added
Sources/ZyquoLocal/Services/PersonaStore.swift
+58 −0
@@ -0,0 +1,58 @@ | ||
| 1 | +// | |
| 2 | +// PersonaStore.swift | |
| 3 | +// Zyquo Local | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import Foundation | |
| 10 | +import Observation | |
| 11 | + | |
| 12 | +/// User personas: system prompt + preferred model + params. | |
| 13 | +@MainActor | |
| 14 | +@Observable | |
| 15 | +final class PersonaStore { | |
| 16 | + var personas: [Persona] { | |
| 17 | + didSet { PersistenceService.saveDocument(personas, named: "personas") } | |
| 18 | + } | |
| 19 | + | |
| 20 | + init() { | |
| 21 | + personas = PersistenceService.loadDocument([Persona].self, named: "personas") ?? Self.builtIns | |
| 22 | + } | |
| 23 | + | |
| 24 | + func add(_ persona: Persona) { | |
| 25 | + personas.append(persona) | |
| 26 | + } | |
| 27 | + | |
| 28 | + func remove(id: UUID) { | |
| 29 | + personas.removeAll { $0.id == id } | |
| 30 | + } | |
| 31 | + | |
| 32 | + func update(_ persona: Persona) { | |
| 33 | + if let i = personas.firstIndex(where: { $0.id == persona.id }) { | |
| 34 | + personas[i] = persona | |
| 35 | + } | |
| 36 | + } | |
| 37 | + | |
| 38 | + private static let builtIns: [Persona] = [ | |
| 39 | + Persona( | |
| 40 | + name: "Helpful Assistant", | |
| 41 | + systemPrompt: "You are a helpful, concise assistant. Answer directly; use short paragraphs and lists when they aid clarity." | |
| 42 | + ), | |
| 43 | + Persona( | |
| 44 | + name: "Code Reviewer", | |
| 45 | + systemPrompt: "You are a rigorous senior engineer. Review code for correctness, clarity, performance and idiomatic style. Point at concrete lines, propose fixes, and keep praise brief.", | |
| 46 | + params: GenerationParams(temperature: 0.3) | |
| 47 | + ), | |
| 48 | + Persona( | |
| 49 | + name: "Technical Writer", | |
| 50 | + systemPrompt: "You are a precise technical writer. Produce clear, well-structured prose with correct terminology, no filler, and consistent formatting.", | |
| 51 | + params: GenerationParams(temperature: 0.5) | |
| 52 | + ), | |
| 53 | + Persona( | |
| 54 | + name: "Socratic Tutor", | |
| 55 | + systemPrompt: "You are a patient tutor. Guide with questions before revealing answers, adapt to the learner's level, and verify understanding with small checks." | |
| 56 | + ), | |
| 57 | + ] | |
| 58 | +} | |
added
Sources/ZyquoLocal/Services/PromptLibrary.swift
+156 −0
@@ -0,0 +1,156 @@ | ||
| 1 | +// | |
| 2 | +// PromptLibrary.swift | |
| 3 | +// Zyquo Local | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import Foundation | |
| 10 | +import Observation | |
| 11 | + | |
| 12 | +/// A reusable prompt template with `{{input}}` variables. | |
| 13 | +struct PromptTemplate: Identifiable, Codable, Hashable, Sendable { | |
| 14 | + var id: UUID | |
| 15 | + var name: String | |
| 16 | + var category: String | |
| 17 | + var template: String | |
| 18 | + var isBuiltIn: Bool | |
| 19 | + | |
| 20 | + init(id: UUID = UUID(), name: String, category: String, template: String, isBuiltIn: Bool = false) { | |
| 21 | + self.id = id | |
| 22 | + self.name = name | |
| 23 | + self.category = category | |
| 24 | + self.template = template | |
| 25 | + self.isBuiltIn = isBuiltIn | |
| 26 | + } | |
| 27 | + | |
| 28 | + /// Variable names in order of appearance ({{input}}, {{language}}, …). | |
| 29 | + var variables: [String] { | |
| 30 | + var seen = Set<String>() | |
| 31 | + var result: [String] = [] | |
| 32 | + var search = template[template.startIndex...] | |
| 33 | + while let open = search.range(of: "{{"), let close = search[open.upperBound...].range(of: "}}") { | |
| 34 | + let name = String(search[open.upperBound..<close.lowerBound]).trimmingCharacters(in: .whitespaces) | |
| 35 | + if !name.isEmpty, seen.insert(name).inserted { result.append(name) } | |
| 36 | + search = search[close.upperBound...] | |
| 37 | + } | |
| 38 | + return result | |
| 39 | + } | |
| 40 | + | |
| 41 | + func render(values: [String: String]) -> String { | |
| 42 | + var output = template | |
| 43 | + for (key, value) in values { | |
| 44 | + output = output.replacingOccurrences(of: "{{\(key)}}", with: value) | |
| 45 | + output = output.replacingOccurrences(of: "{{ \(key) }}", with: value) | |
| 46 | + } | |
| 47 | + return output | |
| 48 | + } | |
| 49 | +} | |
| 50 | + | |
| 51 | +/// Ships ≥50 quality templates; user templates persist alongside. | |
| 52 | +@MainActor | |
| 53 | +@Observable | |
| 54 | +final class PromptLibrary { | |
| 55 | + var userTemplates: [PromptTemplate] { | |
| 56 | + didSet { PersistenceService.saveDocument(userTemplates, named: "prompt-templates") } | |
| 57 | + } | |
| 58 | + | |
| 59 | + var all: [PromptTemplate] { Self.builtIns + userTemplates } | |
| 60 | + | |
| 61 | + var categories: [String] { | |
| 62 | + var seen = Set<String>() | |
| 63 | + return all.compactMap { seen.insert($0.category).inserted ? $0.category : nil } | |
| 64 | + } | |
| 65 | + | |
| 66 | + init() { | |
| 67 | + userTemplates = PersistenceService.loadDocument([PromptTemplate].self, named: "prompt-templates") ?? [] | |
| 68 | + } | |
| 69 | + | |
| 70 | + func add(_ template: PromptTemplate) { | |
| 71 | + userTemplates.append(template) | |
| 72 | + } | |
| 73 | + | |
| 74 | + func remove(id: UUID) { | |
| 75 | + userTemplates.removeAll { $0.id == id } | |
| 76 | + } | |
| 77 | + | |
| 78 | + private static func t(_ name: String, _ category: String, _ template: String) -> PromptTemplate { | |
| 79 | + PromptTemplate(name: name, category: category, template: template, isBuiltIn: true) | |
| 80 | + } | |
| 81 | + | |
| 82 | + /// 56 built-in templates across 8 categories. | |
| 83 | + static let builtIns: [PromptTemplate] = [ | |
| 84 | + // ── Writing ───────────────────────────────────────────────────────── | |
| 85 | + t("Improve writing", "Writing", "Improve the clarity, flow and concision of this text while keeping its meaning and tone:\n\n{{input}}"), | |
| 86 | + t("Fix grammar", "Writing", "Fix all grammar, spelling and punctuation mistakes in this text. Return only the corrected text:\n\n{{input}}"), | |
| 87 | + t("Make it shorter", "Writing", "Rewrite this text at half its length without losing the key information:\n\n{{input}}"), | |
| 88 | + t("Make it longer", "Writing", "Expand this text with more detail, examples and nuance, keeping its voice:\n\n{{input}}"), | |
| 89 | + t("Change tone", "Writing", "Rewrite this text in a {{tone}} tone:\n\n{{input}}"), | |
| 90 | + t("Draft an email", "Writing", "Write a professional email about the following, with a clear subject line. Keep it under 150 words:\n\n{{input}}"), | |
| 91 | + t("Blog post outline", "Writing", "Create a detailed outline for a blog post about: {{input}}. Include a hook, 4–6 sections with bullet points, and a conclusion."), | |
| 92 | + t("Title ideas", "Writing", "Suggest 10 compelling titles for: {{input}}. Mix styles: direct, curiosity-driven, how-to, and listicle."), | |
| 93 | + | |
| 94 | + // ── Coding ────────────────────────────────────────────────────────── | |
| 95 | + t("Explain code", "Coding", "Explain what this code does, step by step, then summarize its purpose in one sentence:\n\n```\n{{input}}\n```"), | |
| 96 | + t("Review code", "Coding", "Review this code for bugs, edge cases, performance and readability. Give concrete fixes:\n\n```\n{{input}}\n```"), | |
| 97 | + t("Refactor code", "Coding", "Refactor this code for clarity and maintainability, preserving exact behavior. Explain each change briefly:\n\n```\n{{input}}\n```"), | |
| 98 | + t("Write tests", "Coding", "Write thorough unit tests for this code, covering happy paths and edge cases:\n\n```\n{{input}}\n```"), | |
| 99 | + t("Add documentation", "Coding", "Add clear documentation comments to this code. Return the documented code:\n\n```\n{{input}}\n```"), | |
| 100 | + t("Translate to language", "Coding", "Translate this code to {{language}}, keeping behavior identical and using idiomatic style:\n\n```\n{{input}}\n```"), | |
| 101 | + t("Debug an error", "Coding", "Here is code and the error it produces. Diagnose the root cause and give a fix.\n\nCode:\n```\n{{input}}\n```\n\nError:\n{{error}}"), | |
| 102 | + t("Regex builder", "Coding", "Write a regular expression that {{input}}. Explain each part and give 3 matching and 3 non-matching examples."), | |
| 103 | + t("SQL query", "Coding", "Write a SQL query that {{input}}. Assume sensible table/column names, state your assumptions, and explain the query."), | |
| 104 | + t("Shell one-liner", "Coding", "Write a macOS shell one-liner that {{input}}. Explain what each part does and note any pitfalls."), | |
| 105 | + | |
| 106 | + // ── Analysis ──────────────────────────────────────────────────────── | |
| 107 | + t("Summarize", "Analysis", "Summarize this text in 5 bullet points, then one sentence:\n\n{{input}}"), | |
| 108 | + t("Key takeaways", "Analysis", "Extract the key takeaways from this text as a prioritized list, most important first:\n\n{{input}}"), | |
| 109 | + t("Pros and cons", "Analysis", "List the pros and cons of: {{input}}. End with a balanced recommendation."), | |
| 110 | + t("Compare options", "Analysis", "Compare these options across the criteria that matter most; use a table, then recommend one:\n\n{{input}}"), | |
| 111 | + t("Find weaknesses", "Analysis", "Steelman the strongest objections to this argument, then assess which objections actually hold:\n\n{{input}}"), | |
| 112 | + t("Fact-check reasoning", "Analysis", "Check this reasoning for logical fallacies and unsupported claims, quoting each problem:\n\n{{input}}"), | |
| 113 | + t("SWOT analysis", "Analysis", "Produce a SWOT analysis (strengths, weaknesses, opportunities, threats) for: {{input}}"), | |
| 114 | + t("Data interpretation", "Analysis", "Interpret this data: what patterns, anomalies and conclusions stand out? What further data would help?\n\n{{input}}"), | |
| 115 | + | |
| 116 | + // ── Learning ──────────────────────────────────────────────────────── | |
| 117 | + t("Explain like I'm five", "Learning", "Explain {{input}} so a curious 5-year-old would get it, using one everyday analogy."), | |
| 118 | + t("Explain in depth", "Learning", "Give an expert-level explanation of {{input}}: precise definitions, how it works, trade-offs, and common misconceptions."), | |
| 119 | + t("Study plan", "Learning", "Design a 4-week study plan to learn {{input}} from scratch: weekly goals, resources, exercises and checkpoints."), | |
| 120 | + t("Quiz me", "Learning", "Create a 10-question quiz on {{input}} with increasing difficulty. Show answers with explanations at the end."), | |
| 121 | + t("Analogy maker", "Learning", "Explain {{input}} through 3 different analogies from unrelated domains, and note where each analogy breaks down."), | |
| 122 | + t("Flashcards", "Learning", "Turn this material into 15 question→answer flashcards, hardest concepts first:\n\n{{input}}"), | |
| 123 | + t("Historical context", "Learning", "Explain the historical context of {{input}}: what led to it, why it mattered, and its lasting consequences."), | |
| 124 | + | |
| 125 | + // ── Productivity ──────────────────────────────────────────────────── | |
| 126 | + t("Meeting agenda", "Productivity", "Create a focused agenda for a {{duration}} meeting about {{input}}: timed sections, owners, and desired outcomes."), | |
| 127 | + t("Meeting minutes", "Productivity", "Turn these raw notes into clean meeting minutes with decisions, action items (owner + due date), and open questions:\n\n{{input}}"), | |
| 128 | + t("Prioritize tasks", "Productivity", "Prioritize these tasks with an effort/impact matrix and propose what to do today, this week, and to drop:\n\n{{input}}"), | |
| 129 | + t("Project plan", "Productivity", "Break this project into phases with milestones, dependencies and risks:\n\n{{input}}"), | |
| 130 | + t("Decision matrix", "Productivity", "Build a weighted decision matrix for this decision; propose criteria and weights, score the options, and conclude:\n\n{{input}}"), | |
| 131 | + t("Brainstorm ideas", "Productivity", "Brainstorm 20 ideas for {{input}} — first 10 sensible, next 10 wild. Then mark the 3 most promising."), | |
| 132 | + t("Weekly review", "Productivity", "Structure a weekly review from these notes: wins, misses, lessons, and next week's top 3 priorities:\n\n{{input}}"), | |
| 133 | + | |
| 134 | + // ── Communication ─────────────────────────────────────────────────── | |
| 135 | + t("Difficult message", "Communication", "Help me write a kind but clear message about this difficult situation. Offer two versions — softer and more direct:\n\n{{input}}"), | |
| 136 | + t("Negotiation prep", "Communication", "Prepare me for this negotiation: my leverage, their likely position, anchors, concessions and walk-away point:\n\n{{input}}"), | |
| 137 | + t("Feedback for a colleague", "Communication", "Turn these observations into constructive, specific, actionable feedback using the SBI (situation-behavior-impact) format:\n\n{{input}}"), | |
| 138 | + t("Announcement", "Communication", "Write a clear announcement about {{input}} for {{audience}}: what changes, why, when, and what they need to do."), | |
| 139 | + t("Apology note", "Communication", "Write a sincere apology for this situation — own the mistake, no excuses, concrete repair step:\n\n{{input}}"), | |
| 140 | + t("Elevator pitch", "Communication", "Craft a 30-second elevator pitch for {{input}}, plus a one-line hook and a follow-up question to keep the conversation going."), | |
| 141 | + | |
| 142 | + // ── Translation & language ────────────────────────────────────────── | |
| 143 | + t("Translate", "Language", "Translate this text to {{language}}, preserving tone and idioms naturally:\n\n{{input}}"), | |
| 144 | + t("Translate + explain", "Language", "Translate this to {{language}}, then explain any idioms or cultural references that required adaptation:\n\n{{input}}"), | |
| 145 | + t("Proofread (non-native)", "Language", "I'm not a native speaker. Correct this text and briefly explain the 3 most instructive mistakes:\n\n{{input}}"), | |
| 146 | + t("Vocabulary builder", "Language", "Give me 10 advanced ways to express “{{input}}”, from formal to casual, with an example sentence each."), | |
| 147 | + | |
| 148 | + // ── Creative ──────────────────────────────────────────────────────── | |
| 149 | + t("Short story", "Creative", "Write a 500-word short story about {{input}} with a strong opening line and an unexpected ending."), | |
| 150 | + t("Character builder", "Creative", "Create a rich character based on: {{input}}. Include appearance, voice, motivation, flaw, secret, and a sample line of dialogue."), | |
| 151 | + t("World-building", "Creative", "Develop a setting from this seed: {{input}}. Cover geography, society, conflict, and 3 story hooks."), | |
| 152 | + t("Poem", "Creative", "Write a poem about {{input}} in the style of {{style}}."), | |
| 153 | + t("Naming ideas", "Creative", "Suggest 15 names for {{input}}: 5 descriptive, 5 evocative, 5 invented words. Note availability concerns for the top 3."), | |
| 154 | + t("Dialogue scene", "Creative", "Write a dialogue-only scene (no narration) where {{input}}. Make each voice distinct."), | |
| 155 | + ] | |
| 156 | +} | |
added
Sources/ZyquoLocal/ViewModels/AppModel.swift
+216 −0
@@ -0,0 +1,216 @@ | ||
| 1 | +// | |
| 2 | +// AppModel.swift | |
| 3 | +// Zyquo Local | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import Foundation | |
| 10 | +import MLX | |
| 11 | +import Observation | |
| 12 | + | |
| 13 | +/// Root coordinator: owns the engine, stores, downloads and conversations. | |
| 14 | +@MainActor | |
| 15 | +@Observable | |
| 16 | +final class AppModel { | |
| 17 | + let settings: AppSettings | |
| 18 | + let store: ModelStore | |
| 19 | + let downloads: DownloadManager | |
| 20 | + let engine = InferenceEngine() | |
| 21 | + let chat: ChatController | |
| 22 | + | |
| 23 | + var conversations: [Conversation] = [] | |
| 24 | + var selectedConversationID: UUID? | |
| 25 | + | |
| 26 | + /// Mirrors the engine actor's state for the UI. | |
| 27 | + var engineState: EngineState = .unloaded | |
| 28 | + /// Live MLX active memory while a model is loaded (footer chip). | |
| 29 | + var liveMemoryBytes: Int = 0 | |
| 30 | + /// Last load error to surface in the UI. | |
| 31 | + var lastError: String? | |
| 32 | + | |
| 33 | + var personaStore: PersonaStore | |
| 34 | + var promptLibrary: PromptLibrary | |
| 35 | + | |
| 36 | + /// Detail column routing. | |
| 37 | + enum DetailRoute: Hashable { | |
| 38 | + case chat | |
| 39 | + case library | |
| 40 | + } | |
| 41 | + var route: DetailRoute = .chat | |
| 42 | + | |
| 43 | + var selectedConversation: Conversation? { | |
| 44 | + get { conversations.first { $0.id == selectedConversationID } } | |
| 45 | + set { | |
| 46 | + guard let newValue, let i = conversations.firstIndex(where: { $0.id == newValue.id }) else { return } | |
| 47 | + conversations[i] = newValue | |
| 48 | + } | |
| 49 | + } | |
| 50 | + | |
| 51 | + var loadedModelID: String? { | |
| 52 | + switch engineState { | |
| 53 | + case .ready(let id), .generating(let id), .loading(let id): id | |
| 54 | + case .unloaded: nil | |
| 55 | + } | |
| 56 | + } | |
| 57 | + | |
| 58 | + init() { | |
| 59 | + let settings = AppSettings() | |
| 60 | + self.settings = settings | |
| 61 | + let store = ModelStore() | |
| 62 | + self.store = store | |
| 63 | + self.downloads = DownloadManager(hub: HubService(token: settings.hfToken), store: store) | |
| 64 | + self.personaStore = PersonaStore() | |
| 65 | + self.promptLibrary = PromptLibrary() | |
| 66 | + self.chat = ChatController() | |
| 67 | + chat.bind(to: self) | |
| 68 | + | |
| 69 | + conversations = PersistenceService.loadConversations() | |
| 70 | + if conversations.isEmpty { | |
| 71 | + newConversation() | |
| 72 | + } else { | |
| 73 | + selectedConversationID = conversations.first?.id | |
| 74 | + } | |
| 75 | + | |
| 76 | + applyGPUCacheLimit() | |
| 77 | + | |
| 78 | + // Load the default model on launch when configured. | |
| 79 | + if let defaultID = settings.defaultModelID, store.model(for: defaultID) != nil { | |
| 80 | + Task { await loadModel(repoID: defaultID) } | |
| 81 | + } | |
| 82 | + // Keep the memory readout fresh while a model is loaded. | |
| 83 | + Task { await memoryTicker() } | |
| 84 | + } | |
| 85 | + | |
| 86 | + func applyGPUCacheLimit() { | |
| 87 | + if settings.gpuCacheLimitMB > 0 { | |
| 88 | + MLX.Memory.cacheLimit = settings.gpuCacheLimitMB * 1_048_576 | |
| 89 | + } | |
| 90 | + } | |
| 91 | + | |
| 92 | + /// Push the (possibly updated) HF token into services that need it. | |
| 93 | + func refreshToken() { | |
| 94 | + downloads.hub = HubService(token: settings.hfToken) | |
| 95 | + } | |
| 96 | + | |
| 97 | + // MARK: - Conversations | |
| 98 | + | |
| 99 | + @discardableResult | |
| 100 | + func newConversation(persona: Persona? = nil) -> Conversation { | |
| 101 | + var conversation = Conversation( | |
| 102 | + modelID: loadedModelID ?? settings.defaultModelID, | |
| 103 | + systemPrompt: persona?.systemPrompt | |
| 104 | + ?? (settings.defaultSystemPrompt.isEmpty ? nil : settings.defaultSystemPrompt), | |
| 105 | + params: persona?.params ?? settings.defaultParams | |
| 106 | + ) | |
| 107 | + if let persona { | |
| 108 | + conversation.title = persona.name | |
| 109 | + if let preferred = persona.preferredModelID { conversation.modelID = preferred } | |
| 110 | + } | |
| 111 | + conversations.insert(conversation, at: 0) | |
| 112 | + selectedConversationID = conversation.id | |
| 113 | + route = .chat | |
| 114 | + PersistenceService.save(conversation) | |
| 115 | + return conversation | |
| 116 | + } | |
| 117 | + | |
| 118 | + func delete(conversationID: UUID) { | |
| 119 | + conversations.removeAll { $0.id == conversationID } | |
| 120 | + PersistenceService.delete(conversationID: conversationID) | |
| 121 | + if selectedConversationID == conversationID { | |
| 122 | + selectedConversationID = conversations.first?.id | |
| 123 | + } | |
| 124 | + } | |
| 125 | + | |
| 126 | + func update(_ conversation: Conversation, touch: Bool = true) { | |
| 127 | + var conversation = conversation | |
| 128 | + if touch { conversation.updatedAt = Date() } | |
| 129 | + if let i = conversations.firstIndex(where: { $0.id == conversation.id }) { | |
| 130 | + conversations[i] = conversation | |
| 131 | + } | |
| 132 | + PersistenceService.save(conversation) | |
| 133 | + } | |
| 134 | + | |
| 135 | + func togglePin(conversationID: UUID) { | |
| 136 | + guard var c = conversations.first(where: { $0.id == conversationID }) else { return } | |
| 137 | + c.pinned.toggle() | |
| 138 | + update(c, touch: false) | |
| 139 | + } | |
| 140 | + | |
| 141 | + // MARK: - Model lifecycle | |
| 142 | + | |
| 143 | + func loadModel(repoID: String) async { | |
| 144 | + guard let model = store.model(for: repoID) else { | |
| 145 | + lastError = "\(repoID) is not downloaded." | |
| 146 | + return | |
| 147 | + } | |
| 148 | + engineState = .loading(repoID: repoID) | |
| 149 | + do { | |
| 150 | + try await engine.load(model: model) | |
| 151 | + engineState = await engine.state | |
| 152 | + store.markUsed(repoID: repoID) | |
| 153 | + // Bind the current conversation to the newly loaded model. | |
| 154 | + if var c = selectedConversation { | |
| 155 | + c.modelID = repoID | |
| 156 | + update(c, touch: false) | |
| 157 | + } | |
| 158 | + } catch { | |
| 159 | + engineState = .unloaded | |
| 160 | + lastError = error.localizedDescription | |
| 161 | + } | |
| 162 | + } | |
| 163 | + | |
| 164 | + func unloadModel() async { | |
| 165 | + await engine.unload() | |
| 166 | + engineState = .unloaded | |
| 167 | + liveMemoryBytes = 0 | |
| 168 | + } | |
| 169 | + | |
| 170 | + private func memoryTicker() async { | |
| 171 | + while !Task.isCancelled { | |
| 172 | + try? await Task.sleep(for: .seconds(2)) | |
| 173 | + if loadedModelID != nil { | |
| 174 | + liveMemoryBytes = MemoryAdvisor.activeMemoryBytes | |
| 175 | + } | |
| 176 | + } | |
| 177 | + } | |
| 178 | + | |
| 179 | + // MARK: - Sidebar grouping & search | |
| 180 | + | |
| 181 | + struct SidebarGroup: Identifiable { | |
| 182 | + var title: String | |
| 183 | + var conversations: [Conversation] | |
| 184 | + var id: String { title } | |
| 185 | + } | |
| 186 | + | |
| 187 | + func sidebarGroups(query: String) -> [SidebarGroup] { | |
| 188 | + let filtered = query.isEmpty | |
| 189 | + ? conversations | |
| 190 | + : conversations.filter { c in | |
| 191 | + c.title.localizedCaseInsensitiveContains(query) | |
| 192 | + || c.messages.contains { $0.content.localizedCaseInsensitiveContains(query) } | |
| 193 | + } | |
| 194 | + var groups: [SidebarGroup] = [] | |
| 195 | + let pinned = filtered.filter(\.pinned) | |
| 196 | + if !pinned.isEmpty { | |
| 197 | + groups.append(SidebarGroup(title: "Pinned", conversations: pinned)) | |
| 198 | + } | |
| 199 | + let rest = filtered.filter { !$0.pinned } | |
| 200 | + let calendar = Calendar.current | |
| 201 | + let now = Date() | |
| 202 | + func bucket(_ date: Date) -> String { | |
| 203 | + if calendar.isDateInToday(date) { return "Today" } | |
| 204 | + if calendar.isDateInYesterday(date) { return "Yesterday" } | |
| 205 | + if date > calendar.date(byAdding: .day, value: -7, to: now)! { return "Previous 7 Days" } | |
| 206 | + return "Older" | |
| 207 | + } | |
| 208 | + for title in ["Today", "Yesterday", "Previous 7 Days", "Older"] { | |
| 209 | + let matching = rest.filter { bucket($0.updatedAt) == title } | |
| 210 | + if !matching.isEmpty { | |
| 211 | + groups.append(SidebarGroup(title: title, conversations: matching)) | |
| 212 | + } | |
| 213 | + } | |
| 214 | + return groups | |
| 215 | + } | |
| 216 | +} | |
added
Sources/ZyquoLocal/ViewModels/ChatController.swift
+278 −0
@@ -0,0 +1,278 @@ | ||
| 1 | +// | |
| 2 | +// ChatController.swift | |
| 3 | +// Zyquo Local | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import Foundation | |
| 10 | +import Observation | |
| 11 | + | |
| 12 | +/// Drives one streaming generation at a time for the selected conversation: | |
| 13 | +/// send/stop/regenerate/edit-resend, live `<think>` parsing, throttled | |
| 14 | +/// tokens-per-second ticker (≤4 Hz), stats capture, and auto-titling. | |
| 15 | +@MainActor | |
| 16 | +@Observable | |
| 17 | +final class ChatController { | |
| 18 | + private(set) var isGenerating = false | |
| 19 | + /// Visible (non-thinking) streamed text of the in-flight response. | |
| 20 | + private(set) var streamingText = "" | |
| 21 | + /// Streamed `<think>` content of the in-flight response. | |
| 22 | + private(set) var streamingThinking = "" | |
| 23 | + /// True while the stream is inside a `<think>` block. | |
| 24 | + private(set) var isThinking = false | |
| 25 | + /// Live generation speed, updated at most 4 Hz. | |
| 26 | + private(set) var liveTokensPerSecond: Double = 0 | |
| 27 | + /// Conversation currently streaming (may differ from the selection). | |
| 28 | + private(set) var streamingConversationID: UUID? | |
| 29 | + | |
| 30 | + private weak var app: AppModel? | |
| 31 | + private var generationTask: Task<Void, Never>? | |
| 32 | + | |
| 33 | + func bind(to app: AppModel) { | |
| 34 | + self.app = app | |
| 35 | + } | |
| 36 | + | |
| 37 | + // MARK: - Actions | |
| 38 | + | |
| 39 | + /// Sends `prompt` in the given conversation, streaming the response. | |
| 40 | + func send(prompt: String, in conversation: Conversation) { | |
| 41 | + guard let app, !isGenerating else { return } | |
| 42 | + var conversation = conversation | |
| 43 | + let userMessage = Message(role: .user, content: prompt) | |
| 44 | + conversation.messages.append(userMessage) | |
| 45 | + app.update(conversation) | |
| 46 | + stream(prompt: prompt, conversation: conversation) | |
| 47 | + } | |
| 48 | + | |
| 49 | + /// Regenerates the last assistant response (optionally after the user | |
| 50 | + /// switched models). | |
| 51 | + func regenerate(in conversation: Conversation) { | |
| 52 | + guard !isGenerating else { return } | |
| 53 | + var conversation = conversation | |
| 54 | + guard let lastUser = conversation.messages.last(where: { $0.role == .user }) else { return } | |
| 55 | + // Drop trailing assistant message(s) after the last user turn. | |
| 56 | + while let last = conversation.messages.last, last.role == .assistant { | |
| 57 | + conversation.messages.removeLast() | |
| 58 | + } | |
| 59 | + app?.update(conversation) | |
| 60 | + stream(prompt: lastUser.content, conversation: conversation, replayingLastUser: true) | |
| 61 | + } | |
| 62 | + | |
| 63 | + /// Edits a previous user message and resends from that point. | |
| 64 | + func editAndResend(messageID: UUID, newText: String, in conversation: Conversation) { | |
| 65 | + guard !isGenerating else { return } | |
| 66 | + var conversation = conversation | |
| 67 | + guard let index = conversation.messages.firstIndex(where: { $0.id == messageID }) else { return } | |
| 68 | + conversation.messages[index].content = newText | |
| 69 | + conversation.messages.removeSubrange((index + 1)...) | |
| 70 | + app?.update(conversation) | |
| 71 | + stream(prompt: newText, conversation: conversation, replayingLastUser: true) | |
| 72 | + } | |
| 73 | + | |
| 74 | + func stop() { | |
| 75 | + generationTask?.cancel() | |
| 76 | + Task { await app?.engine.stopGeneration() } | |
| 77 | + } | |
| 78 | + | |
| 79 | + // MARK: - Streaming core | |
| 80 | + | |
| 81 | + /// `replayingLastUser`: the prompt is already the last user message in | |
| 82 | + /// `conversation.messages`; the engine session must be rebuilt so its | |
| 83 | + /// history excludes it (it is re-sent as the new turn). | |
| 84 | + private func stream(prompt: String, conversation: Conversation, replayingLastUser: Bool = false) { | |
| 85 | + guard let app else { return } | |
| 86 | + let conversationID = conversation.id | |
| 87 | + | |
| 88 | + streamingText = "" | |
| 89 | + streamingThinking = "" | |
| 90 | + isThinking = false | |
| 91 | + liveTokensPerSecond = 0 | |
| 92 | + isGenerating = true | |
| 93 | + streamingConversationID = conversationID | |
| 94 | + | |
| 95 | + generationTask = Task { | |
| 96 | + var parser = ThinkTagParser() | |
| 97 | + var stats: GenerationStats? | |
| 98 | + var finish: GenerationFinishReason = .stop | |
| 99 | + let started = Date() | |
| 100 | + var tokenCount = 0 | |
| 101 | + var lastTick = Date.distantPast | |
| 102 | + | |
| 103 | + do { | |
| 104 | + // The engine session's history must exclude the new prompt: | |
| 105 | + // strip the trailing user message before (re)building. | |
| 106 | + var sessionConversation = conversation | |
| 107 | + if let last = sessionConversation.messages.last, last.role == .user { | |
| 108 | + sessionConversation.messages.removeLast() | |
| 109 | + } | |
| 110 | + if replayingLastUser { | |
| 111 | + try await app.engine.startSession(conversation: sessionConversation) | |
| 112 | + } else { | |
| 113 | + try await app.engine.ensureSession(conversation: sessionConversation) | |
| 114 | + } | |
| 115 | + | |
| 116 | + let events = try await app.engine.generate(prompt: prompt, params: conversation.params) | |
| 117 | + for try await event in events { | |
| 118 | + switch event { | |
| 119 | + case .token(let text): | |
| 120 | + tokenCount += 1 | |
| 121 | + let (visible, thinking, inThink) = parser.consume(text) | |
| 122 | + if !visible.isEmpty { streamingText += visible } | |
| 123 | + if !thinking.isEmpty { streamingThinking += thinking } | |
| 124 | + isThinking = inThink | |
| 125 | + // ≤4 Hz ticker to avoid flicker. | |
| 126 | + let now = Date() | |
| 127 | + if now.timeIntervalSince(lastTick) >= 0.25 { | |
| 128 | + lastTick = now | |
| 129 | + let elapsed = now.timeIntervalSince(started) | |
| 130 | + if elapsed > 0.5 { | |
| 131 | + liveTokensPerSecond = Double(tokenCount) / elapsed | |
| 132 | + } | |
| 133 | + } | |
| 134 | + case .stats(let s): | |
| 135 | + stats = s | |
| 136 | + case .finished(let reason): | |
| 137 | + finish = reason | |
| 138 | + } | |
| 139 | + } | |
| 140 | + } catch { | |
| 141 | + app.lastError = error.localizedDescription | |
| 142 | + } | |
| 143 | + finalize(conversationID: conversationID, stats: stats, finish: finish) | |
| 144 | + } | |
| 145 | + } | |
| 146 | + | |
| 147 | + private func finalize(conversationID: UUID, stats: GenerationStats?, finish: GenerationFinishReason) { | |
| 148 | + defer { | |
| 149 | + isGenerating = false | |
| 150 | + streamingConversationID = nil | |
| 151 | + streamingText = "" | |
| 152 | + streamingThinking = "" | |
| 153 | + isThinking = false | |
| 154 | + liveTokensPerSecond = 0 | |
| 155 | + generationTask = nil | |
| 156 | + } | |
| 157 | + guard let app, var conversation = app.conversations.first(where: { $0.id == conversationID }) | |
| 158 | + else { return } | |
| 159 | + let content = streamingText.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 160 | + let thinking = streamingThinking.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 161 | + guard !content.isEmpty || !thinking.isEmpty else { return } | |
| 162 | + let message = Message( | |
| 163 | + role: .assistant, | |
| 164 | + content: content, | |
| 165 | + thinking: thinking.isEmpty ? nil : thinking, | |
| 166 | + stats: stats.map { | |
| 167 | + MessageStats( | |
| 168 | + timeToFirstToken: $0.timeToFirstToken, | |
| 169 | + tokensPerSecond: $0.tokensPerSecond, | |
| 170 | + promptTokenCount: $0.promptTokenCount, | |
| 171 | + generationTokenCount: $0.generationTokenCount, | |
| 172 | + peakMemoryBytes: $0.peakMemoryBytes | |
| 173 | + ) | |
| 174 | + } | |
| 175 | + ) | |
| 176 | + conversation.messages.append(message) | |
| 177 | + app.update(conversation) | |
| 178 | + _ = finish // reason currently not surfaced beyond stats | |
| 179 | + | |
| 180 | + if conversation.title == "New Chat" { | |
| 181 | + autoTitle(conversation: conversation) | |
| 182 | + } | |
| 183 | + } | |
| 184 | + | |
| 185 | + /// Short, cheap title generation after the first exchange, using the | |
| 186 | + /// loaded model itself. Falls back to a truncated first prompt. | |
| 187 | + private func autoTitle(conversation: Conversation) { | |
| 188 | + guard let app else { return } | |
| 189 | + guard let firstUser = conversation.messages.first(where: { $0.role == .user }) else { return } | |
| 190 | + let fallback = String(firstUser.content.prefix(48)) | |
| 191 | + | |
| 192 | + Task { | |
| 193 | + var title = fallback | |
| 194 | + do { | |
| 195 | + let prompt = """ | |
| 196 | + Reply with a title of at most 5 words for a conversation that starts with \ | |
| 197 | + this message, and nothing else — no quotes, no punctuation at the end: | |
| 198 | + \(String(firstUser.content.prefix(500))) | |
| 199 | + """ | |
| 200 | + let temp = Conversation(params: GenerationParams(temperature: 0.1, maxTokens: 24)) | |
| 201 | + try await app.engine.startSession(conversation: temp) | |
| 202 | + var generated = "" | |
| 203 | + let events = try await app.engine.generate(prompt: prompt, params: temp.params) | |
| 204 | + for try await event in events { | |
| 205 | + if case .token(let t) = event { generated += t } | |
| 206 | + } | |
| 207 | + var parser = ThinkTagParser() | |
| 208 | + let (visible, _, _) = parser.consume(generated) | |
| 209 | + let cleaned = visible | |
| 210 | + .trimmingCharacters(in: .whitespacesAndNewlines) | |
| 211 | + .trimmingCharacters(in: CharacterSet(charactersIn: "\"'.“”")) | |
| 212 | + if !cleaned.isEmpty { title = String(cleaned.prefix(60)) } | |
| 213 | + // Rebind the engine session to the real conversation. | |
| 214 | + try? await app.engine.startSession(conversation: conversation) | |
| 215 | + } catch { | |
| 216 | + // Fallback title already set. | |
| 217 | + } | |
| 218 | + if var c = app.conversations.first(where: { $0.id == conversation.id }) { | |
| 219 | + c.title = title | |
| 220 | + app.update(c, touch: false) | |
| 221 | + } | |
| 222 | + } | |
| 223 | + } | |
| 224 | +} | |
| 225 | + | |
| 226 | +/// Incremental parser splitting a token stream into visible text and | |
| 227 | +/// `<think>…</think>` content, robust to tags split across chunks. | |
| 228 | +struct ThinkTagParser { | |
| 229 | + private var inThink = false | |
| 230 | + private var pending = "" | |
| 231 | + | |
| 232 | + /// Returns (visibleDelta, thinkingDelta, isInsideThink). | |
| 233 | + mutating func consume(_ chunk: String) -> (String, String, Bool) { | |
| 234 | + pending += chunk | |
| 235 | + var visible = "" | |
| 236 | + var thinking = "" | |
| 237 | + | |
| 238 | + while true { | |
| 239 | + if inThink { | |
| 240 | + if let range = pending.range(of: "</think>") { | |
| 241 | + thinking += pending[..<range.lowerBound] | |
| 242 | + pending = String(pending[range.upperBound...]) | |
| 243 | + inThink = false | |
| 244 | + } else { | |
| 245 | + // Keep a possible partial closing tag in the buffer. | |
| 246 | + let safe = safeEmitLength(of: pending, partial: "</think>") | |
| 247 | + thinking += pending.prefix(safe) | |
| 248 | + pending = String(pending.dropFirst(safe)) | |
| 249 | + break | |
| 250 | + } | |
| 251 | + } else { | |
| 252 | + if let range = pending.range(of: "<think>") { | |
| 253 | + visible += pending[..<range.lowerBound] | |
| 254 | + pending = String(pending[range.upperBound...]) | |
| 255 | + inThink = true | |
| 256 | + } else { | |
| 257 | + let safe = safeEmitLength(of: pending, partial: "<think>") | |
| 258 | + visible += pending.prefix(safe) | |
| 259 | + pending = String(pending.dropFirst(safe)) | |
| 260 | + break | |
| 261 | + } | |
| 262 | + } | |
| 263 | + } | |
| 264 | + return (visible, thinking, inThink) | |
| 265 | + } | |
| 266 | + | |
| 267 | + /// Length of `text` that can be emitted without cutting a partial `tag` | |
| 268 | + /// suffix that might complete in the next chunk. | |
| 269 | + private func safeEmitLength(of text: String, partial tag: String) -> Int { | |
| 270 | + let maxKeep = min(tag.count - 1, text.count) | |
| 271 | + for keep in stride(from: maxKeep, through: 1, by: -1) { | |
| 272 | + if text.hasSuffix(String(tag.prefix(keep))) { | |
| 273 | + return text.count - keep | |
| 274 | + } | |
| 275 | + } | |
| 276 | + return text.count | |
| 277 | + } | |
| 278 | +} | |
added
Sources/ZyquoLocal/Views/Chat/ChatHeaderView.swift
+241 −0
@@ -0,0 +1,241 @@ | ||
| 1 | +// | |
| 2 | +// ChatHeaderView.swift | |
| 3 | +// Zyquo Local | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import SwiftUI | |
| 10 | + | |
| 11 | +/// 52 pt header: editable title · centered model chip · perf toggle, export, | |
| 12 | +/// info popover (system prompt, params, context usage bar). | |
| 13 | +struct ChatHeaderView: View { | |
| 14 | + let conversation: Conversation | |
| 15 | + @Environment(AppModel.self) private var app | |
| 16 | + @State private var title = "" | |
| 17 | + @State private var showInfo = false | |
| 18 | + @State private var showPerf = true | |
| 19 | + @State private var contextUsage: (used: Int, window: Int)? | |
| 20 | + | |
| 21 | + var body: some View { | |
| 22 | + HStack(spacing: ZyquoTheme.Spacing.s) { | |
| 23 | + // Editable title (left) | |
| 24 | + TextField( | |
| 25 | + "Title", text: $title, | |
| 26 | + onCommit: { | |
| 27 | + var c = conversation | |
| 28 | + c.title = title.isEmpty ? conversation.title : title | |
| 29 | + app.update(c, touch: false) | |
| 30 | + } | |
| 31 | + ) | |
| 32 | + .textFieldStyle(.plain) | |
| 33 | + .font(ZyquoTheme.bodyEmphasis) | |
| 34 | + .foregroundStyle(ZyquoTheme.textPrimary) | |
| 35 | + .frame(maxWidth: 220, alignment: .leading) | |
| 36 | + .onAppear { title = conversation.title } | |
| 37 | + .onChange(of: conversation.id) { title = conversation.title } | |
| 38 | + .onChange(of: conversation.title) { title = conversation.title } | |
| 39 | + | |
| 40 | + Spacer() | |
| 41 | + | |
| 42 | + ModelChipView(conversation: conversation) | |
| 43 | + | |
| 44 | + Spacer() | |
| 45 | + | |
| 46 | + // Live perf while generating | |
| 47 | + if showPerf, app.chat.isGenerating, app.chat.liveTokensPerSecond > 0 { | |
| 48 | + Text(String(format: "%.1f tok/s", app.chat.liveTokensPerSecond)) | |
| 49 | + .font(ZyquoTheme.caption.monospacedDigit()) | |
| 50 | + .foregroundStyle(ZyquoTheme.accent) | |
| 51 | + .transition(.opacity) | |
| 52 | + } | |
| 53 | + | |
| 54 | + Button { | |
| 55 | + showPerf.toggle() | |
| 56 | + } label: { | |
| 57 | + Image(systemName: "gauge.with.dots.needle.67percent") | |
| 58 | + .foregroundStyle(showPerf ? ZyquoTheme.accent : ZyquoTheme.textSecondary) | |
| 59 | + } | |
| 60 | + .buttonStyle(.plain) | |
| 61 | + .help("Show live tokens/sec while generating") | |
| 62 | + | |
| 63 | + Menu { | |
| 64 | + Button("Export as Markdown…") { ExportService.exportMarkdown(conversation) } | |
| 65 | + Button("Export as PDF…") { ExportService.exportPDF(conversation) } | |
| 66 | + } label: { | |
| 67 | + Image(systemName: "square.and.arrow.up") | |
| 68 | + .foregroundStyle(ZyquoTheme.textSecondary) | |
| 69 | + } | |
| 70 | + .menuStyle(.borderlessButton) | |
| 71 | + .menuIndicator(.hidden) | |
| 72 | + .frame(width: 24) | |
| 73 | + .help("Export conversation (⇧⌘E)") | |
| 74 | + | |
| 75 | + Button { | |
| 76 | + Task { contextUsage = await app.engine.contextUsage } | |
| 77 | + showInfo.toggle() | |
| 78 | + } label: { | |
| 79 | + Image(systemName: "info.circle") | |
| 80 | + .foregroundStyle(ZyquoTheme.textSecondary) | |
| 81 | + } | |
| 82 | + .buttonStyle(.plain) | |
| 83 | + .popover(isPresented: $showInfo, arrowEdge: .bottom) { | |
| 84 | + ConversationInfoPopover(conversation: conversation, contextUsage: contextUsage) | |
| 85 | + } | |
| 86 | + .help("Conversation settings") | |
| 87 | + } | |
| 88 | + .padding(.horizontal, ZyquoTheme.Spacing.m) | |
| 89 | + .frame(height: ZyquoTheme.chatHeaderHeight) | |
| 90 | + .background(ZyquoTheme.background) | |
| 91 | + .onReceive(NotificationCenter.default.publisher(for: .zyquoExportConversation)) { _ in | |
| 92 | + ExportService.exportMarkdown(conversation) | |
| 93 | + } | |
| 94 | + } | |
| 95 | +} | |
| 96 | + | |
| 97 | +/// Info popover: system prompt, per-conversation params, context usage bar. | |
| 98 | +struct ConversationInfoPopover: View { | |
| 99 | + let conversation: Conversation | |
| 100 | + let contextUsage: (used: Int, window: Int)? | |
| 101 | + @Environment(AppModel.self) private var app | |
| 102 | + @State private var systemPrompt = "" | |
| 103 | + | |
| 104 | + var body: some View { | |
| 105 | + VStack(alignment: .leading, spacing: ZyquoTheme.Spacing.m) { | |
| 106 | + Text("Conversation") | |
| 107 | + .font(ZyquoTheme.bodyEmphasis) | |
| 108 | + | |
| 109 | + VStack(alignment: .leading, spacing: ZyquoTheme.Spacing.xxs) { | |
| 110 | + Text("System prompt") | |
| 111 | + .font(ZyquoTheme.caption) | |
| 112 | + .foregroundStyle(ZyquoTheme.textSecondary) | |
| 113 | + TextEditor(text: $systemPrompt) | |
| 114 | + .font(ZyquoTheme.body) | |
| 115 | + .frame(height: 72) | |
| 116 | + .scrollContentBackground(.hidden) | |
| 117 | + .padding(ZyquoTheme.Spacing.xxs) | |
| 118 | + .background(ZyquoTheme.surfaceSecondary, in: RoundedRectangle(cornerRadius: ZyquoTheme.Radius.s)) | |
| 119 | + .onChange(of: systemPrompt) { | |
| 120 | + var c = conversation | |
| 121 | + c.systemPrompt = systemPrompt.isEmpty ? nil : systemPrompt | |
| 122 | + app.update(c, touch: false) | |
| 123 | + } | |
| 124 | + } | |
| 125 | + | |
| 126 | + ParamsEditor( | |
| 127 | + params: Binding( | |
| 128 | + get: { app.selectedConversation?.params ?? conversation.params }, | |
| 129 | + set: { newParams in | |
| 130 | + var c = app.selectedConversation ?? conversation | |
| 131 | + c.params = newParams | |
| 132 | + app.update(c, touch: false) | |
| 133 | + } | |
| 134 | + ) | |
| 135 | + ) | |
| 136 | + | |
| 137 | + if let usage = contextUsage, usage.window > 0 { | |
| 138 | + VStack(alignment: .leading, spacing: ZyquoTheme.Spacing.xxs) { | |
| 139 | + HStack { | |
| 140 | + Text("Context") | |
| 141 | + .font(ZyquoTheme.caption) | |
| 142 | + .foregroundStyle(ZyquoTheme.textSecondary) | |
| 143 | + Spacer() | |
| 144 | + Text("≈\(usage.used) / \(usage.window) tokens") | |
| 145 | + .font(ZyquoTheme.caption.monospacedDigit()) | |
| 146 | + .foregroundStyle(ZyquoTheme.textSecondary) | |
| 147 | + } | |
| 148 | + ProgressView(value: min(1, Double(usage.used) / Double(usage.window))) | |
| 149 | + .tint(usage.used > usage.window * 3 / 4 ? ZyquoTheme.warning : ZyquoTheme.accent) | |
| 150 | + } | |
| 151 | + } | |
| 152 | + } | |
| 153 | + .padding(ZyquoTheme.Spacing.m) | |
| 154 | + .frame(width: 340) | |
| 155 | + .onAppear { systemPrompt = conversation.systemPrompt ?? "" } | |
| 156 | + } | |
| 157 | +} | |
| 158 | + | |
| 159 | +/// Inline editors for the generation parameters, with plain-language help. | |
| 160 | +struct ParamsEditor: View { | |
| 161 | + @Binding var params: GenerationParams | |
| 162 | + | |
| 163 | + var body: some View { | |
| 164 | + VStack(alignment: .leading, spacing: ZyquoTheme.Spacing.xs) { | |
| 165 | + slider( | |
| 166 | + "Temperature", value: Binding( | |
| 167 | + get: { Double(params.temperature) }, | |
| 168 | + set: { params.temperature = Float($0) }), | |
| 169 | + range: 0...2, format: "%.2f", | |
| 170 | + help: "Higher = more creative, lower = more focused") | |
| 171 | + slider( | |
| 172 | + "Top-p", value: Binding( | |
| 173 | + get: { Double(params.topP) }, | |
| 174 | + set: { params.topP = Float($0) }), | |
| 175 | + range: 0.05...1, format: "%.2f", | |
| 176 | + help: "Nucleus sampling probability mass") | |
| 177 | + slider( | |
| 178 | + "Repetition penalty", value: Binding( | |
| 179 | + get: { Double(params.repetitionPenalty ?? 1.0) }, | |
| 180 | + set: { params.repetitionPenalty = $0 <= 1.001 ? nil : Float($0) }), | |
| 181 | + range: 1...1.5, format: "%.2f", | |
| 182 | + help: "Discourages repeating recent tokens (1 = off)") | |
| 183 | + HStack { | |
| 184 | + Text("Max tokens") | |
| 185 | + .font(ZyquoTheme.caption) | |
| 186 | + .foregroundStyle(ZyquoTheme.textSecondary) | |
| 187 | + Spacer() | |
| 188 | + TextField( | |
| 189 | + "∞", | |
| 190 | + text: Binding( | |
| 191 | + get: { params.maxTokens.map(String.init) ?? "" }, | |
| 192 | + set: { params.maxTokens = Int($0).flatMap { $0 > 0 ? $0 : nil } } | |
| 193 | + ) | |
| 194 | + ) | |
| 195 | + .textFieldStyle(.roundedBorder) | |
| 196 | + .frame(width: 80) | |
| 197 | + .multilineTextAlignment(.trailing) | |
| 198 | + .font(ZyquoTheme.caption.monospacedDigit()) | |
| 199 | + } | |
| 200 | + HStack { | |
| 201 | + Text("Seed") | |
| 202 | + .font(ZyquoTheme.caption) | |
| 203 | + .foregroundStyle(ZyquoTheme.textSecondary) | |
| 204 | + Spacer() | |
| 205 | + TextField( | |
| 206 | + "random", | |
| 207 | + text: Binding( | |
| 208 | + get: { params.seed.map(String.init) ?? "" }, | |
| 209 | + set: { params.seed = UInt64($0) } | |
| 210 | + ) | |
| 211 | + ) | |
| 212 | + .textFieldStyle(.roundedBorder) | |
| 213 | + .frame(width: 110) | |
| 214 | + .multilineTextAlignment(.trailing) | |
| 215 | + .font(ZyquoTheme.caption.monospacedDigit()) | |
| 216 | + } | |
| 217 | + } | |
| 218 | + } | |
| 219 | + | |
| 220 | + @ViewBuilder | |
| 221 | + private func slider( | |
| 222 | + _ label: String, value: Binding<Double>, range: ClosedRange<Double>, | |
| 223 | + format: String, help: String | |
| 224 | + ) -> some View { | |
| 225 | + VStack(alignment: .leading, spacing: 2) { | |
| 226 | + HStack { | |
| 227 | + Text(label) | |
| 228 | + .font(ZyquoTheme.caption) | |
| 229 | + .foregroundStyle(ZyquoTheme.textSecondary) | |
| 230 | + Spacer() | |
| 231 | + Text(String(format: format, value.wrappedValue)) | |
| 232 | + .font(ZyquoTheme.caption.monospacedDigit()) | |
| 233 | + .foregroundStyle(ZyquoTheme.textPrimary) | |
| 234 | + } | |
| 235 | + Slider(value: value, in: range) | |
| 236 | + .controlSize(.mini) | |
| 237 | + .tint(ZyquoTheme.accent) | |
| 238 | + } | |
| 239 | + .help(help) | |
| 240 | + } | |
| 241 | +} | |
added
Sources/ZyquoLocal/Views/Chat/ChatView.swift
+86 −0
@@ -0,0 +1,86 @@ | ||
| 1 | +// | |
| 2 | +// ChatView.swift | |
| 3 | +// Zyquo Local | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import SwiftUI | |
| 10 | + | |
| 11 | +/// The chat detail column: 52 pt header, centered transcript, floating input. | |
| 12 | +struct ChatView: View { | |
| 13 | + @Environment(AppModel.self) private var app | |
| 14 | + | |
| 15 | + var body: some View { | |
| 16 | + Group { | |
| 17 | + if app.store.models.isEmpty && app.downloads.tasks.isEmpty { | |
| 18 | + OnboardingHeroView() | |
| 19 | + } else if let conversation = app.selectedConversation { | |
| 20 | + chatBody(conversation) | |
| 21 | + } else { | |
| 22 | + emptySelection | |
| 23 | + } | |
| 24 | + } | |
| 25 | + .background(ZyquoTheme.background) | |
| 26 | + } | |
| 27 | + | |
| 28 | + @ViewBuilder | |
| 29 | + private func chatBody(_ conversation: Conversation) -> some View { | |
| 30 | + VStack(spacing: 0) { | |
| 31 | + ChatHeaderView(conversation: conversation) | |
| 32 | + Rectangle().fill(ZyquoTheme.border).frame(height: ZyquoTheme.hairline) | |
| 33 | + | |
| 34 | + ZStack { | |
| 35 | + TranscriptView(conversation: conversation) | |
| 36 | + if case .loading(let repoID) = app.engineState { | |
| 37 | + ModelLoadingCard(repoID: repoID) | |
| 38 | + } | |
| 39 | + } | |
| 40 | + | |
| 41 | + InputBar(conversation: conversation) | |
| 42 | + } | |
| 43 | + } | |
| 44 | + | |
| 45 | + private var emptySelection: some View { | |
| 46 | + VStack(spacing: ZyquoTheme.Spacing.s) { | |
| 47 | + ZyquoGlyph(size: 40, color: ZyquoTheme.textTertiary) | |
| 48 | + Text("Select a chat or press ⌘N") | |
| 49 | + .font(ZyquoTheme.body) | |
| 50 | + .foregroundStyle(ZyquoTheme.textSecondary) | |
| 51 | + } | |
| 52 | + .frame(maxWidth: .infinity, maxHeight: .infinity) | |
| 53 | + } | |
| 54 | +} | |
| 55 | + | |
| 56 | +/// Elegant centered card while a model loads; input is disabled meanwhile. | |
| 57 | +struct ModelLoadingCard: View { | |
| 58 | + let repoID: String | |
| 59 | + @State private var appeared = false | |
| 60 | + | |
| 61 | + var body: some View { | |
| 62 | + VStack(spacing: ZyquoTheme.Spacing.m) { | |
| 63 | + ProgressView() | |
| 64 | + .controlSize(.large) | |
| 65 | + .tint(ZyquoTheme.accent) | |
| 66 | + Text("Loading \(shortModelName(repoID))") | |
| 67 | + .font(ZyquoTheme.bodyEmphasis) | |
| 68 | + .foregroundStyle(ZyquoTheme.textPrimary) | |
| 69 | + Text("Allocating unified memory for the model weights…") | |
| 70 | + .font(ZyquoTheme.caption) | |
| 71 | + .foregroundStyle(ZyquoTheme.textSecondary) | |
| 72 | + } | |
| 73 | + .padding(ZyquoTheme.Spacing.xxl) | |
| 74 | + .background(ZyquoTheme.surface, in: RoundedRectangle(cornerRadius: ZyquoTheme.Radius.l)) | |
| 75 | + .overlay( | |
| 76 | + RoundedRectangle(cornerRadius: ZyquoTheme.Radius.l) | |
| 77 | + .stroke(ZyquoTheme.border, lineWidth: ZyquoTheme.hairline) | |
| 78 | + ) | |
| 79 | + .floatingShadow() | |
| 80 | + .opacity(appeared ? 1 : 0) | |
| 81 | + .offset(y: appeared ? 0 : 6) | |
| 82 | + .onAppear { | |
| 83 | + withAnimation(.easeOut(duration: 0.15)) { appeared = true } | |
| 84 | + } | |
| 85 | + } | |
| 86 | +} | |
added
Sources/ZyquoLocal/Views/Chat/InputBar.swift
+314 −0
@@ -0,0 +1,314 @@ | ||
| 1 | +// | |
| 2 | +// InputBar.swift | |
| 3 | +// Zyquo Local | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import SwiftUI | |
| 10 | +import UniformTypeIdentifiers | |
| 11 | + | |
| 12 | +/// Floating input card (radius 14, soft shadow): attach text files, prompt | |
| 13 | +/// templates, params quick-toggle, circular emerald send (⌘↩), stop while | |
| 14 | +/// generating. Supports drag & drop of text files. | |
| 15 | +struct InputBar: View { | |
| 16 | + let conversation: Conversation | |
| 17 | + @Environment(AppModel.self) private var app | |
| 18 | + @State private var text = "" | |
| 19 | + @State private var showParams = false | |
| 20 | + @State private var showTemplates = false | |
| 21 | + @State private var dropTargeted = false | |
| 22 | + @FocusState private var focused: Bool | |
| 23 | + | |
| 24 | + private static let allowedTypes: [UTType] = [.plainText, .utf8PlainText, .sourceCode, .json, .commaSeparatedText, .fileURL] | |
| 25 | + private static let allowedExtensions: Set<String> = [ | |
| 26 | + "txt", "md", "markdown", "swift", "py", "js", "ts", "html", "css", "c", | |
| 27 | + "cpp", "h", "m", "rs", "go", "java", "kt", "rb", "sh", "yaml", "yml", | |
| 28 | + "toml", "json", "csv", "xml", "sql", "log", | |
| 29 | + ] | |
| 30 | + | |
| 31 | + private var modelReady: Bool { | |
| 32 | + if case .ready = app.engineState { return true } | |
| 33 | + if case .generating = app.engineState { return true } | |
| 34 | + return false | |
| 35 | + } | |
| 36 | + | |
| 37 | + var body: some View { | |
| 38 | + VStack(spacing: 0) { | |
| 39 | + HStack(alignment: .bottom, spacing: ZyquoTheme.Spacing.xs) { | |
| 40 | + attachButton | |
| 41 | + templatesButton | |
| 42 | + | |
| 43 | + TextField(inputHint, text: $text, axis: .vertical) | |
| 44 | + .textFieldStyle(.plain) | |
| 45 | + .font(ZyquoTheme.chatBody) | |
| 46 | + .lineLimit(1...10) | |
| 47 | + .focused($focused) | |
| 48 | + .disabled(!modelReady && !app.store.models.isEmpty) | |
| 49 | + .onSubmit(send) | |
| 50 | + | |
| 51 | + paramsButton | |
| 52 | + sendOrStopButton | |
| 53 | + } | |
| 54 | + .padding(ZyquoTheme.Spacing.s) | |
| 55 | + .background(ZyquoTheme.surface, in: RoundedRectangle(cornerRadius: ZyquoTheme.Radius.l)) | |
| 56 | + .overlay( | |
| 57 | + RoundedRectangle(cornerRadius: ZyquoTheme.Radius.l) | |
| 58 | + .stroke( | |
| 59 | + dropTargeted ? ZyquoTheme.accent : ZyquoTheme.border, | |
| 60 | + lineWidth: dropTargeted ? 1.5 : ZyquoTheme.hairline | |
| 61 | + ) | |
| 62 | + ) | |
| 63 | + .floatingShadow() | |
| 64 | + .padding(.horizontal, ZyquoTheme.Spacing.xl) | |
| 65 | + .padding(.bottom, ZyquoTheme.Spacing.m) | |
| 66 | + .padding(.top, ZyquoTheme.Spacing.xs) | |
| 67 | + .frame(maxWidth: ZyquoTheme.messageColumnMaxWidth + ZyquoTheme.Spacing.xl * 2) | |
| 68 | + } | |
| 69 | + .onDrop(of: Self.allowedTypes, isTargeted: $dropTargeted) { providers in | |
| 70 | + handleDrop(providers) | |
| 71 | + } | |
| 72 | + .onReceive(NotificationCenter.default.publisher(for: .zyquoQuoteReply)) { note in | |
| 73 | + if let quoted = note.object as? String { | |
| 74 | + text = quoted + text | |
| 75 | + focused = true | |
| 76 | + } | |
| 77 | + } | |
| 78 | + .onChange(of: conversation.id) { focused = true } | |
| 79 | + } | |
| 80 | + | |
| 81 | + private var inputHint: String { | |
| 82 | + if app.store.models.isEmpty { return "Download a model to start chatting" } | |
| 83 | + if !modelReady { return "Load a model to start chatting" } | |
| 84 | + return "Message \(shortModelName(app.loadedModelID ?? ""))…" | |
| 85 | + } | |
| 86 | + | |
| 87 | + private var attachButton: some View { | |
| 88 | + Button { | |
| 89 | + attachFiles() | |
| 90 | + } label: { | |
| 91 | + Image(systemName: "paperclip") | |
| 92 | + .font(.system(size: 14)) | |
| 93 | + .foregroundStyle(ZyquoTheme.textSecondary) | |
| 94 | + } | |
| 95 | + .buttonStyle(.plain) | |
| 96 | + .help("Attach text files (txt, md, code, csv, json)") | |
| 97 | + } | |
| 98 | + | |
| 99 | + private var templatesButton: some View { | |
| 100 | + Button { | |
| 101 | + showTemplates.toggle() | |
| 102 | + } label: { | |
| 103 | + Image(systemName: "text.badge.star") | |
| 104 | + .font(.system(size: 14)) | |
| 105 | + .foregroundStyle(ZyquoTheme.textSecondary) | |
| 106 | + } | |
| 107 | + .buttonStyle(.plain) | |
| 108 | + .popover(isPresented: $showTemplates, arrowEdge: .top) { | |
| 109 | + PromptTemplatePicker { rendered in | |
| 110 | + text = rendered | |
| 111 | + focused = true | |
| 112 | + } | |
| 113 | + } | |
| 114 | + .help("Prompt library") | |
| 115 | + } | |
| 116 | + | |
| 117 | + private var paramsButton: some View { | |
| 118 | + Button { | |
| 119 | + showParams.toggle() | |
| 120 | + } label: { | |
| 121 | + Image(systemName: "slider.horizontal.3") | |
| 122 | + .font(.system(size: 14)) | |
| 123 | + .foregroundStyle(ZyquoTheme.textSecondary) | |
| 124 | + } | |
| 125 | + .buttonStyle(.plain) | |
| 126 | + .popover(isPresented: $showParams, arrowEdge: .top) { | |
| 127 | + ParamsEditor( | |
| 128 | + params: Binding( | |
| 129 | + get: { app.selectedConversation?.params ?? conversation.params }, | |
| 130 | + set: { newParams in | |
| 131 | + var c = app.selectedConversation ?? conversation | |
| 132 | + c.params = newParams | |
| 133 | + app.update(c, touch: false) | |
| 134 | + } | |
| 135 | + ) | |
| 136 | + ) | |
| 137 | + .padding(ZyquoTheme.Spacing.m) | |
| 138 | + .frame(width: 300) | |
| 139 | + } | |
| 140 | + .help("Generation parameters") | |
| 141 | + } | |
| 142 | + | |
| 143 | + @ViewBuilder | |
| 144 | + private var sendOrStopButton: some View { | |
| 145 | + if app.chat.isGenerating { | |
| 146 | + Button { | |
| 147 | + app.chat.stop() | |
| 148 | + } label: { | |
| 149 | + Image(systemName: "stop.fill") | |
| 150 | + .font(.system(size: 12, weight: .bold)) | |
| 151 | + .foregroundStyle(.white) | |
| 152 | + .frame(width: 28, height: 28) | |
| 153 | + .background(ZyquoTheme.danger, in: Circle()) | |
| 154 | + } | |
| 155 | + .buttonStyle(PressableButtonStyle()) | |
| 156 | + .help("Stop generating") | |
| 157 | + } else { | |
| 158 | + Button(action: send) { | |
| 159 | + Image(systemName: "arrow.up") | |
| 160 | + .font(.system(size: 13, weight: .bold)) | |
| 161 | + .foregroundStyle(.white) | |
| 162 | + .frame(width: 28, height: 28) | |
| 163 | + .background(canSend ? ZyquoTheme.accent : ZyquoTheme.textTertiary, in: Circle()) | |
| 164 | + } | |
| 165 | + .buttonStyle(PressableButtonStyle()) | |
| 166 | + .keyboardShortcut(.return, modifiers: .command) | |
| 167 | + .disabled(!canSend) | |
| 168 | + .help("Send (⌘↩)") | |
| 169 | + } | |
| 170 | + } | |
| 171 | + | |
| 172 | + private var canSend: Bool { | |
| 173 | + modelReady && !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty | |
| 174 | + && !app.chat.isGenerating | |
| 175 | + } | |
| 176 | + | |
| 177 | + private func send() { | |
| 178 | + guard canSend else { return } | |
| 179 | + let prompt = text.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 180 | + text = "" | |
| 181 | + app.chat.send(prompt: prompt, in: conversation) | |
| 182 | + } | |
| 183 | + | |
| 184 | + // MARK: - Attachments | |
| 185 | + | |
| 186 | + private func attachFiles() { | |
| 187 | + let panel = NSOpenPanel() | |
| 188 | + panel.allowsMultipleSelection = true | |
| 189 | + panel.canChooseDirectories = false | |
| 190 | + panel.allowedContentTypes = [.plainText, .sourceCode, .json, .commaSeparatedText, .text] | |
| 191 | + if panel.runModal() == .OK { | |
| 192 | + for url in panel.urls { inject(fileURL: url) } | |
| 193 | + } | |
| 194 | + } | |
| 195 | + | |
| 196 | + private func handleDrop(_ providers: [NSItemProvider]) -> Bool { | |
| 197 | + var handled = false | |
| 198 | + for provider in providers where provider.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) { | |
| 199 | + handled = true | |
| 200 | + _ = provider.loadObject(ofClass: URL.self) { url, _ in | |
| 201 | + if let url { | |
| 202 | + Task { @MainActor in inject(fileURL: url) } | |
| 203 | + } | |
| 204 | + } | |
| 205 | + } | |
| 206 | + return handled | |
| 207 | + } | |
| 208 | + | |
| 209 | + /// Injects a text file's content into the draft, fenced and labeled. | |
| 210 | + private func inject(fileURL: URL) { | |
| 211 | + let ext = fileURL.pathExtension.lowercased() | |
| 212 | + guard Self.allowedExtensions.contains(ext) || ext.isEmpty else { | |
| 213 | + app.lastError = "\(fileURL.lastPathComponent) is not a supported text file." | |
| 214 | + return | |
| 215 | + } | |
| 216 | + guard let data = try? Data(contentsOf: fileURL), data.count <= 2_000_000, | |
| 217 | + let content = String(data: data, encoding: .utf8) | |
| 218 | + else { | |
| 219 | + app.lastError = "Could not read \(fileURL.lastPathComponent) as UTF-8 text (2 MB max)." | |
| 220 | + return | |
| 221 | + } | |
| 222 | + let fence = ext == "md" || ext.isEmpty ? "" : ext | |
| 223 | + text += (text.isEmpty ? "" : "\n") + "[\(fileURL.lastPathComponent)]\n```\(fence)\n\(content)\n```\n" | |
| 224 | + } | |
| 225 | +} | |
| 226 | + | |
| 227 | +/// Popover for browsing and filling prompt templates. | |
| 228 | +struct PromptTemplatePicker: View { | |
| 229 | + let onUse: (String) -> Void | |
| 230 | + @Environment(AppModel.self) private var app | |
| 231 | + @Environment(\.dismiss) private var dismiss | |
| 232 | + @State private var selected: PromptTemplate? | |
| 233 | + @State private var values: [String: String] = [:] | |
| 234 | + @State private var filter = "" | |
| 235 | + | |
| 236 | + var body: some View { | |
| 237 | + HSplitView { | |
| 238 | + list | |
| 239 | + .frame(width: 230) | |
| 240 | + detail | |
| 241 | + .frame(width: 300) | |
| 242 | + } | |
| 243 | + .frame(height: 340) | |
| 244 | + } | |
| 245 | + | |
| 246 | + private var list: some View { | |
| 247 | + VStack(spacing: 0) { | |
| 248 | + TextField("Filter templates", text: $filter) | |
| 249 | + .textFieldStyle(.roundedBorder) | |
| 250 | + .padding(ZyquoTheme.Spacing.xs) | |
| 251 | + List(selection: $selected) { | |
| 252 | + ForEach(app.promptLibrary.categories, id: \.self) { category in | |
| 253 | + let matching = app.promptLibrary.all.filter { | |
| 254 | + $0.category == category | |
| 255 | + && (filter.isEmpty || $0.name.localizedCaseInsensitiveContains(filter)) | |
| 256 | + } | |
| 257 | + if !matching.isEmpty { | |
| 258 | + Section(category) { | |
| 259 | + ForEach(matching) { template in | |
| 260 | + Text(template.name) | |
| 261 | + .font(ZyquoTheme.body) | |
| 262 | + .tag(template) | |
| 263 | + } | |
| 264 | + } | |
| 265 | + } | |
| 266 | + } | |
| 267 | + } | |
| 268 | + .listStyle(.sidebar) | |
| 269 | + } | |
| 270 | + } | |
| 271 | + | |
| 272 | + @ViewBuilder | |
| 273 | + private var detail: some View { | |
| 274 | + if let template = selected { | |
| 275 | + VStack(alignment: .leading, spacing: ZyquoTheme.Spacing.s) { | |
| 276 | + Text(template.name) | |
| 277 | + .font(ZyquoTheme.bodyEmphasis) | |
| 278 | + ScrollView { | |
| 279 | + Text(template.template) | |
| 280 | + .font(ZyquoTheme.caption) | |
| 281 | + .foregroundStyle(ZyquoTheme.textSecondary) | |
| 282 | + .frame(maxWidth: .infinity, alignment: .leading) | |
| 283 | + } | |
| 284 | + .frame(maxHeight: 110) | |
| 285 | + ForEach(template.variables, id: \.self) { variable in | |
| 286 | + TextField( | |
| 287 | + variable, | |
| 288 | + text: Binding( | |
| 289 | + get: { values[variable] ?? "" }, | |
| 290 | + set: { values[variable] = $0 } | |
| 291 | + ), | |
| 292 | + axis: .vertical | |
| 293 | + ) | |
| 294 | + .textFieldStyle(.roundedBorder) | |
| 295 | + .lineLimit(1...4) | |
| 296 | + } | |
| 297 | + Spacer() | |
| 298 | + Button("Use Template") { | |
| 299 | + onUse(template.render(values: values)) | |
| 300 | + dismiss() | |
| 301 | + } | |
| 302 | + .buttonStyle(.borderedProminent) | |
| 303 | + .tint(ZyquoTheme.accent) | |
| 304 | + .frame(maxWidth: .infinity) | |
| 305 | + } | |
| 306 | + .padding(ZyquoTheme.Spacing.m) | |
| 307 | + } else { | |
| 308 | + Text("Select a template") | |
| 309 | + .font(ZyquoTheme.body) | |
| 310 | + .foregroundStyle(ZyquoTheme.textTertiary) | |
| 311 | + .frame(maxWidth: .infinity, maxHeight: .infinity) | |
| 312 | + } | |
| 313 | + } | |
| 314 | +} | |
added
Sources/ZyquoLocal/Views/Chat/MarkdownText.swift
+310 −0
@@ -0,0 +1,310 @@ | ||
| 1 | +// | |
| 2 | +// MarkdownText.swift | |
| 3 | +// Zyquo Local | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import Markdown | |
| 10 | +import SwiftUI | |
| 11 | + | |
| 12 | +/// Full Markdown rendering: headings, lists, quotes, tables (as text), | |
| 13 | +/// inline styles via AttributedString, and syntax-highlighted code blocks | |
| 14 | +/// with a copy button. | |
| 15 | +struct MarkdownText: View { | |
| 16 | + let markdown: String | |
| 17 | + | |
| 18 | + init(_ markdown: String) { | |
| 19 | + self.markdown = markdown | |
| 20 | + } | |
| 21 | + | |
| 22 | + var body: some View { | |
| 23 | + let blocks = MarkdownBlockParser.parse(markdown) | |
| 24 | + VStack(alignment: .leading, spacing: ZyquoTheme.Spacing.xs) { | |
| 25 | + ForEach(blocks) { block in | |
| 26 | + switch block.kind { | |
| 27 | + case .paragraph(let text): | |
| 28 | + InlineMarkdown(text: text) | |
| 29 | + case .heading(let text, let level): | |
| 30 | + InlineMarkdown(text: text, font: headingFont(level)) | |
| 31 | + case .code(let code, let language): | |
| 32 | + CodeBlockView(code: code, language: language) | |
| 33 | + case .quote(let text): | |
| 34 | + HStack(alignment: .top, spacing: ZyquoTheme.Spacing.xs) { | |
| 35 | + RoundedRectangle(cornerRadius: 2) | |
| 36 | + .fill(ZyquoTheme.accent.opacity(0.5)) | |
| 37 | + .frame(width: 3) | |
| 38 | + InlineMarkdown(text: text, color: ZyquoTheme.textSecondary) | |
| 39 | + } | |
| 40 | + case .listItem(let text, let marker): | |
| 41 | + HStack(alignment: .top, spacing: ZyquoTheme.Spacing.xs) { | |
| 42 | + Text(marker) | |
| 43 | + .font(ZyquoTheme.chatBody.monospacedDigit()) | |
| 44 | + .foregroundStyle(ZyquoTheme.textSecondary) | |
| 45 | + InlineMarkdown(text: text) | |
| 46 | + } | |
| 47 | + case .rule: | |
| 48 | + Rectangle() | |
| 49 | + .fill(ZyquoTheme.border) | |
| 50 | + .frame(height: ZyquoTheme.hairline) | |
| 51 | + .padding(.vertical, ZyquoTheme.Spacing.xxs) | |
| 52 | + } | |
| 53 | + } | |
| 54 | + } | |
| 55 | + } | |
| 56 | + | |
| 57 | + private func headingFont(_ level: Int) -> Font { | |
| 58 | + switch level { | |
| 59 | + case 1: .system(size: ZyquoTheme.chatFontSize + 6, weight: .semibold) | |
| 60 | + case 2: .system(size: ZyquoTheme.chatFontSize + 4, weight: .semibold) | |
| 61 | + default: .system(size: ZyquoTheme.chatFontSize + 2, weight: .semibold) | |
| 62 | + } | |
| 63 | + } | |
| 64 | +} | |
| 65 | + | |
| 66 | +/// Inline markdown (bold/italic/code/links) via AttributedString. | |
| 67 | +private struct InlineMarkdown: View { | |
| 68 | + let text: String | |
| 69 | + var font: Font? | |
| 70 | + var color: Color = ZyquoTheme.textPrimary | |
| 71 | + | |
| 72 | + var body: some View { | |
| 73 | + Text(attributed) | |
| 74 | + .font(font ?? ZyquoTheme.chatBody) | |
| 75 | + .foregroundStyle(color) | |
| 76 | + .lineSpacing(ZyquoTheme.lineSpacing(fontSize: ZyquoTheme.chatFontSize) * 0.55) | |
| 77 | + .tint(ZyquoTheme.accent) | |
| 78 | + .frame(maxWidth: .infinity, alignment: .leading) | |
| 79 | + } | |
| 80 | + | |
| 81 | + private var attributed: AttributedString { | |
| 82 | + (try? AttributedString( | |
| 83 | + markdown: text, | |
| 84 | + options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace) | |
| 85 | + )) ?? AttributedString(text) | |
| 86 | + } | |
| 87 | +} | |
| 88 | + | |
| 89 | +/// Code block: language label, copy button, lightweight syntax highlighting. | |
| 90 | +struct CodeBlockView: View { | |
| 91 | + let code: String | |
| 92 | + let language: String? | |
| 93 | + @State private var copied = false | |
| 94 | + | |
| 95 | + var body: some View { | |
| 96 | + VStack(alignment: .leading, spacing: 0) { | |
| 97 | + HStack { | |
| 98 | + Text(language?.isEmpty == false ? language! : "code") | |
| 99 | + .font(ZyquoTheme.caption) | |
| 100 | + .foregroundStyle(ZyquoTheme.textTertiary) | |
| 101 | + Spacer() | |
| 102 | + Button { | |
| 103 | + NSPasteboard.general.clearContents() | |
| 104 | + NSPasteboard.general.setString(code, forType: .string) | |
| 105 | + copied = true | |
| 106 | + Task { | |
| 107 | + try? await Task.sleep(for: .seconds(1.4)) | |
| 108 | + copied = false | |
| 109 | + } | |
| 110 | + } label: { | |
| 111 | + Label(copied ? "Copied" : "Copy", systemImage: copied ? "checkmark" : "doc.on.doc") | |
| 112 | + .font(ZyquoTheme.caption) | |
| 113 | + .foregroundStyle(copied ? ZyquoTheme.success : ZyquoTheme.textSecondary) | |
| 114 | + } | |
| 115 | + .buttonStyle(.plain) | |
| 116 | + } | |
| 117 | + .padding(.horizontal, ZyquoTheme.Spacing.s) | |
| 118 | + .padding(.vertical, ZyquoTheme.Spacing.xxs) | |
| 119 | + | |
| 120 | + Rectangle().fill(ZyquoTheme.border).frame(height: ZyquoTheme.hairline) | |
| 121 | + | |
| 122 | + ScrollView(.horizontal, showsIndicators: false) { | |
| 123 | + Text(SyntaxHighlighter.highlight(code, language: language)) | |
| 124 | + .font(ZyquoTheme.chatCode) | |
| 125 | + .lineSpacing(2.5) | |
| 126 | + .textSelection(.enabled) | |
| 127 | + .padding(ZyquoTheme.Spacing.s) | |
| 128 | + } | |
| 129 | + } | |
| 130 | + .background(ZyquoTheme.surfaceSecondary, in: RoundedRectangle(cornerRadius: ZyquoTheme.Radius.s)) | |
| 131 | + .overlay( | |
| 132 | + RoundedRectangle(cornerRadius: ZyquoTheme.Radius.s) | |
| 133 | + .stroke(ZyquoTheme.border, lineWidth: ZyquoTheme.hairline) | |
| 134 | + ) | |
| 135 | + } | |
| 136 | +} | |
| 137 | + | |
| 138 | +// MARK: - Block parsing (swift-markdown → renderable blocks) | |
| 139 | + | |
| 140 | +struct MarkdownBlock: Identifiable { | |
| 141 | + enum Kind { | |
| 142 | + case paragraph(String) | |
| 143 | + case heading(String, Int) | |
| 144 | + case code(String, String?) | |
| 145 | + case quote(String) | |
| 146 | + case listItem(String, marker: String) | |
| 147 | + case rule | |
| 148 | + } | |
| 149 | + | |
| 150 | + let id: Int | |
| 151 | + let kind: Kind | |
| 152 | +} | |
| 153 | + | |
| 154 | +enum MarkdownBlockParser { | |
| 155 | + static func parse(_ text: String) -> [MarkdownBlock] { | |
| 156 | + let document = Document(parsing: text) | |
| 157 | + var blocks: [MarkdownBlock] = [] | |
| 158 | + var counter = 0 | |
| 159 | + func add(_ kind: MarkdownBlock.Kind) { | |
| 160 | + blocks.append(MarkdownBlock(id: counter, kind: kind)) | |
| 161 | + counter += 1 | |
| 162 | + } | |
| 163 | + | |
| 164 | + func visit(_ markup: Markup) { | |
| 165 | + switch markup { | |
| 166 | + case let heading as Heading: | |
| 167 | + add(.heading(heading.plainInlineText, heading.level)) | |
| 168 | + case let code as CodeBlock: | |
| 169 | + add(.code(code.code.trimmingCharacters(in: .newlines), code.language)) | |
| 170 | + case let quote as BlockQuote: | |
| 171 | + let inner = quote.children | |
| 172 | + .compactMap { ($0 as? Paragraph)?.plainInlineText } | |
| 173 | + .joined(separator: "\n") | |
| 174 | + add(.quote(inner)) | |
| 175 | + case let list as UnorderedList: | |
| 176 | + for item in list.listItems { | |
| 177 | + add(.listItem(item.inlineText, marker: "•")) | |
| 178 | + } | |
| 179 | + case let list as OrderedList: | |
| 180 | + for (i, item) in list.listItems.enumerated() { | |
| 181 | + add(.listItem(item.inlineText, marker: "\(Int(list.startIndex) + i).")) | |
| 182 | + } | |
| 183 | + case is ThematicBreak: | |
| 184 | + add(.rule) | |
| 185 | + case let paragraph as Paragraph: | |
| 186 | + add(.paragraph(paragraph.plainInlineText)) | |
| 187 | + case let table as Markdown.Table: | |
| 188 | + // Render tables as monospace text rows. | |
| 189 | + var lines: [String] = [] | |
| 190 | + let head = table.head.cells.map { $0.plainText }.joined(separator: " | ") | |
| 191 | + lines.append(head) | |
| 192 | + lines.append(String(repeating: "—", count: max(8, head.count))) | |
| 193 | + for row in table.body.rows { | |
| 194 | + lines.append(row.cells.map { $0.plainText }.joined(separator: " | ")) | |
| 195 | + } | |
| 196 | + add(.code(lines.joined(separator: "\n"), nil)) | |
| 197 | + default: | |
| 198 | + for child in markup.children { visit(child) } | |
| 199 | + } | |
| 200 | + } | |
| 201 | + for child in document.children { visit(child) } | |
| 202 | + return blocks | |
| 203 | + } | |
| 204 | +} | |
| 205 | + | |
| 206 | +extension Markup { | |
| 207 | + /// Re-serializes inline children so AttributedString can restyle them. | |
| 208 | + var plainInlineText: String { | |
| 209 | + children.compactMap { ($0 as? InlineMarkup)?.format() }.joined() | |
| 210 | + } | |
| 211 | +} | |
| 212 | + | |
| 213 | +extension ListItem { | |
| 214 | + var inlineText: String { | |
| 215 | + children | |
| 216 | + .compactMap { child -> String? in | |
| 217 | + (child as? Paragraph)?.plainInlineText | |
| 218 | + } | |
| 219 | + .joined(separator: "\n") | |
| 220 | + } | |
| 221 | +} | |
| 222 | + | |
| 223 | +extension Markdown.Table.Cell { | |
| 224 | + var plainText: String { | |
| 225 | + children.compactMap { ($0 as? InlineMarkup)?.plainText }.joined() | |
| 226 | + } | |
| 227 | +} | |
| 228 | + | |
| 229 | +// MARK: - Lightweight syntax highlighting | |
| 230 | + | |
| 231 | +enum SyntaxHighlighter { | |
| 232 | + private static let keywords: Set<String> = [ | |
| 233 | + // Swift / general | |
| 234 | + "func", "let", "var", "if", "else", "for", "while", "return", "import", | |
| 235 | + "struct", "class", "enum", "protocol", "extension", "guard", "switch", | |
| 236 | + "case", "default", "break", "continue", "in", "actor", "await", "async", | |
| 237 | + "throws", "throw", "try", "catch", "static", "private", "public", | |
| 238 | + // Python / JS / others | |
| 239 | + "def", "elif", "lambda", "None", "True", "False", "self", "pass", | |
| 240 | + "const", "function", "=>", "new", "this", "null", "undefined", | |
| 241 | + "true", "false", "nil", "typeof", "instanceof", "void", "int", | |
| 242 | + "float", "double", "bool", "string", "match", "impl", "fn", "mut", | |
| 243 | + ] | |
| 244 | + | |
| 245 | + static func highlight(_ code: String, language: String?) -> AttributedString { | |
| 246 | + var result = AttributedString() | |
| 247 | + for (index, rawLine) in code.split(separator: "\n", omittingEmptySubsequences: false).enumerated() { | |
| 248 | + if index > 0 { result += AttributedString("\n") } | |
| 249 | + result += highlightLine(String(rawLine)) | |
| 250 | + } | |
| 251 | + return result | |
| 252 | + } | |
| 253 | + | |
| 254 | + private static func highlightLine(_ line: String) -> AttributedString { | |
| 255 | + // Whole-line comments | |
| 256 | + let trimmed = line.trimmingCharacters(in: .whitespaces) | |
| 257 | + if trimmed.hasPrefix("//") || trimmed.hasPrefix("#") || trimmed.hasPrefix("--") { | |
| 258 | + var comment = AttributedString(line) | |
| 259 | + comment.foregroundColor = NSColor(hex: 0x8A948F) | |
| 260 | + return comment | |
| 261 | + } | |
| 262 | + | |
| 263 | + var result = AttributedString() | |
| 264 | + var current = "" | |
| 265 | + var inString: Character? = nil | |
| 266 | + | |
| 267 | + func flushWord(_ word: String) { | |
| 268 | + var part = AttributedString(word) | |
| 269 | + if keywords.contains(word) { | |
| 270 | + part.foregroundColor = NSColor(hex: 0x0E86C4) | |
| 271 | + } else if word.first?.isNumber == true, Double(word) != nil { | |
| 272 | + part.foregroundColor = NSColor(hex: 0xC47A0E) | |
| 273 | + } | |
| 274 | + result += part | |
| 275 | + } | |
| 276 | + | |
| 277 | + func flush() { | |
| 278 | + guard !current.isEmpty else { return } | |
| 279 | + if inString != nil { | |
| 280 | + var part = AttributedString(current) | |
| 281 | + part.foregroundColor = NSColor(hex: 0x2FA36B) | |
| 282 | + result += part | |
| 283 | + } else { | |
| 284 | + flushWord(current) | |
| 285 | + } | |
| 286 | + current = "" | |
| 287 | + } | |
| 288 | + | |
| 289 | + for char in line { | |
| 290 | + if let quote = inString { | |
| 291 | + current.append(char) | |
| 292 | + if char == quote { | |
| 293 | + flush() | |
| 294 | + inString = nil | |
| 295 | + } | |
| 296 | + } else if char == "\"" || char == "'" { | |
| 297 | + flush() | |
| 298 | + inString = char | |
| 299 | + current.append(char) | |
| 300 | + } else if char.isLetter || char.isNumber || char == "_" || char == "." { | |
| 301 | + current.append(char) | |
| 302 | + } else { | |
| 303 | + flush() | |
| 304 | + result += AttributedString(String(char)) | |
| 305 | + } | |
| 306 | + } | |
| 307 | + flush() | |
| 308 | + return result | |
| 309 | + } | |
| 310 | +} | |
added
Sources/ZyquoLocal/Views/Chat/MessageRow.swift
+184 −0
@@ -0,0 +1,184 @@ | ||
| 1 | +// | |
| 2 | +// MessageRow.swift | |
| 3 | +// Zyquo Local | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import SwiftUI | |
| 10 | + | |
| 11 | +/// One transcript message: bubble, hover timestamp + actions, thinking | |
| 12 | +/// disclosure and the stats caption line for assistant messages. | |
| 13 | +struct MessageRow: View { | |
| 14 | + let message: Message | |
| 15 | + let conversation: Conversation | |
| 16 | + @Environment(AppModel.self) private var app | |
| 17 | + @State private var hovering = false | |
| 18 | + @State private var editing = false | |
| 19 | + @State private var editText = "" | |
| 20 | + | |
| 21 | + var body: some View { | |
| 22 | + HStack(alignment: .top, spacing: 0) { | |
| 23 | + if message.role == .user { Spacer(minLength: ZyquoTheme.Spacing.xxl) } | |
| 24 | + | |
| 25 | + VStack(alignment: message.role == .user ? .trailing : .leading, spacing: ZyquoTheme.Spacing.xxs) { | |
| 26 | + bubble | |
| 27 | + captionLine | |
| 28 | + } | |
| 29 | + .frame( | |
| 30 | + maxWidth: ZyquoTheme.messageColumnMaxWidth * 0.86, | |
| 31 | + alignment: message.role == .user ? .trailing : .leading | |
| 32 | + ) | |
| 33 | + | |
| 34 | + if message.role == .assistant { Spacer(minLength: ZyquoTheme.Spacing.xxl) } | |
| 35 | + } | |
| 36 | + .onHover { hovering = $0 } | |
| 37 | + .transition(.opacity.combined(with: .offset(y: 6))) | |
| 38 | + } | |
| 39 | + | |
| 40 | + @ViewBuilder | |
| 41 | + private var bubble: some View { | |
| 42 | + VStack(alignment: .leading, spacing: ZyquoTheme.Spacing.xs) { | |
| 43 | + if let thinking = message.thinking, !thinking.isEmpty { | |
| 44 | + ThinkingDisclosure(thinking: thinking, isLive: false) | |
| 45 | + } | |
| 46 | + if editing { | |
| 47 | + editor | |
| 48 | + } else { | |
| 49 | + MarkdownText(message.content) | |
| 50 | + .textSelection(.enabled) | |
| 51 | + } | |
| 52 | + } | |
| 53 | + .padding(ZyquoTheme.Spacing.s) | |
| 54 | + .background(bubbleBackground, in: RoundedRectangle(cornerRadius: ZyquoTheme.Radius.m)) | |
| 55 | + .overlay( | |
| 56 | + RoundedRectangle(cornerRadius: ZyquoTheme.Radius.m) | |
| 57 | + .stroke(message.role == .assistant ? ZyquoTheme.border : .clear, lineWidth: ZyquoTheme.hairline) | |
| 58 | + ) | |
| 59 | + .contextMenu { actions } | |
| 60 | + } | |
| 61 | + | |
| 62 | + private var bubbleBackground: Color { | |
| 63 | + message.role == .user ? ZyquoTheme.accentSubtle : ZyquoTheme.surface | |
| 64 | + } | |
| 65 | + | |
| 66 | + private var editor: some View { | |
| 67 | + VStack(alignment: .trailing, spacing: ZyquoTheme.Spacing.xs) { | |
| 68 | + TextEditor(text: $editText) | |
| 69 | + .font(ZyquoTheme.chatBody) | |
| 70 | + .frame(minHeight: 60, maxHeight: 160) | |
| 71 | + .scrollContentBackground(.hidden) | |
| 72 | + HStack { | |
| 73 | + Button("Cancel") { editing = false } | |
| 74 | + .buttonStyle(.bordered) | |
| 75 | + Button("Resend") { | |
| 76 | + editing = false | |
| 77 | + app.chat.editAndResend(messageID: message.id, newText: editText, in: conversation) | |
| 78 | + } | |
| 79 | + .buttonStyle(.borderedProminent) | |
| 80 | + .tint(ZyquoTheme.accent) | |
| 81 | + .disabled(editText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) | |
| 82 | + } | |
| 83 | + } | |
| 84 | + .frame(width: 420) | |
| 85 | + } | |
| 86 | + | |
| 87 | + @ViewBuilder | |
| 88 | + private var actions: some View { | |
| 89 | + Button("Copy") { | |
| 90 | + NSPasteboard.general.clearContents() | |
| 91 | + NSPasteboard.general.setString(message.content, forType: .string) | |
| 92 | + } | |
| 93 | + if message.role == .user { | |
| 94 | + Button("Edit & Resend") { | |
| 95 | + editText = message.content | |
| 96 | + editing = true | |
| 97 | + } | |
| 98 | + } | |
| 99 | + if message.role == .assistant { | |
| 100 | + Button("Regenerate") { | |
| 101 | + app.chat.regenerate(in: conversation) | |
| 102 | + } | |
| 103 | + .disabled(app.chat.isGenerating) | |
| 104 | + } | |
| 105 | + Button("Quote Reply") { | |
| 106 | + let quoted = message.content | |
| 107 | + .split(separator: "\n", omittingEmptySubsequences: false) | |
| 108 | + .map { "> \($0)" } | |
| 109 | + .joined(separator: "\n") | |
| 110 | + NotificationCenter.default.post(name: .zyquoQuoteReply, object: quoted + "\n\n") | |
| 111 | + } | |
| 112 | + Divider() | |
| 113 | + Button("Delete", role: .destructive) { | |
| 114 | + var c = conversation | |
| 115 | + c.messages.removeAll { $0.id == message.id } | |
| 116 | + app.update(c, touch: false) | |
| 117 | + } | |
| 118 | + } | |
| 119 | + | |
| 120 | + @ViewBuilder | |
| 121 | + private var captionLine: some View { | |
| 122 | + HStack(spacing: ZyquoTheme.Spacing.xs) { | |
| 123 | + if message.role == .assistant, let stats = message.stats { | |
| 124 | + Text(String( | |
| 125 | + format: "⚡ %.1f tok/s · %d tokens · %.1fs to first token", | |
| 126 | + stats.tokensPerSecond, stats.generationTokenCount, stats.timeToFirstToken | |
| 127 | + )) | |
| 128 | + .font(ZyquoTheme.caption) | |
| 129 | + .foregroundStyle(ZyquoTheme.textTertiary) | |
| 130 | + } | |
| 131 | + if hovering { | |
| 132 | + Text(message.date, format: .dateTime.hour().minute()) | |
| 133 | + .font(ZyquoTheme.caption) | |
| 134 | + .foregroundStyle(ZyquoTheme.textTertiary) | |
| 135 | + .transition(.opacity) | |
| 136 | + } | |
| 137 | + } | |
| 138 | + .animation(.easeOut(duration: 0.08), value: hovering) | |
| 139 | + } | |
| 140 | +} | |
| 141 | + | |
| 142 | +/// Collapsible "Thinking…" section for reasoning models. | |
| 143 | +struct ThinkingDisclosure: View { | |
| 144 | + let thinking: String | |
| 145 | + let isLive: Bool | |
| 146 | + @State private var expanded = false | |
| 147 | + | |
| 148 | + var body: some View { | |
| 149 | + VStack(alignment: .leading, spacing: ZyquoTheme.Spacing.xxs) { | |
| 150 | + Button { | |
| 151 | + withAnimation(.snappy(duration: 0.2)) { expanded.toggle() } | |
| 152 | + } label: { | |
| 153 | + HStack(spacing: ZyquoTheme.Spacing.xxs) { | |
| 154 | + Image(systemName: "brain") | |
| 155 | + .font(.system(size: 10)) | |
| 156 | + Text(isLive ? "Thinking…" : "Thought process") | |
| 157 | + .font(ZyquoTheme.caption.weight(.medium)) | |
| 158 | + Image(systemName: "chevron.right") | |
| 159 | + .font(.system(size: 8, weight: .semibold)) | |
| 160 | + .rotationEffect(.degrees(expanded ? 90 : 0)) | |
| 161 | + } | |
| 162 | + .foregroundStyle(ZyquoTheme.textSecondary) | |
| 163 | + } | |
| 164 | + .buttonStyle(.plain) | |
| 165 | + | |
| 166 | + if expanded || isLive { | |
| 167 | + Text(thinking) | |
| 168 | + .font(ZyquoTheme.caption) | |
| 169 | + .foregroundStyle(ZyquoTheme.textSecondary) | |
| 170 | + .lineSpacing(3) | |
| 171 | + .textSelection(.enabled) | |
| 172 | + .padding(ZyquoTheme.Spacing.xs) | |
| 173 | + .frame(maxWidth: .infinity, alignment: .leading) | |
| 174 | + .background(ZyquoTheme.surfaceSecondary, in: RoundedRectangle(cornerRadius: ZyquoTheme.Radius.s)) | |
| 175 | + .frame(maxHeight: isLive && !expanded ? 96 : .infinity) | |
| 176 | + .clipped() | |
| 177 | + } | |
| 178 | + } | |
| 179 | + } | |
| 180 | +} | |
| 181 | + | |
| 182 | +extension Notification.Name { | |
| 183 | + static let zyquoQuoteReply = Notification.Name("zyquoQuoteReply") | |
| 184 | +} | |
added
Sources/ZyquoLocal/Views/Chat/ModelChipView.swift
+168 −0
@@ -0,0 +1,168 @@ | ||
| 1 | +// | |
| 2 | +// ModelChipView.swift | |
| 3 | +// Zyquo Local | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import SwiftUI | |
| 10 | + | |
| 11 | +/// Centered header chip: model name + quant badge. Click → switcher popover | |
| 12 | +/// listing downloaded models with RAM verdicts; switching shows inline | |
| 13 | +/// progress in the chip. Morphs smoothly between unloaded/loading/ready. | |
| 14 | +struct ModelChipView: View { | |
| 15 | + let conversation: Conversation | |
| 16 | + @Environment(AppModel.self) private var app | |
| 17 | + @State private var showSwitcher = false | |
| 18 | + | |
| 19 | + var body: some View { | |
| 20 | + Button { | |
| 21 | + showSwitcher.toggle() | |
| 22 | + } label: { | |
| 23 | + HStack(spacing: ZyquoTheme.Spacing.xs) { | |
| 24 | + switch app.engineState { | |
| 25 | + case .loading(let repoID): | |
| 26 | + ProgressView() | |
| 27 | + .controlSize(.mini) | |
| 28 | + Text("Loading \(shortModelName(repoID))…") | |
| 29 | + .font(ZyquoTheme.caption) | |
| 30 | + .foregroundStyle(ZyquoTheme.textSecondary) | |
| 31 | + case .ready(let repoID), .generating(let repoID): | |
| 32 | + Circle() | |
| 33 | + .fill(ZyquoTheme.success) | |
| 34 | + .frame(width: 7, height: 7) | |
| 35 | + Text(chipTitle(repoID)) | |
| 36 | + .font(ZyquoTheme.bodyEmphasis) | |
| 37 | + .foregroundStyle(ZyquoTheme.textPrimary) | |
| 38 | + case .unloaded: | |
| 39 | + Circle() | |
| 40 | + .fill(ZyquoTheme.textTertiary) | |
| 41 | + .frame(width: 7, height: 7) | |
| 42 | + Text(app.store.models.isEmpty ? "No model" : "Choose a model") | |
| 43 | + .font(ZyquoTheme.body) | |
| 44 | + .foregroundStyle(ZyquoTheme.textSecondary) | |
| 45 | + } | |
| 46 | + Image(systemName: "chevron.down") | |
| 47 | + .font(.system(size: 9, weight: .semibold)) | |
| 48 | + .foregroundStyle(ZyquoTheme.textTertiary) | |
| 49 | + } | |
| 50 | + .padding(.horizontal, ZyquoTheme.Spacing.s) | |
| 51 | + .padding(.vertical, 5) | |
| 52 | + .background(ZyquoTheme.surface, in: Capsule()) | |
| 53 | + .overlay(Capsule().stroke(ZyquoTheme.border, lineWidth: ZyquoTheme.hairline)) | |
| 54 | + .animation(.snappy(duration: 0.2), value: app.engineState) | |
| 55 | + } | |
| 56 | + .buttonStyle(PressableButtonStyle()) | |
| 57 | + .popover(isPresented: $showSwitcher, arrowEdge: .bottom) { | |
| 58 | + ModelSwitcherPopover(conversation: conversation) | |
| 59 | + } | |
| 60 | + .onReceive(NotificationCenter.default.publisher(for: .zyquoOpenModelSwitcher)) { _ in | |
| 61 | + showSwitcher = true | |
| 62 | + } | |
| 63 | + } | |
| 64 | + | |
| 65 | + private func chipTitle(_ repoID: String) -> String { | |
| 66 | + let name = shortModelName(repoID) | |
| 67 | + if let quant = app.store.model(for: repoID)?.quantization { | |
| 68 | + let base = name.replacingOccurrences(of: "-\(quant)", with: "") | |
| 69 | + return "\(base) · \(quant)" | |
| 70 | + } | |
| 71 | + return name | |
| 72 | + } | |
| 73 | +} | |
| 74 | + | |
| 75 | +/// Popover listing downloaded models with RAM verdicts. | |
| 76 | +struct ModelSwitcherPopover: View { | |
| 77 | + let conversation: Conversation | |
| 78 | + @Environment(AppModel.self) private var app | |
| 79 | + @Environment(\.dismiss) private var dismiss | |
| 80 | + | |
| 81 | + var body: some View { | |
| 82 | + VStack(alignment: .leading, spacing: 0) { | |
| 83 | + Text("Switch model") | |
| 84 | + .font(ZyquoTheme.caption) | |
| 85 | + .foregroundStyle(ZyquoTheme.textSecondary) | |
| 86 | + .padding(ZyquoTheme.Spacing.s) | |
| 87 | + | |
| 88 | + if app.store.models.isEmpty { | |
| 89 | + VStack(spacing: ZyquoTheme.Spacing.xs) { | |
| 90 | + Text("No models downloaded yet.") | |
| 91 | + .font(ZyquoTheme.body) | |
| 92 | + .foregroundStyle(ZyquoTheme.textSecondary) | |
| 93 | + Button("Open Library") { | |
| 94 | + app.route = .library | |
| 95 | + dismiss() | |
| 96 | + } | |
| 97 | + .buttonStyle(.borderedProminent) | |
| 98 | + .tint(ZyquoTheme.accent) | |
| 99 | + } | |
| 100 | + .padding(ZyquoTheme.Spacing.m) | |
| 101 | + } else { | |
| 102 | + ScrollView { | |
| 103 | + VStack(spacing: 2) { | |
| 104 | + ForEach(app.store.models) { model in | |
| 105 | + row(model) | |
| 106 | + } | |
| 107 | + } | |
| 108 | + .padding(.horizontal, ZyquoTheme.Spacing.xs) | |
| 109 | + .padding(.bottom, ZyquoTheme.Spacing.xs) | |
| 110 | + } | |
| 111 | + .frame(maxHeight: 320) | |
| 112 | + | |
| 113 | + if app.loadedModelID != nil { | |
| 114 | + Rectangle().fill(ZyquoTheme.border).frame(height: ZyquoTheme.hairline) | |
| 115 | + Button { | |
| 116 | + Task { await app.unloadModel() } | |
| 117 | + dismiss() | |
| 118 | + } label: { | |
| 119 | + Label("Unload model", systemImage: "eject") | |
| 120 | + .font(ZyquoTheme.body) | |
| 121 | + .foregroundStyle(ZyquoTheme.textSecondary) | |
| 122 | + } | |
| 123 | + .buttonStyle(.plain) | |
| 124 | + .padding(ZyquoTheme.Spacing.s) | |
| 125 | + } | |
| 126 | + } | |
| 127 | + } | |
| 128 | + .frame(width: 320) | |
| 129 | + } | |
| 130 | + | |
| 131 | + @ViewBuilder | |
| 132 | + private func row(_ model: LocalModel) -> some View { | |
| 133 | + let verdict = MemoryAdvisor.verdict(weightsBytes: model.sizeBytes) | |
| 134 | + let isLoaded = app.loadedModelID == model.repoID | |
| 135 | + Button { | |
| 136 | + guard !isLoaded, verdict != .tooLarge else { return } | |
| 137 | + var c = conversation | |
| 138 | + c.modelID = model.repoID | |
| 139 | + app.update(c, touch: false) | |
| 140 | + Task { await app.loadModel(repoID: model.repoID) } | |
| 141 | + dismiss() | |
| 142 | + } label: { | |
| 143 | + HStack(spacing: ZyquoTheme.Spacing.xs) { | |
| 144 | + VStack(alignment: .leading, spacing: 1) { | |
| 145 | + Text(model.name) | |
| 146 | + .font(ZyquoTheme.body) | |
| 147 | + .foregroundStyle(ZyquoTheme.textPrimary) | |
| 148 | + .lineLimit(1) | |
| 149 | + Text("\(model.organization) · \(formatBytes(model.sizeBytes))") | |
| 150 | + .font(ZyquoTheme.caption) | |
| 151 | + .foregroundStyle(ZyquoTheme.textTertiary) | |
| 152 | + } | |
| 153 | + Spacer() | |
| 154 | + if isLoaded { | |
| 155 | + Image(systemName: "checkmark.circle.fill") | |
| 156 | + .foregroundStyle(ZyquoTheme.accent) | |
| 157 | + } else { | |
| 158 | + VerdictBadge(verdict: verdict) | |
| 159 | + } | |
| 160 | + } | |
| 161 | + .padding(ZyquoTheme.Spacing.xs) | |
| 162 | + .contentShape(Rectangle()) | |
| 163 | + } | |
| 164 | + .buttonStyle(.plain) | |
| 165 | + .hoverHighlight() | |
| 166 | + .disabled(verdict == .tooLarge && !isLoaded) | |
| 167 | + } | |
| 168 | +} | |
added
Sources/ZyquoLocal/Views/Chat/OnboardingHeroView.swift
+133 −0
@@ -0,0 +1,133 @@ | ||
| 1 | +// | |
| 2 | +// OnboardingHeroView.swift | |
| 3 | +// Zyquo Local | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import SwiftUI | |
| 10 | + | |
| 11 | +/// First-run hero: no model downloaded yet. Recommends starter models sized | |
| 12 | +/// for THIS Mac with one-click download. | |
| 13 | +struct OnboardingHeroView: View { | |
| 14 | + @Environment(AppModel.self) private var app | |
| 15 | + @State private var appeared = false | |
| 16 | + | |
| 17 | + var body: some View { | |
| 18 | + VStack(spacing: ZyquoTheme.Spacing.xl) { | |
| 19 | + Spacer() | |
| 20 | + | |
| 21 | + VStack(spacing: ZyquoTheme.Spacing.m) { | |
| 22 | + ZyquoGlyph(size: 56) | |
| 23 | + .padding(ZyquoTheme.Spacing.l) | |
| 24 | + .background(ZyquoTheme.accentSubtle, in: RoundedRectangle(cornerRadius: 22)) | |
| 25 | + Text("Download your first model") | |
| 26 | + .font(ZyquoTheme.title) | |
| 27 | + .foregroundStyle(ZyquoTheme.textPrimary) | |
| 28 | + Text("Zyquo Local runs language models entirely on this Mac.\nNothing ever leaves your machine — pick a starter model sized for your \(Int(MemoryAdvisor.physicalMemoryBytes / 1_073_741_824)) GB of memory.") | |
| 29 | + .font(ZyquoTheme.body) | |
| 30 | + .foregroundStyle(ZyquoTheme.textSecondary) | |
| 31 | + .multilineTextAlignment(.center) | |
| 32 | + .lineSpacing(ZyquoTheme.lineSpacing(fontSize: 13.5) * 0.5) | |
| 33 | + } | |
| 34 | + | |
| 35 | + HStack(spacing: ZyquoTheme.Spacing.m) { | |
| 36 | + ForEach(ModelCatalog.starterPicks()) { model in | |
| 37 | + StarterCard(model: model) | |
| 38 | + } | |
| 39 | + } | |
| 40 | + .padding(.horizontal, ZyquoTheme.Spacing.xl) | |
| 41 | + | |
| 42 | + Button { | |
| 43 | + app.route = .library | |
| 44 | + } label: { | |
| 45 | + Text("Browse all models in the Library") | |
| 46 | + .font(ZyquoTheme.body) | |
| 47 | + .foregroundStyle(ZyquoTheme.accent) | |
| 48 | + } | |
| 49 | + .buttonStyle(.plain) | |
| 50 | + | |
| 51 | + Spacer() | |
| 52 | + } | |
| 53 | + .frame(maxWidth: .infinity, maxHeight: .infinity) | |
| 54 | + .background(ZyquoTheme.background) | |
| 55 | + .opacity(appeared ? 1 : 0) | |
| 56 | + .offset(y: appeared ? 0 : 8) | |
| 57 | + .onAppear { | |
| 58 | + withAnimation(.easeOut(duration: 0.25)) { appeared = true } | |
| 59 | + } | |
| 60 | + } | |
| 61 | +} | |
| 62 | + | |
| 63 | +/// One recommended starter model card. | |
| 64 | +struct StarterCard: View { | |
| 65 | + let model: CatalogModel | |
| 66 | + @Environment(AppModel.self) private var app | |
| 67 | + @State private var hovering = false | |
| 68 | + | |
| 69 | + private var download: DownloadTask? { | |
| 70 | + app.downloads.task(for: model.repoID) | |
| 71 | + } | |
| 72 | + | |
| 73 | + var body: some View { | |
| 74 | + VStack(alignment: .leading, spacing: ZyquoTheme.Spacing.xs) { | |
| 75 | + HStack { | |
| 76 | + Text(shortModelName(model.repoID)) | |
| 77 | + .font(ZyquoTheme.bodyEmphasis) | |
| 78 | + .foregroundStyle(ZyquoTheme.textPrimary) | |
| 79 | + .lineLimit(1) | |
| 80 | + Spacer() | |
| 81 | + VerdictBadge(verdict: model.verdict) | |
| 82 | + } | |
| 83 | + HStack(spacing: ZyquoTheme.Spacing.xxs) { | |
| 84 | + InfoBadge(text: model.params) | |
| 85 | + InfoBadge(text: model.quant) | |
| 86 | + InfoBadge(text: String(format: "%.1f GB", model.sizeGB)) | |
| 87 | + } | |
| 88 | + Text(model.blurb) | |
| 89 | + .font(ZyquoTheme.caption) | |
| 90 | + .foregroundStyle(ZyquoTheme.textSecondary) | |
| 91 | + .lineLimit(2, reservesSpace: true) | |
| 92 | + | |
| 93 | + if let task = download, task.state == .downloading || task.state == .verifying { | |
| 94 | + VStack(alignment: .leading, spacing: 3) { | |
| 95 | + ProgressView(value: task.fractionCompleted) | |
| 96 | + .tint(ZyquoTheme.accent) | |
| 97 | + HStack { | |
| 98 | + Text("\(Int(task.fractionCompleted * 100))%") | |
| 99 | + Spacer() | |
| 100 | + if let speed = app.downloads.speeds[model.repoID] { | |
| 101 | + Text(formatSpeed(speed)) | |
| 102 | + } | |
| 103 | + } | |
| 104 | + .font(ZyquoTheme.caption.monospacedDigit()) | |
| 105 | + .foregroundStyle(ZyquoTheme.textTertiary) | |
| 106 | + } | |
| 107 | + } else { | |
| 108 | + Button { | |
| 109 | + Task { await app.downloads.download(repoID: model.repoID) } | |
| 110 | + } label: { | |
| 111 | + Text("Download") | |
| 112 | + .font(ZyquoTheme.bodyEmphasis) | |
| 113 | + .foregroundStyle(.white) | |
| 114 | + .frame(maxWidth: .infinity) | |
| 115 | + .padding(.vertical, 6) | |
| 116 | + .background(ZyquoTheme.accent, in: RoundedRectangle(cornerRadius: ZyquoTheme.Radius.s)) | |
| 117 | + } | |
| 118 | + .buttonStyle(PressableButtonStyle()) | |
| 119 | + } | |
| 120 | + } | |
| 121 | + .padding(ZyquoTheme.Spacing.m) | |
| 122 | + .frame(width: 190) | |
| 123 | + .background(ZyquoTheme.surface, in: RoundedRectangle(cornerRadius: ZyquoTheme.Radius.m)) | |
| 124 | + .overlay( | |
| 125 | + RoundedRectangle(cornerRadius: ZyquoTheme.Radius.m) | |
| 126 | + .stroke(hovering ? ZyquoTheme.accent.opacity(0.4) : ZyquoTheme.border, lineWidth: hovering ? 1 : ZyquoTheme.hairline) | |
| 127 | + ) | |
| 128 | + .floatingShadow() | |
| 129 | + .onHover { inside in | |
| 130 | + withAnimation(.easeOut(duration: 0.08)) { hovering = inside } | |
| 131 | + } | |
| 132 | + } | |
| 133 | +} | |
added
Sources/ZyquoLocal/Views/Chat/TranscriptView.swift
+121 −0
@@ -0,0 +1,121 @@ | ||
| 1 | +// | |
| 2 | +// TranscriptView.swift | |
| 3 | +// Zyquo Local | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import SwiftUI | |
| 10 | + | |
| 11 | +/// Lazy transcript: user right in accentSubtle, assistant left on surface, | |
| 12 | +/// 16 pt rhythm, hover timestamps, streaming tail with caret, jump-to-bottom. | |
| 13 | +struct TranscriptView: View { | |
| 14 | + let conversation: Conversation | |
| 15 | + @Environment(AppModel.self) private var app | |
| 16 | + @State private var atBottom = true | |
| 17 | + | |
| 18 | + private var isStreamingHere: Bool { | |
| 19 | + app.chat.isGenerating && app.chat.streamingConversationID == conversation.id | |
| 20 | + } | |
| 21 | + | |
| 22 | + var body: some View { | |
| 23 | + ScrollViewReader { proxy in | |
| 24 | + ZStack(alignment: .bottom) { | |
| 25 | + ScrollView { | |
| 26 | + LazyVStack(spacing: ZyquoTheme.Spacing.m) { | |
| 27 | + ForEach(conversation.messages.filter { $0.role != .system }) { message in | |
| 28 | + MessageRow(message: message, conversation: conversation) | |
| 29 | + } | |
| 30 | + if isStreamingHere { | |
| 31 | + StreamingRow() | |
| 32 | + } | |
| 33 | + Color.clear | |
| 34 | + .frame(height: 1) | |
| 35 | + .id("bottom") | |
| 36 | + .onAppear { atBottom = true } | |
| 37 | + .onDisappear { atBottom = false } | |
| 38 | + } | |
| 39 | + .padding(.horizontal, ZyquoTheme.Spacing.xl) | |
| 40 | + .padding(.vertical, ZyquoTheme.Spacing.l) | |
| 41 | + .frame(maxWidth: ZyquoTheme.messageColumnMaxWidth) | |
| 42 | + .frame(maxWidth: .infinity) | |
| 43 | + } | |
| 44 | + .onChange(of: app.chat.streamingText) { | |
| 45 | + if atBottom { | |
| 46 | + proxy.scrollTo("bottom", anchor: .bottom) | |
| 47 | + } | |
| 48 | + } | |
| 49 | + .onChange(of: conversation.messages.count) { | |
| 50 | + withAnimation(.easeOut(duration: 0.15)) { | |
| 51 | + proxy.scrollTo("bottom", anchor: .bottom) | |
| 52 | + } | |
| 53 | + } | |
| 54 | + .onChange(of: conversation.id) { | |
| 55 | + proxy.scrollTo("bottom", anchor: .bottom) | |
| 56 | + } | |
| 57 | + | |
| 58 | + if !atBottom { | |
| 59 | + Button { | |
| 60 | + withAnimation(.snappy(duration: 0.25)) { | |
| 61 | + proxy.scrollTo("bottom", anchor: .bottom) | |
| 62 | + } | |
| 63 | + } label: { | |
| 64 | + Image(systemName: "arrow.down") | |
| 65 | + .font(.system(size: 12, weight: .semibold)) | |
| 66 | + .foregroundStyle(ZyquoTheme.textPrimary) | |
| 67 | + .padding(ZyquoTheme.Spacing.xs) | |
| 68 | + .background(ZyquoTheme.surface, in: Circle()) | |
| 69 | + .overlay(Circle().stroke(ZyquoTheme.border, lineWidth: ZyquoTheme.hairline)) | |
| 70 | + .floatingShadow() | |
| 71 | + } | |
| 72 | + .buttonStyle(PressableButtonStyle()) | |
| 73 | + .padding(.bottom, ZyquoTheme.Spacing.m) | |
| 74 | + .transition(.opacity.combined(with: .move(edge: .bottom))) | |
| 75 | + } | |
| 76 | + } | |
| 77 | + } | |
| 78 | + } | |
| 79 | +} | |
| 80 | + | |
| 81 | +/// The in-flight assistant message: live thinking section, streamed text, | |
| 82 | +/// blinking caret at the stream tail. | |
| 83 | +struct StreamingRow: View { | |
| 84 | + @Environment(AppModel.self) private var app | |
| 85 | + @State private var caretVisible = true | |
| 86 | + | |
| 87 | + var body: some View { | |
| 88 | + HStack(alignment: .top) { | |
| 89 | + VStack(alignment: .leading, spacing: ZyquoTheme.Spacing.xs) { | |
| 90 | + if !app.chat.streamingThinking.isEmpty { | |
| 91 | + ThinkingDisclosure( | |
| 92 | + thinking: app.chat.streamingThinking, | |
| 93 | + isLive: app.chat.isThinking | |
| 94 | + ) | |
| 95 | + } | |
| 96 | + if !app.chat.streamingText.isEmpty || !app.chat.isThinking { | |
| 97 | + HStack(alignment: .bottom, spacing: 2) { | |
| 98 | + MarkdownText(app.chat.streamingText) | |
| 99 | + Rectangle() | |
| 100 | + .fill(ZyquoTheme.accent) | |
| 101 | + .frame(width: 2, height: ZyquoTheme.chatFontSize + 2) | |
| 102 | + .opacity(caretVisible ? 1 : 0.15) | |
| 103 | + .onAppear { | |
| 104 | + withAnimation(.easeInOut(duration: 0.55).repeatForever()) { | |
| 105 | + caretVisible = false | |
| 106 | + } | |
| 107 | + } | |
| 108 | + } | |
| 109 | + } | |
| 110 | + } | |
| 111 | + .padding(ZyquoTheme.Spacing.s) | |
| 112 | + .background(ZyquoTheme.surface, in: RoundedRectangle(cornerRadius: ZyquoTheme.Radius.m)) | |
| 113 | + .overlay( | |
| 114 | + RoundedRectangle(cornerRadius: ZyquoTheme.Radius.m) | |
| 115 | + .stroke(ZyquoTheme.border, lineWidth: ZyquoTheme.hairline) | |
| 116 | + ) | |
| 117 | + .frame(maxWidth: ZyquoTheme.messageColumnMaxWidth * 0.86, alignment: .leading) | |
| 118 | + Spacer(minLength: ZyquoTheme.Spacing.xxl) | |
| 119 | + } | |
| 120 | + } | |
| 121 | +} | |
added
Sources/ZyquoLocal/Views/Compare/CompareView.swift
+251 −0
@@ -0,0 +1,251 @@ | ||
| 1 | +// | |
| 2 | +// CompareView.swift | |
| 3 | +// Zyquo Local | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import SwiftUI | |
| 10 | + | |
| 11 | +/// Compare mode: two models side by side, same prompt broadcast, independent | |
| 12 | +/// streaming and stats. RAM-gated: both models must fit together. | |
| 13 | +struct CompareView: View { | |
| 14 | + @Environment(AppModel.self) private var app | |
| 15 | + @State private var controller = CompareController() | |
| 16 | + @State private var prompt = "" | |
| 17 | + @FocusState private var focused: Bool | |
| 18 | + | |
| 19 | + var body: some View { | |
| 20 | + VStack(spacing: 0) { | |
| 21 | + HStack(spacing: ZyquoTheme.Spacing.m) { | |
| 22 | + columnHeader(side: 0) | |
| 23 | + Rectangle().fill(ZyquoTheme.border).frame(width: ZyquoTheme.hairline) | |
| 24 | + columnHeader(side: 1) | |
| 25 | + } | |
| 26 | + .frame(height: ZyquoTheme.chatHeaderHeight) | |
| 27 | + .padding(.horizontal, ZyquoTheme.Spacing.m) | |
| 28 | + | |
| 29 | + if let warning = controller.ramWarning { | |
| 30 | + Text(warning) | |
| 31 | + .font(ZyquoTheme.caption) | |
| 32 | + .foregroundStyle(ZyquoTheme.warning) | |
| 33 | + .padding(.bottom, ZyquoTheme.Spacing.xxs) | |
| 34 | + } | |
| 35 | + | |
| 36 | + Rectangle().fill(ZyquoTheme.border).frame(height: ZyquoTheme.hairline) | |
| 37 | + | |
| 38 | + HStack(spacing: 0) { | |
| 39 | + column(side: 0) | |
| 40 | + Rectangle().fill(ZyquoTheme.border).frame(width: ZyquoTheme.hairline) | |
| 41 | + column(side: 1) | |
| 42 | + } | |
| 43 | + | |
| 44 | + inputRow | |
| 45 | + } | |
| 46 | + .background(ZyquoTheme.background) | |
| 47 | + .frame(minWidth: 900, minHeight: 560) | |
| 48 | + .onDisappear { | |
| 49 | + Task { await controller.teardown() } | |
| 50 | + } | |
| 51 | + } | |
| 52 | + | |
| 53 | + private func columnHeader(side: Int) -> some View { | |
| 54 | + HStack { | |
| 55 | + Picker( | |
| 56 | + "Model", | |
| 57 | + selection: Binding( | |
| 58 | + get: { controller.selection[side] }, | |
| 59 | + set: { newValue in | |
| 60 | + controller.selection[side] = newValue | |
| 61 | + Task { await controller.loadSelection(app: app) } | |
| 62 | + } | |
| 63 | + ) | |
| 64 | + ) { | |
| 65 | + Text("Choose…").tag(String?.none) | |
| 66 | + ForEach(app.store.models) { model in | |
| 67 | + Text(model.name).tag(String?.some(model.repoID)) | |
| 68 | + } | |
| 69 | + } | |
| 70 | + .labelsHidden() | |
| 71 | + .frame(maxWidth: 280) | |
| 72 | + | |
| 73 | + switch controller.states[side] { | |
| 74 | + case .loading: | |
| 75 | + ProgressView().controlSize(.mini) | |
| 76 | + case .ready: | |
| 77 | + Circle().fill(ZyquoTheme.success).frame(width: 7, height: 7) | |
| 78 | + default: | |
| 79 | + EmptyView() | |
| 80 | + } | |
| 81 | + Spacer() | |
| 82 | + if let stats = controller.stats[side] { | |
| 83 | + Text(String(format: "⚡ %.1f tok/s · %d tok · %.1fs TTFT", | |
| 84 | + stats.tokensPerSecond, stats.generationTokenCount, stats.timeToFirstToken)) | |
| 85 | + .font(ZyquoTheme.caption.monospacedDigit()) | |
| 86 | + .foregroundStyle(ZyquoTheme.textTertiary) | |
| 87 | + } | |
| 88 | + } | |
| 89 | + .frame(maxWidth: .infinity) | |
| 90 | + } | |
| 91 | + | |
| 92 | + private func column(side: Int) -> some View { | |
| 93 | + ScrollView { | |
| 94 | + VStack(alignment: .leading, spacing: ZyquoTheme.Spacing.s) { | |
| 95 | + if !controller.thinking[side].isEmpty { | |
| 96 | + ThinkingDisclosure(thinking: controller.thinking[side], isLive: false) | |
| 97 | + } | |
| 98 | + MarkdownText(controller.outputs[side]) | |
| 99 | + .textSelection(.enabled) | |
| 100 | + if controller.generating[side] { | |
| 101 | + ProgressView().controlSize(.small) | |
| 102 | + } | |
| 103 | + } | |
| 104 | + .padding(ZyquoTheme.Spacing.m) | |
| 105 | + .frame(maxWidth: .infinity, alignment: .leading) | |
| 106 | + } | |
| 107 | + .frame(maxWidth: .infinity) | |
| 108 | + } | |
| 109 | + | |
| 110 | + private var inputRow: some View { | |
| 111 | + HStack(spacing: ZyquoTheme.Spacing.xs) { | |
| 112 | + TextField("Prompt both models…", text: $prompt, axis: .vertical) | |
| 113 | + .textFieldStyle(.plain) | |
| 114 | + .font(ZyquoTheme.chatBody) | |
| 115 | + .lineLimit(1...6) | |
| 116 | + .focused($focused) | |
| 117 | + .onSubmit(broadcast) | |
| 118 | + Button { | |
| 119 | + broadcast() | |
| 120 | + } label: { | |
| 121 | + Image(systemName: "arrow.up") | |
| 122 | + .font(.system(size: 13, weight: .bold)) | |
| 123 | + .foregroundStyle(.white) | |
| 124 | + .frame(width: 28, height: 28) | |
| 125 | + .background(canSend ? ZyquoTheme.accent : ZyquoTheme.textTertiary, in: Circle()) | |
| 126 | + } | |
| 127 | + .buttonStyle(PressableButtonStyle()) | |
| 128 | + .keyboardShortcut(.return, modifiers: .command) | |
| 129 | + .disabled(!canSend) | |
| 130 | + } | |
| 131 | + .padding(ZyquoTheme.Spacing.s) | |
| 132 | + .background(ZyquoTheme.surface, in: RoundedRectangle(cornerRadius: ZyquoTheme.Radius.l)) | |
| 133 | + .overlay( | |
| 134 | + RoundedRectangle(cornerRadius: ZyquoTheme.Radius.l) | |
| 135 | + .stroke(ZyquoTheme.border, lineWidth: ZyquoTheme.hairline) | |
| 136 | + ) | |
| 137 | + .floatingShadow() | |
| 138 | + .padding(ZyquoTheme.Spacing.m) | |
| 139 | + } | |
| 140 | + | |
| 141 | + private var canSend: Bool { | |
| 142 | + !prompt.trimmingCharacters(in: .whitespaces).isEmpty | |
| 143 | + && controller.states.allSatisfy { if case .ready = $0 { true } else { false } } | |
| 144 | + && !controller.generating.contains(true) | |
| 145 | + } | |
| 146 | + | |
| 147 | + private func broadcast() { | |
| 148 | + guard canSend else { return } | |
| 149 | + let text = prompt.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 150 | + prompt = "" | |
| 151 | + controller.broadcast(prompt: text, params: app.settings.defaultParams) | |
| 152 | + } | |
| 153 | +} | |
| 154 | + | |
| 155 | +/// Owns two independent engines for compare mode. The main app engine is | |
| 156 | +/// left untouched; RAM gating warns when both models exceed the safe budget. | |
| 157 | +@MainActor | |
| 158 | +@Observable | |
| 159 | +final class CompareController { | |
| 160 | + enum ColumnState { | |
| 161 | + case empty | |
| 162 | + case loading | |
| 163 | + case ready | |
| 164 | + } | |
| 165 | + | |
| 166 | + var selection: [String?] = [nil, nil] | |
| 167 | + var states: [ColumnState] = [.empty, .empty] | |
| 168 | + var outputs: [String] = ["", ""] | |
| 169 | + var thinking: [String] = ["", ""] | |
| 170 | + var generating: [Bool] = [false, false] | |
| 171 | + var stats: [GenerationStats?] = [nil, nil] | |
| 172 | + var ramWarning: String? | |
| 173 | + | |
| 174 | + private var engines: [InferenceEngine?] = [nil, nil] | |
| 175 | + private var sessions: [Conversation] = [Conversation(), Conversation()] | |
| 176 | + | |
| 177 | + func loadSelection(app: AppModel) async { | |
| 178 | + // RAM gate across both columns. | |
| 179 | + let sizes = selection.compactMap { id in id.flatMap { app.store.model(for: $0)?.sizeBytes } } | |
| 180 | + let combined = sizes.reduce(0, +) | |
| 181 | + switch MemoryAdvisor.verdict(weightsBytes: combined) { | |
| 182 | + case .fits: | |
| 183 | + ramWarning = nil | |
| 184 | + case .tight: | |
| 185 | + ramWarning = "Both models together are a tight fit for this Mac's memory." | |
| 186 | + case .tooLarge: | |
| 187 | + ramWarning = "These two models do not fit in memory together — pick smaller ones." | |
| 188 | + return | |
| 189 | + } | |
| 190 | + | |
| 191 | + for side in 0..<2 { | |
| 192 | + guard let repoID = selection[side], let model = app.store.model(for: repoID) else { | |
| 193 | + engines[side] = nil | |
| 194 | + states[side] = .empty | |
| 195 | + continue | |
| 196 | + } | |
| 197 | + if let engine = engines[side], await engine.currentModel?.repoID == repoID { continue } | |
| 198 | + states[side] = .loading | |
| 199 | + let engine = InferenceEngine() | |
| 200 | + do { | |
| 201 | + try await engine.load(model: model) | |
| 202 | + sessions[side] = Conversation(modelID: repoID) | |
| 203 | + try await engine.startSession(conversation: sessions[side]) | |
| 204 | + engines[side] = engine | |
| 205 | + states[side] = .ready | |
| 206 | + } catch { | |
| 207 | + states[side] = .empty | |
| 208 | + ramWarning = error.localizedDescription | |
| 209 | + } | |
| 210 | + } | |
| 211 | + } | |
| 212 | + | |
| 213 | + func broadcast(prompt: String, params: GenerationParams) { | |
| 214 | + for side in 0..<2 { | |
| 215 | + guard let engine = engines[side] else { continue } | |
| 216 | + outputs[side] = "" | |
| 217 | + thinking[side] = "" | |
| 218 | + stats[side] = nil | |
| 219 | + generating[side] = true | |
| 220 | + Task { | |
| 221 | + var parser = ThinkTagParser() | |
| 222 | + do { | |
| 223 | + let events = try await engine.generate(prompt: prompt, params: params) | |
| 224 | + for try await event in events { | |
| 225 | + switch event { | |
| 226 | + case .token(let t): | |
| 227 | + let (visible, think, _) = parser.consume(t) | |
| 228 | + if !visible.isEmpty { outputs[side] += visible } | |
| 229 | + if !think.isEmpty { thinking[side] += think } | |
| 230 | + case .stats(let s): | |
| 231 | + stats[side] = s | |
| 232 | + case .finished: | |
| 233 | + break | |
| 234 | + } | |
| 235 | + } | |
| 236 | + } catch { | |
| 237 | + outputs[side] += "\n*\(error.localizedDescription)*" | |
| 238 | + } | |
| 239 | + generating[side] = false | |
| 240 | + } | |
| 241 | + } | |
| 242 | + } | |
| 243 | + | |
| 244 | + func teardown() async { | |
| 245 | + for engine in engines.compactMap({ $0 }) { | |
| 246 | + await engine.unload() | |
| 247 | + } | |
| 248 | + engines = [nil, nil] | |
| 249 | + states = [.empty, .empty] | |
| 250 | + } | |
| 251 | +} | |
added
Sources/ZyquoLocal/Views/Components.swift
+124 −0
@@ -0,0 +1,124 @@ | ||
| 1 | +// | |
| 2 | +// Components.swift | |
| 3 | +// Zyquo Local | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import SwiftUI | |
| 10 | + | |
| 11 | +/// RAM verdict badge (Fits / Tight / Too large) for THIS Mac. | |
| 12 | +struct VerdictBadge: View { | |
| 13 | + let verdict: MemoryAdvisor.Verdict | |
| 14 | + | |
| 15 | + private var color: Color { | |
| 16 | + switch verdict { | |
| 17 | + case .fits: ZyquoTheme.success | |
| 18 | + case .tight: ZyquoTheme.warning | |
| 19 | + case .tooLarge: ZyquoTheme.danger | |
| 20 | + } | |
| 21 | + } | |
| 22 | + | |
| 23 | + var body: some View { | |
| 24 | + Text(verdict.label) | |
| 25 | + .font(ZyquoTheme.caption.weight(.medium)) | |
| 26 | + .foregroundStyle(color) | |
| 27 | + .padding(.horizontal, ZyquoTheme.Spacing.xs) | |
| 28 | + .padding(.vertical, 2) | |
| 29 | + .background(color.opacity(0.12), in: Capsule()) | |
| 30 | + } | |
| 31 | +} | |
| 32 | + | |
| 33 | +/// Small neutral badge for quantization / params ("4bit", "8B"…). | |
| 34 | +struct InfoBadge: View { | |
| 35 | + let text: String | |
| 36 | + | |
| 37 | + var body: some View { | |
| 38 | + Text(text) | |
| 39 | + .font(ZyquoTheme.caption) | |
| 40 | + .foregroundStyle(ZyquoTheme.textSecondary) | |
| 41 | + .padding(.horizontal, ZyquoTheme.Spacing.xs) | |
| 42 | + .padding(.vertical, 2) | |
| 43 | + .background(ZyquoTheme.surfaceSecondary, in: Capsule()) | |
| 44 | + .overlay(Capsule().stroke(ZyquoTheme.border, lineWidth: ZyquoTheme.hairline)) | |
| 45 | + } | |
| 46 | +} | |
| 47 | + | |
| 48 | +/// 0.97 press scale + 80 ms hover ease, per the motion spec. | |
| 49 | +struct PressableButtonStyle: ButtonStyle { | |
| 50 | + func makeBody(configuration: Configuration) -> some View { | |
| 51 | + configuration.label | |
| 52 | + .scaleEffect(configuration.isPressed ? 0.97 : 1) | |
| 53 | + .animation(.easeOut(duration: 0.08), value: configuration.isPressed) | |
| 54 | + } | |
| 55 | +} | |
| 56 | + | |
| 57 | +/// Row that highlights on hover with surfaceSecondary. | |
| 58 | +struct HoverHighlight: ViewModifier { | |
| 59 | + @State private var hovering = false | |
| 60 | + var cornerRadius: CGFloat = ZyquoTheme.Radius.s | |
| 61 | + | |
| 62 | + func body(content: Content) -> some View { | |
| 63 | + content | |
| 64 | + .background( | |
| 65 | + RoundedRectangle(cornerRadius: cornerRadius) | |
| 66 | + .fill(hovering ? ZyquoTheme.surfaceSecondary : .clear) | |
| 67 | + ) | |
| 68 | + .onHover { inside in | |
| 69 | + withAnimation(.easeOut(duration: 0.08)) { hovering = inside } | |
| 70 | + } | |
| 71 | + } | |
| 72 | +} | |
| 73 | + | |
| 74 | +extension View { | |
| 75 | + func hoverHighlight(cornerRadius: CGFloat = ZyquoTheme.Radius.s) -> some View { | |
| 76 | + modifier(HoverHighlight(cornerRadius: cornerRadius)) | |
| 77 | + } | |
| 78 | + | |
| 79 | + /// Ultra-soft floating-panel shadow from the spec. | |
| 80 | + func floatingShadow() -> some View { | |
| 81 | + shadow(color: ZyquoTheme.shadowColor, radius: ZyquoTheme.shadowRadius, y: ZyquoTheme.shadowY) | |
| 82 | + } | |
| 83 | +} | |
| 84 | + | |
| 85 | +/// The Z wordmark glyph drawn from the icon's geometry (used in the sidebar | |
| 86 | +/// and empty states). | |
| 87 | +struct ZyquoGlyph: View { | |
| 88 | + var size: CGFloat = 20 | |
| 89 | + var color: Color = ZyquoTheme.accent | |
| 90 | + | |
| 91 | + var body: some View { | |
| 92 | + Canvas { context, canvasSize in | |
| 93 | + let s = canvasSize.width / 1024 | |
| 94 | + var path = Path() | |
| 95 | + path.move(to: CGPoint(x: 300 * s, y: 265 * s)) | |
| 96 | + path.addLine(to: CGPoint(x: 724 * s, y: 265 * s)) | |
| 97 | + path.addLine(to: CGPoint(x: 300 * s, y: 759 * s)) | |
| 98 | + path.addLine(to: CGPoint(x: 724 * s, y: 759 * s)) | |
| 99 | + context.stroke( | |
| 100 | + path, | |
| 101 | + with: .color(color), | |
| 102 | + style: StrokeStyle(lineWidth: 130 * s, lineCap: .round, lineJoin: .round) | |
| 103 | + ) | |
| 104 | + } | |
| 105 | + .frame(width: size, height: size) | |
| 106 | + } | |
| 107 | +} | |
| 108 | + | |
| 109 | +/// Formats byte counts consistently. | |
| 110 | +func formatBytes(_ bytes: Int64) -> String { | |
| 111 | + ByteCountFormatter.string(fromByteCount: bytes, countStyle: .file) | |
| 112 | +} | |
| 113 | + | |
| 114 | +/// "2.3 GB/s", "418 MB/s"… | |
| 115 | +func formatSpeed(_ bytesPerSecond: Double) -> String { | |
| 116 | + formatBytes(Int64(bytesPerSecond)) + "/s" | |
| 117 | +} | |
| 118 | + | |
| 119 | +/// "3 min left", "12 s left"… | |
| 120 | +func formatETA(_ seconds: TimeInterval) -> String { | |
| 121 | + if seconds < 90 { return "\(Int(seconds)) s left" } | |
| 122 | + if seconds < 5400 { return "\(Int(seconds / 60)) min left" } | |
| 123 | + return "\(Int(seconds / 3600)) h \(Int(seconds.truncatingRemainder(dividingBy: 3600) / 60)) min left" | |
| 124 | +} | |
added
Sources/ZyquoLocal/Views/Library/DiscoverView.swift
+220 −0
@@ -0,0 +1,220 @@ | ||
| 1 | +// | |
| 2 | +// DiscoverView.swift | |
| 3 | +// Zyquo Local | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import SwiftUI | |
| 10 | + | |
| 11 | +/// Discover tab: Featured curated catalog + live Hub search with scopes, | |
| 12 | +/// filters and sort. Cards flip into live download state. | |
| 13 | +struct DiscoverView: View { | |
| 14 | + @Environment(AppModel.self) private var app | |
| 15 | + @State private var query = "" | |
| 16 | + @State private var scope: HubService.Scope = .featured | |
| 17 | + @State private var sort: HubService.Sort = .downloads | |
| 18 | + @State private var sizeFilter: SizeClass = .any | |
| 19 | + @State private var results: [HubService.ModelSummary] = [] | |
| 20 | + @State private var searching = false | |
| 21 | + @State private var searchError: String? | |
| 22 | + @State private var searchTask: Task<Void, Never>? | |
| 23 | + | |
| 24 | + enum SizeClass: String, CaseIterable { | |
| 25 | + case any = "Any size" | |
| 26 | + case tiny = "≤4B" | |
| 27 | + case mid = "7–14B" | |
| 28 | + case large = "24B+" | |
| 29 | + } | |
| 30 | + | |
| 31 | + var body: some View { | |
| 32 | + VStack(spacing: 0) { | |
| 33 | + controls | |
| 34 | + Rectangle().fill(ZyquoTheme.border).frame(height: ZyquoTheme.hairline) | |
| 35 | + | |
| 36 | + if scope == .featured && query.isEmpty { | |
| 37 | + featuredList | |
| 38 | + } else { | |
| 39 | + searchResults | |
| 40 | + } | |
| 41 | + } | |
| 42 | + .onChange(of: query) { debounceSearch() } | |
| 43 | + .onChange(of: scope) { debounceSearch() } | |
| 44 | + .onChange(of: sort) { debounceSearch() } | |
| 45 | + } | |
| 46 | + | |
| 47 | + private var controls: some View { | |
| 48 | + HStack(spacing: ZyquoTheme.Spacing.s) { | |
| 49 | + HStack(spacing: ZyquoTheme.Spacing.xxs) { | |
| 50 | + Image(systemName: "magnifyingglass") | |
| 51 | + .font(.system(size: 11)) | |
| 52 | + .foregroundStyle(ZyquoTheme.textTertiary) | |
| 53 | + TextField("Search Hugging Face…", text: $query) | |
| 54 | + .textFieldStyle(.plain) | |
| 55 | + .font(ZyquoTheme.body) | |
| 56 | + if searching { | |
| 57 | + ProgressView().controlSize(.mini) | |
| 58 | + } | |
| 59 | + } | |
| 60 | + .padding(.horizontal, ZyquoTheme.Spacing.xs) | |
| 61 | + .padding(.vertical, 5) | |
| 62 | + .frame(maxWidth: 280) | |
| 63 | + .background(ZyquoTheme.surface, in: RoundedRectangle(cornerRadius: ZyquoTheme.Radius.s)) | |
| 64 | + .overlay( | |
| 65 | + RoundedRectangle(cornerRadius: ZyquoTheme.Radius.s) | |
| 66 | + .stroke(ZyquoTheme.border, lineWidth: ZyquoTheme.hairline) | |
| 67 | + ) | |
| 68 | + | |
| 69 | + Picker("", selection: $scope) { | |
| 70 | + Text("Featured").tag(HubService.Scope.featured) | |
| 71 | + Text("mlx-community").tag(HubService.Scope.mlxCommunity) | |
| 72 | + Text("All MLX").tag(HubService.Scope.allMLX) | |
| 73 | + } | |
| 74 | + .pickerStyle(.segmented) | |
| 75 | + .frame(width: 300) | |
| 76 | + | |
| 77 | + Spacer() | |
| 78 | + | |
| 79 | + Picker("Size", selection: $sizeFilter) { | |
| 80 | + ForEach(SizeClass.allCases, id: \.self) { Text($0.rawValue).tag($0) } | |
| 81 | + } | |
| 82 | + .frame(width: 120) | |
| 83 | + | |
| 84 | + Picker("Sort", selection: $sort) { | |
| 85 | + Text("Downloads").tag(HubService.Sort.downloads) | |
| 86 | + Text("Likes").tag(HubService.Sort.likes) | |
| 87 | + Text("Newest").tag(HubService.Sort.newest) | |
| 88 | + } | |
| 89 | + .frame(width: 130) | |
| 90 | + .disabled(scope == .featured && query.isEmpty) | |
| 91 | + } | |
| 92 | + .padding(ZyquoTheme.Spacing.s) | |
| 93 | + } | |
| 94 | + | |
| 95 | + // MARK: - Featured | |
| 96 | + | |
| 97 | + private var featuredList: some View { | |
| 98 | + ScrollView { | |
| 99 | + LazyVStack(alignment: .leading, spacing: ZyquoTheme.Spacing.l) { | |
| 100 | + Text("Hand-picked models, live-verified for this Mac") | |
| 101 | + .font(ZyquoTheme.caption) | |
| 102 | + .foregroundStyle(ZyquoTheme.textTertiary) | |
| 103 | + .padding(.top, ZyquoTheme.Spacing.m) | |
| 104 | + | |
| 105 | + ForEach(featuredSections, id: \.title) { section in | |
| 106 | + if !section.models.isEmpty { | |
| 107 | + VStack(alignment: .leading, spacing: ZyquoTheme.Spacing.s) { | |
| 108 | + Text(section.title) | |
| 109 | + .font(ZyquoTheme.bodyEmphasis) | |
| 110 | + .foregroundStyle(ZyquoTheme.textPrimary) | |
| 111 | + ForEach(section.models) { model in | |
| 112 | + FeaturedModelCard(model: model) | |
| 113 | + } | |
| 114 | + } | |
| 115 | + } | |
| 116 | + } | |
| 117 | + } | |
| 118 | + .padding(.horizontal, ZyquoTheme.Spacing.l) | |
| 119 | + .padding(.bottom, ZyquoTheme.Spacing.l) | |
| 120 | + .frame(maxWidth: 860) | |
| 121 | + .frame(maxWidth: .infinity) | |
| 122 | + } | |
| 123 | + } | |
| 124 | + | |
| 125 | + private var featuredSections: [(title: String, models: [CatalogModel])] { | |
| 126 | + func matchesSize(_ m: CatalogModel) -> Bool { | |
| 127 | + switch sizeFilter { | |
| 128 | + case .any: true | |
| 129 | + case .tiny: m.categories.contains(.tiny) | |
| 130 | + case .mid: m.categories.contains(.mid) | |
| 131 | + case .large: m.categories.contains(.large) | |
| 132 | + } | |
| 133 | + } | |
| 134 | + let featured = ModelCatalog.featured.filter(matchesSize) | |
| 135 | + return [ | |
| 136 | + ("Starter picks for this Mac", ModelCatalog.starterPicks().filter(matchesSize)), | |
| 137 | + ("General", featured.filter { $0.categories.contains(.mid) || $0.categories.contains(.large) } | |
| 138 | + .filter { !$0.categories.contains(.coding) && !$0.categories.contains(.reasoning) }), | |
| 139 | + ("Small & fast", featured.filter { $0.categories.contains(.tiny) }), | |
| 140 | + ("Coding", featured.filter { $0.categories.contains(.coding) }), | |
| 141 | + ("Reasoning", featured.filter { $0.categories.contains(.reasoning) }), | |
| 142 | + ] | |
| 143 | + } | |
| 144 | + | |
| 145 | + // MARK: - Live search | |
| 146 | + | |
| 147 | + private var searchResults: some View { | |
| 148 | + ScrollView { | |
| 149 | + LazyVStack(spacing: ZyquoTheme.Spacing.s) { | |
| 150 | + if let error = searchError { | |
| 151 | + Text(error) | |
| 152 | + .font(ZyquoTheme.body) | |
| 153 | + .foregroundStyle(ZyquoTheme.danger) | |
| 154 | + .padding(.top, ZyquoTheme.Spacing.xl) | |
| 155 | + } else if results.isEmpty && !searching { | |
| 156 | + Text(query.isEmpty ? "Type to search the Hub" : "No MLX models found for “\(query)”") | |
| 157 | + .font(ZyquoTheme.body) | |
| 158 | + .foregroundStyle(ZyquoTheme.textTertiary) | |
| 159 | + .padding(.top, ZyquoTheme.Spacing.xl) | |
| 160 | + } | |
| 161 | + ForEach(filteredResults) { summary in | |
| 162 | + HubModelCard(summary: summary) | |
| 163 | + } | |
| 164 | + } | |
| 165 | + .padding(ZyquoTheme.Spacing.l) | |
| 166 | + .frame(maxWidth: 860) | |
| 167 | + .frame(maxWidth: .infinity) | |
| 168 | + } | |
| 169 | + } | |
| 170 | + | |
| 171 | + private var filteredResults: [HubService.ModelSummary] { | |
| 172 | + results.filter { summary in | |
| 173 | + switch sizeFilter { | |
| 174 | + case .any: return true | |
| 175 | + case .tiny: | |
| 176 | + return paramsBillions(summary.id).map { $0 <= 4.5 } ?? true | |
| 177 | + case .mid: | |
| 178 | + return paramsBillions(summary.id).map { $0 > 4.5 && $0 <= 20 } ?? true | |
| 179 | + case .large: | |
| 180 | + return paramsBillions(summary.id).map { $0 > 20 } ?? true | |
| 181 | + } | |
| 182 | + } | |
| 183 | + } | |
| 184 | + | |
| 185 | + /// Parses "…-7B-…"/"…-0.6B-…" from a repo name. | |
| 186 | + private func paramsBillions(_ repoID: String) -> Double? { | |
| 187 | + let name = shortModelName(repoID) | |
| 188 | + guard let regex = try? NSRegularExpression(pattern: #"(\d+(?:\.\d+)?)[Bb]"#) else { return nil } | |
| 189 | + let range = NSRange(name.startIndex..., in: name) | |
| 190 | + guard let match = regex.firstMatch(in: name, range: range), | |
| 191 | + let r = Range(match.range(at: 1), in: name) | |
| 192 | + else { return nil } | |
| 193 | + return Double(name[r]) | |
| 194 | + } | |
| 195 | + | |
| 196 | + private func debounceSearch() { | |
| 197 | + searchTask?.cancel() | |
| 198 | + guard !(scope == .featured && query.isEmpty) else { return } | |
| 199 | + searchTask = Task { | |
| 200 | + try? await Task.sleep(for: .milliseconds(350)) | |
| 201 | + guard !Task.isCancelled else { return } | |
| 202 | + await runSearch() | |
| 203 | + } | |
| 204 | + } | |
| 205 | + | |
| 206 | + private func runSearch() async { | |
| 207 | + searching = true | |
| 208 | + searchError = nil | |
| 209 | + do { | |
| 210 | + let hub = HubService(token: app.settings.hfToken.isEmpty ? nil : app.settings.hfToken) | |
| 211 | + let effectiveScope: HubService.Scope = scope == .featured ? .mlxCommunity : scope | |
| 212 | + results = try await hub.search(query: query, scope: effectiveScope, sort: sort) | |
| 213 | + } catch is CancellationError { | |
| 214 | + } catch { | |
| 215 | + searchError = error.localizedDescription | |
| 216 | + results = [] | |
| 217 | + } | |
| 218 | + searching = false | |
| 219 | + } | |
| 220 | +} | |
added
Sources/ZyquoLocal/Views/Library/LibraryView.swift
+231 −0
@@ -0,0 +1,231 @@ | ||
| 1 | +// | |
| 2 | +// LibraryView.swift | |
| 3 | +// Zyquo Local | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import SwiftUI | |
| 10 | + | |
| 11 | +/// Model Library: "Installed" and "Discover" tabs + downloads drawer. | |
| 12 | +struct LibraryView: View { | |
| 13 | + enum Tab: String, CaseIterable { | |
| 14 | + case installed = "Installed" | |
| 15 | + case discover = "Discover" | |
| 16 | + } | |
| 17 | + | |
| 18 | + @Environment(AppModel.self) private var app | |
| 19 | + @State private var tab: Tab = .discover | |
| 20 | + | |
| 21 | + var body: some View { | |
| 22 | + VStack(spacing: 0) { | |
| 23 | + header | |
| 24 | + Rectangle().fill(ZyquoTheme.border).frame(height: ZyquoTheme.hairline) | |
| 25 | + | |
| 26 | + switch tab { | |
| 27 | + case .installed: InstalledModelsView() | |
| 28 | + case .discover: DiscoverView() | |
| 29 | + } | |
| 30 | + | |
| 31 | + if app.downloads.activeCount > 0 || app.downloads.tasks.contains(where: { $0.state == .paused || $0.state == .failed }) { | |
| 32 | + DownloadsDrawer() | |
| 33 | + } | |
| 34 | + } | |
| 35 | + .background(ZyquoTheme.background) | |
| 36 | + .onAppear { | |
| 37 | + tab = app.store.models.isEmpty ? .discover : .installed | |
| 38 | + } | |
| 39 | + } | |
| 40 | + | |
| 41 | + private var header: some View { | |
| 42 | + HStack { | |
| 43 | + Button { | |
| 44 | + app.route = .chat | |
| 45 | + } label: { | |
| 46 | + Label("Back", systemImage: "chevron.left") | |
| 47 | + .font(ZyquoTheme.body) | |
| 48 | + .foregroundStyle(ZyquoTheme.textSecondary) | |
| 49 | + } | |
| 50 | + .buttonStyle(.plain) | |
| 51 | + | |
| 52 | + Spacer() | |
| 53 | + | |
| 54 | + Picker("", selection: $tab) { | |
| 55 | + ForEach(Tab.allCases, id: \.self) { t in | |
| 56 | + Text(t.rawValue).tag(t) | |
| 57 | + } | |
| 58 | + } | |
| 59 | + .pickerStyle(.segmented) | |
| 60 | + .frame(width: 240) | |
| 61 | + | |
| 62 | + Spacer() | |
| 63 | + | |
| 64 | + if tab == .installed { | |
| 65 | + Text("\(formatBytes(app.store.totalSizeBytes)) on disk") | |
| 66 | + .font(ZyquoTheme.caption) | |
| 67 | + .foregroundStyle(ZyquoTheme.textSecondary) | |
| 68 | + } else { | |
| 69 | + Text("mlx-community · Hugging Face") | |
| 70 | + .font(ZyquoTheme.caption) | |
| 71 | + .foregroundStyle(ZyquoTheme.textTertiary) | |
| 72 | + } | |
| 73 | + } | |
| 74 | + .padding(.horizontal, ZyquoTheme.Spacing.m) | |
| 75 | + .frame(height: ZyquoTheme.chatHeaderHeight) | |
| 76 | + } | |
| 77 | +} | |
| 78 | + | |
| 79 | +/// Installed tab: downloaded models with verdicts and actions. | |
| 80 | +struct InstalledModelsView: View { | |
| 81 | + @Environment(AppModel.self) private var app | |
| 82 | + @State private var deleting: LocalModel? | |
| 83 | + | |
| 84 | + var body: some View { | |
| 85 | + Group { | |
| 86 | + if app.store.models.isEmpty { | |
| 87 | + VStack(spacing: ZyquoTheme.Spacing.s) { | |
| 88 | + Image(systemName: "square.stack.3d.up.slash") | |
| 89 | + .font(.system(size: 36, weight: .light)) | |
| 90 | + .foregroundStyle(ZyquoTheme.textTertiary) | |
| 91 | + Text("No models installed yet — find one in Discover.") | |
| 92 | + .font(ZyquoTheme.body) | |
| 93 | + .foregroundStyle(ZyquoTheme.textSecondary) | |
| 94 | + } | |
| 95 | + .frame(maxWidth: .infinity, maxHeight: .infinity) | |
| 96 | + } else { | |
| 97 | + ScrollView { | |
| 98 | + LazyVStack(spacing: ZyquoTheme.Spacing.s) { | |
| 99 | + ForEach(app.store.models) { model in | |
| 100 | + InstalledModelRow(model: model, deleting: $deleting) | |
| 101 | + } | |
| 102 | + } | |
| 103 | + .padding(ZyquoTheme.Spacing.l) | |
| 104 | + .frame(maxWidth: 860) | |
| 105 | + .frame(maxWidth: .infinity) | |
| 106 | + } | |
| 107 | + } | |
| 108 | + } | |
| 109 | + .confirmationDialog( | |
| 110 | + "Delete \(deleting.map { shortModelName($0.repoID) } ?? "model")?", | |
| 111 | + isPresented: Binding(get: { deleting != nil }, set: { if !$0 { deleting = nil } }), | |
| 112 | + titleVisibility: .visible | |
| 113 | + ) { | |
| 114 | + Button( | |
| 115 | + "Delete and reclaim \(formatBytes(deleting?.sizeBytes ?? 0))", | |
| 116 | + role: .destructive | |
| 117 | + ) { | |
| 118 | + if let model = deleting { | |
| 119 | + if app.loadedModelID == model.repoID { | |
| 120 | + Task { await app.unloadModel() } | |
| 121 | + } | |
| 122 | + app.store.delete(repoID: model.repoID) | |
| 123 | + } | |
| 124 | + deleting = nil | |
| 125 | + } | |
| 126 | + } | |
| 127 | + } | |
| 128 | +} | |
| 129 | + | |
| 130 | +struct InstalledModelRow: View { | |
| 131 | + let model: LocalModel | |
| 132 | + @Binding var deleting: LocalModel? | |
| 133 | + @Environment(AppModel.self) private var app | |
| 134 | + @State private var showParams = false | |
| 135 | + | |
| 136 | + private var isLoaded: Bool { app.loadedModelID == model.repoID } | |
| 137 | + private var verdict: MemoryAdvisor.Verdict { MemoryAdvisor.verdict(weightsBytes: model.sizeBytes) } | |
| 138 | + | |
| 139 | + var body: some View { | |
| 140 | + HStack(spacing: ZyquoTheme.Spacing.m) { | |
| 141 | + VStack(alignment: .leading, spacing: ZyquoTheme.Spacing.xxs) { | |
| 142 | + HStack(spacing: ZyquoTheme.Spacing.xs) { | |
| 143 | + Text(model.name) | |
| 144 | + .font(ZyquoTheme.bodyEmphasis) | |
| 145 | + .foregroundStyle(ZyquoTheme.textPrimary) | |
| 146 | + if isLoaded { | |
| 147 | + Text("Loaded") | |
| 148 | + .font(ZyquoTheme.caption.weight(.medium)) | |
| 149 | + .foregroundStyle(ZyquoTheme.accent) | |
| 150 | + .padding(.horizontal, ZyquoTheme.Spacing.xs) | |
| 151 | + .padding(.vertical, 1) | |
| 152 | + .background(ZyquoTheme.accentSubtle, in: Capsule()) | |
| 153 | + } | |
| 154 | + } | |
| 155 | + HStack(spacing: ZyquoTheme.Spacing.xxs) { | |
| 156 | + if let quant = model.quantization { InfoBadge(text: quant) } | |
| 157 | + if let arch = model.architecture { InfoBadge(text: arch) } | |
| 158 | + InfoBadge(text: formatBytes(model.sizeBytes)) | |
| 159 | + if let ctx = model.contextWindow { InfoBadge(text: "\(ctx / 1024)k ctx") } | |
| 160 | + } | |
| 161 | + if let lastUsed = model.lastUsed { | |
| 162 | + Text("Last used \(lastUsed.formatted(.relative(presentation: .named)))") | |
| 163 | + .font(ZyquoTheme.caption) | |
| 164 | + .foregroundStyle(ZyquoTheme.textTertiary) | |
| 165 | + } | |
| 166 | + } | |
| 167 | + Spacer() | |
| 168 | + VerdictBadge(verdict: verdict) | |
| 169 | + | |
| 170 | + HStack(spacing: ZyquoTheme.Spacing.xs) { | |
| 171 | + if isLoaded { | |
| 172 | + Button("Unload") { Task { await app.unloadModel() } } | |
| 173 | + .buttonStyle(.bordered) | |
| 174 | + } else { | |
| 175 | + Button("Load") { Task { await app.loadModel(repoID: model.repoID) } } | |
| 176 | + .buttonStyle(.borderedProminent) | |
| 177 | + .tint(ZyquoTheme.accent) | |
| 178 | + .disabled(verdict == .tooLarge) | |
| 179 | + } | |
| 180 | + Button { | |
| 181 | + var c = app.newConversation() | |
| 182 | + c.modelID = model.repoID | |
| 183 | + app.update(c, touch: false) | |
| 184 | + if !isLoaded { | |
| 185 | + Task { await app.loadModel(repoID: model.repoID) } | |
| 186 | + } | |
| 187 | + } label: { | |
| 188 | + Image(systemName: "bubble.left.and.text.bubble.right") | |
| 189 | + } | |
| 190 | + .buttonStyle(.bordered) | |
| 191 | + .help("New chat with this model") | |
| 192 | + | |
| 193 | + Menu { | |
| 194 | + Button("Default parameters…") { showParams = true } | |
| 195 | + Button("Reveal in Finder") { app.store.revealInFinder(repoID: model.repoID) } | |
| 196 | + Divider() | |
| 197 | + Button("Delete…", role: .destructive) { deleting = model } | |
| 198 | + } label: { | |
| 199 | + Image(systemName: "ellipsis.circle") | |
| 200 | + } | |
| 201 | + .menuStyle(.borderlessButton) | |
| 202 | + .menuIndicator(.hidden) | |
| 203 | + .frame(width: 26) | |
| 204 | + } | |
| 205 | + } | |
| 206 | + .padding(ZyquoTheme.Spacing.m) | |
| 207 | + .background(ZyquoTheme.surface, in: RoundedRectangle(cornerRadius: ZyquoTheme.Radius.m)) | |
| 208 | + .overlay( | |
| 209 | + RoundedRectangle(cornerRadius: ZyquoTheme.Radius.m) | |
| 210 | + .stroke(ZyquoTheme.border, lineWidth: ZyquoTheme.hairline) | |
| 211 | + ) | |
| 212 | + .popover(isPresented: $showParams) { | |
| 213 | + VStack(alignment: .leading, spacing: ZyquoTheme.Spacing.s) { | |
| 214 | + Text("Default parameters for \(model.name)") | |
| 215 | + .font(ZyquoTheme.bodyEmphasis) | |
| 216 | + ParamsEditor( | |
| 217 | + params: Binding( | |
| 218 | + get: { model.defaultParams ?? GenerationParams() }, | |
| 219 | + set: { app.store.setDefaultParams($0, for: model.repoID) } | |
| 220 | + ) | |
| 221 | + ) | |
| 222 | + Button("Reset to app defaults") { | |
| 223 | + app.store.setDefaultParams(nil, for: model.repoID) | |
| 224 | + } | |
| 225 | + .font(ZyquoTheme.caption) | |
| 226 | + } | |
| 227 | + .padding(ZyquoTheme.Spacing.m) | |
| 228 | + .frame(width: 300) | |
| 229 | + } | |
| 230 | + } | |
| 231 | +} | |
added
Sources/ZyquoLocal/Views/Library/ModelCards.swift
+261 −0
@@ -0,0 +1,261 @@ | ||
| 1 | +// | |
| 2 | +// ModelCards.swift | |
| 3 | +// Zyquo Local | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import SwiftUI | |
| 10 | + | |
| 11 | +/// Shared download-state footer: Download button that flips into a live | |
| 12 | +/// progress card (bar, MB/s, ETA, pause/cancel) and into Installed state. | |
| 13 | +struct DownloadStateFooter: View { | |
| 14 | + let repoID: String | |
| 15 | + /// Known download size (from catalog); live search cards fetch on demand. | |
| 16 | + var sizeHint: String? | |
| 17 | + @Environment(AppModel.self) private var app | |
| 18 | + | |
| 19 | + private var installed: Bool { app.store.model(for: repoID) != nil } | |
| 20 | + private var task: DownloadTask? { app.downloads.task(for: repoID) } | |
| 21 | + | |
| 22 | + var body: some View { | |
| 23 | + if installed { | |
| 24 | + HStack(spacing: ZyquoTheme.Spacing.xs) { | |
| 25 | + Label("Installed", systemImage: "checkmark.circle.fill") | |
| 26 | + .font(ZyquoTheme.caption.weight(.medium)) | |
| 27 | + .foregroundStyle(ZyquoTheme.success) | |
| 28 | + Spacer() | |
| 29 | + Button("Load") { | |
| 30 | + Task { await app.loadModel(repoID: repoID) } | |
| 31 | + } | |
| 32 | + .buttonStyle(.bordered) | |
| 33 | + .controlSize(.small) | |
| 34 | + } | |
| 35 | + } else if let task, task.state == .downloading || task.state == .verifying || task.state == .queued { | |
| 36 | + VStack(spacing: 4) { | |
| 37 | + ProgressView(value: task.fractionCompleted) | |
| 38 | + .tint(ZyquoTheme.accent) | |
| 39 | + .animation(.linear(duration: 0.3), value: task.fractionCompleted) | |
| 40 | + HStack(spacing: ZyquoTheme.Spacing.s) { | |
| 41 | + Text("\(formatBytes(task.receivedBytes)) / \(formatBytes(task.totalBytes))") | |
| 42 | + if let speed = app.downloads.speeds[repoID] { | |
| 43 | + Text(formatSpeed(speed)) | |
| 44 | + } | |
| 45 | + if let eta = app.downloads.eta(for: repoID) { | |
| 46 | + Text(formatETA(eta)) | |
| 47 | + } | |
| 48 | + Spacer() | |
| 49 | + Button { | |
| 50 | + app.downloads.pause(repoID: repoID) | |
| 51 | + } label: { | |
| 52 | + Image(systemName: "pause.fill") | |
| 53 | + } | |
| 54 | + .buttonStyle(.plain) | |
| 55 | + .help("Pause") | |
| 56 | + Button { | |
| 57 | + app.downloads.cancel(repoID: repoID) | |
| 58 | + } label: { | |
| 59 | + Image(systemName: "xmark") | |
| 60 | + } | |
| 61 | + .buttonStyle(.plain) | |
| 62 | + .help("Cancel and remove partial files") | |
| 63 | + } | |
| 64 | + .font(ZyquoTheme.caption.monospacedDigit()) | |
| 65 | + .foregroundStyle(ZyquoTheme.textTertiary) | |
| 66 | + } | |
| 67 | + } else if let task, task.state == .paused { | |
| 68 | + HStack { | |
| 69 | + Text("Paused at \(Int(task.fractionCompleted * 100))%") | |
| 70 | + .font(ZyquoTheme.caption) | |
| 71 | + .foregroundStyle(ZyquoTheme.textSecondary) | |
| 72 | + Spacer() | |
| 73 | + Button("Resume") { app.downloads.resume(repoID: repoID) } | |
| 74 | + .buttonStyle(.borderedProminent) | |
| 75 | + .tint(ZyquoTheme.accent) | |
| 76 | + .controlSize(.small) | |
| 77 | + Button("Cancel") { app.downloads.cancel(repoID: repoID) } | |
| 78 | + .buttonStyle(.bordered) | |
| 79 | + .controlSize(.small) | |
| 80 | + } | |
| 81 | + } else if let task, task.state == .failed { | |
| 82 | + HStack { | |
| 83 | + Text(task.errorDescription ?? "Download failed") | |
| 84 | + .font(ZyquoTheme.caption) | |
| 85 | + .foregroundStyle(ZyquoTheme.danger) | |
| 86 | + .lineLimit(2) | |
| 87 | + Spacer() | |
| 88 | + Button("Retry") { app.downloads.resume(repoID: repoID) } | |
| 89 | + .buttonStyle(.bordered) | |
| 90 | + .controlSize(.small) | |
| 91 | + } | |
| 92 | + } else { | |
| 93 | + HStack { | |
| 94 | + if let sizeHint { | |
| 95 | + Text(sizeHint) | |
| 96 | + .font(ZyquoTheme.caption) | |
| 97 | + .foregroundStyle(ZyquoTheme.textTertiary) | |
| 98 | + } | |
| 99 | + Spacer() | |
| 100 | + Button { | |
| 101 | + Task { await app.downloads.download(repoID: repoID) } | |
| 102 | + } label: { | |
| 103 | + Label("Download", systemImage: "arrow.down.circle") | |
| 104 | + .font(ZyquoTheme.bodyEmphasis) | |
| 105 | + } | |
| 106 | + .buttonStyle(.borderedProminent) | |
| 107 | + .tint(ZyquoTheme.accent) | |
| 108 | + .controlSize(.small) | |
| 109 | + } | |
| 110 | + } | |
| 111 | + } | |
| 112 | +} | |
| 113 | + | |
| 114 | +/// One curated catalog entry, editorial style. | |
| 115 | +struct FeaturedModelCard: View { | |
| 116 | + let model: CatalogModel | |
| 117 | + @Environment(AppModel.self) private var app | |
| 118 | + | |
| 119 | + var body: some View { | |
| 120 | + VStack(alignment: .leading, spacing: ZyquoTheme.Spacing.xs) { | |
| 121 | + HStack(spacing: ZyquoTheme.Spacing.xs) { | |
| 122 | + Text(shortModelName(model.repoID)) | |
| 123 | + .font(ZyquoTheme.bodyEmphasis) | |
| 124 | + .foregroundStyle(ZyquoTheme.textPrimary) | |
| 125 | + InfoBadge(text: model.params) | |
| 126 | + InfoBadge(text: model.quant) | |
| 127 | + Spacer() | |
| 128 | + VerdictBadge(verdict: model.verdict) | |
| 129 | + } | |
| 130 | + Text(model.blurb) | |
| 131 | + .font(ZyquoTheme.body) | |
| 132 | + .foregroundStyle(ZyquoTheme.textSecondary) | |
| 133 | + DownloadStateFooter( | |
| 134 | + repoID: model.repoID, | |
| 135 | + sizeHint: String(format: "%.1f GB download · needs %d GB+ Mac", model.sizeGB, model.minRAMGB) | |
| 136 | + ) | |
| 137 | + } | |
| 138 | + .padding(ZyquoTheme.Spacing.m) | |
| 139 | + .background(ZyquoTheme.surface, in: RoundedRectangle(cornerRadius: ZyquoTheme.Radius.m)) | |
| 140 | + .overlay( | |
| 141 | + RoundedRectangle(cornerRadius: ZyquoTheme.Radius.m) | |
| 142 | + .stroke(ZyquoTheme.border, lineWidth: ZyquoTheme.hairline) | |
| 143 | + ) | |
| 144 | + } | |
| 145 | +} | |
| 146 | + | |
| 147 | +/// One live Hub search result. | |
| 148 | +struct HubModelCard: View { | |
| 149 | + let summary: HubService.ModelSummary | |
| 150 | + @Environment(AppModel.self) private var app | |
| 151 | + @State private var totalBytes: Int64? | |
| 152 | + | |
| 153 | + var body: some View { | |
| 154 | + VStack(alignment: .leading, spacing: ZyquoTheme.Spacing.xs) { | |
| 155 | + HStack(spacing: ZyquoTheme.Spacing.xs) { | |
| 156 | + VStack(alignment: .leading, spacing: 1) { | |
| 157 | + Text(shortModelName(summary.id)) | |
| 158 | + .font(ZyquoTheme.bodyEmphasis) | |
| 159 | + .foregroundStyle(ZyquoTheme.textPrimary) | |
| 160 | + .lineLimit(1) | |
| 161 | + Text(summary.id.split(separator: "/").first.map(String.init) ?? "") | |
| 162 | + .font(ZyquoTheme.caption) | |
| 163 | + .foregroundStyle(ZyquoTheme.textTertiary) | |
| 164 | + } | |
| 165 | + Spacer() | |
| 166 | + if let downloads = summary.downloads { | |
| 167 | + Label("\(downloads.formatted(.number.notation(.compactName)))", systemImage: "arrow.down.circle") | |
| 168 | + .font(ZyquoTheme.caption) | |
| 169 | + .foregroundStyle(ZyquoTheme.textTertiary) | |
| 170 | + } | |
| 171 | + if summary.isGated { | |
| 172 | + Label("Gated", systemImage: "lock") | |
| 173 | + .font(ZyquoTheme.caption) | |
| 174 | + .foregroundStyle(ZyquoTheme.warning) | |
| 175 | + .help("Requires an approved Hugging Face token (Settings ▸ Models & Storage)") | |
| 176 | + } | |
| 177 | + if !summary.isSupportedArchitecture { | |
| 178 | + Label("Unsupported", systemImage: "exclamationmark.triangle") | |
| 179 | + .font(ZyquoTheme.caption) | |
| 180 | + .foregroundStyle(ZyquoTheme.danger) | |
| 181 | + .help("Architecture “\(summary.architecture ?? "?")” is not supported by the MLX engine") | |
| 182 | + } | |
| 183 | + if let totalBytes { | |
| 184 | + VerdictBadge(verdict: MemoryAdvisor.verdict(weightsBytes: totalBytes)) | |
| 185 | + } | |
| 186 | + } | |
| 187 | + DownloadStateFooter(repoID: summary.id, sizeHint: totalBytes.map(formatBytes)) | |
| 188 | + } | |
| 189 | + .padding(ZyquoTheme.Spacing.m) | |
| 190 | + .background(ZyquoTheme.surface, in: RoundedRectangle(cornerRadius: ZyquoTheme.Radius.m)) | |
| 191 | + .overlay( | |
| 192 | + RoundedRectangle(cornerRadius: ZyquoTheme.Radius.m) | |
| 193 | + .stroke(ZyquoTheme.border, lineWidth: ZyquoTheme.hairline) | |
| 194 | + ) | |
| 195 | + .task(id: summary.id) { | |
| 196 | + // Fetch true download size lazily per card. | |
| 197 | + guard totalBytes == nil else { return } | |
| 198 | + let hub = HubService(token: app.settings.hfToken.isEmpty ? nil : app.settings.hfToken) | |
| 199 | + totalBytes = try? await hub.requiredFiles(of: summary.id).totalBytes | |
| 200 | + } | |
| 201 | + } | |
| 202 | +} | |
| 203 | + | |
| 204 | +/// Bottom drawer listing all active/queued/paused downloads. | |
| 205 | +struct DownloadsDrawer: View { | |
| 206 | + @Environment(AppModel.self) private var app | |
| 207 | + | |
| 208 | + var body: some View { | |
| 209 | + VStack(spacing: 0) { | |
| 210 | + Rectangle().fill(ZyquoTheme.border).frame(height: ZyquoTheme.hairline) | |
| 211 | + ScrollView(.horizontal, showsIndicators: false) { | |
| 212 | + HStack(spacing: ZyquoTheme.Spacing.s) { | |
| 213 | + ForEach(app.downloads.tasks.filter { $0.state != .completed && $0.state != .cancelled }) { task in | |
| 214 | + HStack(spacing: ZyquoTheme.Spacing.xs) { | |
| 215 | + VStack(alignment: .leading, spacing: 2) { | |
| 216 | + Text(shortModelName(task.repoID)) | |
| 217 | + .font(ZyquoTheme.caption.weight(.medium)) | |
| 218 | + .lineLimit(1) | |
| 219 | + ProgressView(value: task.fractionCompleted) | |
| 220 | + .tint(ZyquoTheme.accent) | |
| 221 | + .frame(width: 140) | |
| 222 | + } | |
| 223 | + switch task.state { | |
| 224 | + case .downloading, .queued, .verifying: | |
| 225 | + Button { | |
| 226 | + app.downloads.pause(repoID: task.repoID) | |
| 227 | + } label: { | |
| 228 | + Image(systemName: "pause.fill").font(.system(size: 10)) | |
| 229 | + } | |
| 230 | + .buttonStyle(.plain) | |
| 231 | + case .paused, .failed: | |
| 232 | + Button { | |
| 233 | + app.downloads.resume(repoID: task.repoID) | |
| 234 | + } label: { | |
| 235 | + Image(systemName: "play.fill").font(.system(size: 10)) | |
| 236 | + } | |
| 237 | + .buttonStyle(.plain) | |
| 238 | + default: | |
| 239 | + EmptyView() | |
| 240 | + } | |
| 241 | + Button { | |
| 242 | + app.downloads.cancel(repoID: task.repoID) | |
| 243 | + } label: { | |
| 244 | + Image(systemName: "xmark").font(.system(size: 10)) | |
| 245 | + } | |
| 246 | + .buttonStyle(.plain) | |
| 247 | + } | |
| 248 | + .padding(ZyquoTheme.Spacing.xs) | |
| 249 | + .background(ZyquoTheme.surface, in: RoundedRectangle(cornerRadius: ZyquoTheme.Radius.s)) | |
| 250 | + .overlay( | |
| 251 | + RoundedRectangle(cornerRadius: ZyquoTheme.Radius.s) | |
| 252 | + .stroke(ZyquoTheme.border, lineWidth: ZyquoTheme.hairline) | |
| 253 | + ) | |
| 254 | + } | |
| 255 | + } | |
| 256 | + .padding(ZyquoTheme.Spacing.s) | |
| 257 | + } | |
| 258 | + } | |
| 259 | + .background(ZyquoTheme.background) | |
| 260 | + } | |
| 261 | +} | |
added
Sources/ZyquoLocal/Views/QuickChat/QuickChatPanel.swift
+206 −0
@@ -0,0 +1,206 @@ | ||
| 1 | +// | |
| 2 | +// QuickChatPanel.swift | |
| 3 | +// Zyquo Local | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import AppKit | |
| 10 | +import Carbon.HIToolbox | |
| 11 | +import SwiftUI | |
| 12 | + | |
| 13 | +/// Spotlight-style floating Quick Chat (⌥Space): one prompt, one streamed | |
| 14 | +/// answer from the currently loaded model; offers one-click load of the | |
| 15 | +/// last-used model when none is loaded. | |
| 16 | +@MainActor | |
| 17 | +final class QuickChatPanelController { | |
| 18 | + static let shared = QuickChatPanelController() | |
| 19 | + | |
| 20 | + private var panel: NSPanel? | |
| 21 | + private var hotKeyRef: EventHotKeyRef? | |
| 22 | + private weak var app: AppModel? | |
| 23 | + | |
| 24 | + func setup(app: AppModel) { | |
| 25 | + self.app = app | |
| 26 | + registerHotKey() | |
| 27 | + } | |
| 28 | + | |
| 29 | + func toggle() { | |
| 30 | + if let panel, panel.isVisible { | |
| 31 | + panel.orderOut(nil) | |
| 32 | + return | |
| 33 | + } | |
| 34 | + show() | |
| 35 | + } | |
| 36 | + | |
| 37 | + private func show() { | |
| 38 | + guard let app else { return } | |
| 39 | + let panel: NSPanel | |
| 40 | + if let existing = self.panel { | |
| 41 | + panel = existing | |
| 42 | + } else { | |
| 43 | + panel = NSPanel( | |
| 44 | + contentRect: NSRect(x: 0, y: 0, width: 560, height: 120), | |
| 45 | + styleMask: [.nonactivatingPanel, .titled, .fullSizeContentView], | |
| 46 | + backing: .buffered, defer: false | |
| 47 | + ) | |
| 48 | + panel.titleVisibility = .hidden | |
| 49 | + panel.titlebarAppearsTransparent = true | |
| 50 | + panel.isMovableByWindowBackground = true | |
| 51 | + panel.level = .floating | |
| 52 | + panel.collectionBehavior = [.canJoinAllSpaces, .transient] | |
| 53 | + panel.isFloatingPanel = true | |
| 54 | + panel.becomesKeyOnlyIfNeeded = false | |
| 55 | + panel.hidesOnDeactivate = false | |
| 56 | + panel.backgroundColor = .clear | |
| 57 | + panel.isOpaque = false | |
| 58 | + let host = NSHostingView( | |
| 59 | + rootView: QuickChatView(close: { [weak panel] in panel?.orderOut(nil) }) | |
| 60 | + .environment(app) | |
| 61 | + ) | |
| 62 | + panel.contentView = host | |
| 63 | + self.panel = panel | |
| 64 | + } | |
| 65 | + if let screen = NSScreen.main { | |
| 66 | + let frame = screen.visibleFrame | |
| 67 | + let size = panel.frame.size | |
| 68 | + panel.setFrameOrigin(NSPoint( | |
| 69 | + x: frame.midX - size.width / 2, | |
| 70 | + y: frame.midY + frame.height * 0.12 | |
| 71 | + )) | |
| 72 | + } | |
| 73 | + panel.makeKeyAndOrderFront(nil) | |
| 74 | + NSApp.activate(ignoringOtherApps: true) | |
| 75 | + } | |
| 76 | + | |
| 77 | + /// Carbon global hotkey: ⌥Space. Works without accessibility permission. | |
| 78 | + private func registerHotKey() { | |
| 79 | + guard hotKeyRef == nil else { return } | |
| 80 | + let hotKeyID = EventHotKeyID(signature: OSType(0x5A51_4348), id: 1) // "ZQCH" | |
| 81 | + var eventType = EventTypeSpec( | |
| 82 | + eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed)) | |
| 83 | + InstallEventHandler( | |
| 84 | + GetApplicationEventTarget(), | |
| 85 | + { _, _, _ in | |
| 86 | + Task { @MainActor in | |
| 87 | + QuickChatPanelController.shared.toggle() | |
| 88 | + } | |
| 89 | + return noErr | |
| 90 | + }, | |
| 91 | + 1, &eventType, nil, nil | |
| 92 | + ) | |
| 93 | + RegisterEventHotKey( | |
| 94 | + UInt32(kVK_Space), UInt32(optionKey), hotKeyID, | |
| 95 | + GetApplicationEventTarget(), 0, &hotKeyRef | |
| 96 | + ) | |
| 97 | + } | |
| 98 | +} | |
| 99 | + | |
| 100 | +/// The panel content. | |
| 101 | +struct QuickChatView: View { | |
| 102 | + let close: () -> Void | |
| 103 | + @Environment(AppModel.self) private var app | |
| 104 | + @State private var prompt = "" | |
| 105 | + @State private var response = "" | |
| 106 | + @State private var generating = false | |
| 107 | + @FocusState private var focused: Bool | |
| 108 | + | |
| 109 | + var body: some View { | |
| 110 | + VStack(spacing: 0) { | |
| 111 | + HStack(spacing: ZyquoTheme.Spacing.s) { | |
| 112 | + ZyquoGlyph(size: 18) | |
| 113 | + TextField(fieldHint, text: $prompt) | |
| 114 | + .textFieldStyle(.plain) | |
| 115 | + .font(.system(size: 17)) | |
| 116 | + .focused($focused) | |
| 117 | + .onSubmit(submit) | |
| 118 | + .disabled(app.loadedModelID == nil) | |
| 119 | + if generating { | |
| 120 | + ProgressView().controlSize(.small) | |
| 121 | + } | |
| 122 | + } | |
| 123 | + .padding(ZyquoTheme.Spacing.m) | |
| 124 | + | |
| 125 | + if app.loadedModelID == nil { | |
| 126 | + loadHint | |
| 127 | + } | |
| 128 | + | |
| 129 | + if !response.isEmpty { | |
| 130 | + Rectangle().fill(ZyquoTheme.border).frame(height: ZyquoTheme.hairline) | |
| 131 | + ScrollView { | |
| 132 | + MarkdownText(response) | |
| 133 | + .padding(ZyquoTheme.Spacing.m) | |
| 134 | + .frame(maxWidth: .infinity, alignment: .leading) | |
| 135 | + } | |
| 136 | + .frame(maxHeight: 340) | |
| 137 | + } | |
| 138 | + } | |
| 139 | + .frame(width: 560) | |
| 140 | + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: ZyquoTheme.Radius.l)) | |
| 141 | + .overlay( | |
| 142 | + RoundedRectangle(cornerRadius: ZyquoTheme.Radius.l) | |
| 143 | + .stroke(ZyquoTheme.border, lineWidth: ZyquoTheme.hairline) | |
| 144 | + ) | |
| 145 | + .onAppear { focused = true } | |
| 146 | + .onExitCommand(perform: close) | |
| 147 | + } | |
| 148 | + | |
| 149 | + private var fieldHint: String { | |
| 150 | + if let repoID = app.loadedModelID { | |
| 151 | + return "Ask \(shortModelName(repoID))…" | |
| 152 | + } | |
| 153 | + return "No model loaded" | |
| 154 | + } | |
| 155 | + | |
| 156 | + @ViewBuilder | |
| 157 | + private var loadHint: some View { | |
| 158 | + if let lastUsed = app.store.models.first { | |
| 159 | + HStack { | |
| 160 | + Text("Load \(lastUsed.name) to use Quick Chat") | |
| 161 | + .font(ZyquoTheme.caption) | |
| 162 | + .foregroundStyle(ZyquoTheme.textSecondary) | |
| 163 | + Spacer() | |
| 164 | + Button("Load") { | |
| 165 | + Task { await app.loadModel(repoID: lastUsed.repoID) } | |
| 166 | + } | |
| 167 | + .buttonStyle(.borderedProminent) | |
| 168 | + .tint(ZyquoTheme.accent) | |
| 169 | + .controlSize(.small) | |
| 170 | + } | |
| 171 | + .padding(.horizontal, ZyquoTheme.Spacing.m) | |
| 172 | + .padding(.bottom, ZyquoTheme.Spacing.s) | |
| 173 | + } else { | |
| 174 | + Text("Download a model in the Library first.") | |
| 175 | + .font(ZyquoTheme.caption) | |
| 176 | + .foregroundStyle(ZyquoTheme.textSecondary) | |
| 177 | + .padding(.bottom, ZyquoTheme.Spacing.s) | |
| 178 | + } | |
| 179 | + } | |
| 180 | + | |
| 181 | + private func submit() { | |
| 182 | + let text = prompt.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 183 | + guard !text.isEmpty, !generating, app.loadedModelID != nil else { return } | |
| 184 | + response = "" | |
| 185 | + generating = true | |
| 186 | + Task { | |
| 187 | + do { | |
| 188 | + // Ephemeral session; the main conversation session is | |
| 189 | + // restored on its next message. | |
| 190 | + let temp = Conversation(params: app.settings.defaultParams) | |
| 191 | + try await app.engine.startSession(conversation: temp) | |
| 192 | + var parser = ThinkTagParser() | |
| 193 | + let events = try await app.engine.generate(prompt: text, params: temp.params) | |
| 194 | + for try await event in events { | |
| 195 | + if case .token(let t) = event { | |
| 196 | + let (visible, _, _) = parser.consume(t) | |
| 197 | + if !visible.isEmpty { response += visible } | |
| 198 | + } | |
| 199 | + } | |
| 200 | + } catch { | |
| 201 | + response = "*\(error.localizedDescription)*" | |
| 202 | + } | |
| 203 | + generating = false | |
| 204 | + } | |
| 205 | + } | |
| 206 | +} | |
added
Sources/ZyquoLocal/Views/RootView.swift
+45 −0
@@ -0,0 +1,45 @@ | ||
| 1 | +// | |
| 2 | +// RootView.swift | |
| 3 | +// Zyquo Local | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import SwiftUI | |
| 10 | + | |
| 11 | +/// Main window: sidebar + detail column (chat or model library). | |
| 12 | +struct RootView: View { | |
| 13 | + @Environment(AppModel.self) private var app | |
| 14 | + private var theme = ThemeStore.shared | |
| 15 | + | |
| 16 | + init() {} | |
| 17 | + | |
| 18 | + var body: some View { | |
| 19 | + NavigationSplitView { | |
| 20 | + SidebarView() | |
| 21 | + .navigationSplitViewColumnWidth( | |
| 22 | + min: ZyquoTheme.sidebarWidth, ideal: ZyquoTheme.sidebarWidth, max: 320) | |
| 23 | + } detail: { | |
| 24 | + switch app.route { | |
| 25 | + case .chat: | |
| 26 | + ChatView() | |
| 27 | + case .library: | |
| 28 | + LibraryView() | |
| 29 | + } | |
| 30 | + } | |
| 31 | + .background(ZyquoTheme.background) | |
| 32 | + .preferredColorScheme(theme.mode.colorScheme) | |
| 33 | + .alert( | |
| 34 | + "Something went wrong", | |
| 35 | + isPresented: Binding( | |
| 36 | + get: { app.lastError != nil }, | |
| 37 | + set: { if !$0 { app.lastError = nil } } | |
| 38 | + ) | |
| 39 | + ) { | |
| 40 | + Button("OK") { app.lastError = nil } | |
| 41 | + } message: { | |
| 42 | + Text(app.lastError ?? "") | |
| 43 | + } | |
| 44 | + } | |
| 45 | +} | |
added
Sources/ZyquoLocal/Views/Settings/SettingsView.swift
+325 −0
@@ -0,0 +1,325 @@ | ||
| 1 | +// | |
| 2 | +// SettingsView.swift | |
| 3 | +// Zyquo Local | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import SwiftUI | |
| 10 | + | |
| 11 | +/// Native tabbed settings (720×520): General, Models & Storage, Inference, | |
| 12 | +/// Appearance, Shortcuts, Advanced. | |
| 13 | +struct SettingsView: View { | |
| 14 | + @Environment(AppModel.self) private var app | |
| 15 | + | |
| 16 | + var body: some View { | |
| 17 | + TabView { | |
| 18 | + GeneralSettings() | |
| 19 | + .tabItem { Label("General", systemImage: "gearshape") } | |
| 20 | + ModelsStorageSettings() | |
| 21 | + .tabItem { Label("Models & Storage", systemImage: "internaldrive") } | |
| 22 | + InferenceSettings() | |
| 23 | + .tabItem { Label("Inference", systemImage: "cpu") } | |
| 24 | + AppearanceSettings() | |
| 25 | + .tabItem { Label("Appearance", systemImage: "paintpalette") } | |
| 26 | + ShortcutsSettings() | |
| 27 | + .tabItem { Label("Shortcuts", systemImage: "keyboard") } | |
| 28 | + AdvancedSettings() | |
| 29 | + .tabItem { Label("Advanced", systemImage: "wrench.and.screwdriver") } | |
| 30 | + } | |
| 31 | + .frame(width: 720, height: 520) | |
| 32 | + } | |
| 33 | +} | |
| 34 | + | |
| 35 | +private struct GeneralSettings: View { | |
| 36 | + @Environment(AppModel.self) private var app | |
| 37 | + | |
| 38 | + var body: some View { | |
| 39 | + @Bindable var settings = app.settings | |
| 40 | + Form { | |
| 41 | + Picker("Load on launch:", selection: $settings.defaultModelID) { | |
| 42 | + Text("None").tag(String?.none) | |
| 43 | + ForEach(app.store.models) { model in | |
| 44 | + Text(model.name).tag(String?.some(model.repoID)) | |
| 45 | + } | |
| 46 | + } | |
| 47 | + .help("Model loaded automatically when Zyquo Local starts") | |
| 48 | + | |
| 49 | + Toggle("Keep model loaded in the background", isOn: $settings.keepModelLoaded) | |
| 50 | + .help("When off, the model unloads and frees memory when all windows close") | |
| 51 | + | |
| 52 | + Toggle("Show menu bar extra", isOn: $settings.menuBarExtraEnabled) | |
| 53 | + | |
| 54 | + LabeledContent("Default system prompt:") { | |
| 55 | + TextEditor(text: $settings.defaultSystemPrompt) | |
| 56 | + .font(ZyquoTheme.body) | |
| 57 | + .frame(height: 90) | |
| 58 | + .overlay( | |
| 59 | + RoundedRectangle(cornerRadius: ZyquoTheme.Radius.s) | |
| 60 | + .stroke(ZyquoTheme.border, lineWidth: ZyquoTheme.hairline) | |
| 61 | + ) | |
| 62 | + } | |
| 63 | + } | |
| 64 | + .formStyle(.grouped) | |
| 65 | + .padding(ZyquoTheme.Spacing.m) | |
| 66 | + } | |
| 67 | +} | |
| 68 | + | |
| 69 | +private struct ModelsStorageSettings: View { | |
| 70 | + @Environment(AppModel.self) private var app | |
| 71 | + @State private var tokenVisible = false | |
| 72 | + | |
| 73 | + var body: some View { | |
| 74 | + @Bindable var settings = app.settings | |
| 75 | + Form { | |
| 76 | + LabeledContent("Models folder:") { | |
| 77 | + HStack { | |
| 78 | + Text(app.store.modelsRoot.path) | |
| 79 | + .font(ZyquoTheme.caption) | |
| 80 | + .foregroundStyle(ZyquoTheme.textSecondary) | |
| 81 | + .truncationMode(.middle) | |
| 82 | + .lineLimit(1) | |
| 83 | + Button("Reveal") { | |
| 84 | + NSWorkspace.shared.activateFileViewerSelecting([app.store.modelsRoot]) | |
| 85 | + } | |
| 86 | + } | |
| 87 | + } | |
| 88 | + LabeledContent("Total usage:") { | |
| 89 | + Text("\(formatBytes(app.store.totalSizeBytes)) across \(app.store.models.count) models") | |
| 90 | + } | |
| 91 | + | |
| 92 | + Section("Hugging Face") { | |
| 93 | + LabeledContent("Access token:") { | |
| 94 | + HStack { | |
| 95 | + Group { | |
| 96 | + if tokenVisible { | |
| 97 | + TextField("hf_…", text: $settings.hfToken) | |
| 98 | + } else { | |
| 99 | + SecureField("hf_…", text: $settings.hfToken) | |
| 100 | + } | |
| 101 | + } | |
| 102 | + .textFieldStyle(.roundedBorder) | |
| 103 | + .frame(width: 260) | |
| 104 | + .onChange(of: settings.hfToken) { app.refreshToken() } | |
| 105 | + | |
| 106 | + Button { | |
| 107 | + tokenVisible.toggle() | |
| 108 | + } label: { | |
| 109 | + Image(systemName: tokenVisible ? "eye.slash" : "eye") | |
| 110 | + } | |
| 111 | + .buttonStyle(.plain) | |
| 112 | + } | |
| 113 | + } | |
| 114 | + Text("Needed only for gated models (Llama, Gemma…). Sent exclusively to huggingface.co.") | |
| 115 | + .font(ZyquoTheme.caption) | |
| 116 | + .foregroundStyle(ZyquoTheme.textTertiary) | |
| 117 | + | |
| 118 | + Toggle("Verify file sizes after download", isOn: $settings.autoVerifyDownloads) | |
| 119 | + } | |
| 120 | + } | |
| 121 | + .formStyle(.grouped) | |
| 122 | + .padding(ZyquoTheme.Spacing.m) | |
| 123 | + } | |
| 124 | +} | |
| 125 | + | |
| 126 | +private struct InferenceSettings: View { | |
| 127 | + @Environment(AppModel.self) private var app | |
| 128 | + | |
| 129 | + var body: some View { | |
| 130 | + @Bindable var settings = app.settings | |
| 131 | + Form { | |
| 132 | + Section("Default generation parameters") { | |
| 133 | + ParamsEditor(params: $settings.defaultParams) | |
| 134 | + Text("Used for new conversations; each conversation can override them.") | |
| 135 | + .font(ZyquoTheme.caption) | |
| 136 | + .foregroundStyle(ZyquoTheme.textTertiary) | |
| 137 | + } | |
| 138 | + Section("Engine") { | |
| 139 | + LabeledContent("GPU cache limit:") { | |
| 140 | + HStack { | |
| 141 | + TextField( | |
| 142 | + "0", | |
| 143 | + value: $settings.gpuCacheLimitMB, | |
| 144 | + format: .number | |
| 145 | + ) | |
| 146 | + .textFieldStyle(.roundedBorder) | |
| 147 | + .frame(width: 90) | |
| 148 | + .onChange(of: settings.gpuCacheLimitMB) { app.applyGPUCacheLimit() } | |
| 149 | + Text("MB (0 = automatic)") | |
| 150 | + .foregroundStyle(ZyquoTheme.textSecondary) | |
| 151 | + } | |
| 152 | + } | |
| 153 | + .help("Caps MLX's buffer cache; lower values return memory to macOS sooner") | |
| 154 | + LabeledContent("Context length cap:") { | |
| 155 | + HStack { | |
| 156 | + TextField("0", value: $settings.contextLengthCap, format: .number) | |
| 157 | + .textFieldStyle(.roundedBorder) | |
| 158 | + .frame(width: 90) | |
| 159 | + Text("tokens (0 = model maximum)") | |
| 160 | + .foregroundStyle(ZyquoTheme.textSecondary) | |
| 161 | + } | |
| 162 | + } | |
| 163 | + } | |
| 164 | + } | |
| 165 | + .formStyle(.grouped) | |
| 166 | + .padding(ZyquoTheme.Spacing.m) | |
| 167 | + } | |
| 168 | +} | |
| 169 | + | |
| 170 | +private struct AppearanceSettings: View { | |
| 171 | + @State private var theme = ThemeStore.shared | |
| 172 | + | |
| 173 | + var body: some View { | |
| 174 | + @Bindable var theme = theme | |
| 175 | + Form { | |
| 176 | + Picker("Theme:", selection: $theme.mode) { | |
| 177 | + ForEach(ThemeStore.Mode.allCases, id: \.self) { Text($0.label).tag($0) } | |
| 178 | + } | |
| 179 | + .pickerStyle(.segmented) | |
| 180 | + | |
| 181 | + LabeledContent("Accent:") { | |
| 182 | + HStack(spacing: ZyquoTheme.Spacing.s) { | |
| 183 | + ForEach(AccentChoice.allCases, id: \.self) { choice in | |
| 184 | + Button { | |
| 185 | + theme.accentChoice = choice | |
| 186 | + } label: { | |
| 187 | + Circle() | |
| 188 | + .fill(choice.color) | |
| 189 | + .frame(width: 22, height: 22) | |
| 190 | + .overlay( | |
| 191 | + Circle().stroke( | |
| 192 | + theme.accentChoice == choice ? ZyquoTheme.textPrimary : .clear, | |
| 193 | + lineWidth: 2 | |
| 194 | + ) | |
| 195 | + .padding(-3) | |
| 196 | + ) | |
| 197 | + } | |
| 198 | + .buttonStyle(.plain) | |
| 199 | + .help(choice.label) | |
| 200 | + } | |
| 201 | + } | |
| 202 | + } | |
| 203 | + | |
| 204 | + Section("Chat text size") { | |
| 205 | + Slider( | |
| 206 | + value: Binding( | |
| 207 | + get: { theme.chatFontSize }, | |
| 208 | + set: { theme.chatFontSize = $0 } | |
| 209 | + ), | |
| 210 | + in: 12...18, step: 0.5 | |
| 211 | + ) { | |
| 212 | + Text("Size") | |
| 213 | + } minimumValueLabel: { | |
| 214 | + Text("A").font(.system(size: 11)) | |
| 215 | + } maximumValueLabel: { | |
| 216 | + Text("A").font(.system(size: 17)) | |
| 217 | + } | |
| 218 | + // Live preview | |
| 219 | + VStack(alignment: .leading, spacing: ZyquoTheme.Spacing.xs) { | |
| 220 | + Text("Live preview — the quick brown fox jumps over the lazy dog.") | |
| 221 | + .font(ZyquoTheme.chatBody) | |
| 222 | + .padding(ZyquoTheme.Spacing.s) | |
| 223 | + .frame(maxWidth: .infinity, alignment: .leading) | |
| 224 | + .background(ZyquoTheme.surface, in: RoundedRectangle(cornerRadius: ZyquoTheme.Radius.m)) | |
| 225 | + .overlay( | |
| 226 | + RoundedRectangle(cornerRadius: ZyquoTheme.Radius.m) | |
| 227 | + .stroke(ZyquoTheme.border, lineWidth: ZyquoTheme.hairline) | |
| 228 | + ) | |
| 229 | + } | |
| 230 | + } | |
| 231 | + } | |
| 232 | + .formStyle(.grouped) | |
| 233 | + .padding(ZyquoTheme.Spacing.m) | |
| 234 | + } | |
| 235 | +} | |
| 236 | + | |
| 237 | +private struct ShortcutsSettings: View { | |
| 238 | + private let shortcuts: [(String, String)] = [ | |
| 239 | + ("⌘N", "New chat"), | |
| 240 | + ("⌘K", "Model switcher"), | |
| 241 | + ("⌘L", "Model library"), | |
| 242 | + ("⌘F", "Search chats"), | |
| 243 | + ("⌘↩", "Send message"), | |
| 244 | + ("⇧⌘E", "Export conversation"), | |
| 245 | + ("⌥Space", "Quick Chat panel (global)"), | |
| 246 | + ("⌘,", "Settings"), | |
| 247 | + ] | |
| 248 | + | |
| 249 | + var body: some View { | |
| 250 | + Form { | |
| 251 | + ForEach(shortcuts, id: \.0) { pair in | |
| 252 | + LabeledContent(pair.1) { | |
| 253 | + Text(pair.0) | |
| 254 | + .font(ZyquoTheme.code) | |
| 255 | + .padding(.horizontal, ZyquoTheme.Spacing.xs) | |
| 256 | + .padding(.vertical, 2) | |
| 257 | + .background(ZyquoTheme.surfaceSecondary, in: RoundedRectangle(cornerRadius: ZyquoTheme.Radius.s)) | |
| 258 | + } | |
| 259 | + } | |
| 260 | + } | |
| 261 | + .formStyle(.grouped) | |
| 262 | + .padding(ZyquoTheme.Spacing.m) | |
| 263 | + } | |
| 264 | +} | |
| 265 | + | |
| 266 | +private struct AdvancedSettings: View { | |
| 267 | + @Environment(AppModel.self) private var app | |
| 268 | + @State private var importMessage: String? | |
| 269 | + | |
| 270 | + var body: some View { | |
| 271 | + Form { | |
| 272 | + LabeledContent("Data folder:") { | |
| 273 | + Button("Reveal in Finder") { | |
| 274 | + NSWorkspace.shared.activateFileViewerSelecting([PersistenceService.appSupportDirectory]) | |
| 275 | + } | |
| 276 | + } | |
| 277 | + Section("Conversations") { | |
| 278 | + HStack { | |
| 279 | + Button("Export all…") { exportAll() } | |
| 280 | + Button("Import…") { importConversations() } | |
| 281 | + } | |
| 282 | + if let importMessage { | |
| 283 | + Text(importMessage) | |
| 284 | + .font(ZyquoTheme.caption) | |
| 285 | + .foregroundStyle(ZyquoTheme.textSecondary) | |
| 286 | + } | |
| 287 | + } | |
| 288 | + } | |
| 289 | + .formStyle(.grouped) | |
| 290 | + .padding(ZyquoTheme.Spacing.m) | |
| 291 | + } | |
| 292 | + | |
| 293 | + private func exportAll() { | |
| 294 | + let panel = NSSavePanel() | |
| 295 | + panel.allowedContentTypes = [.json] | |
| 296 | + panel.nameFieldStringValue = "ZyquoLocal-conversations.json" | |
| 297 | + guard panel.runModal() == .OK, let url = panel.url else { return } | |
| 298 | + let encoder = JSONEncoder() | |
| 299 | + encoder.dateEncodingStrategy = .iso8601 | |
| 300 | + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] | |
| 301 | + try? encoder.encode(app.conversations).write(to: url) | |
| 302 | + } | |
| 303 | + | |
| 304 | + private func importConversations() { | |
| 305 | + let panel = NSOpenPanel() | |
| 306 | + panel.allowedContentTypes = [.json] | |
| 307 | + guard panel.runModal() == .OK, let url = panel.url, | |
| 308 | + let data = try? Data(contentsOf: url) | |
| 309 | + else { return } | |
| 310 | + let decoder = JSONDecoder() | |
| 311 | + decoder.dateDecodingStrategy = .iso8601 | |
| 312 | + guard let imported = try? decoder.decode([Conversation].self, from: data) else { | |
| 313 | + importMessage = "Could not read that file as Zyquo Local conversations." | |
| 314 | + return | |
| 315 | + } | |
| 316 | + var added = 0 | |
| 317 | + for conversation in imported where !app.conversations.contains(where: { $0.id == conversation.id }) { | |
| 318 | + app.conversations.append(conversation) | |
| 319 | + PersistenceService.save(conversation) | |
| 320 | + added += 1 | |
| 321 | + } | |
| 322 | + app.conversations.sort { $0.updatedAt > $1.updatedAt } | |
| 323 | + importMessage = "Imported \(added) conversation\(added == 1 ? "" : "s")." | |
| 324 | + } | |
| 325 | +} | |
added
Sources/ZyquoLocal/Views/SidebarView.swift
+264 −0
@@ -0,0 +1,264 @@ | ||
| 1 | +// | |
| 2 | +// SidebarView.swift | |
| 3 | +// Zyquo Local | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | + | |
| 9 | +import SwiftUI | |
| 10 | + | |
| 11 | +/// Translucent 260 pt sidebar: wordmark, Chats (search + groups), Library | |
| 12 | +/// entry with live download badge, footer with settings + loaded-model chip. | |
| 13 | +struct SidebarView: View { | |
| 14 | + @Environment(AppModel.self) private var app | |
| 15 | + @Environment(\.openSettings) private var openSettings | |
| 16 | + @State private var query = "" | |
| 17 | + @State private var renamingID: UUID? | |
| 18 | + @State private var renameText = "" | |
| 19 | + @FocusState private var searchFocused: Bool | |
| 20 | + | |
| 21 | + var body: some View { | |
| 22 | + VStack(spacing: 0) { | |
| 23 | + wordmark | |
| 24 | + .padding(.horizontal, ZyquoTheme.Spacing.m) | |
| 25 | + .padding(.top, ZyquoTheme.Spacing.s) | |
| 26 | + | |
| 27 | + searchField | |
| 28 | + .padding(.horizontal, ZyquoTheme.Spacing.s) | |
| 29 | + .padding(.top, ZyquoTheme.Spacing.s) | |
| 30 | + | |
| 31 | + List(selection: selectionBinding) { | |
| 32 | + Section { | |
| 33 | + newChatButton | |
| 34 | + libraryRow | |
| 35 | + } | |
| 36 | + ForEach(app.sidebarGroups(query: query)) { group in | |
| 37 | + Section(group.title) { | |
| 38 | + ForEach(group.conversations) { conversation in | |
| 39 | + conversationRow(conversation) | |
| 40 | + .tag(conversation.id) | |
| 41 | + } | |
| 42 | + } | |
| 43 | + } | |
| 44 | + } | |
| 45 | + .scrollContentBackground(.hidden) | |
| 46 | + .listStyle(.sidebar) | |
| 47 | + | |
| 48 | + footer | |
| 49 | + } | |
| 50 | + .background(.regularMaterial) | |
| 51 | + .onReceive(NotificationCenter.default.publisher(for: .zyquoFocusSearch)) { _ in | |
| 52 | + searchFocused = true | |
| 53 | + } | |
| 54 | + } | |
| 55 | + | |
| 56 | + private var selectionBinding: Binding<UUID?> { | |
| 57 | + Binding( | |
| 58 | + get: { app.route == .chat ? app.selectedConversationID : nil }, | |
| 59 | + set: { id in | |
| 60 | + if let id { | |
| 61 | + app.selectedConversationID = id | |
| 62 | + app.route = .chat | |
| 63 | + } | |
| 64 | + } | |
| 65 | + ) | |
| 66 | + } | |
| 67 | + | |
| 68 | + private var wordmark: some View { | |
| 69 | + HStack(spacing: ZyquoTheme.Spacing.xs) { | |
| 70 | + ZyquoGlyph(size: 18) | |
| 71 | + Text("Zyquo Local") | |
| 72 | + .font(ZyquoTheme.bodyEmphasis) | |
| 73 | + .foregroundStyle(ZyquoTheme.textPrimary) | |
| 74 | + Spacer() | |
| 75 | + } | |
| 76 | + } | |
| 77 | + | |
| 78 | + private var searchField: some View { | |
| 79 | + HStack(spacing: ZyquoTheme.Spacing.xxs) { | |
| 80 | + Image(systemName: "magnifyingglass") | |
| 81 | + .font(.system(size: 11)) | |
| 82 | + .foregroundStyle(ZyquoTheme.textTertiary) | |
| 83 | + TextField("Search chats", text: $query) | |
| 84 | + .textFieldStyle(.plain) | |
| 85 | + .font(ZyquoTheme.body) | |
| 86 | + .focused($searchFocused) | |
| 87 | + } | |
| 88 | + .padding(.horizontal, ZyquoTheme.Spacing.xs) | |
| 89 | + .padding(.vertical, 5) | |
| 90 | + .background(ZyquoTheme.surfaceSecondary.opacity(0.6), in: RoundedRectangle(cornerRadius: ZyquoTheme.Radius.s)) | |
| 91 | + .overlay( | |
| 92 | + RoundedRectangle(cornerRadius: ZyquoTheme.Radius.s) | |
| 93 | + .stroke(ZyquoTheme.border, lineWidth: ZyquoTheme.hairline) | |
| 94 | + ) | |
| 95 | + } | |
| 96 | + | |
| 97 | + private var newChatButton: some View { | |
| 98 | + Button { | |
| 99 | + app.newConversation() | |
| 100 | + } label: { | |
| 101 | + Label("New Chat", systemImage: "square.and.pencil") | |
| 102 | + .font(ZyquoTheme.bodyEmphasis) | |
| 103 | + .foregroundStyle(ZyquoTheme.accent) | |
| 104 | + } | |
| 105 | + .buttonStyle(.plain) | |
| 106 | + .keyboardShortcut("n", modifiers: .command) | |
| 107 | + } | |
| 108 | + | |
| 109 | + private var libraryRow: some View { | |
| 110 | + Button { | |
| 111 | + app.route = .library | |
| 112 | + } label: { | |
| 113 | + HStack { | |
| 114 | + Label("Library", systemImage: "square.stack.3d.up") | |
| 115 | + .font(ZyquoTheme.body) | |
| 116 | + Spacer() | |
| 117 | + if app.downloads.activeCount > 0 { | |
| 118 | + HStack(spacing: ZyquoTheme.Spacing.xxs) { | |
| 119 | + ProgressView(value: app.downloads.overallFraction) | |
| 120 | + .progressViewStyle(.circular) | |
| 121 | + .controlSize(.mini) | |
| 122 | + Text("\(app.downloads.activeCount)") | |
| 123 | + .font(ZyquoTheme.caption) | |
| 124 | + .foregroundStyle(.white) | |
| 125 | + .padding(.horizontal, 6) | |
| 126 | + .padding(.vertical, 1) | |
| 127 | + .background(ZyquoTheme.accent, in: Capsule()) | |
| 128 | + } | |
| 129 | + } | |
| 130 | + } | |
| 131 | + .contentShape(Rectangle()) | |
| 132 | + } | |
| 133 | + .buttonStyle(.plain) | |
| 134 | + .keyboardShortcut("l", modifiers: .command) | |
| 135 | + } | |
| 136 | + | |
| 137 | + @ViewBuilder | |
| 138 | + private func conversationRow(_ conversation: Conversation) -> some View { | |
| 139 | + HStack(spacing: ZyquoTheme.Spacing.xs) { | |
| 140 | + VStack(alignment: .leading, spacing: 2) { | |
| 141 | + if renamingID == conversation.id { | |
| 142 | + TextField( | |
| 143 | + "Title", text: $renameText, | |
| 144 | + onCommit: { | |
| 145 | + var c = conversation | |
| 146 | + c.title = renameText.isEmpty ? conversation.title : renameText | |
| 147 | + app.update(c, touch: false) | |
| 148 | + renamingID = nil | |
| 149 | + } | |
| 150 | + ) | |
| 151 | + .textFieldStyle(.plain) | |
| 152 | + .font(ZyquoTheme.body) | |
| 153 | + } else { | |
| 154 | + Text(conversation.title) | |
| 155 | + .font(ZyquoTheme.body) | |
| 156 | + .lineLimit(1) | |
| 157 | + } | |
| 158 | + HStack(spacing: ZyquoTheme.Spacing.xxs) { | |
| 159 | + if let modelID = conversation.modelID { | |
| 160 | + Text(shortModelName(modelID)) | |
| 161 | + .font(ZyquoTheme.caption) | |
| 162 | + .foregroundStyle(ZyquoTheme.textTertiary) | |
| 163 | + .lineLimit(1) | |
| 164 | + } | |
| 165 | + Text(conversation.updatedAt, format: .relative(presentation: .named)) | |
| 166 | + .font(ZyquoTheme.caption) | |
| 167 | + .foregroundStyle(ZyquoTheme.textTertiary) | |
| 168 | + } | |
| 169 | + } | |
| 170 | + Spacer(minLength: 0) | |
| 171 | + if conversation.pinned { | |
| 172 | + Image(systemName: "pin.fill") | |
| 173 | + .font(.system(size: 9)) | |
| 174 | + .foregroundStyle(ZyquoTheme.textTertiary) | |
| 175 | + } | |
| 176 | + } | |
| 177 | + .contextMenu { | |
| 178 | + Button(conversation.pinned ? "Unpin" : "Pin") { | |
| 179 | + app.togglePin(conversationID: conversation.id) | |
| 180 | + } | |
| 181 | + Button("Rename") { | |
| 182 | + renameText = conversation.title | |
| 183 | + renamingID = conversation.id | |
| 184 | + } | |
| 185 | + Divider() | |
| 186 | + Button("Export as Markdown…") { | |
| 187 | + ExportService.exportMarkdown(conversation) | |
| 188 | + } | |
| 189 | + Button("Export as PDF…") { | |
| 190 | + ExportService.exportPDF(conversation) | |
| 191 | + } | |
| 192 | + Divider() | |
| 193 | + Button("Delete", role: .destructive) { | |
| 194 | + app.delete(conversationID: conversation.id) | |
| 195 | + } | |
| 196 | + } | |
| 197 | + } | |
| 198 | + | |
| 199 | + private var footer: some View { | |
| 200 | + VStack(spacing: 0) { | |
| 201 | + Rectangle() | |
| 202 | + .fill(ZyquoTheme.border) | |
| 203 | + .frame(height: ZyquoTheme.hairline) | |
| 204 | + HStack(spacing: ZyquoTheme.Spacing.xs) { | |
| 205 | + Button { | |
| 206 | + openSettings() | |
| 207 | + } label: { | |
| 208 | + Image(systemName: "gearshape") | |
| 209 | + .foregroundStyle(ZyquoTheme.textSecondary) | |
| 210 | + } | |
| 211 | + .buttonStyle(.plain) | |
| 212 | + .help("Settings (⌘,)") | |
| 213 | + | |
| 214 | + Spacer() | |
| 215 | + | |
| 216 | + if let repoID = app.loadedModelID { | |
| 217 | + HStack(spacing: ZyquoTheme.Spacing.xxs) { | |
| 218 | + Circle() | |
| 219 | + .fill(memoryDotColor) | |
| 220 | + .frame(width: 7, height: 7) | |
| 221 | + Text(shortModelName(repoID)) | |
| 222 | + .font(ZyquoTheme.caption) | |
| 223 | + .foregroundStyle(ZyquoTheme.textSecondary) | |
| 224 | + .lineLimit(1) | |
| 225 | + if app.liveMemoryBytes > 0 { | |
| 226 | + Text(formatBytes(Int64(app.liveMemoryBytes))) | |
| 227 | + .font(ZyquoTheme.caption) | |
| 228 | + .foregroundStyle(ZyquoTheme.textTertiary) | |
| 229 | + } | |
| 230 | + } | |
| 231 | + .padding(.horizontal, ZyquoTheme.Spacing.xs) | |
| 232 | + .padding(.vertical, 3) | |
| 233 | + .background(ZyquoTheme.surfaceSecondary.opacity(0.7), in: Capsule()) | |
| 234 | + .help("Loaded model · active memory") | |
| 235 | + } else { | |
| 236 | + Text("No model loaded") | |
| 237 | + .font(ZyquoTheme.caption) | |
| 238 | + .foregroundStyle(ZyquoTheme.textTertiary) | |
| 239 | + } | |
| 240 | + } | |
| 241 | + .padding(.horizontal, ZyquoTheme.Spacing.m) | |
| 242 | + .padding(.vertical, ZyquoTheme.Spacing.s) | |
| 243 | + } | |
| 244 | + } | |
| 245 | + | |
| 246 | + private var memoryDotColor: Color { | |
| 247 | + let ram = Double(MemoryAdvisor.physicalMemoryBytes) | |
| 248 | + let used = Double(app.liveMemoryBytes) | |
| 249 | + if used < ram * 0.5 { return ZyquoTheme.success } | |
| 250 | + if used < ram * 0.7 { return ZyquoTheme.warning } | |
| 251 | + return ZyquoTheme.danger | |
| 252 | + } | |
| 253 | +} | |
| 254 | + | |
| 255 | +/// "Qwen3-8B-4bit" from "mlx-community/Qwen3-8B-4bit". | |
| 256 | +func shortModelName(_ repoID: String) -> String { | |
| 257 | + repoID.split(separator: "/").last.map(String.init) ?? repoID | |
| 258 | +} | |
| 259 | + | |
| 260 | +extension Notification.Name { | |
| 261 | + static let zyquoFocusSearch = Notification.Name("zyquoFocusSearch") | |
| 262 | + static let zyquoOpenModelSwitcher = Notification.Name("zyquoOpenModelSwitcher") | |
| 263 | + static let zyquoExportConversation = Notification.Name("zyquoExportConversation") | |
| 264 | +} | |
| 265 | ||