SPB Git

spb/zyquo-local Public MIT

Native macOS AI chat that runs LLMs 100% locally on Apple Silicon with MLX — no cloud, no API keys.

Swift 97.2% Shell 1.8% Makefile 1%

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>
simon-pierre boucher committed 12 days ago (Jul 30, 2026) parent 27d664e

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