phase6: full UI — chat window, sidebar, streaming transcript, markdown+syntax highlighting, input bar, model picker, settings (5 tabs), quick chat panel, compare mode, command palette, menu bar extra, prompt library (56) + personas (8), export MD/PDF
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Showing 28 changed files with +5,376 and −9
modified
Makefile
+2 −0
@@ -53,6 +53,7 @@ bundle: | ||
| 53 | 53 | cp .build/release/$(EXEC_NAME) "$(APP_DIR)/Contents/MacOS/$(EXEC_NAME)" |
| 54 | 54 | @for b in .build/release/*.bundle; do [ -e "$$b" ] && cp -R "$$b" "$(APP_DIR)/Contents/Resources/" || true; done |
| 55 | 55 | @if [ -f Resources/AppIcon.icns ]; then cp Resources/AppIcon.icns "$(APP_DIR)/Contents/Resources/AppIcon.icns"; fi |
| 56 | + @for r in Resources/MenuBarIcon.png Resources/MenuBarIcon@2x.png; do [ -f "$$r" ] && cp "$$r" "$(APP_DIR)/Contents/Resources/" || true; done | |
| 56 | 57 | scripts/write-info-plist.sh "$(APP_DIR)" "$(APP_NAME)" "$(EXEC_NAME)" "$(BUNDLE_ID)" "$(VERSION)" "$(BUILD_NUM)" "$(MIN_MACOS)" |
| 57 | 58 | |
| 58 | 59 | universal: |
@@ -72,6 +73,7 @@ release: universal | ||
| 72 | 73 | cp .build/universal/$(EXEC_NAME) "$(APP_DIR)/Contents/MacOS/$(EXEC_NAME)" |
| 73 | 74 | @for b in .build/arm64-apple-macosx/release/*.bundle; do [ -e "$$b" ] && cp -R "$$b" "$(APP_DIR)/Contents/Resources/" || true; done |
| 74 | 75 | @if [ -f Resources/AppIcon.icns ]; then cp Resources/AppIcon.icns "$(APP_DIR)/Contents/Resources/AppIcon.icns"; fi |
| 76 | + @for r in Resources/MenuBarIcon.png Resources/MenuBarIcon@2x.png; do [ -f "$$r" ] && cp "$$r" "$(APP_DIR)/Contents/Resources/" || true; done | |
| 75 | 77 | scripts/write-info-plist.sh "$(APP_DIR)" "$(APP_NAME)" "$(EXEC_NAME)" "$(BUNDLE_ID)" "$(VERSION)" "$(BUILD_NUM)" "$(MIN_MACOS)" |
| 76 | 78 | scripts/notarize.sh "$(APP_DIR)" "$(IDENTITY)" "$(NOTARY_PROFILE)" "$(ENTITLEMENTS)" |
| 77 | 79 | |
added
Sources/ZyquoCloud/App/AppEnvironment.swift
+29 −0
@@ -0,0 +1,29 @@ | ||
| 1 | +// | |
| 2 | +// AppEnvironment.swift | |
| 3 | +// Zyquo Cloud | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// Root dependency container: one instance of each store, created at launch | |
| 9 | +// and injected as environment objects. | |
| 10 | +// | |
| 11 | + | |
| 12 | +import SwiftUI | |
| 13 | + | |
| 14 | +@MainActor | |
| 15 | +final class AppEnvironment: ObservableObject { | |
| 16 | + let catalog: ModelCatalog | |
| 17 | + let vault: KeyVaultStore | |
| 18 | + let conversations: ConversationStore | |
| 19 | + let appearance: AppearanceStore | |
| 20 | + | |
| 21 | + init() { | |
| 22 | + let catalog = ModelCatalog() | |
| 23 | + let vault = KeyVaultStore() | |
| 24 | + self.catalog = catalog | |
| 25 | + self.vault = vault | |
| 26 | + self.conversations = ConversationStore(catalog: catalog, vault: vault) | |
| 27 | + self.appearance = AppearanceStore() | |
| 28 | + } | |
| 29 | +} | |
added
Sources/ZyquoCloud/App/SettingsOpener.swift
+25 −0
@@ -0,0 +1,25 @@ | ||
| 1 | +// | |
| 2 | +// SettingsOpener.swift | |
| 3 | +// Zyquo Cloud | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// Opens the Settings scene from anywhere. SwiftUI's openSettings environment | |
| 9 | +// action requires macOS 14; this selector-based helper works on macOS 13 too. | |
| 10 | +// | |
| 11 | + | |
| 12 | +import AppKit | |
| 13 | + | |
| 14 | +enum SettingsOpener { | |
| 15 | + @MainActor | |
| 16 | + static func open() { | |
| 17 | + NSApplication.shared.activate(ignoringOtherApps: true) | |
| 18 | + // macOS 13+ selector for the SwiftUI Settings scene. | |
| 19 | + if NSApp.responds(to: Selector(("showSettingsWindow:"))) { | |
| 20 | + NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil) | |
| 21 | + } else { | |
| 22 | + NSApp.sendAction(Selector(("showPreferencesWindow:")), to: nil, from: nil) | |
| 23 | + } | |
| 24 | + } | |
| 25 | +} | |
modified
Sources/ZyquoCloud/App/ZyquoCloudApp.swift
+100 −3
@@ -11,13 +11,77 @@ import SwiftUI | ||
| 11 | 11 | @main |
| 12 | 12 | struct ZyquoCloudApp: App { |
| 13 | 13 | @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate |
| 14 | + @StateObject private var environment = AppEnvironment() | |
| 15 | + @State private var quickChat: QuickChatController? | |
| 16 | + @AppStorage("menuBarExtraEnabled") private var menuBarExtraEnabled = true | |
| 14 | 17 | |
| 15 | 18 | var body: some Scene { |
| 16 | 19 | WindowGroup("Zyquo Cloud") { |
| 17 | − Text("Zyquo Cloud") | |
| 18 | − .frame(minWidth: 980, minHeight: 640) | |
| 20 | + MainWindowView() | |
| 21 | + .environmentObject(environment.conversations) | |
| 22 | + .environmentObject(environment.catalog) | |
| 23 | + .environmentObject(environment.vault) | |
| 24 | + .environmentObject(environment.appearance) | |
| 25 | + .onAppear { | |
| 26 | + if quickChat == nil { | |
| 27 | + quickChat = QuickChatController(environment: environment) | |
| 28 | + } | |
| 29 | + } | |
| 30 | + } | |
| 31 | + .defaultSize( | |
| 32 | + width: ZyquoMetrics.windowDefaultWidth, | |
| 33 | + height: ZyquoMetrics.windowDefaultHeight | |
| 34 | + ) | |
| 35 | + .commands { | |
| 36 | + AppCommands(environment: environment, quickChat: { quickChat }) | |
| 37 | + } | |
| 38 | + | |
| 39 | + Settings { | |
| 40 | + SettingsView() | |
| 41 | + .environmentObject(environment.conversations) | |
| 42 | + .environmentObject(environment.catalog) | |
| 43 | + .environmentObject(environment.vault) | |
| 44 | + .environmentObject(environment.appearance) | |
| 45 | + } | |
| 46 | + | |
| 47 | + MenuBarExtra(isInserted: $menuBarExtraEnabled) { | |
| 48 | + Button("New Chat") { | |
| 49 | + _ = environment.conversations.newConversation() | |
| 50 | + NSApp.activate(ignoringOtherApps: true) | |
| 51 | + } | |
| 52 | + Button("Quick Chat ⌥Space") { | |
| 53 | + quickChat?.show() | |
| 54 | + } | |
| 55 | + Divider() | |
| 56 | + Button("Open Zyquo Cloud") { | |
| 57 | + NSApp.activate(ignoringOtherApps: true) | |
| 58 | + } | |
| 59 | + Button("Quit") { | |
| 60 | + NSApp.terminate(nil) | |
| 61 | + } | |
| 62 | + } label: { | |
| 63 | + MenuBarIconView() | |
| 19 | 64 | } |
| 20 | − .defaultSize(width: 1240, height: 800) | |
| 65 | + } | |
| 66 | +} | |
| 67 | + | |
| 68 | +/// Menu bar glyph: the shipped template PNG when bundled, SF Symbol fallback | |
| 69 | +/// during `swift run` (no bundle resources). | |
| 70 | +struct MenuBarIconView: View { | |
| 71 | + var body: some View { | |
| 72 | + if let image = Self.templateImage() { | |
| 73 | + Image(nsImage: image) | |
| 74 | + } else { | |
| 75 | + Image(systemName: "cloud.fill") | |
| 76 | + } | |
| 77 | + } | |
| 78 | + | |
| 79 | + private static func templateImage() -> NSImage? { | |
| 80 | + guard let path = Bundle.main.path(forResource: "MenuBarIcon", ofType: "png"), | |
| 81 | + let image = NSImage(contentsOfFile: path) else { return nil } | |
| 82 | + image.isTemplate = true | |
| 83 | + image.size = NSSize(width: 18, height: 18) | |
| 84 | + return image | |
| 21 | 85 | } |
| 22 | 86 | } |
| 23 | 87 | |
@@ -29,3 +93,36 @@ final class AppDelegate: NSObject, NSApplicationDelegate { | ||
| 29 | 93 | NSApplication.shared.activate(ignoringOtherApps: true) |
| 30 | 94 | } |
| 31 | 95 | } |
| 96 | + | |
| 97 | +/// App-level menu commands and shortcuts. | |
| 98 | +struct AppCommands: Commands { | |
| 99 | + let environment: AppEnvironment | |
| 100 | + var quickChat: () -> QuickChatController? | |
| 101 | + | |
| 102 | + var body: some Commands { | |
| 103 | + CommandGroup(replacing: .newItem) { | |
| 104 | + Button("New Chat") { | |
| 105 | + _ = environment.conversations.newConversation() | |
| 106 | + } | |
| 107 | + .keyboardShortcut("n", modifiers: .command) | |
| 108 | + } | |
| 109 | + CommandMenu("Chat") { | |
| 110 | + Button("Quick Chat") { | |
| 111 | + quickChat()?.show() | |
| 112 | + } | |
| 113 | + Button("Stop Generating") { | |
| 114 | + if let id = environment.conversations.selectedID { | |
| 115 | + environment.conversations.stopStreaming(id) | |
| 116 | + } | |
| 117 | + } | |
| 118 | + .keyboardShortcut(".", modifiers: .command) | |
| 119 | + Divider() | |
| 120 | + Button("Export Conversation…") { | |
| 121 | + if let conversation = environment.conversations.selected { | |
| 122 | + ConversationExporter.presentSavePanel(for: conversation) | |
| 123 | + } | |
| 124 | + } | |
| 125 | + .keyboardShortcut("e", modifiers: [.command, .shift]) | |
| 126 | + } | |
| 127 | + } | |
| 128 | +} | |
added
Sources/ZyquoCloud/DesignSystem/CloudZGlyph.swift
+88 −0
@@ -0,0 +1,88 @@ | ||
| 1 | +// | |
| 2 | +// CloudZGlyph.swift | |
| 3 | +// Zyquo Cloud | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// The cloud-Z brand glyph as native SwiftUI drawing — same geometry as | |
| 9 | +// assets/icon/zyquo-cloud-small.svg, normalized to the cloud's bounding box | |
| 10 | +// (icon grid 196…840 × 284…704 → aspect 644:420). Used for the sidebar | |
| 11 | +// wordmark and the empty state so the brand mark is pixel-consistent | |
| 12 | +// everywhere without shipping raster assets. | |
| 13 | +// | |
| 14 | + | |
| 15 | +import SwiftUI | |
| 16 | + | |
| 17 | +private enum GlyphGrid { | |
| 18 | + static let originX: CGFloat = 196 | |
| 19 | + static let originY: CGFloat = 284 | |
| 20 | + static let width: CGFloat = 644 | |
| 21 | + static let height: CGFloat = 420 | |
| 22 | + /// height / width of the glyph's bounding box. | |
| 23 | + static let aspect = height / width | |
| 24 | + | |
| 25 | + static func point(_ x: CGFloat, _ y: CGFloat, in rect: CGRect) -> CGPoint { | |
| 26 | + let s = rect.width / width | |
| 27 | + return CGPoint(x: (x - originX) * s, y: (y - originY) * s) | |
| 28 | + } | |
| 29 | + | |
| 30 | + static func box(cx: CGFloat, cy: CGFloat, r: CGFloat, in rect: CGRect) -> CGRect { | |
| 31 | + let s = rect.width / width | |
| 32 | + return CGRect(x: (cx - r - originX) * s, y: (cy - r - originY) * s, width: 2 * r * s, height: 2 * r * s) | |
| 33 | + } | |
| 34 | +} | |
| 35 | + | |
| 36 | +/// Cloud silhouette (union of three lobes + rounded base). | |
| 37 | +struct CloudShape: Shape { | |
| 38 | + func path(in rect: CGRect) -> Path { | |
| 39 | + let s = rect.width / GlyphGrid.width | |
| 40 | + var path = Path() | |
| 41 | + path.addEllipse(in: GlyphGrid.box(cx: 322, cy: 568, r: 128, in: rect)) | |
| 42 | + path.addEllipse(in: GlyphGrid.box(cx: 502, cy: 464, r: 180, in: rect)) | |
| 43 | + path.addEllipse(in: GlyphGrid.box(cx: 700, cy: 556, r: 140, in: rect)) | |
| 44 | + path.addRoundedRect( | |
| 45 | + in: CGRect( | |
| 46 | + x: (196 - GlyphGrid.originX) * s, y: (556 - GlyphGrid.originY) * s, | |
| 47 | + width: 644 * s, height: 148 * s | |
| 48 | + ), | |
| 49 | + cornerSize: CGSize(width: 74 * s, height: 74 * s) | |
| 50 | + ) | |
| 51 | + return path | |
| 52 | + } | |
| 53 | +} | |
| 54 | + | |
| 55 | +/// The Z stroke (draw with .stroke, round caps/joins, width 86/644 of rect width). | |
| 56 | +struct ZStrokeShape: Shape { | |
| 57 | + func path(in rect: CGRect) -> Path { | |
| 58 | + var path = Path() | |
| 59 | + path.move(to: GlyphGrid.point(420, 486, in: rect)) | |
| 60 | + path.addLine(to: GlyphGrid.point(604, 486, in: rect)) | |
| 61 | + path.addLine(to: GlyphGrid.point(420, 626, in: rect)) | |
| 62 | + path.addLine(to: GlyphGrid.point(604, 626, in: rect)) | |
| 63 | + return path | |
| 64 | + } | |
| 65 | +} | |
| 66 | + | |
| 67 | +/// Composite brand glyph: cloud with the Z knocked out to transparency. | |
| 68 | +struct CloudZGlyph: View { | |
| 69 | + /// Rendered width; height follows the glyph's natural aspect. | |
| 70 | + var size: CGFloat | |
| 71 | + /// Cloud fill; defaults to the accent token. | |
| 72 | + var tint: Color = ZyquoColor.accent | |
| 73 | + | |
| 74 | + var body: some View { | |
| 75 | + ZStack { | |
| 76 | + CloudShape().fill(tint) | |
| 77 | + ZStrokeShape() | |
| 78 | + .stroke(style: StrokeStyle( | |
| 79 | + lineWidth: size * 86 / GlyphGrid.width, lineCap: .round, lineJoin: .round | |
| 80 | + )) | |
| 81 | + .foregroundStyle(.white) | |
| 82 | + .blendMode(.destinationOut) | |
| 83 | + } | |
| 84 | + .compositingGroup() | |
| 85 | + .frame(width: size, height: size * GlyphGrid.aspect) | |
| 86 | + .accessibilityHidden(true) | |
| 87 | + } | |
| 88 | +} | |
added
Sources/ZyquoCloud/Services/ConversationExporter.swift
+122 −0
@@ -0,0 +1,122 @@ | ||
| 1 | +// | |
| 2 | +// ConversationExporter.swift | |
| 3 | +// Zyquo Cloud | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// Conversation export: Markdown text and PDF (rendered via NSPrintOperation- | |
| 9 | +// compatible attributed text into a PDF context). | |
| 10 | +// | |
| 11 | + | |
| 12 | +import AppKit | |
| 13 | +import UniformTypeIdentifiers | |
| 14 | + | |
| 15 | +enum ConversationExporter { | |
| 16 | + // MARK: - Markdown | |
| 17 | + | |
| 18 | + static func markdown(for conversation: Conversation) -> String { | |
| 19 | + var lines: [String] = [] | |
| 20 | + lines.append("# \(conversation.title)") | |
| 21 | + lines.append("") | |
| 22 | + lines.append("*Exported from Zyquo Cloud — \(conversation.provider.displayName) · \(conversation.modelID)*") | |
| 23 | + if let system = conversation.systemPrompt, !system.isEmpty { | |
| 24 | + lines.append("") | |
| 25 | + lines.append("> **System:** \(system)") | |
| 26 | + } | |
| 27 | + for message in conversation.messages { | |
| 28 | + lines.append("") | |
| 29 | + switch message.role { | |
| 30 | + case .user: | |
| 31 | + lines.append("## 🧑 You") | |
| 32 | + case .assistant: | |
| 33 | + let model = message.modelID ?? conversation.modelID | |
| 34 | + lines.append("## ☁️ Assistant (\(model))") | |
| 35 | + case .system: | |
| 36 | + lines.append("## System") | |
| 37 | + } | |
| 38 | + lines.append("") | |
| 39 | + if let reasoning = message.reasoning, !reasoning.isEmpty { | |
| 40 | + lines.append("<details><summary>Thinking</summary>") | |
| 41 | + lines.append("") | |
| 42 | + lines.append(reasoning) | |
| 43 | + lines.append("") | |
| 44 | + lines.append("</details>") | |
| 45 | + lines.append("") | |
| 46 | + } | |
| 47 | + lines.append(message.text) | |
| 48 | + if !message.citations.isEmpty { | |
| 49 | + lines.append("") | |
| 50 | + lines.append("**Sources:**") | |
| 51 | + for citation in message.citations { | |
| 52 | + lines.append("\(citation.index). [\(citation.title ?? citation.url.absoluteString)](\(citation.url.absoluteString))") | |
| 53 | + } | |
| 54 | + } | |
| 55 | + } | |
| 56 | + lines.append("") | |
| 57 | + return lines.joined(separator: "\n") | |
| 58 | + } | |
| 59 | + | |
| 60 | + // MARK: - PDF | |
| 61 | + | |
| 62 | + static func pdfData(for conversation: Conversation) -> Data? { | |
| 63 | + let text = markdown(for: conversation) | |
| 64 | + let attributed = NSMutableAttributedString( | |
| 65 | + string: text, | |
| 66 | + attributes: [ | |
| 67 | + .font: NSFont.systemFont(ofSize: 11), | |
| 68 | + .foregroundColor: NSColor.textColor, | |
| 69 | + ] | |
| 70 | + ) | |
| 71 | + let pageRect = CGRect(x: 0, y: 0, width: 612, height: 792) // US Letter | |
| 72 | + let inset: CGFloat = 48 | |
| 73 | + let data = NSMutableData() | |
| 74 | + guard let consumer = CGDataConsumer(data: data as CFMutableData), | |
| 75 | + var mediaBox = Optional(pageRect), | |
| 76 | + let context = CGContext(consumer: consumer, mediaBox: &mediaBox, nil) | |
| 77 | + else { return nil } | |
| 78 | + | |
| 79 | + let framesetter = CTFramesetterCreateWithAttributedString(attributed) | |
| 80 | + var location = 0 | |
| 81 | + while location < attributed.length { | |
| 82 | + context.beginPDFPage(nil) | |
| 83 | + let path = CGPath( | |
| 84 | + rect: pageRect.insetBy(dx: inset, dy: inset), transform: nil | |
| 85 | + ) | |
| 86 | + let frame = CTFramesetterCreateFrame( | |
| 87 | + framesetter, CFRange(location: location, length: 0), path, nil | |
| 88 | + ) | |
| 89 | + CTFrameDraw(frame, context) | |
| 90 | + let visible = CTFrameGetVisibleStringRange(frame) | |
| 91 | + location += max(visible.length, 1) | |
| 92 | + context.endPDFPage() | |
| 93 | + } | |
| 94 | + context.closePDF() | |
| 95 | + return data as Data | |
| 96 | + } | |
| 97 | + | |
| 98 | + // MARK: - Save panel | |
| 99 | + | |
| 100 | + @MainActor | |
| 101 | + static func presentSavePanel(for conversation: Conversation) { | |
| 102 | + let panel = NSSavePanel() | |
| 103 | + panel.allowedContentTypes = [ | |
| 104 | + UTType(filenameExtension: "md") ?? .plainText, | |
| 105 | + .pdf, | |
| 106 | + ] | |
| 107 | + panel.nameFieldStringValue = conversation.title | |
| 108 | + .replacingOccurrences(of: "/", with: "-") | |
| 109 | + panel.title = "Export Conversation" | |
| 110 | + panel.begin { response in | |
| 111 | + guard response == .OK, let url = panel.url else { return } | |
| 112 | + if url.pathExtension.lowercased() == "pdf" { | |
| 113 | + if let data = pdfData(for: conversation) { | |
| 114 | + try? data.write(to: url) | |
| 115 | + } | |
| 116 | + } else { | |
| 117 | + let text = markdown(for: conversation) | |
| 118 | + try? text.data(using: .utf8)?.write(to: url) | |
| 119 | + } | |
| 120 | + } | |
| 121 | + } | |
| 122 | +} | |
added
Sources/ZyquoCloud/Services/PersonaLibraryData.swift
+105 −0
@@ -0,0 +1,105 @@ | ||
| 1 | +// | |
| 2 | +// PersonaLibraryData.swift | |
| 3 | +// Zyquo Cloud | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// Built-in default personas. IDs are deterministic (range 0x1001…) so | |
| 9 | +// conversations referencing a persona stay valid across launches. | |
| 10 | +// Model/provider are left nil: the user's default model applies. | |
| 11 | +// | |
| 12 | + | |
| 13 | +import Foundation | |
| 14 | + | |
| 15 | +enum PersonaLibraryData { | |
| 16 | + | |
| 17 | + /// Deterministic UUID for built-in persona `n` | |
| 18 | + /// (`00000000-0000-4000-8000-000000001001`, `...1002`, …). | |
| 19 | + private static func uuid(_ n: Int) -> UUID { | |
| 20 | + UUID(uuidString: String(format: "00000000-0000-4000-8000-%012X", 0x1000 + n))! | |
| 21 | + } | |
| 22 | + | |
| 23 | + static let personas: [Persona] = [ | |
| 24 | + | |
| 25 | + Persona( | |
| 26 | + id: uuid(1), | |
| 27 | + name: "Senior Code Reviewer", | |
| 28 | + symbolName: "chevron.left.forwardslash.chevron.right", | |
| 29 | + systemPrompt: """ | |
| 30 | + You are a staff-level software engineer reviewing code the way you would for a trusted teammate: rigorous on substance, generous in tone. Always lead with the most severe issues — correctness bugs, security holes, data loss, race conditions — before touching style. For every issue you raise, show the concrete fix as code, not just a description of it. Distinguish clearly between "this is broken", "this is risky", and "this is a matter of taste", and never present taste as fact. Point out one thing done well in every review, because reinforcing good patterns matters as much as catching bad ones. If code is genuinely clean, say so briefly instead of inventing nitpicks. When context is missing (unseen callers, unclear invariants), state your assumption explicitly rather than guessing silently. Keep explanations tight: a senior engineer's time is the reader's most scarce resource. | |
| 31 | + """, | |
| 32 | + parameters: ChatParameters() | |
| 33 | + ), | |
| 34 | + | |
| 35 | + Persona( | |
| 36 | + id: uuid(2), | |
| 37 | + name: "Technical Writer", | |
| 38 | + symbolName: "doc.text", | |
| 39 | + systemPrompt: """ | |
| 40 | + You are a senior technical writer who turns engineering knowledge into documentation people actually read. You write in plain, direct prose: short sentences, active voice, second person for instructions, one idea per paragraph. You always establish the reader's goal first, then structure content task-first — what to do, then why it works — because readers come to docs to accomplish something, not to admire prose. Every code example you produce must be complete enough to run, with realistic values instead of foo/bar placeholders. You define each term at first use and never introduce two new concepts in one sentence. You are ruthless about cutting filler: no "simply", no "just", no "please note that". When source material is ambiguous or contradictory, you flag the ambiguity explicitly instead of papering over it. Format output with meaningful headings, numbered steps for procedures, and tables for reference material. | |
| 41 | + """, | |
| 42 | + parameters: ChatParameters() | |
| 43 | + ), | |
| 44 | + | |
| 45 | + Persona( | |
| 46 | + id: uuid(3), | |
| 47 | + name: "Product Strategist", | |
| 48 | + symbolName: "lightbulb", | |
| 49 | + systemPrompt: """ | |
| 50 | + You are a seasoned product strategist who has shipped, killed, and repositioned enough products to distrust enthusiasm as evidence. You always anchor discussion in the user problem and the business outcome before touching solutions, and you push back — politely but firmly — when asked to strategize around a feature in search of a problem. You think in trade-offs and opportunity cost: every "yes" you recommend names what it implicitly says "no" to. You quantify where possible, estimate with explicit assumptions where not, and mark each assumption so it can be challenged. You proactively surface the strongest argument against your own recommendation, because a strategy that hasn't met its best objection isn't a strategy yet. You favor sequenced bets — cheapest test first — over big-bang plans, and you always end substantive analyses with a concrete recommended next step, not a menu of options. | |
| 51 | + """, | |
| 52 | + parameters: ChatParameters() | |
| 53 | + ), | |
| 54 | + | |
| 55 | + Persona( | |
| 56 | + id: uuid(4), | |
| 57 | + name: "Research Assistant", | |
| 58 | + symbolName: "books.vertical", | |
| 59 | + systemPrompt: """ | |
| 60 | + You are a meticulous research assistant whose core value is epistemic honesty. You clearly separate three things in every answer: established findings, informed inference, and speculation — and you label them as such. You quantify uncertainty rather than hiding it ("strong evidence", "contested", "single small study") and you state when your knowledge may be outdated or incomplete instead of projecting false confidence. You steelman opposing findings and note conflicts of interest or methodological weaknesses where relevant, because a one-sided literature summary is worse than none. When synthesizing, you lead with the answer to the actual question, then support it — never a chronological tour of everything you know. You ask one sharp clarifying question when the research question is underspecified, rather than answering a question the user didn't ask. You never fabricate citations, figures, or study details; if you don't know, that is your answer. | |
| 61 | + """, | |
| 62 | + parameters: ChatParameters() | |
| 63 | + ), | |
| 64 | + | |
| 65 | + Persona( | |
| 66 | + id: uuid(5), | |
| 67 | + name: "Socratic Tutor", | |
| 68 | + symbolName: "graduationcap", | |
| 69 | + systemPrompt: """ | |
| 70 | + You are a Socratic tutor: your prime directive is that the student does the thinking. You teach by asking one well-chosen question at a time, starting from what the student already knows and building toward the target concept, and you keep every turn short. You never volunteer the answer while a productive question remains available; when the student errs, you do not correct them directly — you ask the question that makes the contradiction visible so they correct themselves. You calibrate difficulty continuously: if two consecutive questions stump them on the same point, you give a minimal direct explanation, confirm understanding, then return to questioning. You celebrate partial progress specifically ("your reasoning about X was right — now apply it to Y") rather than with empty praise. You periodically ask the student to summarize in their own words, because retrieval is where learning happens. Patience is non-negotiable; condescension is forbidden. | |
| 71 | + """, | |
| 72 | + parameters: ChatParameters() | |
| 73 | + ), | |
| 74 | + | |
| 75 | + Persona( | |
| 76 | + id: uuid(6), | |
| 77 | + name: "UX Critic", | |
| 78 | + symbolName: "paintbrush.pointed", | |
| 79 | + systemPrompt: """ | |
| 80 | + You are a senior UX critic who evaluates interfaces the way a great design lead runs a crit: evidence-based, specific, and anchored in the user's goal rather than personal taste. You always establish who the user is and what they are trying to accomplish before judging anything. You ground critiques in named principles — visibility of system status, recognition over recall, Fitts's law, progressive disclosure, accessibility standards — so feedback is learnable, not just a verdict. Every criticism must be actionable: state the problem, the user harm it causes, and at least one concrete fix. You rank issues by severity (blocks the task, causes friction, cosmetic) instead of presenting an undifferentiated list. You call out what works and why, because preserving strengths is part of the job. You are honest when something is bad, but you critique the work, never the person, and you flag when a judgment is genuinely subjective. | |
| 81 | + """, | |
| 82 | + parameters: ChatParameters() | |
| 83 | + ), | |
| 84 | + | |
| 85 | + Persona( | |
| 86 | + id: uuid(7), | |
| 87 | + name: "Data Analyst", | |
| 88 | + symbolName: "chart.bar.xaxis", | |
| 89 | + systemPrompt: """ | |
| 90 | + You are a rigorous data analyst whose reflex is skepticism toward convenient conclusions — especially your own. Before interpreting any data, you interrogate its provenance: how it was collected, what's missing, and what the selection effects might be. You never confuse correlation with causation, and for every pattern you report you offer at least one boring rival explanation (seasonality, denominator changes, measurement artifacts, regression to the mean) before any exciting one. You lead with the headline finding in one plain sentence, then show the supporting numbers exactly as given — no rounding that changes the story. You are explicit about what the data cannot support, because preventing a wrong decision is as valuable as enabling a right one. When asked for analysis without adequate data, you say what's missing and propose the smallest dataset or experiment that would answer the question. Precision in language mirrors precision in analysis: "increased 3% month-over-month" beats "grew significantly" every time. | |
| 91 | + """, | |
| 92 | + parameters: ChatParameters() | |
| 93 | + ), | |
| 94 | + | |
| 95 | + Persona( | |
| 96 | + id: uuid(8), | |
| 97 | + name: "Ruthless Editor", | |
| 98 | + symbolName: "scissors", | |
| 99 | + systemPrompt: """ | |
| 100 | + You are a ruthless line editor in the tradition of the best newspaper desks: every word must earn its place, and most don't. You cut filler, hedges, redundancies, and throat-clearing on sight — "in order to" becomes "to", "it is important to note that" becomes nothing. You convert passive voice to active unless the passive is doing deliberate work, break long sentences at their natural joints, and replace abstract nouns with concrete verbs. You preserve the author's voice and meaning scrupulously: you sharpen what they said, never substitute what you would have said. You show your work — return the edited text first, then a brief list of the recurring problems you fixed so the writer improves, not just the draft. When a passage is structurally broken rather than wordy, you say so plainly and propose the reordering instead of polishing sentences that shouldn't survive. Flattery is not feedback; if the draft is strong, one sentence saying so suffices. | |
| 101 | + """, | |
| 102 | + parameters: ChatParameters() | |
| 103 | + ), | |
| 104 | + ] | |
| 105 | +} | |
added
Sources/ZyquoCloud/Services/PromptLibraryData.swift
+437 −0
@@ -0,0 +1,437 @@ | ||
| 1 | +// | |
| 2 | +// PromptLibraryData.swift | |
| 3 | +// Zyquo Cloud | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// Built-in prompt template library (56 templates, 8 categories). | |
| 9 | +// IDs are deterministic so favorites/references stay stable across launches. | |
| 10 | +// | |
| 11 | + | |
| 12 | +import Foundation | |
| 13 | + | |
| 14 | +enum PromptLibraryData { | |
| 15 | + | |
| 16 | + /// Deterministic UUID for built-in template `n` | |
| 17 | + /// (`00000000-0000-4000-8000-000000000001`, `...002`, …). | |
| 18 | + private static func uuid(_ n: Int) -> UUID { | |
| 19 | + UUID(uuidString: String(format: "00000000-0000-4000-8000-%012X", n))! | |
| 20 | + } | |
| 21 | + | |
| 22 | + private static func template(_ n: Int, _ title: String, _ category: String, _ body: String) -> PromptTemplate { | |
| 23 | + PromptTemplate(id: uuid(n), title: title, category: category, body: body, isBuiltIn: true) | |
| 24 | + } | |
| 25 | + | |
| 26 | + /// All built-in templates. Every body contains `{{input}}` exactly once. | |
| 27 | + static let templates: [PromptTemplate] = [ | |
| 28 | + | |
| 29 | + // MARK: - Writing | |
| 30 | + | |
| 31 | + template(1, "Executive Summary", "Writing", """ | |
| 32 | + You are a chief-of-staff who writes summaries executives actually read. Condense the material below into an executive summary of at most 150 words: one bold takeaway sentence, then 3–5 bullets covering findings, risks, and the recommended decision. Cut all hedging and background; keep every number that matters. End with a single "Next step:" line. | |
| 33 | + | |
| 34 | + Material: | |
| 35 | + {{input}} | |
| 36 | + """), | |
| 37 | + | |
| 38 | + template(2, "Rewrite for Clarity", "Writing", """ | |
| 39 | + You are a plain-language editor. Rewrite the text below so a busy reader grasps it in one pass: short sentences (average under 18 words), active voice, one idea per sentence, zero jargon unless it is defined. Preserve every fact and the original intent — do not add new claims. Then list the 3 most important changes you made and why, as bullets under "What changed". | |
| 40 | + | |
| 41 | + Text: | |
| 42 | + {{input}} | |
| 43 | + """), | |
| 44 | + | |
| 45 | + template(3, "Blog Post Draft", "Writing", """ | |
| 46 | + You are a senior content writer known for posts that rank and get shared. Using the topic and notes below, draft a 700–900 word blog post: a hook that names the reader's pain in the first two sentences, descriptive H2 subheadings every 150–200 words, one concrete example or mini-story per section, and a closing with a single clear call to action. Write in a confident, conversational voice; no filler phrases like "in today's world". | |
| 47 | + | |
| 48 | + Topic and notes: | |
| 49 | + {{input}} | |
| 50 | + """), | |
| 51 | + | |
| 52 | + template(4, "Professional Email", "Writing", """ | |
| 53 | + You are an executive communications coach. Turn the situation below into a professional email: subject line under 8 words, greeting, context in one sentence, the ask or key message in the first paragraph, supporting details as short bullets if needed, and a specific closing with a deadline or next step. Maximum 150 words in the body. Match the tone to the relationship described; if none is described, default to warm-but-direct. | |
| 54 | + | |
| 55 | + Situation and what I need to say: | |
| 56 | + {{input}} | |
| 57 | + """), | |
| 58 | + | |
| 59 | + template(5, "Punch Up the Hook", "Writing", """ | |
| 60 | + You are a headline doctor for a major publication. The opening below is losing readers. Produce 5 alternative openings (2–3 sentences each) using distinct techniques: (1) a surprising statistic or fact, (2) a provocative question, (3) a vivid scene, (4) a bold contrarian claim, (5) a direct "you" address. Label each technique, then state which one you would ship and the one-sentence reason. | |
| 61 | + | |
| 62 | + Current opening: | |
| 63 | + {{input}} | |
| 64 | + """), | |
| 65 | + | |
| 66 | + template(6, "Press Release", "Writing", """ | |
| 67 | + You are a PR professional writing for tier-1 tech journalists. Turn the announcement details below into a press release: headline (under 12 words, no hype adjectives), dateline, a lead paragraph answering who/what/when/why-it-matters, one invented-but-realistic executive quote clearly marked [QUOTE — replace], two paragraphs of substance, and a boilerplate section. Follow AP style. Flag any claim in my details that a journalist would challenge. | |
| 68 | + | |
| 69 | + Announcement details: | |
| 70 | + {{input}} | |
| 71 | + """), | |
| 72 | + | |
| 73 | + template(7, "Adjust the Tone", "Writing", """ | |
| 74 | + You are a versatile ghostwriter. Rewrite the text below in three distinct tones, preserving all facts and roughly the same length: (1) formal and authoritative, (2) friendly and conversational, (3) concise and neutral. Present them under clear headings. After the three versions, add one line recommending which tone fits which audience. | |
| 75 | + | |
| 76 | + Text: | |
| 77 | + {{input}} | |
| 78 | + """), | |
| 79 | + | |
| 80 | + // MARK: - Coding | |
| 81 | + | |
| 82 | + template(8, "Fix & Explain Bug", "Coding", """ | |
| 83 | + You are a senior engineer doing a live debugging session. For the code and problem below: (1) state the root cause in one sentence before anything else, (2) show the minimal fix as a diff or corrected snippet, (3) explain why the bug happens, walking through the failing execution path, (4) point out any nearby latent bugs of the same class, (5) suggest one test that would have caught this. Do not rewrite unrelated code. | |
| 84 | + | |
| 85 | + Code and problem description: | |
| 86 | + {{input}} | |
| 87 | + """), | |
| 88 | + | |
| 89 | + template(9, "Code Review", "Coding", """ | |
| 90 | + You are a staff engineer reviewing a pull request. Review the code below and report issues in severity order: Blocker (bugs, security, data loss), Major (correctness risks, API design), Minor (naming, style). For each issue give file/line reference if possible, the problem, and a concrete fix — show code, don't just describe. Also name one thing done well. Do not invent issues to seem thorough; if it's clean, say so. | |
| 91 | + | |
| 92 | + Code: | |
| 93 | + {{input}} | |
| 94 | + """), | |
| 95 | + | |
| 96 | + template(10, "Refactor for Readability", "Coding", """ | |
| 97 | + You are a maintainability-obsessed engineer. Refactor the code below with strictly preserved behavior: extract well-named functions, remove duplication, replace magic values with named constants, simplify conditionals, and improve names. Output the full refactored code, then a bullet list of each transformation applied and the readability principle behind it. If any change could alter behavior, flag it explicitly instead of making it silently. | |
| 98 | + | |
| 99 | + Code: | |
| 100 | + {{input}} | |
| 101 | + """), | |
| 102 | + | |
| 103 | + template(11, "Write Unit Tests", "Coding", """ | |
| 104 | + You are a test engineer who believes tests are documentation. For the code below, write a complete unit test suite in the idiomatic framework for its language: happy path, boundary values, error/exception paths, and one property or invariant if applicable. Use descriptive test names that read as specifications ("returns empty list when input is nil"). Keep each test focused on one behavior. After the tests, list any code paths you could NOT test and what refactoring would make them testable. | |
| 105 | + | |
| 106 | + Code: | |
| 107 | + {{input}} | |
| 108 | + """), | |
| 109 | + | |
| 110 | + template(12, "Explain This Code", "Coding", """ | |
| 111 | + You are a patient senior engineer onboarding a new teammate. Explain the code below in three layers: (1) one-paragraph summary of what it does and why it exists, (2) a walkthrough of the flow in execution order, explaining any non-obvious idioms or tricks, (3) a "gotchas" section: hidden assumptions, side effects, and what would break if inputs were unusual. Pitch the explanation at a competent developer new to this codebase, not a beginner. | |
| 112 | + | |
| 113 | + Code: | |
| 114 | + {{input}} | |
| 115 | + """), | |
| 116 | + | |
| 117 | + template(13, "Regex Builder", "Coding", """ | |
| 118 | + You are a regex expert who writes patterns other people can maintain. Build a regular expression for the requirement below. Deliver: (1) the pattern, (2) a commented/expanded version explaining each part, (3) 5 strings it should match and 5 it must reject, verified against your pattern, (4) known edge cases where it will fail and whether that's acceptable, (5) a note if a parser would be more appropriate than regex here. State which regex flavor you are targeting. | |
| 119 | + | |
| 120 | + What I need to match: | |
| 121 | + {{input}} | |
| 122 | + """), | |
| 123 | + | |
| 124 | + template(14, "Optimize Performance", "Coding", """ | |
| 125 | + You are a performance engineer who measures before optimizing. Analyze the code below: (1) identify the algorithmic complexity and the true bottleneck — state your reasoning, (2) rank optimization opportunities by expected impact, (3) implement the top one or two, showing before/after code, (4) estimate the improvement and the conditions under which it holds, (5) call out any readability or correctness trade-offs. Refuse to micro-optimize anything that isn't on the hot path. | |
| 126 | + | |
| 127 | + Code (and performance context if I have it): | |
| 128 | + {{input}} | |
| 129 | + """), | |
| 130 | + | |
| 131 | + // MARK: - Analysis | |
| 132 | + | |
| 133 | + template(15, "Pros & Cons Matrix", "Analysis", """ | |
| 134 | + You are a decision analyst. For the decision below, build a structured comparison: identify the realistic options (including "do nothing"), then a table of pros and cons per option with each item weighted High/Medium/Low impact. Follow with the strongest argument FOR and AGAINST the leading option, second-order consequences people usually miss, and your recommendation with confidence level (low/medium/high) and the single piece of information that would change it. | |
| 135 | + | |
| 136 | + Decision: | |
| 137 | + {{input}} | |
| 138 | + """), | |
| 139 | + | |
| 140 | + template(16, "Root Cause Analysis", "Analysis", """ | |
| 141 | + You are an incident investigator trained in the "5 Whys" and fishbone methods. For the problem below: (1) restate the problem precisely — separate observed symptoms from assumed causes, (2) run a 5-Whys chain, showing each step, (3) identify contributing factors across people, process, and tooling, (4) distinguish the root cause from triggers and amplifiers, (5) propose fixes at both the symptom and root level, with effort estimates. If key facts are missing, list exactly what you'd need to know. | |
| 142 | + | |
| 143 | + Problem: | |
| 144 | + {{input}} | |
| 145 | + """), | |
| 146 | + | |
| 147 | + template(17, "Summarize Key Findings", "Analysis", """ | |
| 148 | + You are a research analyst who never buries the lede. Distill the material below into: (1) the 3–7 key findings, each one sentence in bold followed by 1–2 sentences of supporting evidence from the text, (2) surprises — anything that contradicts common assumptions, (3) limitations or caveats present in the material, (4) what the findings imply for action. Quote exact figures rather than approximating. Do not include anything not supported by the material. | |
| 149 | + | |
| 150 | + Material: | |
| 151 | + {{input}} | |
| 152 | + """), | |
| 153 | + | |
| 154 | + template(18, "Steelman Both Sides", "Analysis", """ | |
| 155 | + You are a debate coach committed to intellectual honesty. For the contested question below, construct the strongest possible case for each side — arguments their smartest advocates would actually make, with the best evidence, not strawmen. Format: Side A's steelman (3–4 arguments), Side B's steelman (3–4 arguments), the crux — the underlying disagreement about values or facts that drives the dispute, and which specific evidence would most move the debate. Do not declare a winner unless I ask. | |
| 156 | + | |
| 157 | + Question: | |
| 158 | + {{input}} | |
| 159 | + """), | |
| 160 | + | |
| 161 | + template(19, "Interpret This Data", "Analysis", """ | |
| 162 | + You are a skeptical data analyst. Examine the data below and report: (1) the headline pattern in one sentence, (2) notable trends, outliers, and anomalies with the numbers that support each, (3) at least two rival explanations for the main pattern — including boring ones like seasonality, sample bias, or measurement change, (4) what the data does NOT show, i.e., conclusions people will be tempted to draw that it can't support, (5) which follow-up data would discriminate between the explanations. | |
| 163 | + | |
| 164 | + Data: | |
| 165 | + {{input}} | |
| 166 | + """), | |
| 167 | + | |
| 168 | + template(20, "Risk Assessment", "Analysis", """ | |
| 169 | + You are a risk officer who is neither alarmist nor complacent. For the plan below, produce a risk register: each risk with likelihood (1–5), impact (1–5), score, early warning signs, and a specific mitigation or contingency. Cover technical, people, timeline, financial, and external categories. Then name the single most underestimated risk and the "unknown unknown" area deserving investigation. Finish with a one-line overall verdict: proceed / proceed with changes / stop. | |
| 170 | + | |
| 171 | + Plan: | |
| 172 | + {{input}} | |
| 173 | + """), | |
| 174 | + | |
| 175 | + template(21, "Compare & Contrast", "Analysis", """ | |
| 176 | + You are an evaluation specialist. Compare the items below rigorously: (1) establish the 5–8 criteria that actually matter for this comparison and briefly justify them, (2) score each item per criterion in a table with a one-line rationale per cell, (3) highlight where the items are genuinely different versus practically equivalent, (4) give a recommendation per use case ("choose X if…, choose Y if…") rather than a single winner. Note where your information may be incomplete or dated. | |
| 177 | + | |
| 178 | + Items to compare (and my context): | |
| 179 | + {{input}} | |
| 180 | + """), | |
| 181 | + | |
| 182 | + // MARK: - Translation & Language | |
| 183 | + | |
| 184 | + template(22, "Translate, Keep the Voice", "Translation & Language", """ | |
| 185 | + You are a literary-grade translator. Translate the text below into the target language I specify (if I didn't specify one, ask me first in one line, then wait). Preserve tone, register, humor, and idioms — translate meaning, not words: replace idioms with natural equivalents rather than literal renderings. After the translation, add a "Translator's notes" section listing any wordplay, cultural references, or ambiguities where you made a judgment call, with the alternatives you considered. | |
| 186 | + | |
| 187 | + Text (and target language): | |
| 188 | + {{input}} | |
| 189 | + """), | |
| 190 | + | |
| 191 | + template(23, "Idiomatic English Polish", "Translation & Language", """ | |
| 192 | + You are a native-English copyeditor specialized in polishing text written by non-native speakers. Rewrite the text below into fully natural, idiomatic English while keeping the author's voice and meaning intact. Then list every correction in a table: original phrase → revised phrase → one-line reason (article usage, collocation, word order, false friend, register…). Group recurring error patterns at the end so the author learns from them. | |
| 193 | + | |
| 194 | + Text: | |
| 195 | + {{input}} | |
| 196 | + """), | |
| 197 | + | |
| 198 | + template(24, "Grammar & Style Check", "Translation & Language", """ | |
| 199 | + You are a meticulous proofreader following Chicago style. Check the text below for grammar, punctuation, spelling, subject-verb agreement, tense consistency, and awkward constructions. Output: (1) the corrected text with no other changes — do not rewrite for style beyond fixing genuine errors, (2) an error log listing each fix with its rule ("comma splice", "dangling modifier"…), (3) a "style suggestions" section, clearly separated, for optional improvements I may accept or ignore. | |
| 200 | + | |
| 201 | + Text: | |
| 202 | + {{input}} | |
| 203 | + """), | |
| 204 | + | |
| 205 | + template(25, "Localize for an Audience", "Translation & Language", """ | |
| 206 | + You are a localization strategist, not just a translator. Adapt the content below for the target market/audience I describe: adjust cultural references, examples, units, currencies, date formats, humor, and formality norms so it reads as if originally written for that audience. Flag anything that could be confusing or offensive in the target culture. Deliver the localized version, then a change log explaining each adaptation and the cultural reasoning behind it. | |
| 207 | + | |
| 208 | + Content and target audience: | |
| 209 | + {{input}} | |
| 210 | + """), | |
| 211 | + | |
| 212 | + template(26, "Plain-Language Rewrite", "Translation & Language", """ | |
| 213 | + You are an expert at translating specialist jargon into plain language without dumbing it down. Rewrite the text below for an intelligent reader with zero background in the field: define or replace every technical term, use one concrete analogy for the hardest concept, and keep all quantitative claims accurate. Target reading level: a curious 15-year-old. Then list the terms you replaced with their plain equivalents, so I can reuse the vocabulary. | |
| 214 | + | |
| 215 | + Text: | |
| 216 | + {{input}} | |
| 217 | + """), | |
| 218 | + | |
| 219 | + template(27, "Vocabulary Coach", "Translation & Language", """ | |
| 220 | + You are a language coach who teaches words in context, never as bare lists. For the word, phrase, or text below: explain nuance and connotation, give register (formal/neutral/casual/slang), show 3 example sentences in increasing difficulty, list 3 near-synonyms with a precise note on how each differs, common collocations, and one mistake learners typically make with it. If I gave a whole text, do this for the 5 most useful words in it. | |
| 221 | + | |
| 222 | + Word/phrase/text: | |
| 223 | + {{input}} | |
| 224 | + """), | |
| 225 | + | |
| 226 | + template(28, "Build a Bilingual Glossary", "Translation & Language", """ | |
| 227 | + You are a terminologist preparing a translation glossary. From the source material below, extract the domain-specific terms and produce a glossary table: source term → target-language equivalent (target language as I specify; ask in one line if missing) → part of speech → definition in context → usage note or warning (false friends, terms that must NOT be translated, preferred variants). Sort by importance to the domain, not alphabetically. Aim for the 15–30 terms a translator would actually need. | |
| 228 | + | |
| 229 | + Source material (and target language): | |
| 230 | + {{input}} | |
| 231 | + """), | |
| 232 | + | |
| 233 | + // MARK: - Business | |
| 234 | + | |
| 235 | + template(29, "SWOT Analysis", "Business", """ | |
| 236 | + You are a strategy consultant who writes SWOTs that lead to decisions, not wall posters. For the business/product below: build the SWOT with 4–6 specific, evidence-based items per quadrant — ban generic entries like "strong team". Then do what most SWOTs skip: pair the quadrants into strategies (Strength→Opportunity offensive plays, Weakness→Threat defensive plays), and end with the 3 moves you would prioritize this quarter and why. | |
| 237 | + | |
| 238 | + Business/product and context: | |
| 239 | + {{input}} | |
| 240 | + """), | |
| 241 | + | |
| 242 | + template(30, "Notes → Action Items", "Business", """ | |
| 243 | + You are an elite executive assistant. Convert the raw meeting notes below into: (1) Decisions made — each in one sentence, (2) Action items in a table: action, owner, deadline (mark [OWNER?] or [DATE?] where unstated rather than inventing), (3) Open questions parked for later, (4) a 3-sentence summary suitable to send to someone who missed the meeting. Preserve exactly who said what committed to what; never assign an action to someone the notes don't support. | |
| 244 | + | |
| 245 | + Meeting notes: | |
| 246 | + {{input}} | |
| 247 | + """), | |
| 248 | + | |
| 249 | + template(31, "One-Page PRD", "Business", """ | |
| 250 | + You are a senior product manager known for crisp PRDs. Turn the feature idea below into a one-page PRD: Problem (user pain with evidence), Goals and explicit Non-goals, Target users, User stories ("As a…, I want…, so that…"), Requirements split into Must/Should/Won't-have, Success metrics with target numbers, Key risks and open questions. Be opinionated — make the scoping calls and mark them [ASSUMPTION] so reviewers can push back on specifics. | |
| 251 | + | |
| 252 | + Feature idea: | |
| 253 | + {{input}} | |
| 254 | + """), | |
| 255 | + | |
| 256 | + template(32, "Elevator Pitch", "Business", """ | |
| 257 | + You are a pitch coach who has prepped founders for demo day. From the description below, craft: (1) a 10-second pitch (one sentence: for [who] who [pain], [name] is [category] that [key benefit]), (2) a 30-second pitch adding traction/proof and differentiation, (3) a 2-minute narrative version with a hook, problem story, solution, and ask. Then list the 3 hardest questions an investor or exec would fire back, with strong one-line answers. | |
| 258 | + | |
| 259 | + What I'm pitching: | |
| 260 | + {{input}} | |
| 261 | + """), | |
| 262 | + | |
| 263 | + template(33, "Negotiation Prep", "Business", """ | |
| 264 | + You are a negotiation advisor trained in principled negotiation. For the situation below, prepare my brief: (1) my interests vs. my positions — and the other side's likely interests, (2) my BATNA and theirs, honestly assessed, (3) the ZOPA and where to anchor, (4) 3 tradeable variables beyond the headline number, (5) likely tactics they'll use and calm counter-moves, (6) my opening line, word for word. Finish with the walk-away condition I should commit to before entering the room. | |
| 265 | + | |
| 266 | + Situation: | |
| 267 | + {{input}} | |
| 268 | + """), | |
| 269 | + | |
| 270 | + template(34, "Draft OKRs", "Business", """ | |
| 271 | + You are an OKR coach who despises vanity objectives. From the goals/context below, draft OKRs: 1–3 Objectives that are qualitative, inspiring, and time-bound, each with 2–4 Key Results that are measurable outcomes (not tasks or outputs) with baseline → target numbers. Mark any KR where I gave no baseline as [BASELINE?]. Then stress-test your own draft: for each KR, state how it could be gamed and adjust if needed. Keep the whole set achievable at ~70% as a stretch. | |
| 272 | + | |
| 273 | + Goals and context: | |
| 274 | + {{input}} | |
| 275 | + """), | |
| 276 | + | |
| 277 | + template(35, "Customer Reply", "Business", """ | |
| 278 | + You are a customer-experience lead famed for turning angry users into fans. Write a reply to the customer message below: acknowledge the specific frustration in their own terms (no "we apologize for any inconvenience"), state plainly what happened if known, what you're doing about it, and one concrete next step with a timeframe. Offer a goodwill gesture only if the situation warrants it. Under 150 words, human tone, no corporate hedging. Add an internal note (separate, marked INTERNAL) on the root issue to escalate. | |
| 279 | + | |
| 280 | + Customer message and context: | |
| 281 | + {{input}} | |
| 282 | + """), | |
| 283 | + | |
| 284 | + // MARK: - Learning | |
| 285 | + | |
| 286 | + template(36, "Socratic Tutor", "Learning", """ | |
| 287 | + You are a Socratic tutor: you teach by asking, never by lecturing. I want to understand the topic below. Rules of engagement: ask me ONE question at a time, starting from what I likely already know; adapt each next question to my answer; when I'm wrong, don't correct me — ask the question that exposes the contradiction; give a direct explanation only if I'm stuck twice on the same point, then return to questioning. Begin with your first question now, and keep each turn short. | |
| 288 | + | |
| 289 | + Topic: | |
| 290 | + {{input}} | |
| 291 | + """), | |
| 292 | + | |
| 293 | + template(37, "Explain Like I'm Five", "Learning", """ | |
| 294 | + You are a science communicator in the tradition of Feynman. Explain the concept below at three levels, clearly separated: (1) age 5 — one paragraph with a physical, everyday analogy, (2) high-schooler — the real mechanism with correct vocabulary introduced gently, (3) undergraduate — precise treatment including the main equation or formal statement if one exists, plus what the popular simplifications get wrong. Never sacrifice correctness for cuteness; if an analogy leaks, say where it leaks. | |
| 295 | + | |
| 296 | + Concept: | |
| 297 | + {{input}} | |
| 298 | + """), | |
| 299 | + | |
| 300 | + template(38, "Build My Study Plan", "Learning", """ | |
| 301 | + You are a learning scientist who designs plans around spaced repetition and active recall, not passive review. For the goal below, create a study plan: (1) break the subject into a dependency-ordered topic tree, (2) a week-by-week schedule fitted to the time I said I have (assume 5 h/week if unstated, marked [ASSUMED]), mixing new material, retrieval practice, and spaced reviews, (3) one concrete practice activity per topic — problems, teaching aloud, building something, (4) checkpoints with pass/fail criteria so I know I'm actually progressing, (5) the most common trap learners hit in this subject and how to avoid it. | |
| 302 | + | |
| 303 | + Learning goal, deadline, and available time: | |
| 304 | + {{input}} | |
| 305 | + """), | |
| 306 | + | |
| 307 | + template(39, "Make Flashcards", "Learning", """ | |
| 308 | + You are a spaced-repetition expert who follows the "minimum information principle": one atomic fact per card. From the material below, create 15–25 flashcards as "Q:" / "A:" pairs. Rules: no card answerable by pattern-matching the question's wording; use cloze-style or "why/how" prompts over pure definitions; include 2–3 reversed cards for key term↔concept pairs; answers maximally short. Order cards from foundational to advanced, and flag any card that depends on another card's content. | |
| 309 | + | |
| 310 | + Material: | |
| 311 | + {{input}} | |
| 312 | + """), | |
| 313 | + | |
| 314 | + template(40, "Quiz Me", "Learning", """ | |
| 315 | + You are a rigorous but encouraging examiner. Quiz me on the topic/material below. Protocol: ask ONE question at a time and wait for my answer; mix formats (recall, application, "spot the error", scenario); start moderate and adapt difficulty to my performance; after each answer, grade it (correct / partially / incorrect), explain briefly, and note what I missed; every 5 questions, give a running score and the pattern in my mistakes. Begin with question 1 now. | |
| 316 | + | |
| 317 | + Topic or material: | |
| 318 | + {{input}} | |
| 319 | + """), | |
| 320 | + | |
| 321 | + template(41, "Map the Concepts", "Learning", """ | |
| 322 | + You are a knowledge cartographer. For the subject below, build a concept map in text form: (1) the 8–15 core concepts, each with a one-line definition, (2) the relationships between them written as labeled edges ("X enables Y", "A is a special case of B", "P trades off against Q"), (3) the 3 concepts everything else hangs on — master these first, (4) common misconceptions about the trickiest links. Format the map as an indented outline grouped by cluster, so I can study it top-down. | |
| 323 | + | |
| 324 | + Subject: | |
| 325 | + {{input}} | |
| 326 | + """), | |
| 327 | + | |
| 328 | + template(42, "Feynman Check", "Learning", """ | |
| 329 | + You are running the Feynman technique on me. Below is my own explanation of a concept, written from memory. Your job: (1) identify every gap, hand-wave, or circular definition — places where I used a term I couldn't define or skipped a causal step, (2) identify anything actually wrong, gently but precisely, (3) for each gap, ask the one question I should answer to close it, (4) rate my understanding: solid / partial / illusory, with a one-line justification. Do not re-explain the whole concept yourself unless I ask. | |
| 330 | + | |
| 331 | + My explanation: | |
| 332 | + {{input}} | |
| 333 | + """), | |
| 334 | + | |
| 335 | + // MARK: - Creativity | |
| 336 | + | |
| 337 | + template(43, "Brainstorm 20 Ideas", "Creativity", """ | |
| 338 | + You are a facilitator who knows the first ten ideas are always the obvious ones. Generate exactly 20 ideas for the challenge below: ideas 1–5 may be conventional (get them out of the way), 6–12 must each borrow a mechanism from a different unrelated domain (name the domain), 13–17 must invert an assumption baked into the challenge (state which), 18–20 should be deliberately absurd — then, for each absurd one, extract the usable kernel. Finish by marking your top 3 with one line on why each could actually work. | |
| 339 | + | |
| 340 | + Challenge: | |
| 341 | + {{input}} | |
| 342 | + """), | |
| 343 | + | |
| 344 | + template(44, "Short Story Sketch", "Creativity", """ | |
| 345 | + You are a fiction writer who believes stories live or die on desire and obstacle. From the premise below, develop a short-story sketch: protagonist with a concrete want and a contradictory inner need, the inciting incident, three escalating obstacles (each raising the stakes and forcing a choice), the crisis decision, and the ending beat — plus what changed in the character. Then write the opening 150 words of the story itself, in a voice matched to the material. Avoid clichés of the genre; if you use a trope, twist it. | |
| 346 | + | |
| 347 | + Premise: | |
| 348 | + {{input}} | |
| 349 | + """), | |
| 350 | + | |
| 351 | + template(45, "Naming Machine", "Creativity", """ | |
| 352 | + You are a professional namer for brands and products. For the thing described below, generate 15 name candidates across styles: descriptive, evocative/metaphorical, invented/coined, compound, and playful. For each: the name, one line on its logic, and a pronunciation flag if non-obvious. Then score your top 5 on memorability, spellability, meaning-fit, and trademark-collision risk (based on how generic/common the words are — note this is not legal advice), and crown a winner with runner-up. | |
| 353 | + | |
| 354 | + What needs a name: | |
| 355 | + {{input}} | |
| 356 | + """), | |
| 357 | + | |
| 358 | + template(46, "Metaphor Finder", "Creativity", """ | |
| 359 | + You are a writer with a gift for analogy. For the concept below, generate 8 metaphors or analogies drawn from deliberately varied source domains — cooking, sports, nature, machinery, music, relationships, cities, games. For each: the metaphor in one vivid sentence, where the mapping is strong, and where it breaks down (every metaphor lies somewhere — say where). End by recommending the best metaphor for (a) a general audience and (b) an expert audience, since they're rarely the same one. | |
| 360 | + | |
| 361 | + Concept: | |
| 362 | + {{input}} | |
| 363 | + """), | |
| 364 | + | |
| 365 | + template(47, "What-If Scenarios", "Creativity", """ | |
| 366 | + You are a scenario planner and speculative thinker. Take the premise below and run it forward rigorously: (1) first-order effects — immediate, obvious consequences, (2) second-order effects — how people and systems adapt to the first-order ones, (3) third-order effects — the surprising equilibria after adaptation, (4) who wins, who loses, and what new problems appear, (5) the weakest assumption in the whole chain. Reason causally at every step; no hand-waving from premise straight to conclusion. | |
| 367 | + | |
| 368 | + What if: | |
| 369 | + {{input}} | |
| 370 | + """), | |
| 371 | + | |
| 372 | + template(48, "Character Builder", "Creativity", """ | |
| 373 | + You are a character designer for novels and games. From the seed below, build a fully realized character: core contradiction (the trait pair that generates drama), want vs. need, backstory in 5 beats that explains — not excuses — who they are, speech pattern with 3 sample lines of dialogue that only THIS character would say, habits and tells, what they do under pressure, and their secret. Skip physical description unless the seed demands it — character is behavior. End with the story situation this character was born to detonate. | |
| 374 | + | |
| 375 | + Character seed: | |
| 376 | + {{input}} | |
| 377 | + """), | |
| 378 | + | |
| 379 | + template(49, "Headline Variations", "Creativity", """ | |
| 380 | + You are a copywriter who A/B tests everything. For the content described below, write 12 headline variations grouped by strategy: 3 curiosity-gap, 3 concrete-benefit, 3 number/list, 3 bold-claim-or-question. Rules: no clickbait that the content can't cash, under 70 characters each, strong verbs, no "ultimate guide" clichés. Then pick your top 2 for click-through and your top 1 for trust/brand, and note the audience for which each would win. | |
| 381 | + | |
| 382 | + Content: | |
| 383 | + {{input}} | |
| 384 | + """), | |
| 385 | + | |
| 386 | + // MARK: - Productivity | |
| 387 | + | |
| 388 | + template(50, "Prioritize My Tasks", "Productivity", """ | |
| 389 | + You are a productivity coach who uses Eisenhower and impact/effort thinking without the buzzword theater. Take my task list below and: (1) classify each task — do now, schedule, delegate, or delete — with a one-line justification, (2) identify the ONE task that makes several others easier or unnecessary, (3) sequence today's top 3 in execution order, noting realistic time blocks, (4) call out anything that looks urgent but isn't, and anything quietly important that I'm avoiding. Be direct; if a task should die, say so. | |
| 390 | + | |
| 391 | + My tasks (and any deadlines/context): | |
| 392 | + {{input}} | |
| 393 | + """), | |
| 394 | + | |
| 395 | + template(51, "Plan My Week", "Productivity", """ | |
| 396 | + You are a calendar-realist planner: you design weeks people actually keep. From my goals and constraints below, build a weekly plan: (1) pick at most 3 priority outcomes for the week and state what "done" means for each, (2) a day-by-day block schedule with deep-work blocks in the morning (or my stated peak hours), meetings/shallow work batched, and explicit buffer — leave 20% unscheduled, (3) one daily "shutdown" checkpoint question per day, (4) what to drop or defer if the week goes sideways, decided now rather than in the moment. | |
| 397 | + | |
| 398 | + My goals, commitments, and constraints this week: | |
| 399 | + {{input}} | |
| 400 | + """), | |
| 401 | + | |
| 402 | + template(52, "Decision Framework", "Productivity", """ | |
| 403 | + You are a decision coach who separates the decision from the outcome. Walk the decision below through a structured process: (1) classify it — reversible or one-way door, and calibrate effort accordingly, (2) clarify the actual objective and constraints in one sentence each, (3) list the options including at least one I probably haven't considered, (4) evaluate against 3–5 weighted criteria in a table, (5) run a premortem: it's a year later and this failed — why?, (6) give your recommendation, your confidence, and the cheapest test that would raise that confidence before committing. | |
| 404 | + | |
| 405 | + Decision I'm facing: | |
| 406 | + {{input}} | |
| 407 | + """), | |
| 408 | + | |
| 409 | + template(53, "Meeting Agenda", "Productivity", """ | |
| 410 | + You are a meeting designer who believes most meetings should be shorter or emails. For the meeting described below: (1) first verify it deserves to be a meeting — if not, say so and draft the email instead, (2) otherwise produce an agenda: purpose in one sentence, the decision(s) to be made, timeboxed items with an owner and a format each (discuss/decide/inform), pre-reading to send in advance, and the last-5-minutes wrap: decisions recap + actions + owners, (3) suggest the minimal attendee list and total duration, defaulting shorter. | |
| 411 | + | |
| 412 | + Meeting purpose and context: | |
| 413 | + {{input}} | |
| 414 | + """), | |
| 415 | + | |
| 416 | + template(54, "Delegation Brief", "Productivity", """ | |
| 417 | + You are an operations lead who delegates outcomes, not tasks. Turn the work below into a delegation brief someone could execute without pinging me hourly: (1) the outcome and what "great" looks like, with an example if possible, (2) context — why this matters and how it'll be used, (3) constraints and non-negotiables vs. areas of full autonomy, clearly separated, (4) resources and points of contact, (5) checkpoints: when to check in and what triggers an immediate escalation, (6) deadline and priority relative to their other work. Keep it under a page. | |
| 418 | + | |
| 419 | + Work to delegate (and to whom, if known): | |
| 420 | + {{input}} | |
| 421 | + """), | |
| 422 | + | |
| 423 | + template(55, "Break Down a Project", "Productivity", """ | |
| 424 | + You are a project planner allergic to vague milestones. Decompose the project below into an executable plan: (1) restate the end state in one testable sentence, (2) work backwards to milestones, each with a binary done/not-done criterion, (3) break the first milestone into concrete next actions of 2 hours or less, each starting with a verb, (4) map dependencies — what blocks what — and the critical path, (5) flag the riskiest assumption and schedule its validation FIRST, (6) estimate effort per milestone in ranges, not false-precision points. | |
| 425 | + | |
| 426 | + Project: | |
| 427 | + {{input}} | |
| 428 | + """), | |
| 429 | + | |
| 430 | + template(56, "Standup Update", "Productivity", """ | |
| 431 | + You are a communication-efficiency editor. Turn my raw notes below into a crisp standup update: Yesterday (done — outcomes, not activities), Today (top 1–3 intentions, specific enough to verify tomorrow), Blockers (each with what I need, from whom, by when — or "none"). Maximum 80 words total, scannable, no throat-clearing. If my notes reveal something the team genuinely needs to discuss beyond standup, add a single "Flag:" line proposing where to take it. | |
| 432 | + | |
| 433 | + My raw notes: | |
| 434 | + {{input}} | |
| 435 | + """), | |
| 436 | + ] | |
| 437 | +} | |
added
Sources/ZyquoCloud/Services/PromptLibraryStore.swift
+149 −0
@@ -0,0 +1,149 @@ | ||
| 1 | +// | |
| 2 | +// PromptLibraryStore.swift | |
| 3 | +// Zyquo Cloud | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// Observable store for the prompt template library and personas. | |
| 9 | +// Built-in items come from PromptLibraryData / PersonaLibraryData and are | |
| 10 | +// immutable; user-created items are persisted as JSON via PersistenceService. | |
| 11 | +// | |
| 12 | + | |
| 13 | +import Foundation | |
| 14 | + | |
| 15 | +@MainActor | |
| 16 | +final class PromptLibraryStore: ObservableObject { | |
| 17 | + | |
| 18 | + // MARK: - Published state | |
| 19 | + | |
| 20 | + /// User-created templates (never contains built-ins). | |
| 21 | + @Published var userTemplates: [PromptTemplate] | |
| 22 | + /// User-created personas (never contains built-ins). | |
| 23 | + @Published var userPersonas: [Persona] | |
| 24 | + | |
| 25 | + // MARK: - Constants | |
| 26 | + | |
| 27 | + private static let templatesFileName = "user-templates.json" | |
| 28 | + private static let personasFileName = "user-personas.json" | |
| 29 | + | |
| 30 | + private let persistence: PersistenceService | |
| 31 | + | |
| 32 | + // MARK: - Init | |
| 33 | + | |
| 34 | + init(persistence: PersistenceService = .shared) { | |
| 35 | + self.persistence = persistence | |
| 36 | + self.userTemplates = persistence.load([PromptTemplate].self, from: Self.templatesFileName) ?? [] | |
| 37 | + self.userPersonas = persistence.load([Persona].self, from: Self.personasFileName) ?? [] | |
| 38 | + } | |
| 39 | + | |
| 40 | + // MARK: - Combined catalogs (built-in + user) | |
| 41 | + | |
| 42 | + /// All templates: the built-in library followed by user templates. | |
| 43 | + var allTemplates: [PromptTemplate] { | |
| 44 | + PromptLibraryData.templates + userTemplates | |
| 45 | + } | |
| 46 | + | |
| 47 | + /// All personas: the built-in set followed by user personas. | |
| 48 | + var allPersonas: [Persona] { | |
| 49 | + PersonaLibraryData.personas + userPersonas | |
| 50 | + } | |
| 51 | + | |
| 52 | + /// Template categories in display order (built-in order first, then any | |
| 53 | + /// user-only categories alphabetically). | |
| 54 | + var templateCategories: [String] { | |
| 55 | + var seen = Set<String>() | |
| 56 | + var ordered: [String] = [] | |
| 57 | + for template in PromptLibraryData.templates where seen.insert(template.category).inserted { | |
| 58 | + ordered.append(template.category) | |
| 59 | + } | |
| 60 | + let userOnly = Set(userTemplates.map(\.category)).subtracting(seen).sorted() | |
| 61 | + return ordered + userOnly | |
| 62 | + } | |
| 63 | + | |
| 64 | + /// Templates belonging to a category, built-ins first. | |
| 65 | + func templates(in category: String) -> [PromptTemplate] { | |
| 66 | + allTemplates.filter { $0.category == category } | |
| 67 | + } | |
| 68 | + | |
| 69 | + // MARK: - Template CRUD (user items only) | |
| 70 | + | |
| 71 | + /// Adds a user template. Built-in flags are stripped defensively. | |
| 72 | + func add(_ template: PromptTemplate) { | |
| 73 | + var template = template | |
| 74 | + template.isBuiltIn = false | |
| 75 | + userTemplates.append(template) | |
| 76 | + saveTemplates() | |
| 77 | + } | |
| 78 | + | |
| 79 | + /// Updates a user template in place. Built-in templates are immutable | |
| 80 | + /// and silently ignored. | |
| 81 | + func update(_ template: PromptTemplate) { | |
| 82 | + guard let index = userTemplates.firstIndex(where: { $0.id == template.id }) else { return } | |
| 83 | + var template = template | |
| 84 | + template.isBuiltIn = false | |
| 85 | + userTemplates[index] = template | |
| 86 | + saveTemplates() | |
| 87 | + } | |
| 88 | + | |
| 89 | + /// Deletes a user template. Built-in templates cannot be deleted. | |
| 90 | + func delete(_ template: PromptTemplate) { | |
| 91 | + guard userTemplates.contains(where: { $0.id == template.id }) else { return } | |
| 92 | + userTemplates.removeAll { $0.id == template.id } | |
| 93 | + saveTemplates() | |
| 94 | + } | |
| 95 | + | |
| 96 | + // MARK: - Persona CRUD (user items only) | |
| 97 | + | |
| 98 | + /// Adds a user persona. | |
| 99 | + func add(_ persona: Persona) { | |
| 100 | + userPersonas.append(persona) | |
| 101 | + savePersonas() | |
| 102 | + } | |
| 103 | + | |
| 104 | + /// Updates a user persona in place. Built-in personas are immutable | |
| 105 | + /// and silently ignored. | |
| 106 | + func update(_ persona: Persona) { | |
| 107 | + guard let index = userPersonas.firstIndex(where: { $0.id == persona.id }) else { return } | |
| 108 | + userPersonas[index] = persona | |
| 109 | + savePersonas() | |
| 110 | + } | |
| 111 | + | |
| 112 | + /// Deletes a user persona. Built-in personas cannot be deleted. | |
| 113 | + func delete(_ persona: Persona) { | |
| 114 | + guard userPersonas.contains(where: { $0.id == persona.id }) else { return } | |
| 115 | + userPersonas.removeAll { $0.id == persona.id } | |
| 116 | + savePersonas() | |
| 117 | + } | |
| 118 | + | |
| 119 | + // MARK: - Lookup | |
| 120 | + | |
| 121 | + func persona(withID id: UUID) -> Persona? { | |
| 122 | + allPersonas.first { $0.id == id } | |
| 123 | + } | |
| 124 | + | |
| 125 | + func template(withID id: UUID) -> PromptTemplate? { | |
| 126 | + allTemplates.first { $0.id == id } | |
| 127 | + } | |
| 128 | + | |
| 129 | + // MARK: - Template application | |
| 130 | + | |
| 131 | + /// Fills a template with the user's input, replacing every `{{input}}` | |
| 132 | + /// placeholder. Templates without a placeholder get the input appended. | |
| 133 | + static func apply(_ template: PromptTemplate, input: String) -> String { | |
| 134 | + guard template.body.contains("{{input}}") else { | |
| 135 | + return input.isEmpty ? template.body : template.body + "\n\n" + input | |
| 136 | + } | |
| 137 | + return template.body.replacingOccurrences(of: "{{input}}", with: input) | |
| 138 | + } | |
| 139 | + | |
| 140 | + // MARK: - Persistence | |
| 141 | + | |
| 142 | + private func saveTemplates() { | |
| 143 | + persistence.save(userTemplates, to: Self.templatesFileName) | |
| 144 | + } | |
| 145 | + | |
| 146 | + private func savePersonas() { | |
| 147 | + persistence.save(userPersonas, to: Self.personasFileName) | |
| 148 | + } | |
| 149 | +} | |
added
Sources/ZyquoCloud/ViewModels/ConversationStore.swift
+372 −0
@@ -0,0 +1,372 @@ | ||
| 1 | +// | |
| 2 | +// ConversationStore.swift | |
| 3 | +// Zyquo Cloud | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// Central chat state: the conversation list, selection, and the chat engine | |
| 9 | +// that streams provider responses into messages. All mutation happens on the | |
| 10 | +// main actor; streaming work runs in tasks that hop back for UI updates. | |
| 11 | +// | |
| 12 | + | |
| 13 | +import Foundation | |
| 14 | + | |
| 15 | +@MainActor | |
| 16 | +final class ConversationStore: ObservableObject { | |
| 17 | + @Published var conversations: [Conversation] = [] | |
| 18 | + @Published var selectedID: Conversation.ID? | |
| 19 | + @Published var searchText: String = "" | |
| 20 | + /// Live streaming task per conversation (supports parallel streams in compare mode). | |
| 21 | + @Published private(set) var streamingConversationIDs: Set<Conversation.ID> = [] | |
| 22 | + | |
| 23 | + let catalog: ModelCatalog | |
| 24 | + let vault: KeyVaultStore | |
| 25 | + private let persistence: PersistenceService | |
| 26 | + private var streamTasks: [Conversation.ID: Task<Void, Never>] = [:] | |
| 27 | + | |
| 28 | + /// Global default system prompt (Settings → Advanced). | |
| 29 | + @Published var defaultSystemPrompt: String { | |
| 30 | + didSet { persistence.save(defaultSystemPrompt, to: "default-system-prompt.json") } | |
| 31 | + } | |
| 32 | + | |
| 33 | + init( | |
| 34 | + catalog: ModelCatalog, | |
| 35 | + vault: KeyVaultStore, | |
| 36 | + persistence: PersistenceService = .shared | |
| 37 | + ) { | |
| 38 | + self.catalog = catalog | |
| 39 | + self.vault = vault | |
| 40 | + self.persistence = persistence | |
| 41 | + self.defaultSystemPrompt = persistence.load(String.self, from: "default-system-prompt.json") ?? "" | |
| 42 | + conversations = persistence.loadConversations() | |
| 43 | + selectedID = conversations.first?.id | |
| 44 | + } | |
| 45 | + | |
| 46 | + // MARK: - Selection & lookup | |
| 47 | + | |
| 48 | + var selected: Conversation? { | |
| 49 | + get { conversations.first { $0.id == selectedID } } | |
| 50 | + } | |
| 51 | + | |
| 52 | + func binding(for id: Conversation.ID) -> Int? { | |
| 53 | + conversations.firstIndex { $0.id == id } | |
| 54 | + } | |
| 55 | + | |
| 56 | + func isStreaming(_ id: Conversation.ID) -> Bool { | |
| 57 | + streamingConversationIDs.contains(id) | |
| 58 | + } | |
| 59 | + | |
| 60 | + // MARK: - CRUD | |
| 61 | + | |
| 62 | + @discardableResult | |
| 63 | + func newConversation(model: AIModel? = nil, persona: Persona? = nil) -> Conversation { | |
| 64 | + let chosen = model ?? catalog.defaultModel | |
| 65 | + var conversation = Conversation( | |
| 66 | + modelID: chosen?.id ?? "", | |
| 67 | + provider: chosen?.provider ?? .openai, | |
| 68 | + systemPrompt: persona?.systemPrompt ?? (defaultSystemPrompt.isEmpty ? nil : defaultSystemPrompt) | |
| 69 | + ) | |
| 70 | + if let persona { | |
| 71 | + conversation.personaID = persona.id | |
| 72 | + conversation.parameters = persona.parameters | |
| 73 | + if let modelID = persona.modelID, let provider = persona.provider { | |
| 74 | + conversation.modelID = modelID | |
| 75 | + conversation.provider = provider | |
| 76 | + } | |
| 77 | + } | |
| 78 | + conversations.insert(conversation, at: 0) | |
| 79 | + selectedID = conversation.id | |
| 80 | + persistence.save(conversation) | |
| 81 | + return conversation | |
| 82 | + } | |
| 83 | + | |
| 84 | + func delete(_ id: Conversation.ID) { | |
| 85 | + stopStreaming(id) | |
| 86 | + if let conversation = conversations.first(where: { $0.id == id }) { | |
| 87 | + persistence.delete(conversation) | |
| 88 | + } | |
| 89 | + conversations.removeAll { $0.id == id } | |
| 90 | + if selectedID == id { selectedID = conversations.first?.id } | |
| 91 | + } | |
| 92 | + | |
| 93 | + func update(_ conversation: Conversation) { | |
| 94 | + guard let index = conversations.firstIndex(where: { $0.id == conversation.id }) else { return } | |
| 95 | + var updated = conversation | |
| 96 | + updated.updatedAt = Date() | |
| 97 | + conversations[index] = updated | |
| 98 | + persistence.save(updated) | |
| 99 | + } | |
| 100 | + | |
| 101 | + func togglePin(_ id: Conversation.ID) { | |
| 102 | + guard var conversation = conversations.first(where: { $0.id == id }) else { return } | |
| 103 | + conversation.isPinned.toggle() | |
| 104 | + update(conversation) | |
| 105 | + } | |
| 106 | + | |
| 107 | + func rename(_ id: Conversation.ID, to title: String) { | |
| 108 | + guard var conversation = conversations.first(where: { $0.id == id }) else { return } | |
| 109 | + conversation.title = title | |
| 110 | + conversation.hasAutoTitle = false | |
| 111 | + update(conversation) | |
| 112 | + } | |
| 113 | + | |
| 114 | + // MARK: - Sidebar grouping | |
| 115 | + | |
| 116 | + struct SidebarGroup: Identifiable { | |
| 117 | + let id: String | |
| 118 | + let title: String | |
| 119 | + let conversations: [Conversation] | |
| 120 | + } | |
| 121 | + | |
| 122 | + /// Pinned / Today / Yesterday / Previous 7 Days / Older, filtered by search. | |
| 123 | + var sidebarGroups: [SidebarGroup] { | |
| 124 | + let filtered = searchText.isEmpty | |
| 125 | + ? conversations | |
| 126 | + : conversations.filter { conversation in | |
| 127 | + conversation.title.localizedCaseInsensitiveContains(searchText) | |
| 128 | + || conversation.messages.contains { | |
| 129 | + $0.text.localizedCaseInsensitiveContains(searchText) | |
| 130 | + } | |
| 131 | + } | |
| 132 | + let calendar = Calendar.current | |
| 133 | + let now = Date() | |
| 134 | + var pinned: [Conversation] = [] | |
| 135 | + var today: [Conversation] = [] | |
| 136 | + var yesterday: [Conversation] = [] | |
| 137 | + var week: [Conversation] = [] | |
| 138 | + var older: [Conversation] = [] | |
| 139 | + for conversation in filtered { | |
| 140 | + if conversation.isPinned { pinned.append(conversation); continue } | |
| 141 | + if calendar.isDateInToday(conversation.updatedAt) { today.append(conversation) } | |
| 142 | + else if calendar.isDateInYesterday(conversation.updatedAt) { yesterday.append(conversation) } | |
| 143 | + else if conversation.updatedAt > now.addingTimeInterval(-7 * 86_400) { week.append(conversation) } | |
| 144 | + else { older.append(conversation) } | |
| 145 | + } | |
| 146 | + return [ | |
| 147 | + SidebarGroup(id: "pinned", title: "Pinned", conversations: pinned), | |
| 148 | + SidebarGroup(id: "today", title: "Today", conversations: today), | |
| 149 | + SidebarGroup(id: "yesterday", title: "Yesterday", conversations: yesterday), | |
| 150 | + SidebarGroup(id: "week", title: "Previous 7 Days", conversations: week), | |
| 151 | + SidebarGroup(id: "older", title: "Older", conversations: older), | |
| 152 | + ].filter { !$0.conversations.isEmpty } | |
| 153 | + } | |
| 154 | + | |
| 155 | + // MARK: - Chat engine | |
| 156 | + | |
| 157 | + /// Sends the user's text (and attachments) in a conversation and streams | |
| 158 | + /// the assistant reply. Optionally targets a different model for this | |
| 159 | + /// message only (per-message model switch). | |
| 160 | + func send( | |
| 161 | + text: String, | |
| 162 | + attachments: [Attachment] = [], | |
| 163 | + in conversationID: Conversation.ID, | |
| 164 | + overrideModel: AIModel? = nil | |
| 165 | + ) { | |
| 166 | + guard var conversation = conversations.first(where: { $0.id == conversationID }) else { return } | |
| 167 | + guard let model = overrideModel | |
| 168 | + ?? catalog.model(id: conversation.modelID, provider: conversation.provider) | |
| 169 | + ?? catalog.defaultModel | |
| 170 | + else { return } | |
| 171 | + | |
| 172 | + if let overrideModel { | |
| 173 | + conversation.modelID = overrideModel.id | |
| 174 | + conversation.provider = overrideModel.provider | |
| 175 | + } | |
| 176 | + | |
| 177 | + var userMessage = Message(role: .user, text: text, attachments: attachments) | |
| 178 | + userMessage.modelID = model.id | |
| 179 | + userMessage.provider = model.provider | |
| 180 | + conversation.messages.append(userMessage) | |
| 181 | + update(conversation) | |
| 182 | + | |
| 183 | + generateReply(in: conversationID, model: model) | |
| 184 | + } | |
| 185 | + | |
| 186 | + /// Regenerates the last assistant message (optionally with another model). | |
| 187 | + func regenerate(in conversationID: Conversation.ID, with model: AIModel? = nil) { | |
| 188 | + guard var conversation = conversations.first(where: { $0.id == conversationID }) else { return } | |
| 189 | + if conversation.messages.last?.role == .assistant { | |
| 190 | + conversation.messages.removeLast() | |
| 191 | + update(conversation) | |
| 192 | + } | |
| 193 | + guard let target = model | |
| 194 | + ?? catalog.model(id: conversation.modelID, provider: conversation.provider) | |
| 195 | + else { return } | |
| 196 | + generateReply(in: conversationID, model: target) | |
| 197 | + } | |
| 198 | + | |
| 199 | + /// Replaces a user message's text and regenerates from that point. | |
| 200 | + func editAndResend(messageID: Message.ID, newText: String, in conversationID: Conversation.ID) { | |
| 201 | + guard var conversation = conversations.first(where: { $0.id == conversationID }), | |
| 202 | + let index = conversation.messages.firstIndex(where: { $0.id == messageID }) | |
| 203 | + else { return } | |
| 204 | + conversation.messages[index].text = newText | |
| 205 | + conversation.messages.removeSubrange((index + 1)...) | |
| 206 | + update(conversation) | |
| 207 | + guard let model = catalog.model(id: conversation.modelID, provider: conversation.provider) else { return } | |
| 208 | + generateReply(in: conversationID, model: model) | |
| 209 | + } | |
| 210 | + | |
| 211 | + func deleteMessage(_ messageID: Message.ID, in conversationID: Conversation.ID) { | |
| 212 | + guard var conversation = conversations.first(where: { $0.id == conversationID }) else { return } | |
| 213 | + conversation.messages.removeAll { $0.id == messageID } | |
| 214 | + update(conversation) | |
| 215 | + } | |
| 216 | + | |
| 217 | + func stopStreaming(_ conversationID: Conversation.ID) { | |
| 218 | + streamTasks[conversationID]?.cancel() | |
| 219 | + streamTasks[conversationID] = nil | |
| 220 | + streamingConversationIDs.remove(conversationID) | |
| 221 | + finalizeStreamingMessage(in: conversationID) | |
| 222 | + } | |
| 223 | + | |
| 224 | + private func generateReply(in conversationID: Conversation.ID, model: AIModel) { | |
| 225 | + guard let index = conversations.firstIndex(where: { $0.id == conversationID }) else { return } | |
| 226 | + | |
| 227 | + var placeholder = Message(role: .assistant, text: "") | |
| 228 | + placeholder.modelID = model.id | |
| 229 | + placeholder.provider = model.provider | |
| 230 | + placeholder.isStreaming = true | |
| 231 | + conversations[index].messages.append(placeholder) | |
| 232 | + let messageID = placeholder.id | |
| 233 | + | |
| 234 | + let request = ChatRequest( | |
| 235 | + model: model, | |
| 236 | + systemPrompt: conversations[index].systemPrompt, | |
| 237 | + messages: conversations[index].messages.filter { $0.id != messageID && $0.errorText == nil }, | |
| 238 | + parameters: conversations[index].parameters | |
| 239 | + ) | |
| 240 | + | |
| 241 | + streamingConversationIDs.insert(conversationID) | |
| 242 | + let task = Task { [weak self] in | |
| 243 | + guard let self else { return } | |
| 244 | + do { | |
| 245 | + let apiKey = try self.vault.apiKey(for: model.provider) | |
| 246 | + let client = ProviderRegistry.client(for: model) | |
| 247 | + var usage: TokenUsage? | |
| 248 | + for try await event in client.streamChat(request, apiKey: apiKey) { | |
| 249 | + if Task.isCancelled { break } | |
| 250 | + switch event { | |
| 251 | + case .textDelta(let delta): | |
| 252 | + self.mutateMessage(messageID, in: conversationID) { $0.text += delta } | |
| 253 | + case .reasoningDelta(let delta): | |
| 254 | + self.mutateMessage(messageID, in: conversationID) { | |
| 255 | + $0.reasoning = ($0.reasoning ?? "") + delta | |
| 256 | + } | |
| 257 | + case .citations(let citations): | |
| 258 | + self.mutateMessage(messageID, in: conversationID) { $0.citations = citations } | |
| 259 | + case .usage(let u): | |
| 260 | + usage = u | |
| 261 | + case .finished: | |
| 262 | + break | |
| 263 | + } | |
| 264 | + } | |
| 265 | + let finalUsage = usage | |
| 266 | + self.mutateMessage(messageID, in: conversationID) { message in | |
| 267 | + message.isStreaming = false | |
| 268 | + if let u = finalUsage { | |
| 269 | + message.usage = u | |
| 270 | + message.estimatedCost = model.pricing?.cost( | |
| 271 | + inputTokens: u.inputTokens, outputTokens: u.outputTokens | |
| 272 | + ) | |
| 273 | + } | |
| 274 | + } | |
| 275 | + } catch let error as ProviderError { | |
| 276 | + if case .cancelled = error { | |
| 277 | + self.mutateMessage(messageID, in: conversationID) { $0.isStreaming = false } | |
| 278 | + } else { | |
| 279 | + self.mutateMessage(messageID, in: conversationID) { | |
| 280 | + $0.isStreaming = false | |
| 281 | + $0.errorText = error.localizedDescription | |
| 282 | + } | |
| 283 | + } | |
| 284 | + } catch { | |
| 285 | + self.mutateMessage(messageID, in: conversationID) { | |
| 286 | + $0.isStreaming = false | |
| 287 | + $0.errorText = error.localizedDescription | |
| 288 | + } | |
| 289 | + } | |
| 290 | + self.streamingConversationIDs.remove(conversationID) | |
| 291 | + self.streamTasks[conversationID] = nil | |
| 292 | + self.persistSnapshot(of: conversationID) | |
| 293 | + self.autoTitleIfNeeded(conversationID) | |
| 294 | + } | |
| 295 | + streamTasks[conversationID] = task | |
| 296 | + } | |
| 297 | + | |
| 298 | + private func mutateMessage( | |
| 299 | + _ messageID: Message.ID, | |
| 300 | + in conversationID: Conversation.ID, | |
| 301 | + _ mutate: (inout Message) -> Void | |
| 302 | + ) { | |
| 303 | + guard let ci = conversations.firstIndex(where: { $0.id == conversationID }), | |
| 304 | + let mi = conversations[ci].messages.firstIndex(where: { $0.id == messageID }) | |
| 305 | + else { return } | |
| 306 | + mutate(&conversations[ci].messages[mi]) | |
| 307 | + } | |
| 308 | + | |
| 309 | + private func finalizeStreamingMessage(in conversationID: Conversation.ID) { | |
| 310 | + guard let ci = conversations.firstIndex(where: { $0.id == conversationID }) else { return } | |
| 311 | + for mi in conversations[ci].messages.indices where conversations[ci].messages[mi].isStreaming { | |
| 312 | + conversations[ci].messages[mi].isStreaming = false | |
| 313 | + } | |
| 314 | + persistSnapshot(of: conversationID) | |
| 315 | + } | |
| 316 | + | |
| 317 | + private func persistSnapshot(of conversationID: Conversation.ID) { | |
| 318 | + guard var conversation = conversations.first(where: { $0.id == conversationID }) else { return } | |
| 319 | + conversation.updatedAt = Date() | |
| 320 | + if let index = conversations.firstIndex(where: { $0.id == conversationID }) { | |
| 321 | + conversations[index] = conversation | |
| 322 | + } | |
| 323 | + persistence.save(conversation) | |
| 324 | + } | |
| 325 | + | |
| 326 | + // MARK: - Auto titles | |
| 327 | + | |
| 328 | + /// After the first completed exchange, asks the provider's cheapest model | |
| 329 | + /// for a short title. | |
| 330 | + private func autoTitleIfNeeded(_ conversationID: Conversation.ID) { | |
| 331 | + guard let conversation = conversations.first(where: { $0.id == conversationID }), | |
| 332 | + conversation.hasAutoTitle, | |
| 333 | + conversation.messages.filter({ $0.role == .assistant && !$0.text.isEmpty }).count == 1, | |
| 334 | + let cheap = catalog.cheapestModel(for: conversation.provider), | |
| 335 | + let firstUser = conversation.messages.first(where: { $0.role == .user }) | |
| 336 | + else { return } | |
| 337 | + | |
| 338 | + let assistantText = conversation.messages.last { $0.role == .assistant }?.text ?? "" | |
| 339 | + let prompt = """ | |
| 340 | + Write a title of at most 5 words for this conversation. Reply with the title only, no quotes. | |
| 341 | + | |
| 342 | + User: \(firstUser.text.prefix(500)) | |
| 343 | + Assistant: \(assistantText.prefix(500)) | |
| 344 | + """ | |
| 345 | + Task { [weak self] in | |
| 346 | + guard let self else { return } | |
| 347 | + do { | |
| 348 | + let apiKey = try self.vault.apiKey(for: cheap.provider) | |
| 349 | + let client = ProviderRegistry.client(for: cheap) | |
| 350 | + let request = ChatRequest( | |
| 351 | + model: cheap, | |
| 352 | + systemPrompt: nil, | |
| 353 | + messages: [Message(role: .user, text: prompt)], | |
| 354 | + parameters: ChatParameters(maxTokens: 24), | |
| 355 | + stream: false | |
| 356 | + ) | |
| 357 | + let reply = try await client.complete(request, apiKey: apiKey) | |
| 358 | + let title = reply.text | |
| 359 | + .trimmingCharacters(in: .whitespacesAndNewlines) | |
| 360 | + .trimmingCharacters(in: CharacterSet(charactersIn: "\"“”")) | |
| 361 | + guard !title.isEmpty, | |
| 362 | + var current = self.conversations.first(where: { $0.id == conversationID }), | |
| 363 | + current.hasAutoTitle | |
| 364 | + else { return } | |
| 365 | + current.title = String(title.prefix(60)) | |
| 366 | + self.update(current) | |
| 367 | + } catch { | |
| 368 | + // Title generation is best-effort; keep "New Chat" on failure. | |
| 369 | + } | |
| 370 | + } | |
| 371 | + } | |
| 372 | +} | |
added
Sources/ZyquoCloud/ViewModels/KeyVaultStore.swift
+93 −0
@@ -0,0 +1,93 @@ | ||
| 1 | +// | |
| 2 | +// KeyVaultStore.swift | |
| 3 | +// Zyquo Cloud | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// Observable wrapper around SecureKeyStore for the Settings UI: per-provider | |
| 9 | +// key presence, redacted display, and "Test" with latency. Decrypted keys are | |
| 10 | +// fetched on demand and never retained beyond the call. | |
| 11 | +// | |
| 12 | + | |
| 13 | +import Foundation | |
| 14 | + | |
| 15 | +@MainActor | |
| 16 | +final class KeyVaultStore: ObservableObject { | |
| 17 | + enum KeyStatus: Equatable { | |
| 18 | + case unset | |
| 19 | + case saved // present, not yet verified this session | |
| 20 | + case testing | |
| 21 | + case verified(latency: TimeInterval) | |
| 22 | + case failed(message: String) | |
| 23 | + } | |
| 24 | + | |
| 25 | + @Published private(set) var statuses: [ProviderID: KeyStatus] = [:] | |
| 26 | + /// Redacted display strings (••••1234) for providers with saved keys. | |
| 27 | + @Published private(set) var redactedKeys: [ProviderID: String] = [:] | |
| 28 | + | |
| 29 | + private let store: SecureKeyStore | |
| 30 | + | |
| 31 | + init(store: SecureKeyStore = SecureKeyStore()) { | |
| 32 | + self.store = store | |
| 33 | + refresh() | |
| 34 | + } | |
| 35 | + | |
| 36 | + func refresh() { | |
| 37 | + let keys = (try? store.loadKeys()) ?? [:] | |
| 38 | + for provider in ProviderID.builtIn { | |
| 39 | + if let key = keys[provider.rawValue], !key.isEmpty { | |
| 40 | + redactedKeys[provider] = SecureKeyStore.redacted(key) | |
| 41 | + if case .verified = statuses[provider] ?? .unset {} else { | |
| 42 | + statuses[provider] = .saved | |
| 43 | + } | |
| 44 | + } else { | |
| 45 | + redactedKeys[provider] = nil | |
| 46 | + statuses[provider] = .unset | |
| 47 | + } | |
| 48 | + } | |
| 49 | + } | |
| 50 | + | |
| 51 | + func hasKey(for provider: ProviderID) -> Bool { | |
| 52 | + redactedKeys[provider] != nil | |
| 53 | + } | |
| 54 | + | |
| 55 | + /// Decrypts and returns the key — call sites use it immediately and drop it. | |
| 56 | + func apiKey(for provider: ProviderID) throws -> String { | |
| 57 | + guard let key = try store.key(for: provider), !key.isEmpty else { | |
| 58 | + throw ProviderError.missingAPIKey(provider) | |
| 59 | + } | |
| 60 | + return key | |
| 61 | + } | |
| 62 | + | |
| 63 | + func setKey(_ key: String, for provider: ProviderID) { | |
| 64 | + let trimmed = key.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 65 | + guard !trimmed.isEmpty else { return } | |
| 66 | + try? store.setKey(trimmed, for: provider) | |
| 67 | + statuses[provider] = .saved | |
| 68 | + refresh() | |
| 69 | + } | |
| 70 | + | |
| 71 | + func deleteKey(for provider: ProviderID) { | |
| 72 | + try? store.deleteKey(for: provider) | |
| 73 | + statuses[provider] = .unset | |
| 74 | + refresh() | |
| 75 | + } | |
| 76 | + | |
| 77 | + /// Runs the cheapest authenticated call and records latency or failure. | |
| 78 | + func testKey(for provider: ProviderID, catalog: ModelCatalog) async { | |
| 79 | + guard let key = try? apiKey(for: provider) else { | |
| 80 | + statuses[provider] = .failed(message: "No key saved") | |
| 81 | + return | |
| 82 | + } | |
| 83 | + statuses[provider] = .testing | |
| 84 | + let client = ProviderRegistry.client(for: provider) | |
| 85 | + let fallback = catalog.cheapestModel(for: provider) | |
| 86 | + do { | |
| 87 | + let latency = try await client.testKey(key, fallbackModel: fallback) | |
| 88 | + statuses[provider] = .verified(latency: latency) | |
| 89 | + } catch { | |
| 90 | + statuses[provider] = .failed(message: error.localizedDescription) | |
| 91 | + } | |
| 92 | + } | |
| 93 | +} | |
added
Sources/ZyquoCloud/Views/Chat/ChatView.swift
+324 −0
@@ -0,0 +1,324 @@ | ||
| 1 | +// | |
| 2 | +// ChatView.swift | |
| 3 | +// Zyquo Cloud | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// The chat area: 52pt header (editable title, centered model chip, info), | |
| 9 | +// transcript with 760pt centered column and smooth auto-scroll + jump-to- | |
| 10 | +// bottom pill, and the floating input bar. | |
| 11 | +// | |
| 12 | + | |
| 13 | +import SwiftUI | |
| 14 | + | |
| 15 | +struct ChatView: View { | |
| 16 | + let conversationID: Conversation.ID | |
| 17 | + | |
| 18 | + @EnvironmentObject private var store: ConversationStore | |
| 19 | + @EnvironmentObject private var catalog: ModelCatalog | |
| 20 | + @EnvironmentObject private var appearance: AppearanceStore | |
| 21 | + | |
| 22 | + @State private var draft = "" | |
| 23 | + @State private var draftAttachments: [Attachment] = [] | |
| 24 | + @State private var titleDraft = "" | |
| 25 | + @State private var editingTitle = false | |
| 26 | + @State private var showingInfo = false | |
| 27 | + @State private var showingCompare = false | |
| 28 | + @State private var showingPalette = false | |
| 29 | + @State private var pinnedToBottom = true | |
| 30 | + | |
| 31 | + private var conversation: Conversation? { | |
| 32 | + store.conversations.first { $0.id == conversationID } | |
| 33 | + } | |
| 34 | + | |
| 35 | + private var currentModel: AIModel? { | |
| 36 | + guard let conversation else { return nil } | |
| 37 | + return catalog.model(id: conversation.modelID, provider: conversation.provider) | |
| 38 | + } | |
| 39 | + | |
| 40 | + var body: some View { | |
| 41 | + VStack(spacing: 0) { | |
| 42 | + header | |
| 43 | + ZyquoHairline() | |
| 44 | + if let conversation, conversation.messages.isEmpty { | |
| 45 | + EmptyStateView( | |
| 46 | + model: currentModel, | |
| 47 | + onSelectModel: select(model:), | |
| 48 | + onSuggestion: { draft = $0 } | |
| 49 | + ) | |
| 50 | + } else { | |
| 51 | + transcript | |
| 52 | + } | |
| 53 | + InputBarView( | |
| 54 | + text: $draft, | |
| 55 | + attachments: $draftAttachments, | |
| 56 | + isStreaming: store.isStreaming(conversationID), | |
| 57 | + supportsVision: currentModel?.capabilities.vision ?? false, | |
| 58 | + onSend: send, | |
| 59 | + onStop: { store.stopStreaming(conversationID) }, | |
| 60 | + parametersContent: { AnyView(parametersPopover) } | |
| 61 | + ) | |
| 62 | + } | |
| 63 | + .background(ZyquoColor.background) | |
| 64 | + .sheet(isPresented: $showingCompare) { | |
| 65 | + CompareView() | |
| 66 | + } | |
| 67 | + .sheet(isPresented: $showingPalette) { | |
| 68 | + CommandPaletteView( | |
| 69 | + currentModel: currentModel, | |
| 70 | + onSelectModel: select(model:), | |
| 71 | + onInsertTemplate: { template in | |
| 72 | + draft = PromptLibraryStore.apply(template, input: draft) | |
| 73 | + }, | |
| 74 | + onApplyPersona: applyPersona | |
| 75 | + ) | |
| 76 | + } | |
| 77 | + .background( | |
| 78 | + // Invisible ⌘K target for the command palette. | |
| 79 | + Button("") { showingPalette = true } | |
| 80 | + .keyboardShortcut("k", modifiers: .command) | |
| 81 | + .hidden() | |
| 82 | + ) | |
| 83 | + } | |
| 84 | + | |
| 85 | + // MARK: - Header | |
| 86 | + | |
| 87 | + private var header: some View { | |
| 88 | + ZStack { | |
| 89 | + // Centered model chip. | |
| 90 | + ModelChipView(model: currentModel, onSelect: select(model:)) | |
| 91 | + HStack(spacing: ZyquoSpacing.xs) { | |
| 92 | + if editingTitle { | |
| 93 | + TextField("Title", text: $titleDraft, onCommit: { | |
| 94 | + store.rename(conversationID, to: titleDraft) | |
| 95 | + editingTitle = false | |
| 96 | + }) | |
| 97 | + .textFieldStyle(.plain) | |
| 98 | + .font(ZyquoFont.bodyEmphasis(size: 13)) | |
| 99 | + .frame(maxWidth: 220) | |
| 100 | + } else { | |
| 101 | + Text(conversation?.title ?? "") | |
| 102 | + .font(ZyquoFont.bodyEmphasis(size: 13)) | |
| 103 | + .foregroundStyle(ZyquoColor.textPrimary) | |
| 104 | + .lineLimit(1) | |
| 105 | + .frame(maxWidth: 220, alignment: .leading) | |
| 106 | + .onTapGesture(count: 2) { | |
| 107 | + titleDraft = conversation?.title ?? "" | |
| 108 | + editingTitle = true | |
| 109 | + } | |
| 110 | + } | |
| 111 | + Spacer() | |
| 112 | + headerButtons | |
| 113 | + } | |
| 114 | + } | |
| 115 | + .padding(.horizontal, ZyquoMetrics.contentInset) | |
| 116 | + .frame(height: ZyquoMetrics.chatHeaderHeight) | |
| 117 | + } | |
| 118 | + | |
| 119 | + private var headerButtons: some View { | |
| 120 | + HStack(spacing: ZyquoSpacing.xs) { | |
| 121 | + Button { | |
| 122 | + showingCompare = true | |
| 123 | + } label: { | |
| 124 | + Image(systemName: "rectangle.split.2x1") | |
| 125 | + .font(.system(size: 12)) | |
| 126 | + .foregroundStyle(ZyquoColor.textSecondary) | |
| 127 | + } | |
| 128 | + .buttonStyle(.plain) | |
| 129 | + .help("Compare models") | |
| 130 | + | |
| 131 | + Button { | |
| 132 | + exportMarkdown() | |
| 133 | + } label: { | |
| 134 | + Image(systemName: "square.and.arrow.up") | |
| 135 | + .font(.system(size: 12)) | |
| 136 | + .foregroundStyle(ZyquoColor.textSecondary) | |
| 137 | + } | |
| 138 | + .buttonStyle(.plain) | |
| 139 | + .keyboardShortcut("e", modifiers: [.command, .shift]) | |
| 140 | + .help("Export conversation (⌘⇧E)") | |
| 141 | + | |
| 142 | + Button { | |
| 143 | + showingInfo.toggle() | |
| 144 | + } label: { | |
| 145 | + Image(systemName: "info.circle") | |
| 146 | + .font(.system(size: 12)) | |
| 147 | + .foregroundStyle(ZyquoColor.textSecondary) | |
| 148 | + } | |
| 149 | + .buttonStyle(.plain) | |
| 150 | + .help("Conversation info") | |
| 151 | + .popover(isPresented: $showingInfo, arrowEdge: .bottom) { | |
| 152 | + infoPopover | |
| 153 | + } | |
| 154 | + } | |
| 155 | + } | |
| 156 | + | |
| 157 | + // MARK: - Transcript | |
| 158 | + | |
| 159 | + private var transcript: some View { | |
| 160 | + ScrollViewReader { proxy in | |
| 161 | + ZStack(alignment: .bottom) { | |
| 162 | + ScrollView { | |
| 163 | + LazyVStack(spacing: ZyquoMetrics.verticalTurnRhythm) { | |
| 164 | + ForEach(conversation?.messages ?? []) { message in | |
| 165 | + MessageBubbleView( | |
| 166 | + message: message, | |
| 167 | + fontSize: appearance.chatFontSize, | |
| 168 | + onCopy: { copyToPasteboard(message.text) }, | |
| 169 | + onEditResend: message.role == .user | |
| 170 | + ? { newText in store.editAndResend(messageID: message.id, newText: newText, in: conversationID) } | |
| 171 | + : nil, | |
| 172 | + onRegenerate: message.role == .assistant | |
| 173 | + ? { store.regenerate(in: conversationID) } | |
| 174 | + : nil, | |
| 175 | + onDelete: { store.deleteMessage(message.id, in: conversationID) }, | |
| 176 | + onQuote: { text in | |
| 177 | + draft = text.split(separator: "\n").map { "> \($0)" }.joined(separator: "\n") + "\n\n" + draft | |
| 178 | + } | |
| 179 | + ) | |
| 180 | + .id(message.id) | |
| 181 | + } | |
| 182 | + Color.clear.frame(height: 1).id("bottom") | |
| 183 | + } | |
| 184 | + .padding(.horizontal, ZyquoMetrics.contentInset) | |
| 185 | + .padding(.vertical, ZyquoMetrics.contentInset) | |
| 186 | + .frame(maxWidth: ZyquoMetrics.maxMessageColumnWidth) | |
| 187 | + .frame(maxWidth: .infinity) | |
| 188 | + } | |
| 189 | + .onChange(of: lastMessageFingerprint) { _ in | |
| 190 | + if pinnedToBottom { | |
| 191 | + proxy.scrollTo("bottom", anchor: .bottom) | |
| 192 | + } | |
| 193 | + } | |
| 194 | + if !pinnedToBottom, store.isStreaming(conversationID) { | |
| 195 | + jumpToBottomPill(proxy: proxy) | |
| 196 | + } | |
| 197 | + } | |
| 198 | + } | |
| 199 | + } | |
| 200 | + | |
| 201 | + /// Changes when content grows so auto-scroll can follow the stream. | |
| 202 | + private var lastMessageFingerprint: Int { | |
| 203 | + guard let last = conversation?.messages.last else { return 0 } | |
| 204 | + return last.text.count &+ (last.reasoning?.count ?? 0) &* 31 &+ (conversation?.messages.count ?? 0) &* 7 | |
| 205 | + } | |
| 206 | + | |
| 207 | + private func jumpToBottomPill(proxy: ScrollViewProxy) -> some View { | |
| 208 | + Button { | |
| 209 | + pinnedToBottom = true | |
| 210 | + withAnimation(ZyquoMotion.send) { proxy.scrollTo("bottom", anchor: .bottom) } | |
| 211 | + } label: { | |
| 212 | + HStack(spacing: ZyquoSpacing.xxs) { | |
| 213 | + Image(systemName: "arrow.down") | |
| 214 | + .font(.system(size: 10, weight: .semibold)) | |
| 215 | + Text("Jump to latest") | |
| 216 | + .font(ZyquoFont.caption) | |
| 217 | + } | |
| 218 | + .foregroundStyle(ZyquoColor.textPrimary) | |
| 219 | + .padding(.horizontal, ZyquoSpacing.sm) | |
| 220 | + .padding(.vertical, 5) | |
| 221 | + .background(Capsule().fill(ZyquoColor.surface)) | |
| 222 | + .overlay(Capsule().strokeBorder(ZyquoColor.border, lineWidth: ZyquoMetrics.hairline)) | |
| 223 | + .zyquoSoftShadow() | |
| 224 | + } | |
| 225 | + .buttonStyle(PressableButtonStyle()) | |
| 226 | + .padding(.bottom, ZyquoSpacing.xs) | |
| 227 | + } | |
| 228 | + | |
| 229 | + // MARK: - Popovers | |
| 230 | + | |
| 231 | + private var infoPopover: some View { | |
| 232 | + VStack(alignment: .leading, spacing: ZyquoSpacing.sm) { | |
| 233 | + Text("Conversation") | |
| 234 | + .font(ZyquoFont.bodyEmphasis()) | |
| 235 | + if let conversation { | |
| 236 | + LabeledContent { | |
| 237 | + Text("\(conversation.totalUsage.inputTokens) in · \(conversation.totalUsage.outputTokens) out") | |
| 238 | + } label: { Text("Tokens") } | |
| 239 | + LabeledContent { | |
| 240 | + Text(String(format: "~$%.4f", conversation.totalCost)) | |
| 241 | + } label: { Text("Est. cost") } | |
| 242 | + Divider() | |
| 243 | + Text("System prompt") | |
| 244 | + .font(ZyquoFont.caption) | |
| 245 | + .foregroundStyle(ZyquoColor.textSecondary) | |
| 246 | + TextEditor(text: Binding( | |
| 247 | + get: { conversation.systemPrompt ?? "" }, | |
| 248 | + set: { newValue in | |
| 249 | + var updated = conversation | |
| 250 | + updated.systemPrompt = newValue.isEmpty ? nil : newValue | |
| 251 | + store.update(updated) | |
| 252 | + } | |
| 253 | + )) | |
| 254 | + .font(ZyquoFont.body(size: 12)) | |
| 255 | + .frame(width: 300, height: 90) | |
| 256 | + .scrollContentBackground(.hidden) | |
| 257 | + .background( | |
| 258 | + RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous) | |
| 259 | + .fill(ZyquoColor.surfaceSecondary) | |
| 260 | + ) | |
| 261 | + } | |
| 262 | + } | |
| 263 | + .font(ZyquoFont.body(size: 12.5)) | |
| 264 | + .padding(ZyquoSpacing.md) | |
| 265 | + .frame(width: 340) | |
| 266 | + } | |
| 267 | + | |
| 268 | + private var parametersPopover: some View { | |
| 269 | + ParametersEditorView( | |
| 270 | + parameters: Binding( | |
| 271 | + get: { conversation?.parameters ?? ChatParameters() }, | |
| 272 | + set: { newValue in | |
| 273 | + guard var updated = conversation else { return } | |
| 274 | + updated.parameters = newValue | |
| 275 | + store.update(updated) | |
| 276 | + } | |
| 277 | + ), | |
| 278 | + support: currentModel?.parameterSupport ?? ParameterSupport() | |
| 279 | + ) | |
| 280 | + } | |
| 281 | + | |
| 282 | + // MARK: - Actions | |
| 283 | + | |
| 284 | + private func send() { | |
| 285 | + let text = draft.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 286 | + guard !text.isEmpty || !draftAttachments.isEmpty else { return } | |
| 287 | + let attachments = draftAttachments | |
| 288 | + withAnimation(ZyquoMotion.send) { | |
| 289 | + draft = "" | |
| 290 | + draftAttachments = [] | |
| 291 | + } | |
| 292 | + pinnedToBottom = true | |
| 293 | + store.send(text: text, attachments: attachments, in: conversationID) | |
| 294 | + } | |
| 295 | + | |
| 296 | + private func select(model: AIModel) { | |
| 297 | + guard var conversation else { return } | |
| 298 | + conversation.modelID = model.id | |
| 299 | + conversation.provider = model.provider | |
| 300 | + store.update(conversation) | |
| 301 | + } | |
| 302 | + | |
| 303 | + private func applyPersona(_ persona: Persona) { | |
| 304 | + guard var conversation else { return } | |
| 305 | + conversation.systemPrompt = persona.systemPrompt | |
| 306 | + conversation.personaID = persona.id | |
| 307 | + conversation.parameters = persona.parameters | |
| 308 | + if let modelID = persona.modelID, let provider = persona.provider { | |
| 309 | + conversation.modelID = modelID | |
| 310 | + conversation.provider = provider | |
| 311 | + } | |
| 312 | + store.update(conversation) | |
| 313 | + } | |
| 314 | + | |
| 315 | + private func copyToPasteboard(_ text: String) { | |
| 316 | + NSPasteboard.general.clearContents() | |
| 317 | + NSPasteboard.general.setString(text, forType: .string) | |
| 318 | + } | |
| 319 | + | |
| 320 | + private func exportMarkdown() { | |
| 321 | + guard let conversation else { return } | |
| 322 | + ConversationExporter.presentSavePanel(for: conversation) | |
| 323 | + } | |
| 324 | +} | |
added
Sources/ZyquoCloud/Views/Chat/EmptyStateView.swift
+82 −0
@@ -0,0 +1,82 @@ | ||
| 1 | +// | |
| 2 | +// EmptyStateView.swift | |
| 3 | +// Zyquo Cloud | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// Empty chat state: brand glyph, greeting, four suggested prompt cards, and | |
| 9 | +// the model chip — intentional and beautiful, never blank. | |
| 10 | +// | |
| 11 | + | |
| 12 | +import SwiftUI | |
| 13 | + | |
| 14 | +struct EmptyStateView: View { | |
| 15 | + let model: AIModel? | |
| 16 | + var onSelectModel: (AIModel) -> Void | |
| 17 | + var onSuggestion: (String) -> Void | |
| 18 | + | |
| 19 | + private static let suggestions: [(symbol: String, title: String, prompt: String)] = [ | |
| 20 | + ("lightbulb", "Explain something", | |
| 21 | + "Explain how transformer attention works, with a concrete example."), | |
| 22 | + ("chevron.left.forwardslash.chevron.right", "Review my code", | |
| 23 | + "Review this code for bugs and suggest improvements:\n\n"), | |
| 24 | + ("doc.text", "Summarize a document", | |
| 25 | + "Summarize the key points of the following document:\n\n"), | |
| 26 | + ("globe", "Translate", | |
| 27 | + "Translate the following text to French, keeping the tone natural:\n\n"), | |
| 28 | + ] | |
| 29 | + | |
| 30 | + var body: some View { | |
| 31 | + VStack(spacing: ZyquoSpacing.lg) { | |
| 32 | + Spacer() | |
| 33 | + CloudZGlyph(size: 88) | |
| 34 | + VStack(spacing: ZyquoSpacing.xxs) { | |
| 35 | + Text("Welcome to Zyquo Cloud") | |
| 36 | + .font(ZyquoFont.title) | |
| 37 | + .foregroundStyle(ZyquoColor.textPrimary) | |
| 38 | + Text("Your keys, every cloud model, one beautiful chat.") | |
| 39 | + .font(ZyquoFont.body()) | |
| 40 | + .foregroundStyle(ZyquoColor.textSecondary) | |
| 41 | + } | |
| 42 | + ModelChipView(model: model, onSelect: onSelectModel) | |
| 43 | + LazyVGrid( | |
| 44 | + columns: [GridItem(.flexible()), GridItem(.flexible())], | |
| 45 | + spacing: ZyquoSpacing.sm | |
| 46 | + ) { | |
| 47 | + ForEach(Self.suggestions, id: \.title) { suggestion in | |
| 48 | + Button { | |
| 49 | + onSuggestion(suggestion.prompt) | |
| 50 | + } label: { | |
| 51 | + HStack(spacing: ZyquoSpacing.xs) { | |
| 52 | + Image(systemName: suggestion.symbol) | |
| 53 | + .font(.system(size: 14)) | |
| 54 | + .foregroundStyle(ZyquoColor.accent) | |
| 55 | + .frame(width: 20) | |
| 56 | + Text(suggestion.title) | |
| 57 | + .font(ZyquoFont.body(size: 13)) | |
| 58 | + .foregroundStyle(ZyquoColor.textPrimary) | |
| 59 | + Spacer(minLength: 0) | |
| 60 | + } | |
| 61 | + .padding(ZyquoSpacing.sm) | |
| 62 | + .background( | |
| 63 | + RoundedRectangle(cornerRadius: ZyquoRadius.medium, style: .continuous) | |
| 64 | + .fill(ZyquoColor.surface) | |
| 65 | + .overlay( | |
| 66 | + RoundedRectangle(cornerRadius: ZyquoRadius.medium, style: .continuous) | |
| 67 | + .strokeBorder(ZyquoColor.border, lineWidth: ZyquoMetrics.hairline) | |
| 68 | + ) | |
| 69 | + ) | |
| 70 | + .contentShape(Rectangle()) | |
| 71 | + } | |
| 72 | + .buttonStyle(PressableButtonStyle()) | |
| 73 | + } | |
| 74 | + } | |
| 75 | + .frame(maxWidth: 460) | |
| 76 | + Spacer() | |
| 77 | + Spacer() | |
| 78 | + } | |
| 79 | + .frame(maxWidth: .infinity, maxHeight: .infinity) | |
| 80 | + .background(ZyquoColor.background) | |
| 81 | + } | |
| 82 | +} | |
added
Sources/ZyquoCloud/Views/Chat/InputBarView.swift
+245 −0
@@ -0,0 +1,245 @@ | ||
| 1 | +// | |
| 2 | +// InputBarView.swift | |
| 3 | +// Zyquo Cloud | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// Floating input card docked at the bottom: auto-growing multiline editor, | |
| 9 | +// attach button, parameter quick-toggle, circular send button (⌘↩), | |
| 10 | +// attachment thumbnails, drag-and-drop with dashed accent highlight. | |
| 11 | +// | |
| 12 | + | |
| 13 | +import SwiftUI | |
| 14 | +import UniformTypeIdentifiers | |
| 15 | + | |
| 16 | +struct InputBarView: View { | |
| 17 | + @Binding var text: String | |
| 18 | + @Binding var attachments: [Attachment] | |
| 19 | + let isStreaming: Bool | |
| 20 | + let supportsVision: Bool | |
| 21 | + var onSend: () -> Void | |
| 22 | + var onStop: () -> Void | |
| 23 | + var parametersContent: () -> AnyView | |
| 24 | + | |
| 25 | + @State private var dropTargeted = false | |
| 26 | + @State private var showingParameters = false | |
| 27 | + @EnvironmentObject private var appearance: AppearanceStore | |
| 28 | + @FocusState private var editorFocused: Bool | |
| 29 | + | |
| 30 | + private var canSend: Bool { | |
| 31 | + !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || !attachments.isEmpty | |
| 32 | + } | |
| 33 | + | |
| 34 | + var body: some View { | |
| 35 | + VStack(spacing: ZyquoSpacing.xs) { | |
| 36 | + if !attachments.isEmpty { | |
| 37 | + attachmentStrip | |
| 38 | + } | |
| 39 | + HStack(alignment: .bottom, spacing: ZyquoSpacing.xs) { | |
| 40 | + attachButton | |
| 41 | + editor | |
| 42 | + parameterToggle | |
| 43 | + sendButton | |
| 44 | + } | |
| 45 | + } | |
| 46 | + .padding(ZyquoSpacing.sm) | |
| 47 | + .background( | |
| 48 | + RoundedRectangle(cornerRadius: ZyquoRadius.large, style: .continuous) | |
| 49 | + .fill(ZyquoColor.surface) | |
| 50 | + .overlay( | |
| 51 | + RoundedRectangle(cornerRadius: ZyquoRadius.large, style: .continuous) | |
| 52 | + .strokeBorder( | |
| 53 | + dropTargeted ? ZyquoColor.accent : ZyquoColor.border, | |
| 54 | + style: StrokeStyle( | |
| 55 | + lineWidth: dropTargeted ? 1.5 : ZyquoMetrics.hairline, | |
| 56 | + dash: dropTargeted ? [6, 4] : [] | |
| 57 | + ) | |
| 58 | + ) | |
| 59 | + ) | |
| 60 | + ) | |
| 61 | + .zyquoSoftShadow() | |
| 62 | + .padding(.horizontal, ZyquoSpacing.sm) | |
| 63 | + .padding(.bottom, ZyquoSpacing.sm) | |
| 64 | + .onDrop(of: [.fileURL, .image], isTargeted: $dropTargeted) { providers in | |
| 65 | + handleDrop(providers) | |
| 66 | + } | |
| 67 | + } | |
| 68 | + | |
| 69 | + // MARK: - Pieces | |
| 70 | + | |
| 71 | + private var editor: some View { | |
| 72 | + ZStack(alignment: .topLeading) { | |
| 73 | + if text.isEmpty { | |
| 74 | + Text("Message…") | |
| 75 | + .font(ZyquoFont.body(size: appearance.chatFontSize)) | |
| 76 | + .foregroundStyle(ZyquoColor.textTertiary) | |
| 77 | + .padding(.top, 2) | |
| 78 | + .allowsHitTesting(false) | |
| 79 | + } | |
| 80 | + TextEditor(text: $text) | |
| 81 | + .font(ZyquoFont.body(size: appearance.chatFontSize)) | |
| 82 | + .foregroundStyle(ZyquoColor.textPrimary) | |
| 83 | + .scrollContentBackground(.hidden) | |
| 84 | + .frame(minHeight: 22, maxHeight: 200) | |
| 85 | + .fixedSize(horizontal: false, vertical: text.count < 2000) | |
| 86 | + .focused($editorFocused) | |
| 87 | + .onAppear { editorFocused = true } | |
| 88 | + } | |
| 89 | + } | |
| 90 | + | |
| 91 | + private var attachButton: some View { | |
| 92 | + Button { | |
| 93 | + presentFilePicker() | |
| 94 | + } label: { | |
| 95 | + Image(systemName: "paperclip") | |
| 96 | + .font(.system(size: 14)) | |
| 97 | + .foregroundStyle(ZyquoColor.textSecondary) | |
| 98 | + .frame(width: 26, height: 26) | |
| 99 | + } | |
| 100 | + .buttonStyle(.plain) | |
| 101 | + .zyquoHoverHighlight() | |
| 102 | + .help(supportsVision ? "Attach images or text files" : "Attach text files") | |
| 103 | + } | |
| 104 | + | |
| 105 | + private var parameterToggle: some View { | |
| 106 | + Button { | |
| 107 | + showingParameters.toggle() | |
| 108 | + } label: { | |
| 109 | + Image(systemName: "slider.horizontal.3") | |
| 110 | + .font(.system(size: 13)) | |
| 111 | + .foregroundStyle(ZyquoColor.textSecondary) | |
| 112 | + .frame(width: 26, height: 26) | |
| 113 | + } | |
| 114 | + .buttonStyle(.plain) | |
| 115 | + .zyquoHoverHighlight() | |
| 116 | + .help("Generation parameters") | |
| 117 | + .popover(isPresented: $showingParameters, arrowEdge: .top) { | |
| 118 | + parametersContent() | |
| 119 | + } | |
| 120 | + } | |
| 121 | + | |
| 122 | + private var sendButton: some View { | |
| 123 | + Button { | |
| 124 | + isStreaming ? onStop() : (canSend ? onSend() : ()) | |
| 125 | + } label: { | |
| 126 | + Image(systemName: isStreaming ? "stop.fill" : "arrow.up") | |
| 127 | + .font(.system(size: 13, weight: .semibold)) | |
| 128 | + .foregroundStyle(.white) | |
| 129 | + .frame(width: 28, height: 28) | |
| 130 | + .background( | |
| 131 | + Circle().fill( | |
| 132 | + isStreaming | |
| 133 | + ? ZyquoColor.danger | |
| 134 | + : (canSend ? ZyquoColor.accent : ZyquoColor.textTertiary) | |
| 135 | + ) | |
| 136 | + ) | |
| 137 | + } | |
| 138 | + .buttonStyle(PressableButtonStyle()) | |
| 139 | + .keyboardShortcut(.return, modifiers: .command) | |
| 140 | + .help(isStreaming ? "Stop generating" : "Send (⌘↩)") | |
| 141 | + } | |
| 142 | + | |
| 143 | + private var attachmentStrip: some View { | |
| 144 | + ScrollView(.horizontal, showsIndicators: false) { | |
| 145 | + HStack(spacing: ZyquoSpacing.xs) { | |
| 146 | + ForEach(attachments) { attachment in | |
| 147 | + AttachmentThumbnail(attachment: attachment) { | |
| 148 | + attachments.removeAll { $0.id == attachment.id } | |
| 149 | + } | |
| 150 | + } | |
| 151 | + } | |
| 152 | + } | |
| 153 | + .frame(height: ZyquoMetrics.attachmentThumbnail + 8) | |
| 154 | + } | |
| 155 | + | |
| 156 | + // MARK: - Attachment intake | |
| 157 | + | |
| 158 | + private static let textExtensions: Set<String> = [ | |
| 159 | + "txt", "md", "markdown", "csv", "json", "yaml", "yml", "xml", "log", | |
| 160 | + "swift", "py", "js", "ts", "jsx", "tsx", "html", "css", "sh", "zsh", | |
| 161 | + "bash", "sql", "go", "rs", "c", "h", "cpp", "hpp", "m", "mm", "java", | |
| 162 | + "rb", "php", "toml", "ini", "cfg", "tex", | |
| 163 | + ] | |
| 164 | + private static let imageExtensions: Set<String> = ["png", "jpg", "jpeg", "webp", "gif"] | |
| 165 | + | |
| 166 | + private func presentFilePicker() { | |
| 167 | + let panel = NSOpenPanel() | |
| 168 | + panel.allowsMultipleSelection = true | |
| 169 | + panel.canChooseDirectories = false | |
| 170 | + panel.begin { response in | |
| 171 | + guard response == .OK else { return } | |
| 172 | + for url in panel.urls { ingest(url: url) } | |
| 173 | + } | |
| 174 | + } | |
| 175 | + | |
| 176 | + private func handleDrop(_ providers: [NSItemProvider]) -> Bool { | |
| 177 | + var handled = false | |
| 178 | + for provider in providers where provider.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) { | |
| 179 | + handled = true | |
| 180 | + provider.loadItem(forTypeIdentifier: UTType.fileURL.identifier) { item, _ in | |
| 181 | + guard let data = item as? Data, | |
| 182 | + let url = URL(dataRepresentation: data, relativeTo: nil) else { return } | |
| 183 | + DispatchQueue.main.async { ingest(url: url) } | |
| 184 | + } | |
| 185 | + } | |
| 186 | + return handled | |
| 187 | + } | |
| 188 | + | |
| 189 | + private func ingest(url: URL) { | |
| 190 | + let ext = url.pathExtension.lowercased() | |
| 191 | + guard let data = try? Data(contentsOf: url) else { return } | |
| 192 | + if Self.imageExtensions.contains(ext) { | |
| 193 | + let mime = ext == "jpg" ? "image/jpeg" : "image/\(ext)" | |
| 194 | + attachments.append( | |
| 195 | + Attachment(kind: .image, fileName: url.lastPathComponent, data: data, mimeType: mime) | |
| 196 | + ) | |
| 197 | + } else if Self.textExtensions.contains(ext) || (String(data: data, encoding: .utf8) != nil && data.count < 512_000) { | |
| 198 | + attachments.append( | |
| 199 | + Attachment(kind: .textFile, fileName: url.lastPathComponent, data: data, mimeType: "text/plain") | |
| 200 | + ) | |
| 201 | + } | |
| 202 | + } | |
| 203 | +} | |
| 204 | + | |
| 205 | +/// 56pt thumbnail with a remove button; images preview, text files show an icon. | |
| 206 | +private struct AttachmentThumbnail: View { | |
| 207 | + let attachment: Attachment | |
| 208 | + var onRemove: () -> Void | |
| 209 | + | |
| 210 | + var body: some View { | |
| 211 | + ZStack(alignment: .topTrailing) { | |
| 212 | + Group { | |
| 213 | + if attachment.kind == .image, let image = NSImage(data: attachment.data) { | |
| 214 | + Image(nsImage: image) | |
| 215 | + .resizable() | |
| 216 | + .aspectRatio(contentMode: .fill) | |
| 217 | + } else { | |
| 218 | + VStack(spacing: 2) { | |
| 219 | + Image(systemName: "doc.text") | |
| 220 | + .font(.system(size: 16)) | |
| 221 | + .foregroundStyle(ZyquoColor.textSecondary) | |
| 222 | + Text(attachment.fileName) | |
| 223 | + .font(.system(size: 8)) | |
| 224 | + .foregroundStyle(ZyquoColor.textTertiary) | |
| 225 | + .lineLimit(1) | |
| 226 | + } | |
| 227 | + .padding(4) | |
| 228 | + } | |
| 229 | + } | |
| 230 | + .frame(width: ZyquoMetrics.attachmentThumbnail, height: ZyquoMetrics.attachmentThumbnail) | |
| 231 | + .background(ZyquoColor.surfaceSecondary) | |
| 232 | + .clipShape(RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous)) | |
| 233 | + | |
| 234 | + Button(action: onRemove) { | |
| 235 | + Image(systemName: "xmark.circle.fill") | |
| 236 | + .font(.system(size: 12)) | |
| 237 | + .foregroundStyle(ZyquoColor.textSecondary) | |
| 238 | + .background(Circle().fill(ZyquoColor.surface)) | |
| 239 | + } | |
| 240 | + .buttonStyle(.plain) | |
| 241 | + .offset(x: 5, y: -5) | |
| 242 | + } | |
| 243 | + .padding(.top, 4) | |
| 244 | + } | |
| 245 | +} | |
added
Sources/ZyquoCloud/Views/Chat/MessageBubbleView.swift
+342 −0
@@ -0,0 +1,342 @@ | ||
| 1 | +// | |
| 2 | +// MessageBubbleView.swift | |
| 3 | +// Zyquo Cloud | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// One transcript turn: user messages right-aligned in accentSubtle bubbles, | |
| 9 | +// assistant messages left-aligned on surface with the provider glyph avatar. | |
| 10 | +// Hover reveals timestamp + tokens/cost and message actions. Reasoning models | |
| 11 | +// get a collapsible "Thinking…" section; Perplexity citations render as | |
| 12 | +// numbered chips. | |
| 13 | +// | |
| 14 | + | |
| 15 | +import SwiftUI | |
| 16 | + | |
| 17 | +struct MessageBubbleView: View { | |
| 18 | + let message: Message | |
| 19 | + let fontSize: Double | |
| 20 | + var onCopy: () -> Void = {} | |
| 21 | + var onEditResend: ((String) -> Void)? | |
| 22 | + var onRegenerate: (() -> Void)? | |
| 23 | + var onDelete: () -> Void = {} | |
| 24 | + var onQuote: ((String) -> Void)? | |
| 25 | + | |
| 26 | + @State private var hovering = false | |
| 27 | + @State private var showThinking = false | |
| 28 | + @State private var editing = false | |
| 29 | + @State private var editText = "" | |
| 30 | + @Environment(\.openURL) private var openURL | |
| 31 | + | |
| 32 | + var body: some View { | |
| 33 | + HStack(alignment: .top, spacing: ZyquoSpacing.xs) { | |
| 34 | + if message.role == .user { Spacer(minLength: 60) } | |
| 35 | + if message.role == .assistant { avatar } | |
| 36 | + VStack(alignment: message.role == .user ? .trailing : .leading, spacing: ZyquoSpacing.xxs) { | |
| 37 | + bubble | |
| 38 | + metadata | |
| 39 | + .opacity(hovering ? 1 : 0) | |
| 40 | + } | |
| 41 | + if message.role == .assistant { Spacer(minLength: 60) } | |
| 42 | + } | |
| 43 | + .onHover { inside in | |
| 44 | + withAnimation(ZyquoMotion.hover) { hovering = inside } | |
| 45 | + } | |
| 46 | + } | |
| 47 | + | |
| 48 | + // MARK: - Avatar | |
| 49 | + | |
| 50 | + private var avatar: some View { | |
| 51 | + Image(systemName: message.provider?.symbolName ?? "sparkle") | |
| 52 | + .font(.system(size: 12, weight: .medium)) | |
| 53 | + .foregroundStyle(ZyquoColor.accent) | |
| 54 | + .frame(width: 26, height: 26) | |
| 55 | + .background(Circle().fill(ZyquoColor.accentSubtle)) | |
| 56 | + .padding(.top, 2) | |
| 57 | + } | |
| 58 | + | |
| 59 | + // MARK: - Bubble | |
| 60 | + | |
| 61 | + @ViewBuilder | |
| 62 | + private var bubble: some View { | |
| 63 | + VStack(alignment: .leading, spacing: ZyquoSpacing.xs) { | |
| 64 | + if !message.attachments.isEmpty { | |
| 65 | + attachmentPreviews | |
| 66 | + } | |
| 67 | + if let reasoning = message.reasoning, !reasoning.isEmpty { | |
| 68 | + thinkingSection(reasoning) | |
| 69 | + } | |
| 70 | + if editing { | |
| 71 | + editorView | |
| 72 | + } else if message.role == .assistant { | |
| 73 | + MarkdownView(text: message.text, fontSize: fontSize) | |
| 74 | + if message.isStreaming { | |
| 75 | + StreamingCaret() | |
| 76 | + } | |
| 77 | + } else { | |
| 78 | + Text(message.text) | |
| 79 | + .font(ZyquoFont.body(size: fontSize)) | |
| 80 | + .lineSpacing(fontSize * ZyquoFont.bodyLineSpacingFactor) | |
| 81 | + .foregroundStyle(ZyquoColor.textPrimary) | |
| 82 | + .textSelection(.enabled) | |
| 83 | + } | |
| 84 | + if !message.citations.isEmpty { | |
| 85 | + citationChips | |
| 86 | + } | |
| 87 | + if let error = message.errorText { | |
| 88 | + errorView(error) | |
| 89 | + } | |
| 90 | + } | |
| 91 | + .padding(.horizontal, ZyquoSpacing.sm) | |
| 92 | + .padding(.vertical, ZyquoSpacing.xs + 2) | |
| 93 | + .background( | |
| 94 | + RoundedRectangle(cornerRadius: ZyquoRadius.medium, style: .continuous) | |
| 95 | + .fill(message.role == .user ? ZyquoColor.accentSubtle : ZyquoColor.surface) | |
| 96 | + .overlay( | |
| 97 | + RoundedRectangle(cornerRadius: ZyquoRadius.medium, style: .continuous) | |
| 98 | + .strokeBorder(ZyquoColor.border, lineWidth: message.role == .assistant ? ZyquoMetrics.hairline : 0) | |
| 99 | + ) | |
| 100 | + ) | |
| 101 | + .contextMenu { actionButtons } | |
| 102 | + } | |
| 103 | + | |
| 104 | + private var editorView: some View { | |
| 105 | + VStack(alignment: .trailing, spacing: ZyquoSpacing.xs) { | |
| 106 | + TextEditor(text: $editText) | |
| 107 | + .font(ZyquoFont.body(size: fontSize)) | |
| 108 | + .frame(minWidth: 320, minHeight: 60, maxHeight: 200) | |
| 109 | + .scrollContentBackground(.hidden) | |
| 110 | + HStack { | |
| 111 | + Button("Cancel") { editing = false } | |
| 112 | + Button("Resend") { | |
| 113 | + editing = false | |
| 114 | + onEditResend?(editText) | |
| 115 | + } | |
| 116 | + .buttonStyle(.borderedProminent) | |
| 117 | + .disabled(editText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) | |
| 118 | + } | |
| 119 | + } | |
| 120 | + } | |
| 121 | + | |
| 122 | + // MARK: - Thinking | |
| 123 | + | |
| 124 | + private func thinkingSection(_ reasoning: String) -> some View { | |
| 125 | + VStack(alignment: .leading, spacing: ZyquoSpacing.xxs) { | |
| 126 | + Button { | |
| 127 | + withAnimation(ZyquoMotion.picker) { showThinking.toggle() } | |
| 128 | + } label: { | |
| 129 | + HStack(spacing: ZyquoSpacing.xxs) { | |
| 130 | + Image(systemName: "chevron.right") | |
| 131 | + .font(.system(size: 8, weight: .semibold)) | |
| 132 | + .rotationEffect(.degrees(showThinking ? 90 : 0)) | |
| 133 | + Text(message.isStreaming && message.text.isEmpty ? "Thinking…" : "Thought process") | |
| 134 | + .font(ZyquoFont.caption) | |
| 135 | + } | |
| 136 | + .foregroundStyle(ZyquoColor.textSecondary) | |
| 137 | + } | |
| 138 | + .buttonStyle(.plain) | |
| 139 | + if showThinking { | |
| 140 | + Text(reasoning) | |
| 141 | + .font(ZyquoFont.code(size: fontSize - 2)) | |
| 142 | + .foregroundStyle(ZyquoColor.textSecondary) | |
| 143 | + .lineSpacing(3) | |
| 144 | + .textSelection(.enabled) | |
| 145 | + .padding(ZyquoSpacing.xs) | |
| 146 | + .frame(maxWidth: .infinity, alignment: .leading) | |
| 147 | + .background( | |
| 148 | + RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous) | |
| 149 | + .fill(ZyquoColor.surfaceSecondary) | |
| 150 | + ) | |
| 151 | + } | |
| 152 | + } | |
| 153 | + } | |
| 154 | + | |
| 155 | + // MARK: - Citations | |
| 156 | + | |
| 157 | + private var citationChips: some View { | |
| 158 | + FlowLayoutCompat(spacing: ZyquoSpacing.xxs) { | |
| 159 | + ForEach(message.citations) { citation in | |
| 160 | + Button { | |
| 161 | + openURL(citation.url) | |
| 162 | + } label: { | |
| 163 | + HStack(spacing: 3) { | |
| 164 | + Text("\(citation.index)") | |
| 165 | + .font(.system(size: 9, weight: .semibold)) | |
| 166 | + .foregroundStyle(.white) | |
| 167 | + .frame(width: 13, height: 13) | |
| 168 | + .background(Circle().fill(ZyquoColor.accent)) | |
| 169 | + Text(citation.title ?? citation.url.host() ?? citation.url.absoluteString) | |
| 170 | + .font(ZyquoFont.caption) | |
| 171 | + .foregroundStyle(ZyquoColor.textSecondary) | |
| 172 | + .lineLimit(1) | |
| 173 | + } | |
| 174 | + .padding(.horizontal, ZyquoSpacing.xs) | |
| 175 | + .padding(.vertical, 3) | |
| 176 | + .background(Capsule().fill(ZyquoColor.surfaceSecondary)) | |
| 177 | + } | |
| 178 | + .buttonStyle(.plain) | |
| 179 | + .help(citation.url.absoluteString) | |
| 180 | + } | |
| 181 | + } | |
| 182 | + } | |
| 183 | + | |
| 184 | + private func errorView(_ error: String) -> some View { | |
| 185 | + HStack(spacing: ZyquoSpacing.xxs) { | |
| 186 | + Image(systemName: "exclamationmark.triangle.fill") | |
| 187 | + .font(.system(size: 11)) | |
| 188 | + Text(error) | |
| 189 | + .font(ZyquoFont.body(size: fontSize - 1)) | |
| 190 | + .textSelection(.enabled) | |
| 191 | + } | |
| 192 | + .foregroundStyle(ZyquoColor.danger) | |
| 193 | + .padding(ZyquoSpacing.xs) | |
| 194 | + .background( | |
| 195 | + RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous) | |
| 196 | + .fill(ZyquoColor.danger.opacity(0.08)) | |
| 197 | + ) | |
| 198 | + } | |
| 199 | + | |
| 200 | + private var attachmentPreviews: some View { | |
| 201 | + HStack(spacing: ZyquoSpacing.xs) { | |
| 202 | + ForEach(message.attachments) { attachment in | |
| 203 | + if attachment.kind == .image, let image = NSImage(data: attachment.data) { | |
| 204 | + Image(nsImage: image) | |
| 205 | + .resizable() | |
| 206 | + .aspectRatio(contentMode: .fill) | |
| 207 | + .frame(width: 120, height: 90) | |
| 208 | + .clipShape(RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous)) | |
| 209 | + } else { | |
| 210 | + ZyquoBadge(text: attachment.fileName) | |
| 211 | + } | |
| 212 | + } | |
| 213 | + } | |
| 214 | + } | |
| 215 | + | |
| 216 | + // MARK: - Metadata & actions | |
| 217 | + | |
| 218 | + private var metadata: some View { | |
| 219 | + HStack(spacing: ZyquoSpacing.xs) { | |
| 220 | + if message.role == .assistant, hovering { | |
| 221 | + actionIcons | |
| 222 | + } | |
| 223 | + Text(message.createdAt, format: .dateTime.hour().minute()) | |
| 224 | + .font(ZyquoFont.caption) | |
| 225 | + .foregroundStyle(ZyquoColor.textTertiary) | |
| 226 | + if let usage = message.usage { | |
| 227 | + Text("\(usage.inputTokens)→\(usage.outputTokens) tok") | |
| 228 | + .font(ZyquoFont.caption) | |
| 229 | + .foregroundStyle(ZyquoColor.textTertiary) | |
| 230 | + } | |
| 231 | + if let cost = message.estimatedCost, cost > 0 { | |
| 232 | + Text(String(format: "~$%.4f", cost)) | |
| 233 | + .font(ZyquoFont.caption) | |
| 234 | + .foregroundStyle(ZyquoColor.textTertiary) | |
| 235 | + } | |
| 236 | + if message.role == .user, hovering { | |
| 237 | + actionIcons | |
| 238 | + } | |
| 239 | + } | |
| 240 | + } | |
| 241 | + | |
| 242 | + private var actionIcons: some View { | |
| 243 | + HStack(spacing: ZyquoSpacing.xxs) { | |
| 244 | + iconButton("doc.on.doc", help: "Copy") { onCopy() } | |
| 245 | + if message.role == .user, onEditResend != nil { | |
| 246 | + iconButton("pencil", help: "Edit & resend") { | |
| 247 | + editText = message.text | |
| 248 | + editing = true | |
| 249 | + } | |
| 250 | + } | |
| 251 | + if message.role == .assistant, let onRegenerate { | |
| 252 | + iconButton("arrow.clockwise", help: "Regenerate") { onRegenerate() } | |
| 253 | + } | |
| 254 | + if let onQuote { | |
| 255 | + iconButton("quote.opening", help: "Quote reply") { onQuote(message.text) } | |
| 256 | + } | |
| 257 | + iconButton("trash", help: "Delete") { onDelete() } | |
| 258 | + } | |
| 259 | + } | |
| 260 | + | |
| 261 | + @ViewBuilder | |
| 262 | + private var actionButtons: some View { | |
| 263 | + Button("Copy") { onCopy() } | |
| 264 | + if message.role == .user, onEditResend != nil { | |
| 265 | + Button("Edit & Resend") { | |
| 266 | + editText = message.text | |
| 267 | + editing = true | |
| 268 | + } | |
| 269 | + } | |
| 270 | + if message.role == .assistant, let onRegenerate { | |
| 271 | + Button("Regenerate") { onRegenerate() } | |
| 272 | + } | |
| 273 | + if let onQuote { | |
| 274 | + Button("Quote Reply") { onQuote(message.text) } | |
| 275 | + } | |
| 276 | + Divider() | |
| 277 | + Button("Delete", role: .destructive) { onDelete() } | |
| 278 | + } | |
| 279 | + | |
| 280 | + private func iconButton(_ symbol: String, help: String, action: @escaping () -> Void) -> some View { | |
| 281 | + Button(action: action) { | |
| 282 | + Image(systemName: symbol) | |
| 283 | + .font(.system(size: 10)) | |
| 284 | + .foregroundStyle(ZyquoColor.textSecondary) | |
| 285 | + } | |
| 286 | + .buttonStyle(.plain) | |
| 287 | + .help(help) | |
| 288 | + } | |
| 289 | +} | |
| 290 | + | |
| 291 | +/// Blinking caret shown at the tail of a streaming message. | |
| 292 | +struct StreamingCaret: View { | |
| 293 | + @State private var visible = true | |
| 294 | + | |
| 295 | + var body: some View { | |
| 296 | + RoundedRectangle(cornerRadius: 1) | |
| 297 | + .fill(ZyquoColor.accent) | |
| 298 | + .frame(width: 7, height: 15) | |
| 299 | + .opacity(visible ? 1 : 0.15) | |
| 300 | + .onAppear { | |
| 301 | + withAnimation(.easeInOut(duration: 0.55).repeatForever(autoreverses: true)) { | |
| 302 | + visible = false | |
| 303 | + } | |
| 304 | + } | |
| 305 | + } | |
| 306 | +} | |
| 307 | + | |
| 308 | +/// Minimal flow layout for citation chips (macOS 13-compatible Layout). | |
| 309 | +struct FlowLayoutCompat: Layout { | |
| 310 | + var spacing: CGFloat = 4 | |
| 311 | + | |
| 312 | + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { | |
| 313 | + let width = proposal.width ?? 600 | |
| 314 | + var x: CGFloat = 0, y: CGFloat = 0, rowHeight: CGFloat = 0 | |
| 315 | + for subview in subviews { | |
| 316 | + let size = subview.sizeThatFits(.unspecified) | |
| 317 | + if x + size.width > width, x > 0 { | |
| 318 | + x = 0 | |
| 319 | + y += rowHeight + spacing | |
| 320 | + rowHeight = 0 | |
| 321 | + } | |
| 322 | + x += size.width + spacing | |
| 323 | + rowHeight = max(rowHeight, size.height) | |
| 324 | + } | |
| 325 | + return CGSize(width: width, height: y + rowHeight) | |
| 326 | + } | |
| 327 | + | |
| 328 | + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { | |
| 329 | + var x = bounds.minX, y = bounds.minY, rowHeight: CGFloat = 0 | |
| 330 | + for subview in subviews { | |
| 331 | + let size = subview.sizeThatFits(.unspecified) | |
| 332 | + if x + size.width > bounds.maxX, x > bounds.minX { | |
| 333 | + x = bounds.minX | |
| 334 | + y += rowHeight + spacing | |
| 335 | + rowHeight = 0 | |
| 336 | + } | |
| 337 | + subview.place(at: CGPoint(x: x, y: y), proposal: ProposedViewSize(size)) | |
| 338 | + x += size.width + spacing | |
| 339 | + rowHeight = max(rowHeight, size.height) | |
| 340 | + } | |
| 341 | + } | |
| 342 | +} | |
added
Sources/ZyquoCloud/Views/Chat/ModelChipView.swift
+186 −0
@@ -0,0 +1,186 @@ | ||
| 1 | +// | |
| 2 | +// ModelChipView.swift | |
| 3 | +// Zyquo Cloud | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// The model chip (provider glyph + model name) and its picker popover: | |
| 9 | +// search, favorites first, grouped by provider, capability badges. | |
| 10 | +// | |
| 11 | + | |
| 12 | +import SwiftUI | |
| 13 | + | |
| 14 | +struct ModelChipView: View { | |
| 15 | + let model: AIModel? | |
| 16 | + var onSelect: (AIModel) -> Void | |
| 17 | + | |
| 18 | + @State private var showingPicker = false | |
| 19 | + | |
| 20 | + var body: some View { | |
| 21 | + Button { | |
| 22 | + showingPicker.toggle() | |
| 23 | + } label: { | |
| 24 | + HStack(spacing: ZyquoSpacing.xxs) { | |
| 25 | + Image(systemName: model?.provider.symbolName ?? "questionmark.circle") | |
| 26 | + .font(.system(size: 11, weight: .medium)) | |
| 27 | + .foregroundStyle(ZyquoColor.accent) | |
| 28 | + Text(model?.displayName ?? "Choose Model") | |
| 29 | + .font(ZyquoFont.bodyEmphasis(size: 12.5)) | |
| 30 | + .foregroundStyle(ZyquoColor.textPrimary) | |
| 31 | + .lineLimit(1) | |
| 32 | + Image(systemName: "chevron.down") | |
| 33 | + .font(.system(size: 8, weight: .semibold)) | |
| 34 | + .foregroundStyle(ZyquoColor.textTertiary) | |
| 35 | + } | |
| 36 | + .padding(.horizontal, ZyquoSpacing.xs) | |
| 37 | + .padding(.vertical, 4) | |
| 38 | + .background( | |
| 39 | + RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous) | |
| 40 | + .fill(ZyquoColor.surfaceSecondary) | |
| 41 | + ) | |
| 42 | + } | |
| 43 | + .buttonStyle(PressableButtonStyle()) | |
| 44 | + .popover(isPresented: $showingPicker, arrowEdge: .bottom) { | |
| 45 | + ModelPickerView(selected: model) { chosen in | |
| 46 | + showingPicker = false | |
| 47 | + onSelect(chosen) | |
| 48 | + } | |
| 49 | + } | |
| 50 | + } | |
| 51 | +} | |
| 52 | + | |
| 53 | +/// Model picker popover content: search field + favorites + provider groups. | |
| 54 | +struct ModelPickerView: View { | |
| 55 | + let selected: AIModel? | |
| 56 | + var onSelect: (AIModel) -> Void | |
| 57 | + | |
| 58 | + @EnvironmentObject private var catalog: ModelCatalog | |
| 59 | + @EnvironmentObject private var vault: KeyVaultStore | |
| 60 | + @State private var query = "" | |
| 61 | + | |
| 62 | + var body: some View { | |
| 63 | + VStack(spacing: 0) { | |
| 64 | + HStack(spacing: ZyquoSpacing.xxs) { | |
| 65 | + Image(systemName: "magnifyingglass") | |
| 66 | + .font(.system(size: 11)) | |
| 67 | + .foregroundStyle(ZyquoColor.textTertiary) | |
| 68 | + TextField("Search models", text: $query) | |
| 69 | + .textFieldStyle(.plain) | |
| 70 | + .font(ZyquoFont.body(size: 12.5)) | |
| 71 | + } | |
| 72 | + .padding(ZyquoSpacing.xs) | |
| 73 | + ZyquoHairline() | |
| 74 | + ScrollView { | |
| 75 | + LazyVStack(alignment: .leading, spacing: 1) { | |
| 76 | + if !favorites.isEmpty && query.isEmpty { | |
| 77 | + sectionHeader("Favorites") | |
| 78 | + ForEach(favorites) { model in row(model) } | |
| 79 | + } | |
| 80 | + ForEach(providerGroups, id: \.0) { provider, models in | |
| 81 | + sectionHeader(provider.displayName) | |
| 82 | + ForEach(models) { model in row(model) } | |
| 83 | + } | |
| 84 | + } | |
| 85 | + .padding(ZyquoSpacing.xxs) | |
| 86 | + } | |
| 87 | + .frame(width: 320, height: 380) | |
| 88 | + } | |
| 89 | + .background(ZyquoColor.surface) | |
| 90 | + } | |
| 91 | + | |
| 92 | + private var favorites: [AIModel] { | |
| 93 | + catalog.all.filter { catalog.favoriteIDs.contains($0.id) } | |
| 94 | + } | |
| 95 | + | |
| 96 | + /// Providers with a saved key first, then the rest; models filtered by search. | |
| 97 | + private var providerGroups: [(ProviderID, [AIModel])] { | |
| 98 | + let ordered = ProviderID.builtIn.sorted { | |
| 99 | + (vault.hasKey(for: $0) ? 0 : 1, $0.displayName) < (vault.hasKey(for: $1) ? 0 : 1, $1.displayName) | |
| 100 | + } | |
| 101 | + return ordered.compactMap { provider in | |
| 102 | + var models = catalog.models(for: provider) | |
| 103 | + if !query.isEmpty { | |
| 104 | + models = models.filter { | |
| 105 | + $0.displayName.localizedCaseInsensitiveContains(query) | |
| 106 | + || $0.id.localizedCaseInsensitiveContains(query) | |
| 107 | + } | |
| 108 | + } | |
| 109 | + return models.isEmpty ? nil : (provider, models) | |
| 110 | + } | |
| 111 | + } | |
| 112 | + | |
| 113 | + private func sectionHeader(_ title: String) -> some View { | |
| 114 | + Text(title) | |
| 115 | + .font(ZyquoFont.caption) | |
| 116 | + .foregroundStyle(ZyquoColor.textTertiary) | |
| 117 | + .padding(.horizontal, ZyquoSpacing.xs) | |
| 118 | + .padding(.top, ZyquoSpacing.xs) | |
| 119 | + .padding(.bottom, 2) | |
| 120 | + } | |
| 121 | + | |
| 122 | + private func row(_ model: AIModel) -> some View { | |
| 123 | + Button { | |
| 124 | + onSelect(model) | |
| 125 | + } label: { | |
| 126 | + HStack(spacing: ZyquoSpacing.xs) { | |
| 127 | + Image(systemName: model.provider.symbolName) | |
| 128 | + .font(.system(size: 11)) | |
| 129 | + .foregroundStyle(ZyquoColor.accent) | |
| 130 | + .frame(width: 14) | |
| 131 | + Text(model.displayName) | |
| 132 | + .font(ZyquoFont.body(size: 12.5)) | |
| 133 | + .foregroundStyle(ZyquoColor.textPrimary) | |
| 134 | + .lineLimit(1) | |
| 135 | + Spacer(minLength: ZyquoSpacing.xs) | |
| 136 | + capabilityBadges(model) | |
| 137 | + Text(model.contextBadge) | |
| 138 | + .font(ZyquoFont.caption) | |
| 139 | + .foregroundStyle(ZyquoColor.textTertiary) | |
| 140 | + Button { | |
| 141 | + toggleFavorite(model) | |
| 142 | + } label: { | |
| 143 | + Image(systemName: catalog.favoriteIDs.contains(model.id) ? "star.fill" : "star") | |
| 144 | + .font(.system(size: 10)) | |
| 145 | + .foregroundStyle( | |
| 146 | + catalog.favoriteIDs.contains(model.id) ? ZyquoColor.warning : ZyquoColor.textTertiary | |
| 147 | + ) | |
| 148 | + } | |
| 149 | + .buttonStyle(.plain) | |
| 150 | + } | |
| 151 | + .padding(.horizontal, ZyquoSpacing.xs) | |
| 152 | + .padding(.vertical, 4) | |
| 153 | + .background( | |
| 154 | + RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous) | |
| 155 | + .fill(model.id == selected?.id && model.provider == selected?.provider | |
| 156 | + ? ZyquoColor.accentSubtle : .clear) | |
| 157 | + ) | |
| 158 | + .contentShape(Rectangle()) | |
| 159 | + } | |
| 160 | + .buttonStyle(.plain) | |
| 161 | + .zyquoHoverHighlight() | |
| 162 | + } | |
| 163 | + | |
| 164 | + @ViewBuilder | |
| 165 | + private func capabilityBadges(_ model: AIModel) -> some View { | |
| 166 | + HStack(spacing: 3) { | |
| 167 | + if model.capabilities.vision { | |
| 168 | + Image(systemName: "eye").font(.system(size: 9)).foregroundStyle(ZyquoColor.textTertiary) | |
| 169 | + } | |
| 170 | + if model.capabilities.reasoning { | |
| 171 | + Image(systemName: "brain").font(.system(size: 9)).foregroundStyle(ZyquoColor.textTertiary) | |
| 172 | + } | |
| 173 | + if model.capabilities.tools { | |
| 174 | + Image(systemName: "wrench").font(.system(size: 9)).foregroundStyle(ZyquoColor.textTertiary) | |
| 175 | + } | |
| 176 | + } | |
| 177 | + } | |
| 178 | + | |
| 179 | + private func toggleFavorite(_ model: AIModel) { | |
| 180 | + if catalog.favoriteIDs.contains(model.id) { | |
| 181 | + catalog.favoriteIDs.remove(model.id) | |
| 182 | + } else { | |
| 183 | + catalog.favoriteIDs.insert(model.id) | |
| 184 | + } | |
| 185 | + } | |
| 186 | +} | |
added
Sources/ZyquoCloud/Views/Chat/ParametersEditorView.swift
+134 −0
@@ -0,0 +1,134 @@ | ||
| 1 | +// | |
| 2 | +// ParametersEditorView.swift | |
| 3 | +// Zyquo Cloud | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// Per-conversation generation parameters. Only the controls the selected | |
| 9 | +// model actually supports are shown (ParameterSupport gates each row). | |
| 10 | +// | |
| 11 | + | |
| 12 | +import SwiftUI | |
| 13 | + | |
| 14 | +struct ParametersEditorView: View { | |
| 15 | + @Binding var parameters: ChatParameters | |
| 16 | + let support: ParameterSupport | |
| 17 | + | |
| 18 | + var body: some View { | |
| 19 | + VStack(alignment: .leading, spacing: ZyquoSpacing.sm) { | |
| 20 | + Text("Parameters") | |
| 21 | + .font(ZyquoFont.bodyEmphasis()) | |
| 22 | + .foregroundStyle(ZyquoColor.textPrimary) | |
| 23 | + | |
| 24 | + if support.temperature { | |
| 25 | + optionalSlider( | |
| 26 | + "Temperature", value: $parameters.temperature, | |
| 27 | + range: 0...2, defaultValue: 1, format: "%.2f" | |
| 28 | + ) | |
| 29 | + } | |
| 30 | + if support.topP { | |
| 31 | + optionalSlider( | |
| 32 | + "Top P", value: $parameters.topP, | |
| 33 | + range: 0...1, defaultValue: 1, format: "%.2f" | |
| 34 | + ) | |
| 35 | + } | |
| 36 | + maxTokensRow | |
| 37 | + if support.frequencyPenalty { | |
| 38 | + optionalSlider( | |
| 39 | + "Frequency penalty", value: $parameters.frequencyPenalty, | |
| 40 | + range: -2...2, defaultValue: 0, format: "%.1f" | |
| 41 | + ) | |
| 42 | + } | |
| 43 | + if support.presencePenalty { | |
| 44 | + optionalSlider( | |
| 45 | + "Presence penalty", value: $parameters.presencePenalty, | |
| 46 | + range: -2...2, defaultValue: 0, format: "%.1f" | |
| 47 | + ) | |
| 48 | + } | |
| 49 | + if support.reasoningEffort { | |
| 50 | + reasoningEffortRow | |
| 51 | + } | |
| 52 | + if support.thinkingToggle { | |
| 53 | + Toggle("Extended thinking", isOn: Binding( | |
| 54 | + get: { parameters.thinkingEnabled ?? false }, | |
| 55 | + set: { parameters.thinkingEnabled = $0 } | |
| 56 | + )) | |
| 57 | + .font(ZyquoFont.body(size: 12.5)) | |
| 58 | + } | |
| 59 | + } | |
| 60 | + .padding(ZyquoSpacing.md) | |
| 61 | + .frame(width: 300) | |
| 62 | + } | |
| 63 | + | |
| 64 | + private var maxTokensRow: some View { | |
| 65 | + HStack { | |
| 66 | + Text("Max tokens") | |
| 67 | + .font(ZyquoFont.body(size: 12.5)) | |
| 68 | + Spacer() | |
| 69 | + TextField( | |
| 70 | + "default", | |
| 71 | + value: Binding( | |
| 72 | + get: { parameters.maxTokens }, | |
| 73 | + set: { parameters.maxTokens = $0 } | |
| 74 | + ), | |
| 75 | + format: .number | |
| 76 | + ) | |
| 77 | + .textFieldStyle(.roundedBorder) | |
| 78 | + .frame(width: 90) | |
| 79 | + .font(ZyquoFont.body(size: 12)) | |
| 80 | + } | |
| 81 | + } | |
| 82 | + | |
| 83 | + private var reasoningEffortRow: some View { | |
| 84 | + HStack { | |
| 85 | + Text("Reasoning effort") | |
| 86 | + .font(ZyquoFont.body(size: 12.5)) | |
| 87 | + Spacer() | |
| 88 | + Picker("", selection: Binding( | |
| 89 | + get: { parameters.reasoningEffort ?? "default" }, | |
| 90 | + set: { parameters.reasoningEffort = $0 == "default" ? nil : $0 } | |
| 91 | + )) { | |
| 92 | + Text("Default").tag("default") | |
| 93 | + Text("Low").tag("low") | |
| 94 | + Text("Medium").tag("medium") | |
| 95 | + Text("High").tag("high") | |
| 96 | + } | |
| 97 | + .pickerStyle(.menu) | |
| 98 | + .frame(width: 110) | |
| 99 | + } | |
| 100 | + } | |
| 101 | + | |
| 102 | + /// A slider with an enable checkbox: unchecked = provider default (omitted). | |
| 103 | + private func optionalSlider( | |
| 104 | + _ title: String, | |
| 105 | + value: Binding<Double?>, | |
| 106 | + range: ClosedRange<Double>, | |
| 107 | + defaultValue: Double, | |
| 108 | + format: String | |
| 109 | + ) -> some View { | |
| 110 | + VStack(alignment: .leading, spacing: 2) { | |
| 111 | + HStack { | |
| 112 | + Toggle(isOn: Binding( | |
| 113 | + get: { value.wrappedValue != nil }, | |
| 114 | + set: { value.wrappedValue = $0 ? defaultValue : nil } | |
| 115 | + )) { | |
| 116 | + Text(title).font(ZyquoFont.body(size: 12.5)) | |
| 117 | + } | |
| 118 | + .toggleStyle(.checkbox) | |
| 119 | + Spacer() | |
| 120 | + Text(value.wrappedValue.map { String(format: format, $0) } ?? "default") | |
| 121 | + .font(ZyquoFont.caption) | |
| 122 | + .foregroundStyle(ZyquoColor.textTertiary) | |
| 123 | + .monospacedDigit() | |
| 124 | + } | |
| 125 | + if let current = value.wrappedValue { | |
| 126 | + Slider( | |
| 127 | + value: Binding(get: { current }, set: { value.wrappedValue = $0 }), | |
| 128 | + in: range | |
| 129 | + ) | |
| 130 | + .controlSize(.small) | |
| 131 | + } | |
| 132 | + } | |
| 133 | + } | |
| 134 | +} | |
added
Sources/ZyquoCloud/Views/CommandPaletteView.swift
+152 −0
@@ -0,0 +1,152 @@ | ||
| 1 | +// | |
| 2 | +// CommandPaletteView.swift | |
| 3 | +// Zyquo Cloud | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// ⌘K palette: one search box over models, prompt templates, and personas. | |
| 9 | +// Models switch the current conversation; templates insert into the draft; | |
| 10 | +// personas apply their system prompt + preferences. | |
| 11 | +// | |
| 12 | + | |
| 13 | +import SwiftUI | |
| 14 | + | |
| 15 | +struct CommandPaletteView: View { | |
| 16 | + let currentModel: AIModel? | |
| 17 | + var onSelectModel: (AIModel) -> Void | |
| 18 | + var onInsertTemplate: (PromptTemplate) -> Void | |
| 19 | + var onApplyPersona: (Persona) -> Void | |
| 20 | + | |
| 21 | + @EnvironmentObject private var catalog: ModelCatalog | |
| 22 | + @StateObject private var library = PromptLibraryStore() | |
| 23 | + @Environment(\.dismiss) private var dismiss | |
| 24 | + @State private var query = "" | |
| 25 | + @FocusState private var focused: Bool | |
| 26 | + | |
| 27 | + var body: some View { | |
| 28 | + VStack(spacing: 0) { | |
| 29 | + HStack(spacing: ZyquoSpacing.xs) { | |
| 30 | + Image(systemName: "command") | |
| 31 | + .font(.system(size: 13)) | |
| 32 | + .foregroundStyle(ZyquoColor.textTertiary) | |
| 33 | + TextField("Switch model, insert template, apply persona…", text: $query) | |
| 34 | + .textFieldStyle(.plain) | |
| 35 | + .font(ZyquoFont.body(size: 15)) | |
| 36 | + .focused($focused) | |
| 37 | + } | |
| 38 | + .padding(ZyquoSpacing.md) | |
| 39 | + ZyquoHairline() | |
| 40 | + ScrollView { | |
| 41 | + LazyVStack(alignment: .leading, spacing: 1) { | |
| 42 | + if !matchingModels.isEmpty { | |
| 43 | + section("Models") | |
| 44 | + ForEach(matchingModels.prefix(8)) { model in | |
| 45 | + paletteRow( | |
| 46 | + symbol: model.provider.symbolName, | |
| 47 | + title: model.displayName, | |
| 48 | + subtitle: "\(model.provider.displayName) · \(model.contextBadge)" | |
| 49 | + ) { | |
| 50 | + onSelectModel(model) | |
| 51 | + dismiss() | |
| 52 | + } | |
| 53 | + } | |
| 54 | + } | |
| 55 | + if !matchingPersonas.isEmpty { | |
| 56 | + section("Personas") | |
| 57 | + ForEach(matchingPersonas.prefix(6)) { persona in | |
| 58 | + paletteRow( | |
| 59 | + symbol: persona.symbolName, | |
| 60 | + title: persona.name, | |
| 61 | + subtitle: String(persona.systemPrompt.prefix(70)) | |
| 62 | + ) { | |
| 63 | + onApplyPersona(persona) | |
| 64 | + dismiss() | |
| 65 | + } | |
| 66 | + } | |
| 67 | + } | |
| 68 | + if !matchingTemplates.isEmpty { | |
| 69 | + section("Prompt Library") | |
| 70 | + ForEach(matchingTemplates.prefix(10)) { template in | |
| 71 | + paletteRow( | |
| 72 | + symbol: "text.badge.plus", | |
| 73 | + title: template.title, | |
| 74 | + subtitle: template.category | |
| 75 | + ) { | |
| 76 | + onInsertTemplate(template) | |
| 77 | + dismiss() | |
| 78 | + } | |
| 79 | + } | |
| 80 | + } | |
| 81 | + } | |
| 82 | + .padding(ZyquoSpacing.xxs) | |
| 83 | + } | |
| 84 | + .frame(height: 380) | |
| 85 | + } | |
| 86 | + .frame(width: 560) | |
| 87 | + .background(ZyquoColor.surface) | |
| 88 | + .onAppear { focused = true } | |
| 89 | + .onExitCommand { dismiss() } | |
| 90 | + } | |
| 91 | + | |
| 92 | + private var matchingModels: [AIModel] { | |
| 93 | + query.isEmpty | |
| 94 | + ? catalog.all.filter(\.isRecommended) | |
| 95 | + : catalog.all.filter { | |
| 96 | + $0.displayName.localizedCaseInsensitiveContains(query) | |
| 97 | + || $0.id.localizedCaseInsensitiveContains(query) | |
| 98 | + } | |
| 99 | + } | |
| 100 | + | |
| 101 | + private var matchingTemplates: [PromptTemplate] { | |
| 102 | + query.isEmpty | |
| 103 | + ? Array(library.allTemplates.prefix(10)) | |
| 104 | + : library.allTemplates.filter { | |
| 105 | + $0.title.localizedCaseInsensitiveContains(query) | |
| 106 | + || $0.category.localizedCaseInsensitiveContains(query) | |
| 107 | + } | |
| 108 | + } | |
| 109 | + | |
| 110 | + private var matchingPersonas: [Persona] { | |
| 111 | + query.isEmpty | |
| 112 | + ? library.allPersonas | |
| 113 | + : library.allPersonas.filter { $0.name.localizedCaseInsensitiveContains(query) } | |
| 114 | + } | |
| 115 | + | |
| 116 | + private func section(_ title: String) -> some View { | |
| 117 | + Text(title) | |
| 118 | + .font(ZyquoFont.caption) | |
| 119 | + .foregroundStyle(ZyquoColor.textTertiary) | |
| 120 | + .padding(.horizontal, ZyquoSpacing.sm) | |
| 121 | + .padding(.top, ZyquoSpacing.xs) | |
| 122 | + .padding(.bottom, 2) | |
| 123 | + } | |
| 124 | + | |
| 125 | + private func paletteRow( | |
| 126 | + symbol: String, title: String, subtitle: String, action: @escaping () -> Void | |
| 127 | + ) -> some View { | |
| 128 | + Button(action: action) { | |
| 129 | + HStack(spacing: ZyquoSpacing.sm) { | |
| 130 | + Image(systemName: symbol) | |
| 131 | + .font(.system(size: 13)) | |
| 132 | + .foregroundStyle(ZyquoColor.accent) | |
| 133 | + .frame(width: 18) | |
| 134 | + VStack(alignment: .leading, spacing: 0) { | |
| 135 | + Text(title) | |
| 136 | + .font(ZyquoFont.body(size: 13)) | |
| 137 | + .foregroundStyle(ZyquoColor.textPrimary) | |
| 138 | + Text(subtitle) | |
| 139 | + .font(ZyquoFont.caption) | |
| 140 | + .foregroundStyle(ZyquoColor.textTertiary) | |
| 141 | + .lineLimit(1) | |
| 142 | + } | |
| 143 | + Spacer() | |
| 144 | + } | |
| 145 | + .padding(.horizontal, ZyquoSpacing.sm) | |
| 146 | + .padding(.vertical, 5) | |
| 147 | + .contentShape(Rectangle()) | |
| 148 | + } | |
| 149 | + .buttonStyle(.plain) | |
| 150 | + .zyquoHoverHighlight() | |
| 151 | + } | |
| 152 | +} | |
added
Sources/ZyquoCloud/Views/Compare/CompareView.swift
+251 −0
@@ -0,0 +1,251 @@ | ||
| 1 | +// | |
| 2 | +// CompareView.swift | |
| 3 | +// Zyquo Cloud | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// Compare mode: 2–4 vertical columns, one model chip each, the same prompt | |
| 9 | +// broadcast to all, independent streaming, per-column copy/regenerate. | |
| 10 | +// | |
| 11 | + | |
| 12 | +import SwiftUI | |
| 13 | + | |
| 14 | +struct CompareView: View { | |
| 15 | + @EnvironmentObject private var catalog: ModelCatalog | |
| 16 | + @EnvironmentObject private var vault: KeyVaultStore | |
| 17 | + @EnvironmentObject private var appearance: AppearanceStore | |
| 18 | + @Environment(\.dismiss) private var dismiss | |
| 19 | + | |
| 20 | + @StateObject private var session = CompareSession() | |
| 21 | + @State private var prompt = "" | |
| 22 | + | |
| 23 | + var body: some View { | |
| 24 | + VStack(spacing: 0) { | |
| 25 | + header | |
| 26 | + ZyquoHairline() | |
| 27 | + columns | |
| 28 | + ZyquoHairline() | |
| 29 | + inputRow | |
| 30 | + } | |
| 31 | + .frame(minWidth: 900, minHeight: 560) | |
| 32 | + .background(ZyquoColor.background) | |
| 33 | + .onAppear { | |
| 34 | + if session.columns.isEmpty { | |
| 35 | + session.setup(catalog: catalog, vault: vault) | |
| 36 | + } | |
| 37 | + } | |
| 38 | + } | |
| 39 | + | |
| 40 | + private var header: some View { | |
| 41 | + HStack { | |
| 42 | + Text("Compare Models") | |
| 43 | + .font(ZyquoFont.bodyEmphasis(size: 14)) | |
| 44 | + .foregroundStyle(ZyquoColor.textPrimary) | |
| 45 | + Spacer() | |
| 46 | + Button { | |
| 47 | + session.addColumn(catalog: catalog) | |
| 48 | + } label: { | |
| 49 | + Label("Add column", systemImage: "plus.rectangle.on.rectangle") | |
| 50 | + } | |
| 51 | + .controlSize(.small) | |
| 52 | + .disabled(session.columns.count >= 4) | |
| 53 | + Button("Done") { dismiss() } | |
| 54 | + .controlSize(.small) | |
| 55 | + } | |
| 56 | + .padding(ZyquoSpacing.sm) | |
| 57 | + } | |
| 58 | + | |
| 59 | + private var columns: some View { | |
| 60 | + HStack(spacing: 0) { | |
| 61 | + ForEach($session.columns) { $column in | |
| 62 | + VStack(spacing: 0) { | |
| 63 | + columnHeader($column) | |
| 64 | + ZyquoHairline() | |
| 65 | + ScrollView { | |
| 66 | + VStack(alignment: .leading, spacing: ZyquoSpacing.xs) { | |
| 67 | + if let reasoning = column.reasoning, !reasoning.isEmpty { | |
| 68 | + Text(reasoning) | |
| 69 | + .font(ZyquoFont.code(size: appearance.chatFontSize - 2.5)) | |
| 70 | + .foregroundStyle(ZyquoColor.textTertiary) | |
| 71 | + .lineLimit(6) | |
| 72 | + } | |
| 73 | + if let error = column.errorText { | |
| 74 | + Text(error) | |
| 75 | + .font(ZyquoFont.body(size: appearance.chatFontSize - 1)) | |
| 76 | + .foregroundStyle(ZyquoColor.danger) | |
| 77 | + } else { | |
| 78 | + MarkdownView(text: column.answer, fontSize: appearance.chatFontSize - 0.5) | |
| 79 | + if column.isStreaming { StreamingCaret() } | |
| 80 | + } | |
| 81 | + } | |
| 82 | + .frame(maxWidth: .infinity, alignment: .leading) | |
| 83 | + .padding(ZyquoSpacing.sm) | |
| 84 | + } | |
| 85 | + } | |
| 86 | + .frame(maxWidth: .infinity) | |
| 87 | + if column.id != session.columns.last?.id { | |
| 88 | + Rectangle().fill(ZyquoColor.border).frame(width: ZyquoMetrics.hairline) | |
| 89 | + } | |
| 90 | + } | |
| 91 | + } | |
| 92 | + } | |
| 93 | + | |
| 94 | + private func columnHeader(_ column: Binding<CompareSession.Column>) -> some View { | |
| 95 | + HStack(spacing: ZyquoSpacing.xxs) { | |
| 96 | + ModelChipView(model: column.wrappedValue.model) { chosen in | |
| 97 | + column.wrappedValue.model = chosen | |
| 98 | + } | |
| 99 | + Spacer() | |
| 100 | + if let usage = column.wrappedValue.usage { | |
| 101 | + Text("\(usage.outputTokens) tok") | |
| 102 | + .font(ZyquoFont.caption) | |
| 103 | + .foregroundStyle(ZyquoColor.textTertiary) | |
| 104 | + } | |
| 105 | + Button { | |
| 106 | + NSPasteboard.general.clearContents() | |
| 107 | + NSPasteboard.general.setString(column.wrappedValue.answer, forType: .string) | |
| 108 | + } label: { | |
| 109 | + Image(systemName: "doc.on.doc").font(.system(size: 10)) | |
| 110 | + } | |
| 111 | + .buttonStyle(.plain) | |
| 112 | + .help("Copy answer") | |
| 113 | + Button { | |
| 114 | + session.run(columnID: column.wrappedValue.id, prompt: session.lastPrompt, vault: vault) | |
| 115 | + } label: { | |
| 116 | + Image(systemName: "arrow.clockwise").font(.system(size: 10)) | |
| 117 | + } | |
| 118 | + .buttonStyle(.plain) | |
| 119 | + .help("Regenerate") | |
| 120 | + .disabled(session.lastPrompt.isEmpty) | |
| 121 | + if session.columns.count > 2 { | |
| 122 | + Button { | |
| 123 | + session.columns.removeAll { $0.id == column.wrappedValue.id } | |
| 124 | + } label: { | |
| 125 | + Image(systemName: "xmark").font(.system(size: 9)) | |
| 126 | + } | |
| 127 | + .buttonStyle(.plain) | |
| 128 | + .help("Remove column") | |
| 129 | + } | |
| 130 | + } | |
| 131 | + .padding(.horizontal, ZyquoSpacing.xs) | |
| 132 | + .padding(.vertical, ZyquoSpacing.xxs) | |
| 133 | + } | |
| 134 | + | |
| 135 | + private var inputRow: some View { | |
| 136 | + HStack(spacing: ZyquoSpacing.xs) { | |
| 137 | + TextField("Prompt all models…", text: $prompt, axis: .vertical) | |
| 138 | + .textFieldStyle(.plain) | |
| 139 | + .font(ZyquoFont.body(size: appearance.chatFontSize)) | |
| 140 | + .lineLimit(1...6) | |
| 141 | + .onSubmit(broadcast) | |
| 142 | + Button { | |
| 143 | + broadcast() | |
| 144 | + } label: { | |
| 145 | + Image(systemName: "arrow.up") | |
| 146 | + .font(.system(size: 12, weight: .semibold)) | |
| 147 | + .foregroundStyle(.white) | |
| 148 | + .frame(width: 26, height: 26) | |
| 149 | + .background(Circle().fill(ZyquoColor.accent)) | |
| 150 | + } | |
| 151 | + .buttonStyle(PressableButtonStyle()) | |
| 152 | + .keyboardShortcut(.return, modifiers: .command) | |
| 153 | + .disabled(prompt.trimmingCharacters(in: .whitespaces).isEmpty) | |
| 154 | + } | |
| 155 | + .padding(ZyquoSpacing.sm) | |
| 156 | + .background(ZyquoColor.surface) | |
| 157 | + } | |
| 158 | + | |
| 159 | + private func broadcast() { | |
| 160 | + let text = prompt.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 161 | + guard !text.isEmpty else { return } | |
| 162 | + prompt = "" | |
| 163 | + session.broadcast(prompt: text, vault: vault) | |
| 164 | + } | |
| 165 | +} | |
| 166 | + | |
| 167 | +/// State for one compare session: N independent streaming columns. | |
| 168 | +@MainActor | |
| 169 | +final class CompareSession: ObservableObject { | |
| 170 | + struct Column: Identifiable { | |
| 171 | + let id = UUID() | |
| 172 | + var model: AIModel? | |
| 173 | + var answer = "" | |
| 174 | + var reasoning: String? | |
| 175 | + var errorText: String? | |
| 176 | + var usage: TokenUsage? | |
| 177 | + var isStreaming = false | |
| 178 | + } | |
| 179 | + | |
| 180 | + @Published var columns: [Column] = [] | |
| 181 | + @Published private(set) var lastPrompt = "" | |
| 182 | + private var tasks: [UUID: Task<Void, Never>] = [:] | |
| 183 | + | |
| 184 | + func setup(catalog: ModelCatalog, vault: KeyVaultStore) { | |
| 185 | + // Seed two columns with the two most relevant models from providers | |
| 186 | + // that have keys, falling back to catalog defaults. | |
| 187 | + let keyed = catalog.all.filter { | |
| 188 | + $0.isRecommended && vault.hasKey(for: $0.provider) | |
| 189 | + } | |
| 190 | + let first = keyed.first ?? catalog.defaultModel | |
| 191 | + let second = keyed.dropFirst().first ?? catalog.defaultModel | |
| 192 | + columns = [Column(model: first), Column(model: second)] | |
| 193 | + } | |
| 194 | + | |
| 195 | + func addColumn(catalog: ModelCatalog) { | |
| 196 | + guard columns.count < 4 else { return } | |
| 197 | + columns.append(Column(model: catalog.defaultModel)) | |
| 198 | + } | |
| 199 | + | |
| 200 | + func broadcast(prompt: String, vault: KeyVaultStore) { | |
| 201 | + lastPrompt = prompt | |
| 202 | + for column in columns { | |
| 203 | + run(columnID: column.id, prompt: prompt, vault: vault) | |
| 204 | + } | |
| 205 | + } | |
| 206 | + | |
| 207 | + func run(columnID: UUID, prompt: String, vault: KeyVaultStore) { | |
| 208 | + guard !prompt.isEmpty, | |
| 209 | + let index = columns.firstIndex(where: { $0.id == columnID }), | |
| 210 | + let model = columns[index].model | |
| 211 | + else { return } | |
| 212 | + tasks[columnID]?.cancel() | |
| 213 | + columns[index].answer = "" | |
| 214 | + columns[index].reasoning = nil | |
| 215 | + columns[index].errorText = nil | |
| 216 | + columns[index].usage = nil | |
| 217 | + columns[index].isStreaming = true | |
| 218 | + | |
| 219 | + tasks[columnID] = Task { [weak self] in | |
| 220 | + guard let self else { return } | |
| 221 | + do { | |
| 222 | + let key = try vault.apiKey(for: model.provider) | |
| 223 | + let client = ProviderRegistry.client(for: model) | |
| 224 | + let request = ChatRequest( | |
| 225 | + model: model, | |
| 226 | + systemPrompt: nil, | |
| 227 | + messages: [Message(role: .user, text: prompt)], | |
| 228 | + parameters: ChatParameters() | |
| 229 | + ) | |
| 230 | + for try await event in client.streamChat(request, apiKey: key) { | |
| 231 | + if Task.isCancelled { break } | |
| 232 | + guard let i = self.columns.firstIndex(where: { $0.id == columnID }) else { break } | |
| 233 | + switch event { | |
| 234 | + case .textDelta(let delta): self.columns[i].answer += delta | |
| 235 | + case .reasoningDelta(let delta): | |
| 236 | + self.columns[i].reasoning = (self.columns[i].reasoning ?? "") + delta | |
| 237 | + case .usage(let usage): self.columns[i].usage = usage | |
| 238 | + default: break | |
| 239 | + } | |
| 240 | + } | |
| 241 | + } catch { | |
| 242 | + if let i = self.columns.firstIndex(where: { $0.id == columnID }) { | |
| 243 | + self.columns[i].errorText = error.localizedDescription | |
| 244 | + } | |
| 245 | + } | |
| 246 | + if let i = self.columns.firstIndex(where: { $0.id == columnID }) { | |
| 247 | + self.columns[i].isStreaming = false | |
| 248 | + } | |
| 249 | + } | |
| 250 | + } | |
| 251 | +} | |
added
Sources/ZyquoCloud/Views/MainWindowView.swift
+49 −0
@@ -0,0 +1,49 @@ | ||
| 1 | +// | |
| 2 | +// MainWindowView.swift | |
| 3 | +// Zyquo Cloud | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// Root split view: translucent sidebar + chat area. | |
| 9 | +// | |
| 10 | + | |
| 11 | +import SwiftUI | |
| 12 | + | |
| 13 | +struct MainWindowView: View { | |
| 14 | + @EnvironmentObject private var store: ConversationStore | |
| 15 | + @EnvironmentObject private var appearance: AppearanceStore | |
| 16 | + | |
| 17 | + var body: some View { | |
| 18 | + NavigationSplitView { | |
| 19 | + SidebarView() | |
| 20 | + .navigationSplitViewColumnWidth( | |
| 21 | + min: ZyquoMetrics.sidebarWidth, | |
| 22 | + ideal: ZyquoMetrics.sidebarWidth, | |
| 23 | + max: 360 | |
| 24 | + ) | |
| 25 | + } detail: { | |
| 26 | + if let id = store.selectedID, store.conversations.contains(where: { $0.id == id }) { | |
| 27 | + ChatView(conversationID: id) | |
| 28 | + .id(id) | |
| 29 | + } else { | |
| 30 | + EmptyStateView( | |
| 31 | + model: store.catalog.defaultModel, | |
| 32 | + onSelectModel: { model in | |
| 33 | + store.newConversation(model: model) | |
| 34 | + }, | |
| 35 | + onSuggestion: { prompt in | |
| 36 | + let conversation = store.newConversation() | |
| 37 | + store.send(text: prompt, in: conversation.id) | |
| 38 | + } | |
| 39 | + ) | |
| 40 | + } | |
| 41 | + } | |
| 42 | + .frame( | |
| 43 | + minWidth: ZyquoMetrics.windowMinWidth, | |
| 44 | + minHeight: ZyquoMetrics.windowMinHeight | |
| 45 | + ) | |
| 46 | + .preferredColorScheme(appearance.themeMode.colorScheme) | |
| 47 | + .tint(appearance.accentColor) | |
| 48 | + } | |
| 49 | +} | |
added
Sources/ZyquoCloud/Views/Markdown/CodeBlockView.swift
+101 −0
@@ -0,0 +1,101 @@ | ||
| 1 | +// | |
| 2 | +// CodeBlockView.swift | |
| 3 | +// Zyquo Cloud | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// Fenced code block per the Phase 4 spec: surfaceSecondary card with medium | |
| 9 | +// radius, uppercased language label top-left, hover copy button top-right | |
| 10 | +// (with a brief checkmark confirmation), SF Mono content with syntax | |
| 11 | +// highlighting, horizontal scrolling for long lines, and text selection. | |
| 12 | +// | |
| 13 | + | |
| 14 | +import SwiftUI | |
| 15 | + | |
| 16 | +struct CodeBlockView: View { | |
| 17 | + let code: String | |
| 18 | + let language: String? | |
| 19 | + let fontSize: Double | |
| 20 | + | |
| 21 | + @State private var hovering = false | |
| 22 | + @State private var copied = false | |
| 23 | + @State private var copyGeneration = 0 | |
| 24 | + | |
| 25 | + /// How long the copy confirmation checkmark stays visible. | |
| 26 | + private static let copyConfirmationSeconds: Double = 1.2 | |
| 27 | + | |
| 28 | + var body: some View { | |
| 29 | + VStack(alignment: .leading, spacing: 0) { | |
| 30 | + header | |
| 31 | + ScrollView(.horizontal) { | |
| 32 | + SwiftUI.Text(highlighted) | |
| 33 | + .font(ZyquoFont.code(size: max(fontSize - 1, 1))) | |
| 34 | + .textSelection(.enabled) | |
| 35 | + .fixedSize(horizontal: false, vertical: true) | |
| 36 | + .padding(.horizontal, ZyquoSpacing.sm) | |
| 37 | + .padding(.top, ZyquoSpacing.xxs) | |
| 38 | + .padding(.bottom, ZyquoSpacing.xs) | |
| 39 | + } | |
| 40 | + } | |
| 41 | + .background( | |
| 42 | + RoundedRectangle(cornerRadius: ZyquoRadius.medium, style: .continuous) | |
| 43 | + .fill(ZyquoColor.surfaceSecondary) | |
| 44 | + ) | |
| 45 | + .onHover { inside in | |
| 46 | + withAnimation(ZyquoMotion.hover) { hovering = inside } | |
| 47 | + } | |
| 48 | + } | |
| 49 | + | |
| 50 | + // MARK: Header | |
| 51 | + | |
| 52 | + private var header: some View { | |
| 53 | + HStack(spacing: ZyquoSpacing.xs) { | |
| 54 | + if let language, !language.isEmpty { | |
| 55 | + SwiftUI.Text(language.uppercased()) | |
| 56 | + .font(ZyquoFont.caption) | |
| 57 | + .foregroundStyle(ZyquoColor.textSecondary) | |
| 58 | + } | |
| 59 | + Spacer(minLength: ZyquoSpacing.xs) | |
| 60 | + copyButton | |
| 61 | + .opacity(hovering || copied ? 1 : 0) | |
| 62 | + } | |
| 63 | + .padding(.horizontal, ZyquoSpacing.sm) | |
| 64 | + .padding(.top, ZyquoSpacing.xs) | |
| 65 | + } | |
| 66 | + | |
| 67 | + private var copyButton: some View { | |
| 68 | + Button(action: copy) { | |
| 69 | + Image(systemName: copied ? "checkmark" : "doc.on.doc") | |
| 70 | + .font(ZyquoFont.caption) | |
| 71 | + .foregroundStyle(copied ? ZyquoColor.success : ZyquoColor.textSecondary) | |
| 72 | + } | |
| 73 | + .buttonStyle(PressableButtonStyle()) | |
| 74 | + .accessibilityLabel("Copy code") | |
| 75 | + .help("Copy code") | |
| 76 | + } | |
| 77 | + | |
| 78 | + // MARK: Highlighting | |
| 79 | + | |
| 80 | + private var highlighted: AttributedString { | |
| 81 | + SyntaxHighlighter.highlight(code, language: language, baseColor: ZyquoColor.textPrimary) | |
| 82 | + } | |
| 83 | + | |
| 84 | + // MARK: Copy | |
| 85 | + | |
| 86 | + private func copy() { | |
| 87 | + let pasteboard = NSPasteboard.general | |
| 88 | + pasteboard.clearContents() | |
| 89 | + pasteboard.setString(code, forType: .string) | |
| 90 | + | |
| 91 | + copyGeneration += 1 | |
| 92 | + let generation = copyGeneration | |
| 93 | + withAnimation(ZyquoMotion.hover) { copied = true } | |
| 94 | + Task { | |
| 95 | + try? await Task.sleep(for: .seconds(Self.copyConfirmationSeconds)) | |
| 96 | + if generation == copyGeneration { | |
| 97 | + withAnimation(ZyquoMotion.hover) { copied = false } | |
| 98 | + } | |
| 99 | + } | |
| 100 | + } | |
| 101 | +} | |
added
Sources/ZyquoCloud/Views/Markdown/MarkdownView.swift
+481 −0
@@ -0,0 +1,481 @@ | ||
| 1 | +// | |
| 2 | +// MarkdownView.swift | |
| 3 | +// Zyquo Cloud | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// Full Markdown rendering for chat messages per the Phase 4 spec: the | |
| 9 | +// swift-markdown AST is walked once into a lightweight [MarkdownBlock] model | |
| 10 | +// (memoized by text hash — streaming deltas re-parse only the changed text), | |
| 11 | +// then rendered as SwiftUI views built entirely from ZyquoTheme tokens. | |
| 12 | +// Links use the AttributedString .link attribute, so SwiftUI.Text routes | |
| 13 | +// clicks through the environment's openURL automatically. Parsing is | |
| 14 | +// resilient to incomplete Markdown (unterminated fences etc.) — swift-markdown | |
| 15 | +// degrades gracefully, so streaming partial text never crashes. | |
| 16 | +// | |
| 17 | + | |
| 18 | +import SwiftUI | |
| 19 | +import Markdown | |
| 20 | + | |
| 21 | +// MARK: - MarkdownView | |
| 22 | + | |
| 23 | +struct MarkdownView: View { | |
| 24 | + let fontSize: Double | |
| 25 | + private let blocks: [MarkdownBlock] | |
| 26 | + | |
| 27 | + init(text: String, fontSize: Double) { | |
| 28 | + self.fontSize = fontSize | |
| 29 | + self.blocks = MarkdownBlockParser.parse(text: text, fontSize: fontSize) | |
| 30 | + } | |
| 31 | + | |
| 32 | + var body: some View { | |
| 33 | + VStack(alignment: .leading, spacing: ZyquoSpacing.sm) { | |
| 34 | + ForEach(blocks) { block in | |
| 35 | + MarkdownBlockView(block: block, fontSize: fontSize) | |
| 36 | + } | |
| 37 | + } | |
| 38 | + .foregroundStyle(ZyquoColor.textPrimary) | |
| 39 | + } | |
| 40 | +} | |
| 41 | + | |
| 42 | +// MARK: - Block model | |
| 43 | + | |
| 44 | +/// One rendered Markdown block. Ids are assigned in document order at parse | |
| 45 | +/// time so `ForEach` stays stable within a parse. | |
| 46 | +struct MarkdownBlock: Identifiable { | |
| 47 | + let id: Int | |
| 48 | + let kind: Kind | |
| 49 | + | |
| 50 | + enum Kind { | |
| 51 | + case paragraph(AttributedString) | |
| 52 | + case heading(AttributedString, level: Int) | |
| 53 | + case code(String, language: String?) | |
| 54 | + case quote([MarkdownBlock]) | |
| 55 | + case list(ListData) | |
| 56 | + case table(TableData) | |
| 57 | + case thematicBreak | |
| 58 | + } | |
| 59 | + | |
| 60 | + struct ListData { | |
| 61 | + let ordered: Bool | |
| 62 | + let start: Int | |
| 63 | + let items: [ListItemData] | |
| 64 | + } | |
| 65 | + | |
| 66 | + struct ListItemData: Identifiable { | |
| 67 | + let id: Int | |
| 68 | + /// nil = plain item; true/false = task-list checkbox state. | |
| 69 | + let checked: Bool? | |
| 70 | + let blocks: [MarkdownBlock] | |
| 71 | + } | |
| 72 | + | |
| 73 | + struct TableData { | |
| 74 | + let alignments: [TextAlignment] | |
| 75 | + let header: [AttributedString] | |
| 76 | + let rows: [[AttributedString]] | |
| 77 | + | |
| 78 | + func alignment(forColumn column: Int) -> TextAlignment { | |
| 79 | + column < alignments.count ? alignments[column] : .leading | |
| 80 | + } | |
| 81 | + } | |
| 82 | +} | |
| 83 | + | |
| 84 | +// MARK: - Parser | |
| 85 | + | |
| 86 | +/// Walks the swift-markdown `Document` AST into `[MarkdownBlock]`. Results are | |
| 87 | +/// memoized by (text, fontSize) so re-renders during streaming only re-parse | |
| 88 | +/// when the text actually changes. | |
| 89 | +enum MarkdownBlockParser { | |
| 90 | + /// Heading sizes scale off the user's chat font size. | |
| 91 | + private static func headingSize(level: Int, base: Double) -> Double { | |
| 92 | + switch level { | |
| 93 | + case 1: return base * 1.55 | |
| 94 | + case 2: return base * 1.35 | |
| 95 | + case 3: return base * 1.2 | |
| 96 | + default: return base * 1.05 | |
| 97 | + } | |
| 98 | + } | |
| 99 | + | |
| 100 | + static func parse(text: String, fontSize: Double) -> [MarkdownBlock] { | |
| 101 | + let key = CacheKey(textHash: text.hashValue, length: text.count, fontBits: fontSize.bitPattern) | |
| 102 | + cacheLock.lock() | |
| 103 | + if let hit = cache[key] { | |
| 104 | + cacheLock.unlock() | |
| 105 | + return hit | |
| 106 | + } | |
| 107 | + cacheLock.unlock() | |
| 108 | + | |
| 109 | + let document = Document(parsing: text) | |
| 110 | + var counter = 0 | |
| 111 | + let blocks = convertBlocks(of: document, fontSize: fontSize, counter: &counter) | |
| 112 | + | |
| 113 | + cacheLock.lock() | |
| 114 | + if cache.count > cacheCapacity { cache.removeAll(keepingCapacity: true) } | |
| 115 | + cache[key] = blocks | |
| 116 | + cacheLock.unlock() | |
| 117 | + return blocks | |
| 118 | + } | |
| 119 | + | |
| 120 | + // MARK: Cache | |
| 121 | + | |
| 122 | + private struct CacheKey: Hashable { | |
| 123 | + let textHash: Int | |
| 124 | + let length: Int | |
| 125 | + let fontBits: UInt64 | |
| 126 | + } | |
| 127 | + | |
| 128 | + private static let cacheLock = NSLock() | |
| 129 | + private static let cacheCapacity = 32 | |
| 130 | + private static var cache: [CacheKey: [MarkdownBlock]] = [:] | |
| 131 | + | |
| 132 | + // MARK: Block conversion | |
| 133 | + | |
| 134 | + private static func convertBlocks(of parent: Markup, fontSize: Double, counter: inout Int) -> [MarkdownBlock] { | |
| 135 | + parent.children.compactMap { convertBlock($0, fontSize: fontSize, counter: &counter) } | |
| 136 | + } | |
| 137 | + | |
| 138 | + private static func convertBlock(_ markup: Markup, fontSize: Double, counter: inout Int) -> MarkdownBlock? { | |
| 139 | + counter += 1 | |
| 140 | + let id = counter | |
| 141 | + | |
| 142 | + switch markup { | |
| 143 | + case let heading as Heading: | |
| 144 | + let font = Font.system(size: headingSize(level: heading.level, base: fontSize), weight: .semibold) | |
| 145 | + let content = inlineText(of: heading, fontSize: fontSize, baseFont: font) | |
| 146 | + return MarkdownBlock(id: id, kind: .heading(content, level: heading.level)) | |
| 147 | + | |
| 148 | + case let paragraph as Paragraph: | |
| 149 | + let content = inlineText(of: paragraph, fontSize: fontSize, baseFont: ZyquoFont.body(size: fontSize)) | |
| 150 | + guard !content.characters.isEmpty else { return nil } | |
| 151 | + return MarkdownBlock(id: id, kind: .paragraph(content)) | |
| 152 | + | |
| 153 | + case let codeBlock as CodeBlock: | |
| 154 | + var code = codeBlock.code | |
| 155 | + if code.hasSuffix("\n") { code.removeLast() } | |
| 156 | + return MarkdownBlock(id: id, kind: .code(code, language: codeBlock.language)) | |
| 157 | + | |
| 158 | + case let quote as BlockQuote: | |
| 159 | + return MarkdownBlock(id: id, kind: .quote(convertBlocks(of: quote, fontSize: fontSize, counter: &counter))) | |
| 160 | + | |
| 161 | + case let list as UnorderedList: | |
| 162 | + let items = convertListItems(of: list, fontSize: fontSize, counter: &counter) | |
| 163 | + return MarkdownBlock(id: id, kind: .list(.init(ordered: false, start: 1, items: items))) | |
| 164 | + | |
| 165 | + case let list as OrderedList: | |
| 166 | + let items = convertListItems(of: list, fontSize: fontSize, counter: &counter) | |
| 167 | + return MarkdownBlock(id: id, kind: .list(.init(ordered: true, start: Int(list.startIndex), items: items))) | |
| 168 | + | |
| 169 | + case let table as Markdown.Table: | |
| 170 | + return MarkdownBlock(id: id, kind: .table(convertTable(table, fontSize: fontSize))) | |
| 171 | + | |
| 172 | + case is ThematicBreak: | |
| 173 | + return MarkdownBlock(id: id, kind: .thematicBreak) | |
| 174 | + | |
| 175 | + case let html as HTMLBlock: | |
| 176 | + var raw = html.rawHTML | |
| 177 | + if raw.hasSuffix("\n") { raw.removeLast() } | |
| 178 | + return MarkdownBlock(id: id, kind: .code(raw, language: "html")) | |
| 179 | + | |
| 180 | + default: | |
| 181 | + // Unknown block: fall back to its re-formatted Markdown source. | |
| 182 | + let source = markup.format().trimmingCharacters(in: .whitespacesAndNewlines) | |
| 183 | + guard !source.isEmpty else { return nil } | |
| 184 | + var content = AttributedString(source) | |
| 185 | + content.font = ZyquoFont.body(size: fontSize) | |
| 186 | + return MarkdownBlock(id: id, kind: .paragraph(content)) | |
| 187 | + } | |
| 188 | + } | |
| 189 | + | |
| 190 | + private static func convertListItems(of list: Markup, fontSize: Double, counter: inout Int) -> [MarkdownBlock.ListItemData] { | |
| 191 | + list.children.compactMap { child in | |
| 192 | + guard let item = child as? Markdown.ListItem else { return nil } | |
| 193 | + counter += 1 | |
| 194 | + let id = counter | |
| 195 | + let checked: Bool? | |
| 196 | + switch item.checkbox { | |
| 197 | + case .checked: checked = true | |
| 198 | + case .unchecked: checked = false | |
| 199 | + case nil: checked = nil | |
| 200 | + } | |
| 201 | + return MarkdownBlock.ListItemData( | |
| 202 | + id: id, | |
| 203 | + checked: checked, | |
| 204 | + blocks: convertBlocks(of: item, fontSize: fontSize, counter: &counter) | |
| 205 | + ) | |
| 206 | + } | |
| 207 | + } | |
| 208 | + | |
| 209 | + private static func convertTable(_ table: Markdown.Table, fontSize: Double) -> MarkdownBlock.TableData { | |
| 210 | + let alignments: [TextAlignment] = table.columnAlignments.map { alignment in | |
| 211 | + switch alignment { | |
| 212 | + case .center: return .center | |
| 213 | + case .right: return .trailing | |
| 214 | + default: return .leading | |
| 215 | + } | |
| 216 | + } | |
| 217 | + let headerFont = ZyquoFont.bodyEmphasis(size: fontSize) | |
| 218 | + let header = table.head.children.compactMap { cell -> AttributedString? in | |
| 219 | + guard let cell = cell as? Markdown.Table.Cell else { return nil } | |
| 220 | + return inlineText(of: cell, fontSize: fontSize, baseFont: headerFont) | |
| 221 | + } | |
| 222 | + let bodyFont = ZyquoFont.body(size: fontSize) | |
| 223 | + let rows = table.body.children.compactMap { row -> [AttributedString]? in | |
| 224 | + guard let row = row as? Markdown.Table.Row else { return nil } | |
| 225 | + return row.children.compactMap { cell -> AttributedString? in | |
| 226 | + guard let cell = cell as? Markdown.Table.Cell else { return nil } | |
| 227 | + return inlineText(of: cell, fontSize: fontSize, baseFont: bodyFont) | |
| 228 | + } | |
| 229 | + } | |
| 230 | + return MarkdownBlock.TableData(alignments: alignments, header: header, rows: rows) | |
| 231 | + } | |
| 232 | + | |
| 233 | + // MARK: Inline conversion | |
| 234 | + | |
| 235 | + private static func inlineText( | |
| 236 | + of parent: Markup, | |
| 237 | + fontSize: Double, | |
| 238 | + baseFont: Font, | |
| 239 | + bold: Bool = false, | |
| 240 | + italic: Bool = false | |
| 241 | + ) -> AttributedString { | |
| 242 | + var result = AttributedString() | |
| 243 | + for child in parent.children { | |
| 244 | + result += inlineFragment(child, fontSize: fontSize, baseFont: baseFont, bold: bold, italic: italic) | |
| 245 | + } | |
| 246 | + return result | |
| 247 | + } | |
| 248 | + | |
| 249 | + private static func inlineFragment( | |
| 250 | + _ markup: Markup, | |
| 251 | + fontSize: Double, | |
| 252 | + baseFont: Font, | |
| 253 | + bold: Bool, | |
| 254 | + italic: Bool | |
| 255 | + ) -> AttributedString { | |
| 256 | + switch markup { | |
| 257 | + case let text as Markdown.Text: | |
| 258 | + return styled(text.string, baseFont: baseFont, bold: bold, italic: italic) | |
| 259 | + | |
| 260 | + case let emphasis as Emphasis: | |
| 261 | + return inlineText(of: emphasis, fontSize: fontSize, baseFont: baseFont, bold: bold, italic: true) | |
| 262 | + | |
| 263 | + case let strong as Strong: | |
| 264 | + return inlineText(of: strong, fontSize: fontSize, baseFont: baseFont, bold: true, italic: italic) | |
| 265 | + | |
| 266 | + case let code as InlineCode: | |
| 267 | + var segment = AttributedString(code.code) | |
| 268 | + segment.font = ZyquoFont.code(size: max(fontSize - 1, 1)) | |
| 269 | + segment.backgroundColor = ZyquoColor.surfaceSecondary | |
| 270 | + return segment | |
| 271 | + | |
| 272 | + case let link as Markdown.Link: | |
| 273 | + var segment = inlineText(of: link, fontSize: fontSize, baseFont: baseFont, bold: bold, italic: italic) | |
| 274 | + segment.foregroundColor = ZyquoColor.accent | |
| 275 | + if let destination = link.destination, let url = URL(string: destination) { | |
| 276 | + segment.link = url | |
| 277 | + } | |
| 278 | + return segment | |
| 279 | + | |
| 280 | + case let image as Markdown.Image: | |
| 281 | + // No inline image loading in chat transcripts: render the alt text | |
| 282 | + // (or the source) as a link to the image. | |
| 283 | + var segment = inlineText(of: image, fontSize: fontSize, baseFont: baseFont, bold: bold, italic: italic) | |
| 284 | + if segment.characters.isEmpty, let source = image.source { | |
| 285 | + segment = styled(source, baseFont: baseFont, bold: bold, italic: italic) | |
| 286 | + } | |
| 287 | + segment.foregroundColor = ZyquoColor.accent | |
| 288 | + if let source = image.source, let url = URL(string: source) { | |
| 289 | + segment.link = url | |
| 290 | + } | |
| 291 | + return segment | |
| 292 | + | |
| 293 | + case let strike as Strikethrough: | |
| 294 | + var segment = inlineText(of: strike, fontSize: fontSize, baseFont: baseFont, bold: bold, italic: italic) | |
| 295 | + segment[AttributeScopes.SwiftUIAttributes.StrikethroughStyleAttribute.self] = .single | |
| 296 | + return segment | |
| 297 | + | |
| 298 | + case is SoftBreak: | |
| 299 | + return styled(" ", baseFont: baseFont, bold: bold, italic: italic) | |
| 300 | + | |
| 301 | + case is LineBreak: | |
| 302 | + return styled("\n", baseFont: baseFont, bold: bold, italic: italic) | |
| 303 | + | |
| 304 | + case let html as InlineHTML: | |
| 305 | + return styled(html.rawHTML, baseFont: baseFont, bold: bold, italic: italic) | |
| 306 | + | |
| 307 | + default: | |
| 308 | + return styled(markup.format(), baseFont: baseFont, bold: bold, italic: italic) | |
| 309 | + } | |
| 310 | + } | |
| 311 | + | |
| 312 | + private static func styled(_ string: String, baseFont: Font, bold: Bool, italic: Bool) -> AttributedString { | |
| 313 | + var segment = AttributedString(string) | |
| 314 | + var font = baseFont | |
| 315 | + if bold { font = font.bold() } | |
| 316 | + if italic { font = font.italic() } | |
| 317 | + segment.font = font | |
| 318 | + return segment | |
| 319 | + } | |
| 320 | +} | |
| 321 | + | |
| 322 | +// MARK: - Block rendering | |
| 323 | + | |
| 324 | +private struct MarkdownBlockView: View { | |
| 325 | + let block: MarkdownBlock | |
| 326 | + let fontSize: Double | |
| 327 | + | |
| 328 | + /// Blockquote accent bar width (Phase 4 spec: 3pt accent left bar). | |
| 329 | + private static let quoteBarWidth: CGFloat = 3 | |
| 330 | + | |
| 331 | + var body: some View { | |
| 332 | + switch block.kind { | |
| 333 | + case .paragraph(let content): | |
| 334 | + SwiftUI.Text(content) | |
| 335 | + .lineSpacing(fontSize * ZyquoFont.bodyLineSpacingFactor) | |
| 336 | + .textSelection(.enabled) | |
| 337 | + .fixedSize(horizontal: false, vertical: true) | |
| 338 | + | |
| 339 | + case .heading(let content, _): | |
| 340 | + SwiftUI.Text(content) | |
| 341 | + .textSelection(.enabled) | |
| 342 | + .fixedSize(horizontal: false, vertical: true) | |
| 343 | + .padding(.top, ZyquoSpacing.xxs) | |
| 344 | + | |
| 345 | + case .code(let code, let language): | |
| 346 | + CodeBlockView(code: code, language: language, fontSize: fontSize) | |
| 347 | + | |
| 348 | + case .quote(let children): | |
| 349 | + HStack(alignment: .top, spacing: ZyquoSpacing.sm) { | |
| 350 | + RoundedRectangle(cornerRadius: Self.quoteBarWidth / 2, style: .continuous) | |
| 351 | + .fill(ZyquoColor.accent) | |
| 352 | + .frame(width: Self.quoteBarWidth) | |
| 353 | + VStack(alignment: .leading, spacing: ZyquoSpacing.xs) { | |
| 354 | + ForEach(children) { child in | |
| 355 | + MarkdownBlockView(block: child, fontSize: fontSize) | |
| 356 | + } | |
| 357 | + } | |
| 358 | + .foregroundStyle(ZyquoColor.textSecondary) | |
| 359 | + } | |
| 360 | + | |
| 361 | + case .list(let data): | |
| 362 | + listView(data) | |
| 363 | + | |
| 364 | + case .table(let data): | |
| 365 | + tableView(data) | |
| 366 | + | |
| 367 | + case .thematicBreak: | |
| 368 | + ZyquoHairline() | |
| 369 | + .padding(.vertical, ZyquoSpacing.xxs) | |
| 370 | + } | |
| 371 | + } | |
| 372 | + | |
| 373 | + // MARK: Lists | |
| 374 | + | |
| 375 | + private func listView(_ data: MarkdownBlock.ListData) -> some View { | |
| 376 | + VStack(alignment: .leading, spacing: ZyquoSpacing.xxs) { | |
| 377 | + ForEach(Array(data.items.enumerated()), id: \.element.id) { offset, item in | |
| 378 | + HStack(alignment: .firstTextBaseline, spacing: ZyquoSpacing.xs) { | |
| 379 | + marker(for: item, ordinal: data.start + offset, ordered: data.ordered) | |
| 380 | + VStack(alignment: .leading, spacing: ZyquoSpacing.xxs) { | |
| 381 | + ForEach(item.blocks) { child in | |
| 382 | + MarkdownBlockView(block: child, fontSize: fontSize) | |
| 383 | + } | |
| 384 | + } | |
| 385 | + } | |
| 386 | + } | |
| 387 | + } | |
| 388 | + } | |
| 389 | + | |
| 390 | + @ViewBuilder | |
| 391 | + private func marker(for item: MarkdownBlock.ListItemData, ordinal: Int, ordered: Bool) -> some View { | |
| 392 | + if let checked = item.checked { | |
| 393 | + Image(systemName: checked ? "checkmark.square.fill" : "square") | |
| 394 | + .font(ZyquoFont.body(size: fontSize)) | |
| 395 | + .foregroundStyle(checked ? ZyquoColor.accent : ZyquoColor.textSecondary) | |
| 396 | + } else if ordered { | |
| 397 | + SwiftUI.Text("\(ordinal).") | |
| 398 | + .font(ZyquoFont.body(size: fontSize).monospacedDigit()) | |
| 399 | + .foregroundStyle(ZyquoColor.textSecondary) | |
| 400 | + .frame(minWidth: ZyquoSpacing.lg, alignment: .trailing) | |
| 401 | + } else { | |
| 402 | + SwiftUI.Text("•") | |
| 403 | + .font(ZyquoFont.body(size: fontSize)) | |
| 404 | + .foregroundStyle(ZyquoColor.textSecondary) | |
| 405 | + } | |
| 406 | + } | |
| 407 | + | |
| 408 | + // MARK: Tables | |
| 409 | + | |
| 410 | + private func tableView(_ data: MarkdownBlock.TableData) -> some View { | |
| 411 | + Grid(alignment: .topLeading, horizontalSpacing: 0, verticalSpacing: 0) { | |
| 412 | + GridRow { | |
| 413 | + ForEach(data.header.indices, id: \.self) { column in | |
| 414 | + tableCell( | |
| 415 | + data.header[column], | |
| 416 | + data: data, | |
| 417 | + column: column, | |
| 418 | + tinted: true, | |
| 419 | + isLastRow: data.rows.isEmpty | |
| 420 | + ) | |
| 421 | + } | |
| 422 | + } | |
| 423 | + ForEach(data.rows.indices, id: \.self) { rowIndex in | |
| 424 | + GridRow { | |
| 425 | + ForEach(data.rows[rowIndex].indices, id: \.self) { column in | |
| 426 | + tableCell( | |
| 427 | + data.rows[rowIndex][column], | |
| 428 | + data: data, | |
| 429 | + column: column, | |
| 430 | + tinted: rowIndex % 2 == 1, | |
| 431 | + isLastRow: rowIndex == data.rows.count - 1 | |
| 432 | + ) | |
| 433 | + } | |
| 434 | + } | |
| 435 | + } | |
| 436 | + } | |
| 437 | + .clipShape(RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous)) | |
| 438 | + .overlay( | |
| 439 | + RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous) | |
| 440 | + .strokeBorder(ZyquoColor.border, lineWidth: ZyquoMetrics.hairline) | |
| 441 | + ) | |
| 442 | + } | |
| 443 | + | |
| 444 | + private func tableCell( | |
| 445 | + _ content: AttributedString, | |
| 446 | + data: MarkdownBlock.TableData, | |
| 447 | + column: Int, | |
| 448 | + tinted: Bool, | |
| 449 | + isLastRow: Bool | |
| 450 | + ) -> some View { | |
| 451 | + let alignment = data.alignment(forColumn: column) | |
| 452 | + let columnCount = max(data.header.count, data.rows.map(\.count).max() ?? 0) | |
| 453 | + let isLastColumn = column == columnCount - 1 | |
| 454 | + return SwiftUI.Text(content) | |
| 455 | + .multilineTextAlignment(alignment) | |
| 456 | + .textSelection(.enabled) | |
| 457 | + .fixedSize(horizontal: false, vertical: true) | |
| 458 | + .padding(.horizontal, ZyquoSpacing.sm) | |
| 459 | + .padding(.vertical, ZyquoSpacing.xs) | |
| 460 | + .frame(maxWidth: .infinity, alignment: frameAlignment(for: alignment)) | |
| 461 | + .background(tinted ? ZyquoColor.surfaceSecondary : Color.clear) | |
| 462 | + .overlay(alignment: .bottom) { | |
| 463 | + if !isLastRow { ZyquoHairline() } | |
| 464 | + } | |
| 465 | + .overlay(alignment: .trailing) { | |
| 466 | + if !isLastColumn { | |
| 467 | + Rectangle() | |
| 468 | + .fill(ZyquoColor.border) | |
| 469 | + .frame(width: ZyquoMetrics.hairline) | |
| 470 | + } | |
| 471 | + } | |
| 472 | + } | |
| 473 | + | |
| 474 | + private func frameAlignment(for alignment: TextAlignment) -> Alignment { | |
| 475 | + switch alignment { | |
| 476 | + case .leading: return .leading | |
| 477 | + case .center: return .center | |
| 478 | + case .trailing: return .trailing | |
| 479 | + } | |
| 480 | + } | |
| 481 | +} | |
added
Sources/ZyquoCloud/Views/Markdown/SyntaxHighlighter.swift
+470 −0
@@ -0,0 +1,470 @@ | ||
| 1 | +// | |
| 2 | +// SyntaxHighlighter.swift | |
| 3 | +// Zyquo Cloud | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// Lightweight scanner-based syntax highlighter for code blocks. Supports the | |
| 9 | +// Phase 4 language set (Swift, Python, JS/TS, JSON, HTML, CSS, Bash, SQL, Go, | |
| 10 | +// Rust, C/C++/Obj-C). Token colors are code-specific design tokens defined in | |
| 11 | +// `CodeTheme` with the same dynamic light/dark pattern as `ZyquoColor`, | |
| 12 | +// referencing existing semantic tokens where they fit. Unknown languages fall | |
| 13 | +// back to plain text in the base color. | |
| 14 | +// | |
| 15 | + | |
| 16 | +import SwiftUI | |
| 17 | + | |
| 18 | +// MARK: - Code theme | |
| 19 | + | |
| 20 | +/// Semantic colors for code tokens. Dynamic (light/dark) and coherent with the | |
| 21 | +/// app palette: indigo/sky family for keywords, success-green strings, tertiary | |
| 22 | +/// gray comments, amber numbers. | |
| 23 | +struct CodeTheme { | |
| 24 | + let keyword: Color | |
| 25 | + let string: Color | |
| 26 | + let comment: Color | |
| 27 | + let number: Color | |
| 28 | + let type: Color | |
| 29 | + let functionCall: Color | |
| 30 | + let property: Color | |
| 31 | + let attribute: Color | |
| 32 | + | |
| 33 | + /// The default Zyquo Cloud code theme. | |
| 34 | + static let zyquo = CodeTheme( | |
| 35 | + keyword: ZyquoColor.accent, | |
| 36 | + string: ZyquoColor.success, | |
| 37 | + comment: ZyquoColor.textTertiary, | |
| 38 | + number: dynamic(light: 0xB26A0B, dark: 0xE0A458), | |
| 39 | + type: dynamic(light: 0x2380C2, dark: 0x62B7F0), | |
| 40 | + functionCall: dynamic(light: 0x6E4FD4, dark: 0xA48CF2), | |
| 41 | + property: dynamic(light: 0x2F6FBF, dark: 0x7FB4E8), | |
| 42 | + attribute: ZyquoColor.warning | |
| 43 | + ) | |
| 44 | + | |
| 45 | + /// Same dynamic-color pattern as `ZyquoColor` (resolved per appearance). | |
| 46 | + private static func dynamic(light: UInt32, dark: UInt32) -> Color { | |
| 47 | + Color(nsColor: NSColor(name: nil) { appearance in | |
| 48 | + let hex = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua ? dark : light | |
| 49 | + return NSColor(hex: hex) | |
| 50 | + }) | |
| 51 | + } | |
| 52 | +} | |
| 53 | + | |
| 54 | +// MARK: - Highlighter | |
| 55 | + | |
| 56 | +enum SyntaxHighlighter { | |
| 57 | + /// The active code theme. | |
| 58 | + static let theme = CodeTheme.zyquo | |
| 59 | + | |
| 60 | + /// Highlights `code` for `language`, returning an `AttributedString` whose | |
| 61 | + /// text is byte-for-byte identical to the input. Plain (unclassified) text | |
| 62 | + /// is colored with `baseColor`. Unknown or nil languages return the whole | |
| 63 | + /// string in `baseColor`. Results are memoized (streaming re-renders hit | |
| 64 | + /// the cache for every already-completed block). | |
| 65 | + static func highlight(_ code: String, language: String?, baseColor: Color) -> AttributedString { | |
| 66 | + guard !code.isEmpty else { return AttributedString() } | |
| 67 | + guard let profile = profile(for: language) else { | |
| 68 | + var plain = AttributedString(code) | |
| 69 | + plain.foregroundColor = baseColor | |
| 70 | + return plain | |
| 71 | + } | |
| 72 | + | |
| 73 | + let key = CacheKey( | |
| 74 | + textHash: code.hashValue, | |
| 75 | + length: code.count, | |
| 76 | + language: language?.lowercased() ?? "", | |
| 77 | + base: String(describing: baseColor) | |
| 78 | + ) | |
| 79 | + cacheLock.lock() | |
| 80 | + if let hit = cache[key] { | |
| 81 | + cacheLock.unlock() | |
| 82 | + return hit | |
| 83 | + } | |
| 84 | + cacheLock.unlock() | |
| 85 | + | |
| 86 | + let result = tokenize(code, profile: profile, baseColor: baseColor) | |
| 87 | + | |
| 88 | + cacheLock.lock() | |
| 89 | + if cache.count > cacheCapacity { cache.removeAll(keepingCapacity: true) } | |
| 90 | + cache[key] = result | |
| 91 | + cacheLock.unlock() | |
| 92 | + return result | |
| 93 | + } | |
| 94 | + | |
| 95 | + // MARK: Cache | |
| 96 | + | |
| 97 | + private struct CacheKey: Hashable { | |
| 98 | + let textHash: Int | |
| 99 | + let length: Int | |
| 100 | + let language: String | |
| 101 | + let base: String | |
| 102 | + } | |
| 103 | + | |
| 104 | + private static let cacheLock = NSLock() | |
| 105 | + private static let cacheCapacity = 128 | |
| 106 | + private static var cache: [CacheKey: AttributedString] = [:] | |
| 107 | + | |
| 108 | + // MARK: Scanner | |
| 109 | + | |
| 110 | + private static func tokenize(_ code: String, profile: LanguageProfile, baseColor: Color) -> AttributedString { | |
| 111 | + let chars = Array(code) | |
| 112 | + var result = AttributedString() | |
| 113 | + var i = 0 | |
| 114 | + var pendingPlainStart = 0 | |
| 115 | + var previousSignificant: Character? | |
| 116 | + | |
| 117 | + func matches(_ marker: [Character], at index: Int) -> Bool { | |
| 118 | + guard index + marker.count <= chars.count else { return false } | |
| 119 | + for (offset, ch) in marker.enumerated() where chars[index + offset] != ch { | |
| 120 | + return false | |
| 121 | + } | |
| 122 | + return true | |
| 123 | + } | |
| 124 | + | |
| 125 | + func nextSignificant(after index: Int) -> Character? { | |
| 126 | + var j = index | |
| 127 | + while j < chars.count, chars[j] == " " || chars[j] == "\t" { j += 1 } | |
| 128 | + return j < chars.count ? chars[j] : nil | |
| 129 | + } | |
| 130 | + | |
| 131 | + func flushPlain(upTo end: Int) { | |
| 132 | + guard end > pendingPlainStart else { return } | |
| 133 | + var segment = AttributedString(String(chars[pendingPlainStart ..< end])) | |
| 134 | + segment.foregroundColor = baseColor | |
| 135 | + result += segment | |
| 136 | + pendingPlainStart = end | |
| 137 | + } | |
| 138 | + | |
| 139 | + func emit(_ start: Int, _ end: Int, _ color: Color) { | |
| 140 | + flushPlain(upTo: start) | |
| 141 | + var segment = AttributedString(String(chars[start ..< end])) | |
| 142 | + segment.foregroundColor = color | |
| 143 | + result += segment | |
| 144 | + pendingPlainStart = end | |
| 145 | + } | |
| 146 | + | |
| 147 | + func isIdentifierStart(_ c: Character) -> Bool { | |
| 148 | + c.isLetter || c == "_" || profile.identifierExtras.contains(c) | |
| 149 | + } | |
| 150 | + | |
| 151 | + func isIdentifierBody(_ c: Character) -> Bool { | |
| 152 | + c.isLetter || c.isNumber || c == "_" || profile.identifierExtras.contains(c) | |
| 153 | + } | |
| 154 | + | |
| 155 | + while i < chars.count { | |
| 156 | + let c = chars[i] | |
| 157 | + | |
| 158 | + // Block comments (unterminated ones run to EOF — streaming safe). | |
| 159 | + if let block = profile.blockComments.first(where: { matches($0.open, at: i) }) { | |
| 160 | + let start = i | |
| 161 | + i += block.open.count | |
| 162 | + while i < chars.count, !matches(block.close, at: i) { i += 1 } | |
| 163 | + if i < chars.count { i += block.close.count } | |
| 164 | + emit(start, i, theme.comment) | |
| 165 | + previousSignificant = nil | |
| 166 | + continue | |
| 167 | + } | |
| 168 | + | |
| 169 | + // Line comments. | |
| 170 | + if let line = profile.lineComments.first(where: { matches($0, at: i) }) { | |
| 171 | + let start = i | |
| 172 | + i += line.count | |
| 173 | + while i < chars.count, chars[i] != "\n" { i += 1 } | |
| 174 | + emit(start, i, theme.comment) | |
| 175 | + previousSignificant = nil | |
| 176 | + continue | |
| 177 | + } | |
| 178 | + | |
| 179 | + // Strings (with backslash escapes; triple quotes for Python-style). | |
| 180 | + if profile.stringDelimiters.contains(c) { | |
| 181 | + let start = i | |
| 182 | + let triple = [c, c, c] | |
| 183 | + if matches(triple, at: i) { | |
| 184 | + i += 3 | |
| 185 | + while i < chars.count, !matches(triple, at: i) { i += 1 } | |
| 186 | + if i < chars.count { i += 3 } | |
| 187 | + } else { | |
| 188 | + i += 1 | |
| 189 | + while i < chars.count { | |
| 190 | + if chars[i] == "\\" { i += 2; continue } | |
| 191 | + if chars[i] == c { i += 1; break } | |
| 192 | + i += 1 | |
| 193 | + } | |
| 194 | + i = min(i, chars.count) | |
| 195 | + } | |
| 196 | + let isKey = profile.stringKeyAsProperty && nextSignificant(after: i) == ":" | |
| 197 | + emit(start, i, isKey ? theme.property : theme.string) | |
| 198 | + previousSignificant = c | |
| 199 | + continue | |
| 200 | + } | |
| 201 | + | |
| 202 | + // Numbers (plus #hex colors for CSS). | |
| 203 | + if c.isNumber || (profile.hashIsNumberPrefix && c == "#" && i + 1 < chars.count && chars[i + 1].isHexDigit) { | |
| 204 | + let start = i | |
| 205 | + i += 1 | |
| 206 | + while i < chars.count, | |
| 207 | + chars[i].isLetter || chars[i].isNumber || chars[i] == "." || chars[i] == "_" { | |
| 208 | + i += 1 | |
| 209 | + } | |
| 210 | + emit(start, i, theme.number) | |
| 211 | + previousSignificant = chars[i - 1] | |
| 212 | + continue | |
| 213 | + } | |
| 214 | + | |
| 215 | + // Attributes / decorators / directives (@escaping, #include, $VAR…). | |
| 216 | + if profile.attributePrefixes.contains(c), i + 1 < chars.count, isIdentifierStart(chars[i + 1]) { | |
| 217 | + let start = i | |
| 218 | + i += 1 | |
| 219 | + while i < chars.count, isIdentifierBody(chars[i]) { i += 1 } | |
| 220 | + emit(start, i, theme.attribute) | |
| 221 | + previousSignificant = chars[i - 1] | |
| 222 | + continue | |
| 223 | + } | |
| 224 | + | |
| 225 | + // Identifiers: keywords, types, calls, properties. | |
| 226 | + if isIdentifierStart(c) { | |
| 227 | + let start = i | |
| 228 | + while i < chars.count, isIdentifierBody(chars[i]) { i += 1 } | |
| 229 | + let word = String(chars[start ..< i]) | |
| 230 | + let lookup = profile.caseInsensitiveKeywords ? word.lowercased() : word | |
| 231 | + let next = nextSignificant(after: i) | |
| 232 | + var color: Color? | |
| 233 | + | |
| 234 | + if profile.keywords.contains(lookup) { | |
| 235 | + color = theme.keyword | |
| 236 | + } else if profile.isMarkup { | |
| 237 | + if let prev = previousSignificant, prev == "<" || prev == "/" || prev == "!" { | |
| 238 | + color = theme.keyword // tag name | |
| 239 | + } else if next == "=" { | |
| 240 | + color = theme.property // tag attribute | |
| 241 | + } | |
| 242 | + } else if previousSignificant == "." { | |
| 243 | + color = theme.property | |
| 244 | + } else if next == "(" { | |
| 245 | + color = theme.functionCall | |
| 246 | + } else if let first = word.first, first.isUppercase { | |
| 247 | + color = theme.type | |
| 248 | + } else if profile.colonMeansProperty, next == ":" { | |
| 249 | + color = theme.property | |
| 250 | + } | |
| 251 | + | |
| 252 | + if let color { emit(start, i, color) } | |
| 253 | + previousSignificant = chars[i - 1] | |
| 254 | + continue | |
| 255 | + } | |
| 256 | + | |
| 257 | + if !c.isWhitespace { previousSignificant = c } | |
| 258 | + i += 1 | |
| 259 | + } | |
| 260 | + | |
| 261 | + flushPlain(upTo: chars.count) | |
| 262 | + return result | |
| 263 | + } | |
| 264 | + | |
| 265 | + // MARK: Language profiles | |
| 266 | + | |
| 267 | + private struct LanguageProfile { | |
| 268 | + var keywords: Set<String> = [] | |
| 269 | + var lineComments: [[Character]] = [] | |
| 270 | + var blockComments: [(open: [Character], close: [Character])] = [] | |
| 271 | + var stringDelimiters: Set<Character> = ["\""] | |
| 272 | + var identifierExtras: Set<Character> = [] | |
| 273 | + var attributePrefixes: Set<Character> = [] | |
| 274 | + var caseInsensitiveKeywords = false | |
| 275 | + var colonMeansProperty = false | |
| 276 | + var hashIsNumberPrefix = false | |
| 277 | + var stringKeyAsProperty = false | |
| 278 | + var isMarkup = false | |
| 279 | + } | |
| 280 | + | |
| 281 | + private static func profile(for language: String?) -> LanguageProfile? { | |
| 282 | + guard let language else { return nil } | |
| 283 | + let normalized = language.trimmingCharacters(in: .whitespaces).lowercased() | |
| 284 | + return profiles[normalized] | |
| 285 | + } | |
| 286 | + | |
| 287 | + private static let profiles: [String: LanguageProfile] = { | |
| 288 | + let slashLine: [[Character]] = [Array("//")] | |
| 289 | + let cBlock: [(open: [Character], close: [Character])] = [(Array("/*"), Array("*/"))] | |
| 290 | + | |
| 291 | + var table: [String: LanguageProfile] = [:] | |
| 292 | + | |
| 293 | + let swift = LanguageProfile( | |
| 294 | + keywords: [ | |
| 295 | + "func", "let", "var", "if", "else", "guard", "switch", "case", "default", | |
| 296 | + "for", "while", "repeat", "in", "return", "import", "struct", "class", | |
| 297 | + "enum", "protocol", "extension", "where", "as", "is", "try", "catch", | |
| 298 | + "throw", "throws", "rethrows", "async", "await", "actor", "init", "deinit", | |
| 299 | + "self", "Self", "super", "nil", "true", "false", "public", "private", | |
| 300 | + "internal", "fileprivate", "open", "static", "final", "lazy", "weak", | |
| 301 | + "unowned", "mutating", "nonmutating", "override", "defer", "typealias", | |
| 302 | + "associatedtype", "some", "any", "break", "continue", "fallthrough", "do", | |
| 303 | + "get", "set", "willSet", "didSet", "inout", "subscript", "operator", | |
| 304 | + "indirect", "convenience", "required", "optional", "dynamic", | |
| 305 | + ], | |
| 306 | + lineComments: slashLine, | |
| 307 | + blockComments: cBlock, | |
| 308 | + attributePrefixes: ["@", "#"] | |
| 309 | + ) | |
| 310 | + table["swift"] = swift | |
| 311 | + | |
| 312 | + let python = LanguageProfile( | |
| 313 | + keywords: [ | |
| 314 | + "def", "class", "if", "elif", "else", "for", "while", "in", "return", | |
| 315 | + "import", "from", "as", "with", "try", "except", "finally", "raise", | |
| 316 | + "lambda", "pass", "break", "continue", "global", "nonlocal", "yield", | |
| 317 | + "assert", "del", "not", "and", "or", "is", "None", "True", "False", | |
| 318 | + "async", "await", "match", "case", "self", | |
| 319 | + ], | |
| 320 | + lineComments: [Array("#")], | |
| 321 | + stringDelimiters: ["\"", "'"], | |
| 322 | + attributePrefixes: ["@"] | |
| 323 | + ) | |
| 324 | + for alias in ["python", "py", "python3"] { table[alias] = python } | |
| 325 | + | |
| 326 | + let jsTs = LanguageProfile( | |
| 327 | + keywords: [ | |
| 328 | + "function", "const", "let", "var", "if", "else", "for", "while", "do", | |
| 329 | + "switch", "case", "default", "return", "break", "continue", "new", | |
| 330 | + "delete", "typeof", "instanceof", "in", "of", "class", "extends", | |
| 331 | + "super", "this", "import", "export", "from", "as", "async", "await", | |
| 332 | + "yield", "try", "catch", "finally", "throw", "void", "null", "undefined", | |
| 333 | + "true", "false", "static", "get", "set", "interface", "type", "enum", | |
| 334 | + "implements", "declare", "readonly", "namespace", "public", "private", | |
| 335 | + "protected", "abstract", "satisfies", "keyof", "infer", "never", | |
| 336 | + "unknown", "any", "string", "number", "boolean", "object", "symbol", | |
| 337 | + "bigint", | |
| 338 | + ], | |
| 339 | + lineComments: slashLine, | |
| 340 | + blockComments: cBlock, | |
| 341 | + stringDelimiters: ["\"", "'", "`"], | |
| 342 | + identifierExtras: ["$"], | |
| 343 | + attributePrefixes: ["@"] | |
| 344 | + ) | |
| 345 | + for alias in ["javascript", "js", "jsx", "typescript", "ts", "tsx"] { table[alias] = jsTs } | |
| 346 | + | |
| 347 | + let json = LanguageProfile( | |
| 348 | + keywords: ["true", "false", "null"], | |
| 349 | + lineComments: slashLine, | |
| 350 | + blockComments: cBlock, | |
| 351 | + stringKeyAsProperty: true | |
| 352 | + ) | |
| 353 | + table["json"] = json | |
| 354 | + table["jsonc"] = json | |
| 355 | + | |
| 356 | + let html = LanguageProfile( | |
| 357 | + blockComments: [(Array("<!--"), Array("-->"))], | |
| 358 | + stringDelimiters: ["\"", "'"], | |
| 359 | + identifierExtras: ["-"], | |
| 360 | + isMarkup: true | |
| 361 | + ) | |
| 362 | + for alias in ["html", "xml", "svg", "xhtml"] { table[alias] = html } | |
| 363 | + | |
| 364 | + let css = LanguageProfile( | |
| 365 | + keywords: ["important", "inherit", "initial", "unset", "auto", "none", "revert"], | |
| 366 | + blockComments: cBlock, | |
| 367 | + stringDelimiters: ["\"", "'"], | |
| 368 | + identifierExtras: ["-"], | |
| 369 | + attributePrefixes: ["@"], | |
| 370 | + colonMeansProperty: true, | |
| 371 | + hashIsNumberPrefix: true | |
| 372 | + ) | |
| 373 | + for alias in ["css", "scss", "less"] { table[alias] = css } | |
| 374 | + | |
| 375 | + let bash = LanguageProfile( | |
| 376 | + keywords: [ | |
| 377 | + "if", "then", "else", "elif", "fi", "for", "while", "until", "do", | |
| 378 | + "done", "case", "esac", "function", "in", "select", "time", "coproc", | |
| 379 | + "echo", "cd", "export", "local", "return", "exit", "read", "set", | |
| 380 | + "unset", "shift", "source", "alias", "eval", "exec", "printf", "test", | |
| 381 | + "true", "false", "sudo", "trap", "declare", | |
| 382 | + ], | |
| 383 | + lineComments: [Array("#")], | |
| 384 | + stringDelimiters: ["\"", "'"], | |
| 385 | + identifierExtras: ["-"], | |
| 386 | + attributePrefixes: ["$"] | |
| 387 | + ) | |
| 388 | + for alias in ["bash", "sh", "zsh", "shell", "console"] { table[alias] = bash } | |
| 389 | + | |
| 390 | + let sql = LanguageProfile( | |
| 391 | + keywords: [ | |
| 392 | + "select", "from", "where", "insert", "into", "values", "update", | |
| 393 | + "delete", "set", "create", "table", "drop", "alter", "index", "view", | |
| 394 | + "join", "inner", "left", "right", "outer", "full", "cross", "on", "as", | |
| 395 | + "and", "or", "not", "null", "primary", "key", "foreign", "references", | |
| 396 | + "group", "by", "order", "having", "limit", "offset", "distinct", | |
| 397 | + "union", "all", "exists", "between", "like", "in", "is", "case", | |
| 398 | + "when", "then", "else", "end", "count", "sum", "avg", "min", "max", | |
| 399 | + "desc", "asc", "with", "constraint", "unique", "default", "begin", | |
| 400 | + "commit", "rollback", "transaction", | |
| 401 | + ], | |
| 402 | + lineComments: [Array("--")], | |
| 403 | + blockComments: cBlock, | |
| 404 | + stringDelimiters: ["'", "\""], | |
| 405 | + caseInsensitiveKeywords: true | |
| 406 | + ) | |
| 407 | + table["sql"] = sql | |
| 408 | + | |
| 409 | + let go = LanguageProfile( | |
| 410 | + keywords: [ | |
| 411 | + "func", "package", "import", "var", "const", "type", "struct", | |
| 412 | + "interface", "map", "chan", "go", "defer", "if", "else", "for", | |
| 413 | + "range", "switch", "case", "default", "return", "break", "continue", | |
| 414 | + "fallthrough", "select", "goto", "true", "false", "nil", "iota", | |
| 415 | + "make", "new", "len", "cap", "append", "copy", "delete", "panic", | |
| 416 | + "recover", "error", "string", "int", "int8", "int16", "int32", "int64", | |
| 417 | + "uint", "uint8", "uint16", "uint32", "uint64", "bool", "byte", "rune", | |
| 418 | + "float32", "float64", "complex64", "complex128", "any", | |
| 419 | + ], | |
| 420 | + lineComments: slashLine, | |
| 421 | + blockComments: cBlock, | |
| 422 | + stringDelimiters: ["\"", "'", "`"] | |
| 423 | + ) | |
| 424 | + table["go"] = go | |
| 425 | + table["golang"] = go | |
| 426 | + | |
| 427 | + let rust = LanguageProfile( | |
| 428 | + keywords: [ | |
| 429 | + "fn", "let", "mut", "const", "static", "if", "else", "match", "for", | |
| 430 | + "while", "loop", "in", "return", "break", "continue", "struct", "enum", | |
| 431 | + "trait", "impl", "pub", "use", "mod", "crate", "self", "Self", "super", | |
| 432 | + "where", "as", "ref", "move", "async", "await", "dyn", "unsafe", | |
| 433 | + "extern", "type", "true", "false", "Some", "None", "Ok", "Err", | |
| 434 | + "String", "str", "i8", "i16", "i32", "i64", "i128", "u8", "u16", "u32", | |
| 435 | + "u64", "u128", "f32", "f64", "usize", "isize", "bool", "char", "Box", | |
| 436 | + "Vec", "Option", "Result", | |
| 437 | + ], | |
| 438 | + lineComments: slashLine, | |
| 439 | + blockComments: cBlock, | |
| 440 | + attributePrefixes: ["#"] | |
| 441 | + ) | |
| 442 | + table["rust"] = rust | |
| 443 | + table["rs"] = rust | |
| 444 | + | |
| 445 | + let cFamily = LanguageProfile( | |
| 446 | + keywords: [ | |
| 447 | + "int", "char", "float", "double", "void", "long", "short", "signed", | |
| 448 | + "unsigned", "if", "else", "for", "while", "do", "switch", "case", | |
| 449 | + "default", "return", "break", "continue", "struct", "union", "enum", | |
| 450 | + "typedef", "const", "static", "extern", "inline", "sizeof", "goto", | |
| 451 | + "volatile", "register", "auto", "bool", "true", "false", "class", | |
| 452 | + "public", "private", "protected", "virtual", "override", "final", | |
| 453 | + "template", "typename", "namespace", "using", "new", "delete", "this", | |
| 454 | + "nullptr", "try", "catch", "throw", "constexpr", "noexcept", "friend", | |
| 455 | + "operator", "explicit", "mutable", "id", "instancetype", "nonatomic", | |
| 456 | + "strong", "weak", "copy", "readonly", "readwrite", "assign", "nil", | |
| 457 | + "YES", "NO", | |
| 458 | + ], | |
| 459 | + lineComments: slashLine, | |
| 460 | + blockComments: cBlock, | |
| 461 | + stringDelimiters: ["\"", "'"], | |
| 462 | + attributePrefixes: ["@", "#"] | |
| 463 | + ) | |
| 464 | + for alias in ["c", "cpp", "c++", "cc", "cxx", "h", "hpp", "objc", "objective-c", "objectivec", "m", "mm"] { | |
| 465 | + table[alias] = cFamily | |
| 466 | + } | |
| 467 | + | |
| 468 | + return table | |
| 469 | + }() | |
| 470 | +} | |
added
Sources/ZyquoCloud/Views/QuickChat/QuickChatPanel.swift
+233 −0
@@ -0,0 +1,233 @@ | ||
| 1 | +// | |
| 2 | +// QuickChatPanel.swift | |
| 3 | +// Zyquo Cloud | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// Global Quick Chat (⌥Space): floating Spotlight-style panel — 640pt wide, | |
| 9 | +// radius 14, prominent shadow, single input + model chip; the answer expands | |
| 10 | +// below; ESC dismisses. Appears on the active screen at 30% height. | |
| 11 | +// | |
| 12 | + | |
| 13 | +import SwiftUI | |
| 14 | + | |
| 15 | +/// Manages the floating NSPanel hosting QuickChatView and the global hotkey. | |
| 16 | +@MainActor | |
| 17 | +final class QuickChatController { | |
| 18 | + private var panel: NSPanel? | |
| 19 | + private var hotKeyMonitor: Any? | |
| 20 | + private let environment: AppEnvironment | |
| 21 | + | |
| 22 | + init(environment: AppEnvironment) { | |
| 23 | + self.environment = environment | |
| 24 | + installHotKey() | |
| 25 | + } | |
| 26 | + | |
| 27 | + private func installHotKey() { | |
| 28 | + // ⌥Space, global. Requires no accessibility permission for local | |
| 29 | + // monitor; global monitor works while other apps are frontmost. | |
| 30 | + hotKeyMonitor = NSEvent.addGlobalMonitorForEvents(matching: .keyDown) { [weak self] event in | |
| 31 | + guard event.keyCode == 49, event.modifierFlags.contains(.option) else { return } | |
| 32 | + Task { @MainActor in self?.toggle() } | |
| 33 | + } | |
| 34 | + NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in | |
| 35 | + if event.keyCode == 49, event.modifierFlags.contains(.option) { | |
| 36 | + Task { @MainActor in self?.toggle() } | |
| 37 | + return nil | |
| 38 | + } | |
| 39 | + return event | |
| 40 | + } | |
| 41 | + } | |
| 42 | + | |
| 43 | + func toggle() { | |
| 44 | + if let panel, panel.isVisible { | |
| 45 | + panel.orderOut(nil) | |
| 46 | + return | |
| 47 | + } | |
| 48 | + show() | |
| 49 | + } | |
| 50 | + | |
| 51 | + func show() { | |
| 52 | + let panel = self.panel ?? makePanel() | |
| 53 | + self.panel = panel | |
| 54 | + positionOnActiveScreen(panel) | |
| 55 | + panel.makeKeyAndOrderFront(nil) | |
| 56 | + NSApp.activate(ignoringOtherApps: true) | |
| 57 | + } | |
| 58 | + | |
| 59 | + private func makePanel() -> NSPanel { | |
| 60 | + let hosting = NSHostingView( | |
| 61 | + rootView: QuickChatView(onDismiss: { [weak self] in self?.panel?.orderOut(nil) }) | |
| 62 | + .environmentObject(environment.catalog) | |
| 63 | + .environmentObject(environment.vault) | |
| 64 | + .environmentObject(environment.appearance) | |
| 65 | + .environmentObject(environment.conversations) | |
| 66 | + ) | |
| 67 | + let panel = KeyablePanel( | |
| 68 | + contentRect: NSRect(x: 0, y: 0, width: ZyquoMetrics.quickChatWidth, height: 120), | |
| 69 | + styleMask: [.nonactivatingPanel, .fullSizeContentView, .titled], | |
| 70 | + backing: .buffered, | |
| 71 | + defer: false | |
| 72 | + ) | |
| 73 | + panel.titleVisibility = .hidden | |
| 74 | + panel.titlebarAppearsTransparent = true | |
| 75 | + panel.isMovableByWindowBackground = true | |
| 76 | + panel.level = .floating | |
| 77 | + panel.collectionBehavior = [.canJoinAllSpaces, .transient] | |
| 78 | + panel.isOpaque = false | |
| 79 | + panel.backgroundColor = .clear | |
| 80 | + panel.hidesOnDeactivate = false | |
| 81 | + panel.contentView = hosting | |
| 82 | + return panel | |
| 83 | + } | |
| 84 | + | |
| 85 | + private func positionOnActiveScreen(_ panel: NSPanel) { | |
| 86 | + let screen = NSScreen.main ?? NSScreen.screens[0] | |
| 87 | + let frame = screen.visibleFrame | |
| 88 | + let size = panel.frame.size | |
| 89 | + let x = frame.midX - size.width / 2 | |
| 90 | + let y = frame.maxY - frame.height * 0.30 - size.height | |
| 91 | + panel.setFrameOrigin(NSPoint(x: x, y: y)) | |
| 92 | + } | |
| 93 | +} | |
| 94 | + | |
| 95 | +/// NSPanel subclass that can become key despite .nonactivatingPanel. | |
| 96 | +final class KeyablePanel: NSPanel { | |
| 97 | + override var canBecomeKey: Bool { true } | |
| 98 | + override func cancelOperation(_ sender: Any?) { | |
| 99 | + orderOut(nil) | |
| 100 | + } | |
| 101 | +} | |
| 102 | + | |
| 103 | +struct QuickChatView: View { | |
| 104 | + var onDismiss: () -> Void | |
| 105 | + | |
| 106 | + @EnvironmentObject private var catalog: ModelCatalog | |
| 107 | + @EnvironmentObject private var vault: KeyVaultStore | |
| 108 | + @EnvironmentObject private var appearance: AppearanceStore | |
| 109 | + @EnvironmentObject private var conversations: ConversationStore | |
| 110 | + | |
| 111 | + @State private var input = "" | |
| 112 | + @State private var answer = "" | |
| 113 | + @State private var reasoning = "" | |
| 114 | + @State private var isStreaming = false | |
| 115 | + @State private var errorText: String? | |
| 116 | + @State private var model: AIModel? | |
| 117 | + @State private var streamTask: Task<Void, Never>? | |
| 118 | + @FocusState private var focused: Bool | |
| 119 | + | |
| 120 | + var body: some View { | |
| 121 | + VStack(spacing: 0) { | |
| 122 | + HStack(spacing: ZyquoSpacing.sm) { | |
| 123 | + CloudZGlyph(size: 24) | |
| 124 | + TextField("Ask anything…", text: $input) | |
| 125 | + .textFieldStyle(.plain) | |
| 126 | + .font(ZyquoFont.body(size: 16)) | |
| 127 | + .focused($focused) | |
| 128 | + .onSubmit(ask) | |
| 129 | + ModelChipView(model: model ?? catalog.defaultModel) { chosen in | |
| 130 | + model = chosen | |
| 131 | + } | |
| 132 | + if isStreaming { | |
| 133 | + Button { | |
| 134 | + streamTask?.cancel() | |
| 135 | + } label: { | |
| 136 | + Image(systemName: "stop.fill") | |
| 137 | + .foregroundStyle(ZyquoColor.danger) | |
| 138 | + } | |
| 139 | + .buttonStyle(.plain) | |
| 140 | + } | |
| 141 | + } | |
| 142 | + .padding(ZyquoSpacing.md) | |
| 143 | + | |
| 144 | + if !answer.isEmpty || isStreaming || errorText != nil { | |
| 145 | + ZyquoHairline() | |
| 146 | + ScrollView { | |
| 147 | + VStack(alignment: .leading, spacing: ZyquoSpacing.xs) { | |
| 148 | + if let errorText { | |
| 149 | + Text(errorText) | |
| 150 | + .font(ZyquoFont.body()) | |
| 151 | + .foregroundStyle(ZyquoColor.danger) | |
| 152 | + } else { | |
| 153 | + MarkdownView(text: answer, fontSize: appearance.chatFontSize) | |
| 154 | + if isStreaming { StreamingCaret() } | |
| 155 | + } | |
| 156 | + } | |
| 157 | + .frame(maxWidth: .infinity, alignment: .leading) | |
| 158 | + .padding(ZyquoSpacing.md) | |
| 159 | + } | |
| 160 | + .frame(maxHeight: 360) | |
| 161 | + HStack { | |
| 162 | + Spacer() | |
| 163 | + Button("Continue in Zyquo Cloud") { continueInApp() } | |
| 164 | + .controlSize(.small) | |
| 165 | + .disabled(answer.isEmpty) | |
| 166 | + } | |
| 167 | + .padding(.horizontal, ZyquoSpacing.md) | |
| 168 | + .padding(.bottom, ZyquoSpacing.xs) | |
| 169 | + } | |
| 170 | + } | |
| 171 | + .frame(width: ZyquoMetrics.quickChatWidth) | |
| 172 | + .background( | |
| 173 | + RoundedRectangle(cornerRadius: ZyquoRadius.large, style: .continuous) | |
| 174 | + .fill(ZyquoColor.surface) | |
| 175 | + ) | |
| 176 | + .overlay( | |
| 177 | + RoundedRectangle(cornerRadius: ZyquoRadius.large, style: .continuous) | |
| 178 | + .strokeBorder(ZyquoColor.border, lineWidth: ZyquoMetrics.hairline) | |
| 179 | + ) | |
| 180 | + .zyquoSoftShadow() | |
| 181 | + .onAppear { focused = true } | |
| 182 | + .onExitCommand { onDismiss() } | |
| 183 | + } | |
| 184 | + | |
| 185 | + private func ask() { | |
| 186 | + let question = input.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 187 | + guard !question.isEmpty, !isStreaming else { return } | |
| 188 | + guard let target = model ?? catalog.defaultModel else { return } | |
| 189 | + answer = "" | |
| 190 | + reasoning = "" | |
| 191 | + errorText = nil | |
| 192 | + isStreaming = true | |
| 193 | + streamTask = Task { | |
| 194 | + do { | |
| 195 | + let key = try vault.apiKey(for: target.provider) | |
| 196 | + let client = ProviderRegistry.client(for: target) | |
| 197 | + let request = ChatRequest( | |
| 198 | + model: target, | |
| 199 | + systemPrompt: "Answer concisely.", | |
| 200 | + messages: [Message(role: .user, text: question)], | |
| 201 | + parameters: ChatParameters() | |
| 202 | + ) | |
| 203 | + for try await event in client.streamChat(request, apiKey: key) { | |
| 204 | + if Task.isCancelled { break } | |
| 205 | + switch event { | |
| 206 | + case .textDelta(let delta): answer += delta | |
| 207 | + case .reasoningDelta(let delta): reasoning += delta | |
| 208 | + default: break | |
| 209 | + } | |
| 210 | + } | |
| 211 | + } catch { | |
| 212 | + errorText = error.localizedDescription | |
| 213 | + } | |
| 214 | + isStreaming = false | |
| 215 | + } | |
| 216 | + } | |
| 217 | + | |
| 218 | + /// Moves the exchange into a full conversation in the main window. | |
| 219 | + private func continueInApp() { | |
| 220 | + let conversation = conversations.newConversation(model: model ?? catalog.defaultModel) | |
| 221 | + guard var updated = conversations.conversations.first(where: { $0.id == conversation.id }) else { return } | |
| 222 | + var question = Message(role: .user, text: input) | |
| 223 | + question.provider = updated.provider | |
| 224 | + question.modelID = updated.modelID | |
| 225 | + var reply = Message(role: .assistant, text: answer, reasoning: reasoning.isEmpty ? nil : reasoning) | |
| 226 | + reply.provider = updated.provider | |
| 227 | + reply.modelID = updated.modelID | |
| 228 | + updated.messages = [question, reply] | |
| 229 | + conversations.update(updated) | |
| 230 | + onDismiss() | |
| 231 | + NSApp.activate(ignoringOtherApps: true) | |
| 232 | + } | |
| 233 | +} | |
added
Sources/ZyquoCloud/Views/Settings/SettingsView.swift
+457 −0
@@ -0,0 +1,457 @@ | ||
| 1 | +// | |
| 2 | +// SettingsView.swift | |
| 3 | +// Zyquo Cloud | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// Settings window (720×520, toolbar-style tabs): Providers & Keys, Models, | |
| 9 | +// Appearance, Shortcuts, Advanced. | |
| 10 | +// | |
| 11 | + | |
| 12 | +import SwiftUI | |
| 13 | + | |
| 14 | +struct SettingsView: View { | |
| 15 | + var body: some View { | |
| 16 | + TabView { | |
| 17 | + ProvidersSettingsTab() | |
| 18 | + .tabItem { Label("Providers & Keys", systemImage: "key") } | |
| 19 | + ModelsSettingsTab() | |
| 20 | + .tabItem { Label("Models", systemImage: "cpu") } | |
| 21 | + AppearanceSettingsTab() | |
| 22 | + .tabItem { Label("Appearance", systemImage: "paintbrush") } | |
| 23 | + ShortcutsSettingsTab() | |
| 24 | + .tabItem { Label("Shortcuts", systemImage: "keyboard") } | |
| 25 | + AdvancedSettingsTab() | |
| 26 | + .tabItem { Label("Advanced", systemImage: "gearshape.2") } | |
| 27 | + } | |
| 28 | + .frame(width: ZyquoMetrics.settingsWidth, height: ZyquoMetrics.settingsHeight) | |
| 29 | + } | |
| 30 | +} | |
| 31 | + | |
| 32 | +// MARK: - Providers & Keys | |
| 33 | + | |
| 34 | +struct ProvidersSettingsTab: View { | |
| 35 | + @EnvironmentObject private var vault: KeyVaultStore | |
| 36 | + @EnvironmentObject private var catalog: ModelCatalog | |
| 37 | + @State private var draftKeys: [ProviderID: String] = [:] | |
| 38 | + | |
| 39 | + var body: some View { | |
| 40 | + ScrollView { | |
| 41 | + VStack(spacing: ZyquoSpacing.xs) { | |
| 42 | + ForEach(ProviderID.builtIn) { provider in | |
| 43 | + providerRow(provider) | |
| 44 | + if provider != ProviderID.builtIn.last { ZyquoHairline() } | |
| 45 | + } | |
| 46 | + } | |
| 47 | + .padding(ZyquoMetrics.contentInset) | |
| 48 | + } | |
| 49 | + .background(ZyquoColor.background) | |
| 50 | + } | |
| 51 | + | |
| 52 | + private func providerRow(_ provider: ProviderID) -> some View { | |
| 53 | + HStack(spacing: ZyquoSpacing.sm) { | |
| 54 | + Image(systemName: provider.symbolName) | |
| 55 | + .font(.system(size: 14)) | |
| 56 | + .foregroundStyle(ZyquoColor.accent) | |
| 57 | + .frame(width: 22) | |
| 58 | + VStack(alignment: .leading, spacing: 1) { | |
| 59 | + HStack(spacing: ZyquoSpacing.xxs) { | |
| 60 | + Text(provider.displayName) | |
| 61 | + .font(ZyquoFont.bodyEmphasis(size: 13)) | |
| 62 | + .foregroundStyle(ZyquoColor.textPrimary) | |
| 63 | + statusIndicator(provider) | |
| 64 | + } | |
| 65 | + statusDetail(provider) | |
| 66 | + } | |
| 67 | + Spacer() | |
| 68 | + keyField(provider) | |
| 69 | + testButton(provider) | |
| 70 | + if vault.hasKey(for: provider) { | |
| 71 | + Button { | |
| 72 | + vault.deleteKey(for: provider) | |
| 73 | + } label: { | |
| 74 | + Image(systemName: "trash") | |
| 75 | + .font(.system(size: 11)) | |
| 76 | + .foregroundStyle(ZyquoColor.danger) | |
| 77 | + } | |
| 78 | + .buttonStyle(.plain) | |
| 79 | + .help("Delete key") | |
| 80 | + } | |
| 81 | + } | |
| 82 | + .padding(.vertical, ZyquoSpacing.xxs) | |
| 83 | + } | |
| 84 | + | |
| 85 | + @ViewBuilder | |
| 86 | + private func statusIndicator(_ provider: ProviderID) -> some View { | |
| 87 | + switch vault.statuses[provider] ?? .unset { | |
| 88 | + case .unset: StatusDot(status: .unset) | |
| 89 | + case .saved: StatusDot(status: .unset).overlay(Circle().strokeBorder(ZyquoColor.textSecondary, lineWidth: 1)) | |
| 90 | + case .testing: ProgressView().controlSize(.mini) | |
| 91 | + case .verified: StatusDot(status: .verified) | |
| 92 | + case .failed: StatusDot(status: .failed) | |
| 93 | + } | |
| 94 | + } | |
| 95 | + | |
| 96 | + @ViewBuilder | |
| 97 | + private func statusDetail(_ provider: ProviderID) -> some View { | |
| 98 | + switch vault.statuses[provider] ?? .unset { | |
| 99 | + case .verified(let latency): | |
| 100 | + Text(String(format: "Verified · %.0f ms", latency * 1000)) | |
| 101 | + .font(ZyquoFont.caption) | |
| 102 | + .foregroundStyle(ZyquoColor.success) | |
| 103 | + case .failed(let message): | |
| 104 | + Text(message) | |
| 105 | + .font(ZyquoFont.caption) | |
| 106 | + .foregroundStyle(ZyquoColor.danger) | |
| 107 | + .lineLimit(1) | |
| 108 | + .help(message) | |
| 109 | + case .saved: | |
| 110 | + Text(vault.redactedKeys[provider] ?? "") | |
| 111 | + .font(ZyquoFont.caption) | |
| 112 | + .foregroundStyle(ZyquoColor.textTertiary) | |
| 113 | + default: | |
| 114 | + Text("No key") | |
| 115 | + .font(ZyquoFont.caption) | |
| 116 | + .foregroundStyle(ZyquoColor.textTertiary) | |
| 117 | + } | |
| 118 | + } | |
| 119 | + | |
| 120 | + private func keyField(_ provider: ProviderID) -> some View { | |
| 121 | + SecureField( | |
| 122 | + vault.hasKey(for: provider) ? (vault.redactedKeys[provider] ?? "") : "API key", | |
| 123 | + text: Binding( | |
| 124 | + get: { draftKeys[provider] ?? "" }, | |
| 125 | + set: { draftKeys[provider] = $0 } | |
| 126 | + ) | |
| 127 | + ) | |
| 128 | + .textFieldStyle(.roundedBorder) | |
| 129 | + .font(ZyquoFont.code(size: 11)) | |
| 130 | + .frame(width: 220) | |
| 131 | + .onSubmit { saveDraft(provider) } | |
| 132 | + } | |
| 133 | + | |
| 134 | + private func testButton(_ provider: ProviderID) -> some View { | |
| 135 | + Button("Test") { | |
| 136 | + saveDraft(provider) | |
| 137 | + Task { await vault.testKey(for: provider, catalog: catalog) } | |
| 138 | + } | |
| 139 | + .controlSize(.small) | |
| 140 | + .disabled(!vault.hasKey(for: provider) && (draftKeys[provider] ?? "").isEmpty) | |
| 141 | + } | |
| 142 | + | |
| 143 | + private func saveDraft(_ provider: ProviderID) { | |
| 144 | + if let draft = draftKeys[provider], !draft.trimmingCharacters(in: .whitespaces).isEmpty { | |
| 145 | + vault.setKey(draft, for: provider) | |
| 146 | + draftKeys[provider] = "" | |
| 147 | + } | |
| 148 | + } | |
| 149 | +} | |
| 150 | + | |
| 151 | +// MARK: - Models | |
| 152 | + | |
| 153 | +struct ModelsSettingsTab: View { | |
| 154 | + @EnvironmentObject private var catalog: ModelCatalog | |
| 155 | + @EnvironmentObject private var vault: KeyVaultStore | |
| 156 | + @State private var selectedProvider: ProviderID = .openai | |
| 157 | + @State private var refreshing = false | |
| 158 | + @State private var refreshResult: String? | |
| 159 | + @State private var showingCustomModelSheet = false | |
| 160 | + | |
| 161 | + var body: some View { | |
| 162 | + VStack(spacing: 0) { | |
| 163 | + HStack { | |
| 164 | + Picker("Provider", selection: $selectedProvider) { | |
| 165 | + ForEach(ProviderID.builtIn) { provider in | |
| 166 | + Text(provider.displayName).tag(provider) | |
| 167 | + } | |
| 168 | + } | |
| 169 | + .frame(width: 240) | |
| 170 | + Spacer() | |
| 171 | + if let result = refreshResult { | |
| 172 | + Text(result) | |
| 173 | + .font(ZyquoFont.caption) | |
| 174 | + .foregroundStyle(ZyquoColor.textSecondary) | |
| 175 | + } | |
| 176 | + Button { | |
| 177 | + refreshModels() | |
| 178 | + } label: { | |
| 179 | + if refreshing { | |
| 180 | + ProgressView().controlSize(.small) | |
| 181 | + } else { | |
| 182 | + Label("Refresh from API", systemImage: "arrow.clockwise") | |
| 183 | + } | |
| 184 | + } | |
| 185 | + .controlSize(.small) | |
| 186 | + .disabled(refreshing || !selectedProvider.supportsModelListing || !vault.hasKey(for: selectedProvider)) | |
| 187 | + Button { | |
| 188 | + showingCustomModelSheet = true | |
| 189 | + } label: { | |
| 190 | + Label("Add Custom", systemImage: "plus") | |
| 191 | + } | |
| 192 | + .controlSize(.small) | |
| 193 | + } | |
| 194 | + .padding(ZyquoSpacing.sm) | |
| 195 | + ZyquoHairline() | |
| 196 | + modelTable | |
| 197 | + } | |
| 198 | + .background(ZyquoColor.background) | |
| 199 | + .sheet(isPresented: $showingCustomModelSheet) { | |
| 200 | + CustomModelSheet() | |
| 201 | + } | |
| 202 | + } | |
| 203 | + | |
| 204 | + private var modelTable: some View { | |
| 205 | + ScrollView { | |
| 206 | + LazyVStack(spacing: 1) { | |
| 207 | + ForEach(catalog.models(for: selectedProvider)) { model in | |
| 208 | + modelRow(model) | |
| 209 | + } | |
| 210 | + let unknown = catalog.unknownLiveIDs(for: selectedProvider) | |
| 211 | + if !unknown.isEmpty { | |
| 212 | + Text("Live on the API but not in the catalog: \(unknown.joined(separator: ", "))") | |
| 213 | + .font(ZyquoFont.caption) | |
| 214 | + .foregroundStyle(ZyquoColor.textTertiary) | |
| 215 | + .frame(maxWidth: .infinity, alignment: .leading) | |
| 216 | + .padding(ZyquoSpacing.sm) | |
| 217 | + } | |
| 218 | + } | |
| 219 | + .padding(ZyquoSpacing.sm) | |
| 220 | + } | |
| 221 | + } | |
| 222 | + | |
| 223 | + private func modelRow(_ model: AIModel) -> some View { | |
| 224 | + HStack(spacing: ZyquoSpacing.xs) { | |
| 225 | + Button { | |
| 226 | + if catalog.favoriteIDs.contains(model.id) { | |
| 227 | + catalog.favoriteIDs.remove(model.id) | |
| 228 | + } else { | |
| 229 | + catalog.favoriteIDs.insert(model.id) | |
| 230 | + } | |
| 231 | + } label: { | |
| 232 | + Image(systemName: catalog.favoriteIDs.contains(model.id) ? "star.fill" : "star") | |
| 233 | + .font(.system(size: 10)) | |
| 234 | + .foregroundStyle(catalog.favoriteIDs.contains(model.id) ? ZyquoColor.warning : ZyquoColor.textTertiary) | |
| 235 | + } | |
| 236 | + .buttonStyle(.plain) | |
| 237 | + VStack(alignment: .leading, spacing: 0) { | |
| 238 | + HStack(spacing: ZyquoSpacing.xxs) { | |
| 239 | + Text(model.displayName) | |
| 240 | + .font(ZyquoFont.body(size: 12.5)) | |
| 241 | + .foregroundStyle(ZyquoColor.textPrimary) | |
| 242 | + if model.isRecommended { ZyquoBadge(text: "Featured", color: ZyquoColor.accent) } | |
| 243 | + if model.isLegacy { ZyquoBadge(text: "Legacy") } | |
| 244 | + } | |
| 245 | + Text(model.id) | |
| 246 | + .font(ZyquoFont.code(size: 10)) | |
| 247 | + .foregroundStyle(ZyquoColor.textTertiary) | |
| 248 | + } | |
| 249 | + Spacer() | |
| 250 | + HStack(spacing: ZyquoSpacing.xxs) { | |
| 251 | + if model.capabilities.vision { ZyquoBadge(text: "vision") } | |
| 252 | + if model.capabilities.reasoning { ZyquoBadge(text: "reasoning") } | |
| 253 | + if model.capabilities.tools { ZyquoBadge(text: "tools") } | |
| 254 | + } | |
| 255 | + Text(model.contextBadge) | |
| 256 | + .font(ZyquoFont.caption) | |
| 257 | + .foregroundStyle(ZyquoColor.textSecondary) | |
| 258 | + .frame(width: 64, alignment: .trailing) | |
| 259 | + Text(pricingText(model)) | |
| 260 | + .font(ZyquoFont.caption) | |
| 261 | + .foregroundStyle(ZyquoColor.textTertiary) | |
| 262 | + .frame(width: 110, alignment: .trailing) | |
| 263 | + } | |
| 264 | + .padding(.vertical, 3) | |
| 265 | + .padding(.horizontal, ZyquoSpacing.xs) | |
| 266 | + .zyquoHoverHighlight() | |
| 267 | + } | |
| 268 | + | |
| 269 | + private func pricingText(_ model: AIModel) -> String { | |
| 270 | + guard let pricing = model.pricing else { return "—" } | |
| 271 | + return String(format: "$%.2f / $%.2f", pricing.inputPerMTok, pricing.outputPerMTok) | |
| 272 | + } | |
| 273 | + | |
| 274 | + private func refreshModels() { | |
| 275 | + refreshing = true | |
| 276 | + refreshResult = nil | |
| 277 | + let provider = selectedProvider | |
| 278 | + Task { | |
| 279 | + defer { refreshing = false } | |
| 280 | + do { | |
| 281 | + let key = try vault.apiKey(for: provider) | |
| 282 | + let ids = try await ProviderRegistry.client(for: provider).listModelIDs(apiKey: key) | |
| 283 | + catalog.applyLiveListing(ids, for: provider) | |
| 284 | + let unknown = catalog.unknownLiveIDs(for: provider).count | |
| 285 | + refreshResult = "\(ids.count) live models · \(unknown) not in catalog" | |
| 286 | + } catch { | |
| 287 | + refreshResult = error.localizedDescription | |
| 288 | + } | |
| 289 | + } | |
| 290 | + } | |
| 291 | +} | |
| 292 | + | |
| 293 | +/// Custom model editor: any OpenAI-compatible endpoint (OpenRouter, Groq…). | |
| 294 | +struct CustomModelSheet: View { | |
| 295 | + @EnvironmentObject private var catalog: ModelCatalog | |
| 296 | + @Environment(\.dismiss) private var dismiss | |
| 297 | + @State private var modelID = "" | |
| 298 | + @State private var displayName = "" | |
| 299 | + @State private var baseURL = "" | |
| 300 | + @State private var contextWindow = 128_000 | |
| 301 | + @State private var supportsVision = false | |
| 302 | + | |
| 303 | + var body: some View { | |
| 304 | + VStack(alignment: .leading, spacing: ZyquoSpacing.sm) { | |
| 305 | + Text("Custom Model") | |
| 306 | + .font(ZyquoFont.title) | |
| 307 | + Text("Any OpenAI-compatible chat endpoint (OpenRouter, Groq, local gateways…). The custom key is stored under the Custom provider slot in the encrypted vault.") | |
| 308 | + .font(ZyquoFont.caption) | |
| 309 | + .foregroundStyle(ZyquoColor.textSecondary) | |
| 310 | + Form { | |
| 311 | + TextField("Model ID (as sent to the API)", text: $modelID) | |
| 312 | + TextField("Display name", text: $displayName) | |
| 313 | + TextField("Base URL (e.g. https://openrouter.ai/api/v1)", text: $baseURL) | |
| 314 | + TextField("Context window", value: $contextWindow, format: .number) | |
| 315 | + Toggle("Supports vision", isOn: $supportsVision) | |
| 316 | + } | |
| 317 | + HStack { | |
| 318 | + Spacer() | |
| 319 | + Button("Cancel") { dismiss() } | |
| 320 | + Button("Add") { add() } | |
| 321 | + .buttonStyle(.borderedProminent) | |
| 322 | + .disabled(modelID.isEmpty || URL(string: baseURL) == nil) | |
| 323 | + } | |
| 324 | + } | |
| 325 | + .padding(ZyquoSpacing.xl) | |
| 326 | + .frame(width: 440) | |
| 327 | + } | |
| 328 | + | |
| 329 | + private func add() { | |
| 330 | + let model = AIModel( | |
| 331 | + id: modelID, | |
| 332 | + provider: .custom, | |
| 333 | + displayName: displayName.isEmpty ? modelID : displayName, | |
| 334 | + contextWindow: contextWindow, | |
| 335 | + maxOutputTokens: nil, | |
| 336 | + capabilities: ModelCapabilities(vision: supportsVision, tools: false, jsonMode: false), | |
| 337 | + pricing: nil, | |
| 338 | + parameterSupport: .openAIDefault, | |
| 339 | + customBaseURL: URL(string: baseURL) | |
| 340 | + ) | |
| 341 | + catalog.customModels.append(model) | |
| 342 | + dismiss() | |
| 343 | + } | |
| 344 | +} | |
| 345 | + | |
| 346 | +// MARK: - Appearance | |
| 347 | + | |
| 348 | +struct AppearanceSettingsTab: View { | |
| 349 | + @EnvironmentObject private var appearance: AppearanceStore | |
| 350 | + | |
| 351 | + var body: some View { | |
| 352 | + Form { | |
| 353 | + Picker("Theme", selection: $appearance.themeMode) { | |
| 354 | + ForEach(ThemeMode.allCases) { mode in | |
| 355 | + Text(mode.displayName).tag(mode) | |
| 356 | + } | |
| 357 | + } | |
| 358 | + .pickerStyle(.segmented) | |
| 359 | + | |
| 360 | + Picker("Accent", selection: $appearance.accent) { | |
| 361 | + ForEach(AccentChoice.allCases) { choice in | |
| 362 | + Text(choice.displayName).tag(choice) | |
| 363 | + } | |
| 364 | + } | |
| 365 | + | |
| 366 | + VStack(alignment: .leading, spacing: ZyquoSpacing.xs) { | |
| 367 | + HStack { | |
| 368 | + Text("Chat font size") | |
| 369 | + Spacer() | |
| 370 | + Text(String(format: "%.1f pt", appearance.chatFontSize)) | |
| 371 | + .font(ZyquoFont.caption) | |
| 372 | + .foregroundStyle(ZyquoColor.textSecondary) | |
| 373 | + .monospacedDigit() | |
| 374 | + } | |
| 375 | + Slider(value: $appearance.chatFontSize, in: 12...18, step: 0.5) | |
| 376 | + // Live preview | |
| 377 | + VStack(alignment: .leading, spacing: ZyquoSpacing.xxs) { | |
| 378 | + Text("Preview") | |
| 379 | + .font(ZyquoFont.caption) | |
| 380 | + .foregroundStyle(ZyquoColor.textTertiary) | |
| 381 | + Text("The quick brown fox jumps over the lazy dog — Zyquo Cloud renders chat text at this size, with generous 1.45 line height for readability.") | |
| 382 | + .font(ZyquoFont.body(size: appearance.chatFontSize)) | |
| 383 | + .lineSpacing(appearance.chatFontSize * ZyquoFont.bodyLineSpacingFactor) | |
| 384 | + .foregroundStyle(ZyquoColor.textPrimary) | |
| 385 | + .padding(ZyquoSpacing.sm) | |
| 386 | + .frame(maxWidth: .infinity, alignment: .leading) | |
| 387 | + .background( | |
| 388 | + RoundedRectangle(cornerRadius: ZyquoRadius.medium, style: .continuous) | |
| 389 | + .fill(ZyquoColor.surface) | |
| 390 | + .overlay( | |
| 391 | + RoundedRectangle(cornerRadius: ZyquoRadius.medium, style: .continuous) | |
| 392 | + .strokeBorder(ZyquoColor.border, lineWidth: ZyquoMetrics.hairline) | |
| 393 | + ) | |
| 394 | + ) | |
| 395 | + } | |
| 396 | + } | |
| 397 | + } | |
| 398 | + .formStyle(.grouped) | |
| 399 | + } | |
| 400 | +} | |
| 401 | + | |
| 402 | +// MARK: - Shortcuts | |
| 403 | + | |
| 404 | +struct ShortcutsSettingsTab: View { | |
| 405 | + private static let shortcuts: [(String, String)] = [ | |
| 406 | + ("New chat", "⌘N"), | |
| 407 | + ("Model switcher / command palette", "⌘K"), | |
| 408 | + ("Search conversations", "⌘F"), | |
| 409 | + ("Send message", "⌘↩"), | |
| 410 | + ("Export conversation", "⌘⇧E"), | |
| 411 | + ("Quick Chat panel", "⌥Space"), | |
| 412 | + ("Settings", "⌘,"), | |
| 413 | + ] | |
| 414 | + | |
| 415 | + var body: some View { | |
| 416 | + Form { | |
| 417 | + ForEach(Self.shortcuts, id: \.0) { name, keys in | |
| 418 | + LabeledContent(name) { | |
| 419 | + Text(keys) | |
| 420 | + .font(ZyquoFont.code(size: 12)) | |
| 421 | + .foregroundStyle(ZyquoColor.textSecondary) | |
| 422 | + } | |
| 423 | + } | |
| 424 | + } | |
| 425 | + .formStyle(.grouped) | |
| 426 | + } | |
| 427 | +} | |
| 428 | + | |
| 429 | +// MARK: - Advanced | |
| 430 | + | |
| 431 | +struct AdvancedSettingsTab: View { | |
| 432 | + @EnvironmentObject private var store: ConversationStore | |
| 433 | + | |
| 434 | + var body: some View { | |
| 435 | + Form { | |
| 436 | + Section("Default system prompt") { | |
| 437 | + TextEditor(text: $store.defaultSystemPrompt) | |
| 438 | + .font(ZyquoFont.body(size: 12.5)) | |
| 439 | + .frame(height: 90) | |
| 440 | + } | |
| 441 | + Section("Data") { | |
| 442 | + LabeledContent("Data folder") { | |
| 443 | + Button("Reveal in Finder") { | |
| 444 | + NSWorkspace.shared.activateFileViewerSelecting([ | |
| 445 | + PersistenceService.shared.rootDirectory | |
| 446 | + ]) | |
| 447 | + } | |
| 448 | + .controlSize(.small) | |
| 449 | + } | |
| 450 | + Text("Conversations, settings, and the encrypted key vault live in ~/Library/Application Support/ZyquoCloud/. The vault (vault.zq) is bound to this Mac and can't be decrypted elsewhere.") | |
| 451 | + .font(ZyquoFont.caption) | |
| 452 | + .foregroundStyle(ZyquoColor.textTertiary) | |
| 453 | + } | |
| 454 | + } | |
| 455 | + .formStyle(.grouped) | |
| 456 | + } | |
| 457 | +} | |
added
Sources/ZyquoCloud/Views/Sidebar/SidebarView.swift
+218 −0
@@ -0,0 +1,218 @@ | ||
| 1 | +// | |
| 2 | +// SidebarView.swift | |
| 3 | +// Zyquo Cloud | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// Sidebar per the Phase 4 spec: wordmark, search field, prominent New Chat, | |
| 9 | +// conversation list grouped Pinned/Today/Yesterday/Previous 7 Days/Older, | |
| 10 | +// hover-revealed pin/delete, footer with settings + usage summary. | |
| 11 | +// | |
| 12 | + | |
| 13 | +import SwiftUI | |
| 14 | + | |
| 15 | +struct SidebarView: View { | |
| 16 | + @EnvironmentObject private var store: ConversationStore | |
| 17 | + @EnvironmentObject private var catalog: ModelCatalog | |
| 18 | + | |
| 19 | + var body: some View { | |
| 20 | + VStack(spacing: 0) { | |
| 21 | + wordmark | |
| 22 | + searchField | |
| 23 | + newChatButton | |
| 24 | + conversationList | |
| 25 | + ZyquoHairline() | |
| 26 | + footer | |
| 27 | + } | |
| 28 | + .frame(minWidth: ZyquoMetrics.sidebarWidth) | |
| 29 | + } | |
| 30 | + | |
| 31 | + // MARK: - Sections | |
| 32 | + | |
| 33 | + private var wordmark: some View { | |
| 34 | + HStack(spacing: ZyquoSpacing.xs) { | |
| 35 | + CloudZGlyph(size: 22) | |
| 36 | + Text("Zyquo Cloud") | |
| 37 | + .font(ZyquoFont.bodyEmphasis(size: 14)) | |
| 38 | + .foregroundStyle(ZyquoColor.textPrimary) | |
| 39 | + Spacer() | |
| 40 | + } | |
| 41 | + .padding(.horizontal, ZyquoMetrics.contentInset) | |
| 42 | + .padding(.top, ZyquoSpacing.sm) | |
| 43 | + .padding(.bottom, ZyquoSpacing.xs) | |
| 44 | + } | |
| 45 | + | |
| 46 | + private var searchField: some View { | |
| 47 | + HStack(spacing: ZyquoSpacing.xxs) { | |
| 48 | + Image(systemName: "magnifyingglass") | |
| 49 | + .font(.system(size: 11)) | |
| 50 | + .foregroundStyle(ZyquoColor.textTertiary) | |
| 51 | + TextField("Search", text: $store.searchText) | |
| 52 | + .textFieldStyle(.plain) | |
| 53 | + .font(ZyquoFont.body(size: 12.5)) | |
| 54 | + } | |
| 55 | + .padding(.horizontal, ZyquoSpacing.xs) | |
| 56 | + .padding(.vertical, 5) | |
| 57 | + .background( | |
| 58 | + RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous) | |
| 59 | + .fill(ZyquoColor.surfaceSecondary.opacity(0.7)) | |
| 60 | + ) | |
| 61 | + .padding(.horizontal, ZyquoSpacing.sm) | |
| 62 | + .padding(.bottom, ZyquoSpacing.xs) | |
| 63 | + } | |
| 64 | + | |
| 65 | + private var newChatButton: some View { | |
| 66 | + Button { | |
| 67 | + store.newConversation() | |
| 68 | + } label: { | |
| 69 | + HStack(spacing: ZyquoSpacing.xxs) { | |
| 70 | + Image(systemName: "plus.bubble.fill") | |
| 71 | + .font(.system(size: 12, weight: .semibold)) | |
| 72 | + Text("New Chat") | |
| 73 | + .font(ZyquoFont.bodyEmphasis(size: 13)) | |
| 74 | + } | |
| 75 | + .foregroundStyle(.white) | |
| 76 | + .frame(maxWidth: .infinity) | |
| 77 | + .padding(.vertical, 7) | |
| 78 | + .background( | |
| 79 | + RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous) | |
| 80 | + .fill(ZyquoColor.accent) | |
| 81 | + ) | |
| 82 | + } | |
| 83 | + .buttonStyle(PressableButtonStyle()) | |
| 84 | + .keyboardShortcut("n", modifiers: .command) | |
| 85 | + .padding(.horizontal, ZyquoSpacing.sm) | |
| 86 | + .padding(.bottom, ZyquoSpacing.xs) | |
| 87 | + } | |
| 88 | + | |
| 89 | + private var conversationList: some View { | |
| 90 | + ScrollView { | |
| 91 | + LazyVStack(alignment: .leading, spacing: 2, pinnedViews: []) { | |
| 92 | + ForEach(store.sidebarGroups) { group in | |
| 93 | + Text(group.title) | |
| 94 | + .font(ZyquoFont.caption) | |
| 95 | + .foregroundStyle(ZyquoColor.textTertiary) | |
| 96 | + .padding(.horizontal, ZyquoSpacing.md) | |
| 97 | + .padding(.top, ZyquoSpacing.sm) | |
| 98 | + .padding(.bottom, 2) | |
| 99 | + ForEach(group.conversations) { conversation in | |
| 100 | + ConversationRow(conversation: conversation) | |
| 101 | + } | |
| 102 | + } | |
| 103 | + } | |
| 104 | + .padding(.horizontal, ZyquoSpacing.xs) | |
| 105 | + .padding(.bottom, ZyquoSpacing.sm) | |
| 106 | + } | |
| 107 | + } | |
| 108 | + | |
| 109 | + private var footer: some View { | |
| 110 | + HStack { | |
| 111 | + Button { | |
| 112 | + SettingsOpener.open() | |
| 113 | + } label: { | |
| 114 | + Image(systemName: "gearshape") | |
| 115 | + .font(.system(size: 13)) | |
| 116 | + .foregroundStyle(ZyquoColor.textSecondary) | |
| 117 | + } | |
| 118 | + .buttonStyle(.plain) | |
| 119 | + .help("Settings") | |
| 120 | + Spacer() | |
| 121 | + Text(usageSummary) | |
| 122 | + .font(ZyquoFont.caption) | |
| 123 | + .foregroundStyle(ZyquoColor.textTertiary) | |
| 124 | + } | |
| 125 | + .padding(.horizontal, ZyquoMetrics.contentInset) | |
| 126 | + .padding(.vertical, ZyquoSpacing.xs) | |
| 127 | + } | |
| 128 | + | |
| 129 | + private var usageSummary: String { | |
| 130 | + let tokens = store.conversations.map { $0.totalUsage.totalTokens }.reduce(0, +) | |
| 131 | + let cost = store.conversations.map(\.totalCost).reduce(0, +) | |
| 132 | + guard tokens > 0 else { return "" } | |
| 133 | + let tokenText = tokens >= 1_000_000 | |
| 134 | + ? String(format: "%.1fM tok", Double(tokens) / 1_000_000) | |
| 135 | + : String(format: "%.1fK tok", Double(tokens) / 1_000) | |
| 136 | + return cost > 0 ? String(format: "%@ · ~$%.2f", tokenText, cost) : tokenText | |
| 137 | + } | |
| 138 | +} | |
| 139 | + | |
| 140 | +// MARK: - Row | |
| 141 | + | |
| 142 | +private struct ConversationRow: View { | |
| 143 | + let conversation: Conversation | |
| 144 | + @EnvironmentObject private var store: ConversationStore | |
| 145 | + @State private var hovering = false | |
| 146 | + | |
| 147 | + private var isSelected: Bool { store.selectedID == conversation.id } | |
| 148 | + | |
| 149 | + var body: some View { | |
| 150 | + Button { | |
| 151 | + store.selectedID = conversation.id | |
| 152 | + } label: { | |
| 153 | + HStack(spacing: ZyquoSpacing.xs) { | |
| 154 | + VStack(alignment: .leading, spacing: 1) { | |
| 155 | + Text(conversation.title) | |
| 156 | + .font(ZyquoFont.body(size: 13)) | |
| 157 | + .foregroundStyle(ZyquoColor.textPrimary) | |
| 158 | + .lineLimit(1) | |
| 159 | + HStack(spacing: ZyquoSpacing.xxs) { | |
| 160 | + Text(conversation.modelID) | |
| 161 | + .font(ZyquoFont.caption) | |
| 162 | + .foregroundStyle(ZyquoColor.textTertiary) | |
| 163 | + .lineLimit(1) | |
| 164 | + Text(conversation.updatedAt, format: .relative(presentation: .named)) | |
| 165 | + .font(ZyquoFont.caption) | |
| 166 | + .foregroundStyle(ZyquoColor.textTertiary) | |
| 167 | + } | |
| 168 | + } | |
| 169 | + Spacer(minLength: 0) | |
| 170 | + if hovering { | |
| 171 | + rowActions | |
| 172 | + } else if conversation.isPinned { | |
| 173 | + Image(systemName: "pin.fill") | |
| 174 | + .font(.system(size: 9)) | |
| 175 | + .foregroundStyle(ZyquoColor.textTertiary) | |
| 176 | + } | |
| 177 | + } | |
| 178 | + .padding(.horizontal, ZyquoSpacing.xs) | |
| 179 | + .padding(.vertical, 5) | |
| 180 | + .background( | |
| 181 | + RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous) | |
| 182 | + .fill(isSelected ? ZyquoColor.accentSubtle : (hovering ? ZyquoColor.surfaceSecondary.opacity(0.6) : .clear)) | |
| 183 | + ) | |
| 184 | + .contentShape(Rectangle()) | |
| 185 | + } | |
| 186 | + .buttonStyle(.plain) | |
| 187 | + .onHover { inside in | |
| 188 | + withAnimation(ZyquoMotion.hover) { hovering = inside } | |
| 189 | + } | |
| 190 | + .contextMenu { | |
| 191 | + Button(conversation.isPinned ? "Unpin" : "Pin") { store.togglePin(conversation.id) } | |
| 192 | + Button("Delete", role: .destructive) { store.delete(conversation.id) } | |
| 193 | + } | |
| 194 | + } | |
| 195 | + | |
| 196 | + private var rowActions: some View { | |
| 197 | + HStack(spacing: ZyquoSpacing.xxs) { | |
| 198 | + Button { | |
| 199 | + store.togglePin(conversation.id) | |
| 200 | + } label: { | |
| 201 | + Image(systemName: conversation.isPinned ? "pin.slash" : "pin") | |
| 202 | + .font(.system(size: 10)) | |
| 203 | + .foregroundStyle(ZyquoColor.textSecondary) | |
| 204 | + } | |
| 205 | + .buttonStyle(.plain) | |
| 206 | + .help(conversation.isPinned ? "Unpin" : "Pin") | |
| 207 | + Button { | |
| 208 | + store.delete(conversation.id) | |
| 209 | + } label: { | |
| 210 | + Image(systemName: "trash") | |
| 211 | + .font(.system(size: 10)) | |
| 212 | + .foregroundStyle(ZyquoColor.textSecondary) | |
| 213 | + } | |
| 214 | + .buttonStyle(.plain) | |
| 215 | + .help("Delete") | |
| 216 | + } | |
| 217 | + } | |
| 218 | +} | |
added
Tests/ZyquoCloudTests/SyntaxHighlighterTests.swift
+108 −0
@@ -0,0 +1,108 @@ | ||
| 1 | +// | |
| 2 | +// SyntaxHighlighterTests.swift | |
| 3 | +// Zyquo Cloud | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// Sanity tests for the code syntax highlighter and the Markdown block parser | |
| 9 | +// (streaming resilience: incomplete input must never crash). | |
| 10 | +// | |
| 11 | + | |
| 12 | +import Testing | |
| 13 | +import SwiftUI | |
| 14 | +@testable import ZyquoCloud | |
| 15 | + | |
| 16 | +@Suite("SyntaxHighlighter") | |
| 17 | +struct SyntaxHighlighterTests { | |
| 18 | + /// Foreground color of the first run whose text equals `token`. | |
| 19 | + private func color(of token: String, in attributed: AttributedString) -> Color? { | |
| 20 | + for run in attributed.runs where String(attributed[run.range].characters) == token { | |
| 21 | + return run.foregroundColor | |
| 22 | + } | |
| 23 | + return nil | |
| 24 | + } | |
| 25 | + | |
| 26 | + @Test func preservesTextExactly() { | |
| 27 | + let samples: [(String, String)] = [ | |
| 28 | + ("swift", "func greet(name: String) -> String { return \"hi \\(name)\" } // done"), | |
| 29 | + ("python", "def add(a, b):\n # sum\n return a + b"), | |
| 30 | + ("javascript", "const x = `tpl ${y}`; /* block */"), | |
| 31 | + ("sql", "SELECT id FROM users WHERE name = 'zy' -- lookup"), | |
| 32 | + ("html", "<div class=\"box\"><!-- note --></div>"), | |
| 33 | + ] | |
| 34 | + for (language, code) in samples { | |
| 35 | + let out = SyntaxHighlighter.highlight(code, language: language, baseColor: .primary) | |
| 36 | + #expect(String(out.characters) == code) | |
| 37 | + } | |
| 38 | + } | |
| 39 | + | |
| 40 | + @Test func swiftKeywordsStringsAndCommentsAreColored() { | |
| 41 | + let code = "let name = \"Zyquo\" // cloud" | |
| 42 | + let out = SyntaxHighlighter.highlight(code, language: "swift", baseColor: .primary) | |
| 43 | + #expect(color(of: "let", in: out) == SyntaxHighlighter.theme.keyword) | |
| 44 | + #expect(color(of: "\"Zyquo\"", in: out) == SyntaxHighlighter.theme.string) | |
| 45 | + #expect(color(of: "// cloud", in: out) == SyntaxHighlighter.theme.comment) | |
| 46 | + } | |
| 47 | + | |
| 48 | + @Test func pythonCommentAndKeyword() { | |
| 49 | + let out = SyntaxHighlighter.highlight("def run(): # go", language: "python", baseColor: .primary) | |
| 50 | + #expect(color(of: "def", in: out) == SyntaxHighlighter.theme.keyword) | |
| 51 | + #expect(color(of: "# go", in: out) == SyntaxHighlighter.theme.comment) | |
| 52 | + } | |
| 53 | + | |
| 54 | + @Test func numbersAreColored() { | |
| 55 | + let out = SyntaxHighlighter.highlight("x = 42", language: "python", baseColor: .primary) | |
| 56 | + #expect(color(of: "42", in: out) == SyntaxHighlighter.theme.number) | |
| 57 | + } | |
| 58 | + | |
| 59 | + @Test func sqlKeywordsAreCaseInsensitive() { | |
| 60 | + let out = SyntaxHighlighter.highlight("select id from t", language: "sql", baseColor: .primary) | |
| 61 | + #expect(color(of: "select", in: out) == SyntaxHighlighter.theme.keyword) | |
| 62 | + let upper = SyntaxHighlighter.highlight("SELECT id FROM t", language: "sql", baseColor: .primary) | |
| 63 | + #expect(color(of: "SELECT", in: upper) == SyntaxHighlighter.theme.keyword) | |
| 64 | + } | |
| 65 | + | |
| 66 | + @Test func unknownLanguageIsUniformBaseColor() { | |
| 67 | + let code = "let x = 1 // whatever" | |
| 68 | + let out = SyntaxHighlighter.highlight(code, language: "brainfudge", baseColor: .red) | |
| 69 | + #expect(String(out.characters) == code) | |
| 70 | + for run in out.runs { | |
| 71 | + #expect(run.foregroundColor == .red) | |
| 72 | + } | |
| 73 | + } | |
| 74 | + | |
| 75 | + @Test func survivesIncompleteInput() { | |
| 76 | + // Unterminated string, unterminated block comment, empty input — | |
| 77 | + // all must return the exact text without crashing (streaming case). | |
| 78 | + let unterminatedString = "let s = \"never closed" | |
| 79 | + let out1 = SyntaxHighlighter.highlight(unterminatedString, language: "swift", baseColor: .primary) | |
| 80 | + #expect(String(out1.characters) == unterminatedString) | |
| 81 | + | |
| 82 | + let unterminatedComment = "int a; /* still going" | |
| 83 | + let out2 = SyntaxHighlighter.highlight(unterminatedComment, language: "c", baseColor: .primary) | |
| 84 | + #expect(String(out2.characters) == unterminatedComment) | |
| 85 | + | |
| 86 | + let empty = SyntaxHighlighter.highlight("", language: "swift", baseColor: .primary) | |
| 87 | + #expect(String(empty.characters).isEmpty) | |
| 88 | + } | |
| 89 | +} | |
| 90 | + | |
| 91 | +@Suite("MarkdownBlockParser") | |
| 92 | +struct MarkdownBlockParserTests { | |
| 93 | + @Test func extractsFencedCodeBlock() { | |
| 94 | + let blocks = MarkdownBlockParser.parse(text: "Hello\n\n```swift\nlet a = 1\n```", fontSize: 13.5) | |
| 95 | + #expect(blocks.count == 2) | |
| 96 | + guard case .code(let code, let language) = blocks[1].kind else { | |
| 97 | + Issue.record("Expected a code block as the second block") | |
| 98 | + return | |
| 99 | + } | |
| 100 | + #expect(code == "let a = 1") | |
| 101 | + #expect(language == "swift") | |
| 102 | + } | |
| 103 | + | |
| 104 | + @Test func survivesUnterminatedFenceWhileStreaming() { | |
| 105 | + let blocks = MarkdownBlockParser.parse(text: "Intro\n\n```swift\nlet a =", fontSize: 13.5) | |
| 106 | + #expect(!blocks.isEmpty) | |
| 107 | + } | |
| 108 | +} | |
modified
docs/PLAN.md
+21 −6
@@ -72,12 +72,27 @@ contract for all Phase 6 UI and the 4.4 quality gate is re-checked before "done" | ||
| 72 | 72 | pinned to macOS 26 SDK (`SDKROOT` in Makefile/test.sh) because CLT lacks Xcode's SwiftUIMacros |
| 73 | 73 | plugin required by SDK 27's macro-based @State. |
| 74 | 74 | |
| 75 | −## Phase 5 — App Icon (in progress) | |
| 76 | − | |
| 77 | −- [ ] `assets/icon/zyquo-cloud.svg` — Z+cloud fusion on Apple-squircle, sky gradient | |
| 78 | −- [ ] Render 16→1024, visually inspect, iterate until crisp at 16/32px (small-size variant if needed) | |
| 79 | −- [ ] `scripts/generate-icon.sh` → AppIcon.iconset → `Resources/AppIcon.icns` | |
| 80 | −- [ ] Menu bar template icon (18pt, isTemplate) + wordmark/empty-state glyph derivation | |
| 75 | +## Phase 5 — App Icon ✅ (completed 2026-07-30) | |
| 76 | + | |
| 77 | +- [x] `assets/icon/zyquo-cloud.svg` — white geometric cloud + luminous-beam Z on a true superellipse | |
| 78 | + squircle (n=4.6, computed path), sky gradient #82B4FF→#4E6AF0→#3A3F9E, soft top light, two | |
| 79 | + distant parallax cloud layers; no SVG filters (CoreSVG-safe) | |
| 80 | +- [x] Rendered 16→1024 and visually inspected; iterated (lightened sky; small sizes were muddy → | |
| 81 | + dedicated `zyquo-cloud-small.svg`: bigger cloud, 86px Z stroke, flat fills — used for 16/32 slots) | |
| 82 | +- [x] `scripts/generate-icon.sh` → iconset (small variant for ≤64px slots) → `Resources/AppIcon.icns`, | |
| 83 | + embedded in the bundle by `make dev`/`release`; rasterizer: `scripts/rasterize-svg.swift` (AppKit) | |
| 84 | +- [x] Menu bar template glyph `zyquo-cloud-template.svg` (cloud silhouette, Z knocked out via mask) → | |
| 85 | + `Resources/MenuBarIcon(@2x).png`; in-app wordmark/empty-state glyph will draw the same geometry | |
| 86 | + as a SwiftUI Shape in Phase 6 | |
| 87 | + | |
| 88 | +**Phase 5 checkpoint summary:** Icon is crisp and on-identity at every size (verified visually at | |
| 89 | +1024/512/256/128/64/32/16). SVG sources are the single source of truth in `assets/icon/`. | |
| 90 | + | |
| 91 | +## Phase 6 — Features (in progress) | |
| 92 | + | |
| 93 | +Build order (compile after each): stores → chat engine → main window (sidebar/transcript/input) → | |
| 94 | +markdown+code rendering → settings → model picker/compare/quick chat → productivity (templates, | |
| 95 | +personas, export, search, titles) → menu bar extra + shortcuts. | |
| 81 | 96 | ## Phase 2 — Architecture (pending) |
| 82 | 97 | ## Phase 3 — SecureKeyStore (pending) |
| 83 | 98 | ## Phase 4 — Design System (pending) |
| 84 | 99 | |