phase4: design system + UI — ZyquoTheme graphite-cyan, navigator shell, all six screens, settings
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Showing 19 changed files with +2,400 and −70
modified
Makefile
+2 −0
@@ -52,6 +52,7 @@ bundle: | ||
| 52 | 52 | @for b in .build/release/*.bundle; do [ -e "$$b" ] && cp -R "$$b" "$(APP_DIR)/Contents/Resources/" || true; done |
| 53 | 53 | @if [ -f Resources/AppIcon.icns ]; then cp Resources/AppIcon.icns "$(APP_DIR)/Contents/Resources/AppIcon.icns"; fi |
| 54 | 54 | @for r in Resources/MenuBarIcon.png Resources/MenuBarIcon@2x.png; do [ -f "$$r" ] && cp "$$r" "$(APP_DIR)/Contents/Resources/" || true; done |
| 55 | + @if [ -f docs/API.md ]; then cp docs/API.md "$(APP_DIR)/Contents/Resources/API.md"; fi | |
| 55 | 56 | scripts/write-info-plist.sh "$(APP_DIR)" "$(APP_NAME)" "$(EXEC_NAME)" "$(BUNDLE_ID)" "$(VERSION)" "$(BUILD_NUM)" "$(MIN_MACOS)" |
| 56 | 57 | |
| 57 | 58 | # Both arch builds land in .build/<triple>/Release; each slice is copied aside |
@@ -79,6 +80,7 @@ release: universal | ||
| 79 | 80 | @for b in .build/arm64-apple-macosx/release/*.bundle; do [ -e "$$b" ] && cp -R "$$b" "$(APP_DIR)/Contents/Resources/" || true; done |
| 80 | 81 | @if [ -f Resources/AppIcon.icns ]; then cp Resources/AppIcon.icns "$(APP_DIR)/Contents/Resources/AppIcon.icns"; fi |
| 81 | 82 | @for r in Resources/MenuBarIcon.png Resources/MenuBarIcon@2x.png; do [ -f "$$r" ] && cp "$$r" "$(APP_DIR)/Contents/Resources/" || true; done |
| 83 | + @if [ -f docs/API.md ]; then cp docs/API.md "$(APP_DIR)/Contents/Resources/API.md"; fi | |
| 82 | 84 | scripts/write-info-plist.sh "$(APP_DIR)" "$(APP_NAME)" "$(EXEC_NAME)" "$(BUNDLE_ID)" "$(VERSION)" "$(BUILD_NUM)" "$(MIN_MACOS)" |
| 83 | 85 | scripts/notarize.sh "$(APP_DIR)" "$(IDENTITY)" "$(NOTARY_PROFILE)" "$(ENTITLEMENTS)" |
| 84 | 86 | |
added
Sources/ZyquoRouter/App/AppEnvironment.swift
+44 −0
@@ -0,0 +1,44 @@ | ||
| 1 | +// | |
| 2 | +// AppEnvironment.swift | |
| 3 | +// Zyquo Router | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// Composition root for the app's observable stores. Owned by the App and | |
| 9 | +// injected into the view tree as environment objects. | |
| 10 | +// | |
| 11 | + | |
| 12 | +import SwiftUI | |
| 13 | + | |
| 14 | +@MainActor | |
| 15 | +final class AppEnvironment: ObservableObject { | |
| 16 | + let server = ServerController() | |
| 17 | + let catalog = ModelCatalog() | |
| 18 | + let appearance = AppearanceStore() | |
| 19 | + let vault = KeyVaultStore() | |
| 20 | + let localKeys = LocalKeysStore() | |
| 21 | +} | |
| 22 | + | |
| 23 | +/// Navigator sections (⌘1–6). | |
| 24 | +enum AppSection: String, CaseIterable, Identifiable { | |
| 25 | + case dashboard = "Dashboard" | |
| 26 | + case models = "Models" | |
| 27 | + case requests = "Requests" | |
| 28 | + case keys = "Keys" | |
| 29 | + case playground = "Playground" | |
| 30 | + case docs = "Docs" | |
| 31 | + | |
| 32 | + var id: String { rawValue } | |
| 33 | + | |
| 34 | + var systemImage: String { | |
| 35 | + switch self { | |
| 36 | + case .dashboard: return "gauge.with.dots.needle.50percent" | |
| 37 | + case .models: return "square.grid.2x2" | |
| 38 | + case .requests: return "list.bullet.rectangle" | |
| 39 | + case .keys: return "key" | |
| 40 | + case .playground: return "paperplane" | |
| 41 | + case .docs: return "book" | |
| 42 | + } | |
| 43 | + } | |
| 44 | +} | |
added
Sources/ZyquoRouter/App/SettingsOpener.swift
+25 −0
@@ -0,0 +1,25 @@ | ||
| 1 | +// | |
| 2 | +// SettingsOpener.swift | |
| 3 | +// Zyquo Router | |
| 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/ZyquoRouter/App/ZyquoRouterApp.swift
+54 −68
@@ -10,90 +10,76 @@ import SwiftUI | ||
| 10 | 10 | |
| 11 | 11 | struct ZyquoRouterApp: App { |
| 12 | 12 | @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate |
| 13 | − @StateObject private var server = ServerController() | |
| 13 | + @StateObject private var environment = AppEnvironment() | |
| 14 | + @AppStorage("autoStartServer") private var autoStart = false | |
| 14 | 15 | |
| 15 | 16 | var body: some Scene { |
| 16 | 17 | WindowGroup("Zyquo Router") { |
| 17 | − Phase2ControlView() | |
| 18 | − .environmentObject(server) | |
| 18 | + MainWindowView() | |
| 19 | + .environmentObject(environment.server) | |
| 20 | + .environmentObject(environment.catalog) | |
| 21 | + .environmentObject(environment.appearance) | |
| 22 | + .environmentObject(environment.vault) | |
| 23 | + .environmentObject(environment.localKeys) | |
| 24 | + .onAppear { | |
| 25 | + if autoStart, !environment.server.isRunning { | |
| 26 | + environment.server.start() | |
| 27 | + } | |
| 28 | + } | |
| 29 | + } | |
| 30 | + .defaultSize( | |
| 31 | + width: ZyquoMetrics.windowDefaultWidth, | |
| 32 | + height: ZyquoMetrics.windowDefaultHeight | |
| 33 | + ) | |
| 34 | + .commands { | |
| 35 | + AppCommands(server: environment.server) | |
| 19 | 36 | } |
| 20 | − .defaultSize(width: 1280, height: 820) | |
| 21 | − } | |
| 22 | −} | |
| 23 | 37 | |
| 24 | −final class AppDelegate: NSObject, NSApplicationDelegate { | |
| 25 | − func applicationDidFinishLaunching(_ notification: Notification) { | |
| 26 | − // Ensure the app fronts correctly when launched outside Finder | |
| 27 | − // (e.g. `swift run` or `open` from a terminal during development). | |
| 28 | − NSApplication.shared.setActivationPolicy(.regular) | |
| 29 | − NSApplication.shared.activate(ignoringOtherApps: true) | |
| 38 | + Settings { | |
| 39 | + SettingsView() | |
| 40 | + .environmentObject(environment.server) | |
| 41 | + .environmentObject(environment.catalog) | |
| 42 | + .environmentObject(environment.appearance) | |
| 43 | + .environmentObject(environment.vault) | |
| 44 | + .environmentObject(environment.localKeys) | |
| 45 | + } | |
| 30 | 46 | } |
| 31 | 47 | } |
| 32 | 48 | |
| 33 | −/// Minimal Phase 2 control surface — port field, Start/Stop, status, endpoint. | |
| 34 | −/// Replaced by the real dashboard (ZyquoTheme) in Phases 4–6. | |
| 35 | −private struct Phase2ControlView: View { | |
| 36 | − @EnvironmentObject private var server: ServerController | |
| 37 | − | |
| 38 | − var body: some View { | |
| 39 | − VStack(spacing: 16) { | |
| 40 | − Text("Zyquo Router") | |
| 41 | − .font(.largeTitle.weight(.semibold)) | |
| 42 | − | |
| 43 | − HStack(spacing: 12) { | |
| 44 | − statusDot | |
| 45 | − Text(statusText) | |
| 46 | − .font(.body.monospaced()) | |
| 47 | − } | |
| 48 | − | |
| 49 | − HStack(spacing: 12) { | |
| 50 | − TextField("Port", value: $server.port, format: .number.grouping(.never)) | |
| 51 | − .textFieldStyle(.roundedBorder) | |
| 52 | − .frame(width: 90) | |
| 53 | − .disabled(server.isRunning) | |
| 49 | +/// App-level menu commands: ⌘1–6 sections, ⌘⇧C copy endpoint. | |
| 50 | +/// (⌘R start/stop lives on the dashboard's Start button.) | |
| 51 | +struct AppCommands: Commands { | |
| 52 | + let server: ServerController | |
| 54 | 53 | |
| 55 | − Button(server.isRunning ? "Stop" : "Start") { | |
| 56 | − server.toggle() | |
| 54 | + var body: some Commands { | |
| 55 | + CommandMenu("Go") { | |
| 56 | + ForEach(Array(AppSection.allCases.enumerated()), id: \.element.id) { index, section in | |
| 57 | + Button(section.rawValue) { | |
| 58 | + UserDefaults.standard.set(section.rawValue, forKey: "selectedSection") | |
| 57 | 59 | } |
| 58 | − .keyboardShortcut("r", modifiers: .command) | |
| 60 | + .keyboardShortcut(KeyEquivalent(Character("\(index + 1)")), modifiers: .command) | |
| 59 | 61 | } |
| 60 | − | |
| 61 | − if server.isRunning { | |
| 62 | − HStack(spacing: 8) { | |
| 63 | − Text(server.endpointURL) | |
| 64 | − .font(.body.monospaced()) | |
| 65 | − .textSelection(.enabled) | |
| 66 | − Button("Copy") { | |
| 67 | − NSPasteboard.general.clearContents() | |
| 68 | − NSPasteboard.general.setString(server.endpointURL, forType: .string) | |
| 69 | − } | |
| 70 | − } | |
| 62 | + } | |
| 63 | + CommandGroup(after: .pasteboard) { | |
| 64 | + Button("Copy Endpoint URL") { | |
| 65 | + NSPasteboard.general.clearContents() | |
| 66 | + NSPasteboard.general.setString(server.endpointURL, forType: .string) | |
| 71 | 67 | } |
| 68 | + .keyboardShortcut("c", modifiers: [.command, .shift]) | |
| 72 | 69 | } |
| 73 | − .frame(minWidth: 1020, minHeight: 660) | |
| 74 | − } | |
| 75 | − | |
| 76 | − private var statusDot: some View { | |
| 77 | − Circle() | |
| 78 | − .fill(dotColor) | |
| 79 | − .frame(width: 10, height: 10) | |
| 80 | 70 | } |
| 71 | +} | |
| 81 | 72 | |
| 82 | − private var dotColor: Color { | |
| 83 | − switch server.state { | |
| 84 | − case .running: return .green | |
| 85 | − case .starting: return .orange | |
| 86 | − case .failed: return .red | |
| 87 | − case .stopped: return .secondary.opacity(0.5) | |
| 88 | − } | |
| 73 | +final class AppDelegate: NSObject, NSApplicationDelegate { | |
| 74 | + func applicationDidFinishLaunching(_ notification: Notification) { | |
| 75 | + // Ensure the app fronts correctly when launched outside Finder | |
| 76 | + // (e.g. `swift run` or `open` from a terminal during development). | |
| 77 | + NSApplication.shared.setActivationPolicy(.regular) | |
| 78 | + NSApplication.shared.activate(ignoringOtherApps: true) | |
| 89 | 79 | } |
| 90 | 80 | |
| 91 | − private var statusText: String { | |
| 92 | − switch server.state { | |
| 93 | − case .stopped: return "Stopped" | |
| 94 | − case .starting: return "Starting…" | |
| 95 | − case .running(let port): return "Running on :\(port)" | |
| 96 | − case .failed(let message): return message | |
| 97 | − } | |
| 81 | + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { | |
| 82 | + // "Keep serving when window closed" — the server survives by default. | |
| 83 | + !UserDefaults.standard.bool(forKey: "keepServingWhenClosed") | |
| 98 | 84 | } |
| 99 | 85 | } |
added
Sources/ZyquoRouter/DesignSystem/AppearanceStore.swift
+134 −0
@@ -0,0 +1,134 @@ | ||
| 1 | +// | |
| 2 | +// AppearanceStore.swift | |
| 3 | +// Zyquo Router | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// User appearance preferences: theme mode, accent choice, font size. | |
| 9 | +// Persisted to appearance.json via PersistenceService. Family design with | |
| 10 | +// the Router's accent set: cyan (default) + graphite, sky, emerald, violet, | |
| 11 | +// copper. | |
| 12 | +// | |
| 13 | + | |
| 14 | +import SwiftUI | |
| 15 | + | |
| 16 | +/// Accent color choices in Settings → Appearance. | |
| 17 | +enum AccentChoice: String, Codable, CaseIterable, Identifiable { | |
| 18 | + case cyan | |
| 19 | + case graphite | |
| 20 | + case sky | |
| 21 | + case emerald | |
| 22 | + case violet | |
| 23 | + case copper | |
| 24 | + | |
| 25 | + var id: String { rawValue } | |
| 26 | + | |
| 27 | + var displayName: String { | |
| 28 | + switch self { | |
| 29 | + case .cyan: return "Signal Cyan" | |
| 30 | + case .graphite: return "Graphite" | |
| 31 | + case .sky: return "Sky" | |
| 32 | + case .emerald: return "Emerald" | |
| 33 | + case .violet: return "Violet" | |
| 34 | + case .copper: return "Copper" | |
| 35 | + } | |
| 36 | + } | |
| 37 | + | |
| 38 | + /// (light, dark) accent hex pair. | |
| 39 | + var accentHex: (UInt32, UInt32) { | |
| 40 | + switch self { | |
| 41 | + case .cyan: return (0x0891B2, 0x22B8D4) | |
| 42 | + case .graphite: return (0x374151, 0x9AA4B2) | |
| 43 | + case .sky: return (0x2E7CD6, 0x5A9BE3) | |
| 44 | + case .emerald: return (0x1E9E6A, 0x3CBA87) | |
| 45 | + case .violet: return (0x7451D8, 0x9273E4) | |
| 46 | + case .copper: return (0xB2622B, 0xC97F4B) | |
| 47 | + } | |
| 48 | + } | |
| 49 | + | |
| 50 | + /// (light, dark) subtle-tint hex pair (selected rows, active tints). | |
| 51 | + var subtleHex: (UInt32, UInt32) { | |
| 52 | + switch self { | |
| 53 | + case .cyan: return (0xE5F5F9, 0x143A44) | |
| 54 | + case .graphite: return (0xEEF1F4, 0x2A2F36) | |
| 55 | + case .sky: return (0xE8F1FC, 0x1B3350) | |
| 56 | + case .emerald: return (0xE6F6EF, 0x173B2C) | |
| 57 | + case .violet: return (0xF0EBFB, 0x2C2347) | |
| 58 | + case .copper: return (0xF9EFE6, 0x3D2C1D) | |
| 59 | + } | |
| 60 | + } | |
| 61 | +} | |
| 62 | + | |
| 63 | +enum ThemeMode: String, Codable, CaseIterable, Identifiable { | |
| 64 | + case system, light, dark | |
| 65 | + var id: String { rawValue } | |
| 66 | + | |
| 67 | + var displayName: String { | |
| 68 | + switch self { | |
| 69 | + case .system: return "System" | |
| 70 | + case .light: return "Light" | |
| 71 | + case .dark: return "Dark" | |
| 72 | + } | |
| 73 | + } | |
| 74 | + | |
| 75 | + var colorScheme: ColorScheme? { | |
| 76 | + switch self { | |
| 77 | + case .system: return nil | |
| 78 | + case .light: return .light | |
| 79 | + case .dark: return .dark | |
| 80 | + } | |
| 81 | + } | |
| 82 | +} | |
| 83 | + | |
| 84 | +/// Observable appearance preferences, persisted with the app settings. | |
| 85 | +@MainActor | |
| 86 | +final class AppearanceStore: ObservableObject { | |
| 87 | + struct Stored: Codable { | |
| 88 | + var themeMode: ThemeMode = .system | |
| 89 | + var accent: AccentChoice = .cyan | |
| 90 | + var fontSize: Double = 13 | |
| 91 | + } | |
| 92 | + | |
| 93 | + static let fileName = "appearance.json" | |
| 94 | + | |
| 95 | + @Published var themeMode: ThemeMode { didSet { save() } } | |
| 96 | + @Published var accent: AccentChoice { didSet { save() } } | |
| 97 | + /// Base body font size, clamped to 12–16pt. | |
| 98 | + @Published var fontSize: Double { | |
| 99 | + didSet { | |
| 100 | + let clamped = min(16, max(12, fontSize)) | |
| 101 | + if clamped != fontSize { fontSize = clamped } | |
| 102 | + save() | |
| 103 | + } | |
| 104 | + } | |
| 105 | + | |
| 106 | + private let persistence: PersistenceService | |
| 107 | + | |
| 108 | + init(persistence: PersistenceService = .shared) { | |
| 109 | + self.persistence = persistence | |
| 110 | + let stored = persistence.load(Stored.self, from: Self.fileName) ?? Stored() | |
| 111 | + themeMode = stored.themeMode | |
| 112 | + accent = stored.accent | |
| 113 | + fontSize = stored.fontSize | |
| 114 | + } | |
| 115 | + | |
| 116 | + /// Current accent color resolved for the active appearance. | |
| 117 | + var accentColor: Color { | |
| 118 | + let (light, dark) = accent.accentHex | |
| 119 | + return ZyquoColor.dynamic(light: light, dark: dark) | |
| 120 | + } | |
| 121 | + | |
| 122 | + /// Current subtle accent tint resolved for the active appearance. | |
| 123 | + var accentSubtleColor: Color { | |
| 124 | + let (light, dark) = accent.subtleHex | |
| 125 | + return ZyquoColor.dynamic(light: light, dark: dark) | |
| 126 | + } | |
| 127 | + | |
| 128 | + private func save() { | |
| 129 | + persistence.save( | |
| 130 | + Stored(themeMode: themeMode, accent: accent, fontSize: fontSize), | |
| 131 | + to: Self.fileName | |
| 132 | + ) | |
| 133 | + } | |
| 134 | +} | |
added
Sources/ZyquoRouter/DesignSystem/Components.swift
+221 −0
@@ -0,0 +1,221 @@ | ||
| 1 | +// | |
| 2 | +// Components.swift | |
| 3 | +// Zyquo Router | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// Shared design-system atoms: the server status pill, copyable mono fields, | |
| 9 | +// capability badges, stat tiles, section headers, empty states. | |
| 10 | +// | |
| 11 | + | |
| 12 | +import SwiftUI | |
| 13 | + | |
| 14 | +// MARK: - Status pill | |
| 15 | + | |
| 16 | +/// ● Running on :8787 / ○ Stopped — visible from every screen. | |
| 17 | +struct StatusPill: View { | |
| 18 | + @EnvironmentObject private var server: ServerController | |
| 19 | + | |
| 20 | + var body: some View { | |
| 21 | + HStack(spacing: ZyquoSpacing.xs) { | |
| 22 | + Circle() | |
| 23 | + .fill(color) | |
| 24 | + .frame(width: 7, height: 7) | |
| 25 | + Text(label) | |
| 26 | + .font(ZyquoFont.mono(size: 11, weight: .medium)) | |
| 27 | + .foregroundStyle(ZyquoColor.textPrimary) | |
| 28 | + .lineLimit(1) | |
| 29 | + Button(server.isRunning || server.state == .starting ? "Stop" : "Start") { | |
| 30 | + server.toggle() | |
| 31 | + } | |
| 32 | + .buttonStyle(.plain) | |
| 33 | + .font(ZyquoFont.caption.weight(.semibold)) | |
| 34 | + .foregroundStyle(ZyquoColor.accent) | |
| 35 | + } | |
| 36 | + .padding(.horizontal, ZyquoSpacing.sm) | |
| 37 | + .padding(.vertical, 6) | |
| 38 | + .background( | |
| 39 | + Capsule() | |
| 40 | + .fill(server.isRunning ? ZyquoColor.accentSubtle : ZyquoColor.surfaceSecondary) | |
| 41 | + .overlay(Capsule().strokeBorder(ZyquoColor.border, lineWidth: ZyquoMetrics.hairline)) | |
| 42 | + ) | |
| 43 | + .animation(ZyquoMotion.state, value: server.state) | |
| 44 | + } | |
| 45 | + | |
| 46 | + private var color: Color { | |
| 47 | + switch server.state { | |
| 48 | + case .running: return ZyquoColor.success | |
| 49 | + case .starting: return ZyquoColor.warning | |
| 50 | + case .failed: return ZyquoColor.danger | |
| 51 | + case .stopped: return ZyquoColor.textTertiary | |
| 52 | + } | |
| 53 | + } | |
| 54 | + | |
| 55 | + private var label: String { | |
| 56 | + switch server.state { | |
| 57 | + case .running(let port): return "Running on :\(port)" | |
| 58 | + case .starting: return "Starting…" | |
| 59 | + case .failed: return "Error" | |
| 60 | + case .stopped: return "Stopped" | |
| 61 | + } | |
| 62 | + } | |
| 63 | +} | |
| 64 | + | |
| 65 | +// MARK: - Copyable mono value | |
| 66 | + | |
| 67 | +/// A mono value with a one-click copy button (endpoints, model IDs, keys). | |
| 68 | +struct CopyField: View { | |
| 69 | + let value: String | |
| 70 | + var display: String? | |
| 71 | + var size: Double = 12.5 | |
| 72 | + @State private var copied = false | |
| 73 | + | |
| 74 | + var body: some View { | |
| 75 | + HStack(spacing: ZyquoSpacing.xs) { | |
| 76 | + Text(display ?? value) | |
| 77 | + .font(ZyquoFont.mono(size: size)) | |
| 78 | + .foregroundStyle(ZyquoColor.textPrimary) | |
| 79 | + .lineLimit(1) | |
| 80 | + .truncationMode(.middle) | |
| 81 | + .textSelection(.enabled) | |
| 82 | + CopyButton(value: value) | |
| 83 | + } | |
| 84 | + } | |
| 85 | +} | |
| 86 | + | |
| 87 | +/// Small copy icon button with a transient "copied" checkmark. | |
| 88 | +struct CopyButton: View { | |
| 89 | + let value: String | |
| 90 | + @State private var copied = false | |
| 91 | + | |
| 92 | + var body: some View { | |
| 93 | + Button { | |
| 94 | + NSPasteboard.general.clearContents() | |
| 95 | + NSPasteboard.general.setString(value, forType: .string) | |
| 96 | + withAnimation(ZyquoMotion.state) { copied = true } | |
| 97 | + Task { | |
| 98 | + try? await Task.sleep(nanoseconds: 1_200_000_000) | |
| 99 | + withAnimation(ZyquoMotion.state) { copied = false } | |
| 100 | + } | |
| 101 | + } label: { | |
| 102 | + Image(systemName: copied ? "checkmark" : "doc.on.doc") | |
| 103 | + .font(.system(size: 10.5, weight: .medium)) | |
| 104 | + .foregroundStyle(copied ? ZyquoColor.success : ZyquoColor.textSecondary) | |
| 105 | + } | |
| 106 | + .buttonStyle(.plain) | |
| 107 | + .help("Copy") | |
| 108 | + } | |
| 109 | +} | |
| 110 | + | |
| 111 | +// MARK: - Badges | |
| 112 | + | |
| 113 | +/// vision / tools / reasoning capability chips on model rows. | |
| 114 | +struct CapabilityBadge: View { | |
| 115 | + let label: String | |
| 116 | + var tint: Color = ZyquoColor.graphite | |
| 117 | + | |
| 118 | + var body: some View { | |
| 119 | + Text(label) | |
| 120 | + .font(.system(size: 9.5, weight: .semibold)) | |
| 121 | + .foregroundStyle(tint) | |
| 122 | + .padding(.horizontal, 6) | |
| 123 | + .padding(.vertical, 2.5) | |
| 124 | + .background( | |
| 125 | + Capsule() | |
| 126 | + .fill(tint.opacity(0.12)) | |
| 127 | + ) | |
| 128 | + } | |
| 129 | +} | |
| 130 | + | |
| 131 | +/// Provider chip with its hue dot. | |
| 132 | +struct ProviderBadge: View { | |
| 133 | + let provider: ProviderID | |
| 134 | + | |
| 135 | + var body: some View { | |
| 136 | + HStack(spacing: 5) { | |
| 137 | + Circle() | |
| 138 | + .fill(ZyquoColor.providerHue(provider)) | |
| 139 | + .frame(width: 6, height: 6) | |
| 140 | + Text(provider.displayName) | |
| 141 | + .font(ZyquoFont.caption) | |
| 142 | + .foregroundStyle(ZyquoColor.textSecondary) | |
| 143 | + } | |
| 144 | + } | |
| 145 | +} | |
| 146 | + | |
| 147 | +// MARK: - Dashboard tile | |
| 148 | + | |
| 149 | +/// One live metric tile: label, big value, optional footnote. | |
| 150 | +struct StatTile: View { | |
| 151 | + let label: String | |
| 152 | + let value: String | |
| 153 | + var footnote: String? | |
| 154 | + var valueColor: Color = ZyquoColor.textPrimary | |
| 155 | + | |
| 156 | + var body: some View { | |
| 157 | + VStack(alignment: .leading, spacing: ZyquoSpacing.xxs) { | |
| 158 | + Text(label.uppercased()) | |
| 159 | + .font(.system(size: 9.5, weight: .semibold)) | |
| 160 | + .foregroundStyle(ZyquoColor.textTertiary) | |
| 161 | + .kerning(0.4) | |
| 162 | + Text(value) | |
| 163 | + .font(ZyquoFont.tileValue) | |
| 164 | + .foregroundStyle(valueColor) | |
| 165 | + .contentTransition(.numericText()) | |
| 166 | + if let footnote { | |
| 167 | + Text(footnote) | |
| 168 | + .font(ZyquoFont.caption) | |
| 169 | + .foregroundStyle(ZyquoColor.textSecondary) | |
| 170 | + } | |
| 171 | + } | |
| 172 | + .frame(minWidth: ZyquoMetrics.tileMinWidth, alignment: .leading) | |
| 173 | + .padding(ZyquoSpacing.md) | |
| 174 | + .zyquoCard() | |
| 175 | + } | |
| 176 | +} | |
| 177 | + | |
| 178 | +// MARK: - Section header | |
| 179 | + | |
| 180 | +struct SectionHeader: View { | |
| 181 | + let title: String | |
| 182 | + var subtitle: String? | |
| 183 | + | |
| 184 | + var body: some View { | |
| 185 | + VStack(alignment: .leading, spacing: 2) { | |
| 186 | + Text(title) | |
| 187 | + .font(ZyquoFont.title) | |
| 188 | + .foregroundStyle(ZyquoColor.textPrimary) | |
| 189 | + if let subtitle { | |
| 190 | + Text(subtitle) | |
| 191 | + .font(ZyquoFont.body()) | |
| 192 | + .foregroundStyle(ZyquoColor.textSecondary) | |
| 193 | + } | |
| 194 | + } | |
| 195 | + } | |
| 196 | +} | |
| 197 | + | |
| 198 | +// MARK: - Empty state | |
| 199 | + | |
| 200 | +struct EmptyState: View { | |
| 201 | + let systemImage: String | |
| 202 | + let title: String | |
| 203 | + let message: String | |
| 204 | + | |
| 205 | + var body: some View { | |
| 206 | + VStack(spacing: ZyquoSpacing.sm) { | |
| 207 | + Image(systemName: systemImage) | |
| 208 | + .font(.system(size: 28, weight: .light)) | |
| 209 | + .foregroundStyle(ZyquoColor.textTertiary) | |
| 210 | + Text(title) | |
| 211 | + .font(ZyquoFont.heading) | |
| 212 | + .foregroundStyle(ZyquoColor.textPrimary) | |
| 213 | + Text(message) | |
| 214 | + .font(ZyquoFont.body()) | |
| 215 | + .foregroundStyle(ZyquoColor.textSecondary) | |
| 216 | + .multilineTextAlignment(.center) | |
| 217 | + .frame(maxWidth: 380) | |
| 218 | + } | |
| 219 | + .frame(maxWidth: .infinity, maxHeight: .infinity) | |
| 220 | + } | |
| 221 | +} | |
added
Sources/ZyquoRouter/DesignSystem/ZyquoTheme.swift
+197 −0
@@ -0,0 +1,197 @@ | ||
| 1 | +// | |
| 2 | +// ZyquoTheme.swift | |
| 3 | +// Zyquo Router | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// The design system — Zyquo family DNA with the Router's graphite-cyan | |
| 9 | +// "control room" identity. Every color, font, spacing, radius, and shadow in | |
| 10 | +// the app comes from these tokens; views contain zero raw hex values or | |
| 11 | +// magic numbers. Light is the flagship; dark derives as a deep graphite | |
| 12 | +// "ops room". SF Mono everywhere data lives: endpoints, model IDs, logs, | |
| 13 | +// JSON, keys. | |
| 14 | +// | |
| 15 | + | |
| 16 | +import SwiftUI | |
| 17 | + | |
| 18 | +// MARK: - Colors | |
| 19 | + | |
| 20 | +enum ZyquoColor { | |
| 21 | + /// Canvas — crisp cool off-white / deep graphite ops room. | |
| 22 | + static let background = dynamic(light: 0xFAFBFC, dark: 0x15181C) | |
| 23 | + /// Cards, panels. | |
| 24 | + static let surface = dynamic(light: 0xFFFFFF, dark: 0x1D2126) | |
| 25 | + /// Hover, log rows, code blocks. | |
| 26 | + static let surfaceSecondary = dynamic(light: 0xF1F4F6, dark: 0x252A31) | |
| 27 | + /// Signal cyan — Start button, active states, links, request charts. | |
| 28 | + static let accent = dynamic(light: 0x0891B2, dark: 0x22B8D4) | |
| 29 | + /// Graphite pair — secondary emphasis, latency charts. | |
| 30 | + static let graphite = dynamic(light: 0x374151, dark: 0x9AA4B2) | |
| 31 | + /// Selected rows, active-server tint. | |
| 32 | + static let accentSubtle = dynamic(light: 0xE5F5F9, dark: 0x143A44) | |
| 33 | + static let textPrimary = dynamic(light: 0x191C1F, dark: 0xE9ECEF) | |
| 34 | + static let textSecondary = dynamic(light: 0x697077, dark: 0x9BA3AA) | |
| 35 | + static let textTertiary = dynamic(light: 0x9BA3AA, dark: 0x6B7280) | |
| 36 | + /// Hairline separators (draw at 0.5pt). | |
| 37 | + static let border = dynamic(light: 0xE4E8EB, dark: 0x2E343B) | |
| 38 | + /// Server running / degraded / stopped & errors. | |
| 39 | + static let success = dynamic(light: 0x2FA36B, dark: 0x43BD83) | |
| 40 | + static let warning = dynamic(light: 0xD9822B, dark: 0xE59A4D) | |
| 41 | + static let danger = dynamic(light: 0xD64545, dark: 0xE36363) | |
| 42 | + | |
| 43 | + /// Chart series tokens: cyan = requests, graphite = latency. | |
| 44 | + static let chartRequests = accent | |
| 45 | + static let chartLatency = graphite | |
| 46 | + | |
| 47 | + /// Per-provider hue for breakdown bars/badges (stable, muted family). | |
| 48 | + static func providerHue(_ provider: ProviderID) -> Color { | |
| 49 | + switch provider { | |
| 50 | + case .openai: return dynamic(light: 0x10A37F, dark: 0x2CBF9B) | |
| 51 | + case .anthropic: return dynamic(light: 0xC96F4A, dark: 0xD98A66) | |
| 52 | + case .xai: return dynamic(light: 0x30343B, dark: 0xAAB2BD) | |
| 53 | + case .mistral: return dynamic(light: 0xE8722E, dark: 0xF08A4D) | |
| 54 | + case .gemini: return dynamic(light: 0x4285F4, dark: 0x6BA1F7) | |
| 55 | + case .qwen: return dynamic(light: 0x7B4DE0, dark: 0x9670E8) | |
| 56 | + case .deepseek: return dynamic(light: 0x4D6BFE, dark: 0x7189FE) | |
| 57 | + case .kimi: return dynamic(light: 0x1F7A67, dark: 0x359984) | |
| 58 | + case .perplexity: return dynamic(light: 0x20808D, dark: 0x3D9AA7) | |
| 59 | + case .together: return dynamic(light: 0x2E62D9, dark: 0x5581E3) | |
| 60 | + case .deepinfra: return dynamic(light: 0x5B8DEF, dark: 0x7BA4F3) | |
| 61 | + case .cerebras: return dynamic(light: 0xB0491F, dark: 0xC66A42) | |
| 62 | + case .custom: return graphite | |
| 63 | + } | |
| 64 | + } | |
| 65 | + | |
| 66 | + static func dynamic(light: UInt32, dark: UInt32) -> Color { | |
| 67 | + Color(nsColor: NSColor(name: nil) { appearance in | |
| 68 | + let hex = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua ? dark : light | |
| 69 | + return NSColor(hex: hex) | |
| 70 | + }) | |
| 71 | + } | |
| 72 | +} | |
| 73 | + | |
| 74 | +extension NSColor { | |
| 75 | + /// 0xRRGGBB → NSColor (sRGB). | |
| 76 | + convenience init(hex: UInt32) { | |
| 77 | + self.init( | |
| 78 | + srgbRed: CGFloat((hex >> 16) & 0xFF) / 255, | |
| 79 | + green: CGFloat((hex >> 8) & 0xFF) / 255, | |
| 80 | + blue: CGFloat(hex & 0xFF) / 255, | |
| 81 | + alpha: 1 | |
| 82 | + ) | |
| 83 | + } | |
| 84 | +} | |
| 85 | + | |
| 86 | +// MARK: - Typography | |
| 87 | + | |
| 88 | +/// Type scale (SF Pro; SF Mono for everything data-shaped). | |
| 89 | +enum ZyquoFont { | |
| 90 | + /// 20pt semibold — window/section titles. | |
| 91 | + static let title = Font.system(size: 20, weight: .semibold) | |
| 92 | + /// 15pt semibold — card titles. | |
| 93 | + static let heading = Font.system(size: 15, weight: .semibold) | |
| 94 | + static func body(size: Double = 13) -> Font { | |
| 95 | + .system(size: size, weight: .regular) | |
| 96 | + } | |
| 97 | + static func bodyEmphasis(size: Double = 13) -> Font { | |
| 98 | + .system(size: size, weight: .medium) | |
| 99 | + } | |
| 100 | + /// 11pt — captions, timestamps, table headers. | |
| 101 | + static let caption = Font.system(size: 11, weight: .regular) | |
| 102 | + /// SF Mono — endpoints, model IDs, logs, JSON, keys, metrics. | |
| 103 | + static func mono(size: Double = 12.5, weight: Font.Weight = .regular) -> Font { | |
| 104 | + .system(size: size, weight: weight, design: .monospaced) | |
| 105 | + } | |
| 106 | + /// Big dashboard numbers. | |
| 107 | + static let tileValue = Font.system(size: 22, weight: .semibold, design: .rounded) | |
| 108 | +} | |
| 109 | + | |
| 110 | +// MARK: - Spacing, radii, shadows, metrics, motion | |
| 111 | + | |
| 112 | +/// Spacing scale: 4 / 8 / 12 / 16 / 20 / 24 / 32. | |
| 113 | +enum ZyquoSpacing { | |
| 114 | + static let xxs: CGFloat = 4 | |
| 115 | + static let xs: CGFloat = 8 | |
| 116 | + static let sm: CGFloat = 12 | |
| 117 | + static let md: CGFloat = 16 | |
| 118 | + static let lg: CGFloat = 20 | |
| 119 | + static let xl: CGFloat = 24 | |
| 120 | + static let xxl: CGFloat = 32 | |
| 121 | +} | |
| 122 | + | |
| 123 | +/// Corner radii: 6 (small controls), 10 (cards), 14 (floating panels). | |
| 124 | +enum ZyquoRadius { | |
| 125 | + static let small: CGFloat = 6 | |
| 126 | + static let medium: CGFloat = 10 | |
| 127 | + static let large: CGFloat = 14 | |
| 128 | +} | |
| 129 | + | |
| 130 | +/// Ultra-soft shadows, floating panels only. | |
| 131 | +enum ZyquoShadow { | |
| 132 | + static let soft = ShadowStyle(color: .black.opacity(0.06), radius: 12, y: 2) | |
| 133 | + | |
| 134 | + struct ShadowStyle { | |
| 135 | + let color: Color | |
| 136 | + let radius: CGFloat | |
| 137 | + var x: CGFloat = 0 | |
| 138 | + var y: CGFloat = 0 | |
| 139 | + } | |
| 140 | +} | |
| 141 | + | |
| 142 | +/// Fixed layout metrics from the Phase 4 spec. | |
| 143 | +enum ZyquoMetrics { | |
| 144 | + static let navigatorWidth: CGFloat = 240 | |
| 145 | + static let hairline: CGFloat = 0.5 | |
| 146 | + static let windowMinWidth: CGFloat = 1020 | |
| 147 | + static let windowMinHeight: CGFloat = 660 | |
| 148 | + static let windowDefaultWidth: CGFloat = 1280 | |
| 149 | + static let windowDefaultHeight: CGFloat = 820 | |
| 150 | + static let settingsWidth: CGFloat = 720 | |
| 151 | + static let settingsHeight: CGFloat = 540 | |
| 152 | + static let contentInset: CGFloat = 20 | |
| 153 | + static let tileMinWidth: CGFloat = 148 | |
| 154 | +} | |
| 155 | + | |
| 156 | +/// Motion tokens — the status pill transitions cleanly, tiles update smoothly. | |
| 157 | +enum ZyquoMotion { | |
| 158 | + static let hover = Animation.easeInOut(duration: 0.08) | |
| 159 | + static let state = Animation.easeOut(duration: 0.15) | |
| 160 | + static let pressedScale: CGFloat = 0.97 | |
| 161 | + static let live = Animation.easeInOut(duration: 0.25) | |
| 162 | +} | |
| 163 | + | |
| 164 | +// MARK: - View helpers | |
| 165 | + | |
| 166 | +extension View { | |
| 167 | + /// Standard soft shadow for floating panels/popovers only. | |
| 168 | + func zyquoSoftShadow() -> some View { | |
| 169 | + shadow( | |
| 170 | + color: ZyquoShadow.soft.color, | |
| 171 | + radius: ZyquoShadow.soft.radius, | |
| 172 | + x: ZyquoShadow.soft.x, | |
| 173 | + y: ZyquoShadow.soft.y | |
| 174 | + ) | |
| 175 | + } | |
| 176 | + | |
| 177 | + /// Standard card container: surface, medium radius, hairline border. | |
| 178 | + func zyquoCard() -> some View { | |
| 179 | + background( | |
| 180 | + RoundedRectangle(cornerRadius: ZyquoRadius.medium) | |
| 181 | + .fill(ZyquoColor.surface) | |
| 182 | + .overlay( | |
| 183 | + RoundedRectangle(cornerRadius: ZyquoRadius.medium) | |
| 184 | + .strokeBorder(ZyquoColor.border, lineWidth: ZyquoMetrics.hairline) | |
| 185 | + ) | |
| 186 | + ) | |
| 187 | + } | |
| 188 | +} | |
| 189 | + | |
| 190 | +/// 0.5pt hairline separator in the border token color. | |
| 191 | +struct ZyquoHairline: View { | |
| 192 | + var body: some View { | |
| 193 | + Rectangle() | |
| 194 | + .fill(ZyquoColor.border) | |
| 195 | + .frame(height: ZyquoMetrics.hairline) | |
| 196 | + } | |
| 197 | +} | |
added
Sources/ZyquoRouter/ViewModels/KeyVaultStore.swift
+93 −0
@@ -0,0 +1,93 @@ | ||
| 1 | +// | |
| 2 | +// KeyVaultStore.swift | |
| 3 | +// Zyquo Router | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// Observable wrapper around SecureKeyStore for the Keys screen: 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/ZyquoRouter/ViewModels/LocalKeysStore.swift
+54 −0
@@ -0,0 +1,54 @@ | ||
| 1 | +// | |
| 2 | +// LocalKeysStore.swift | |
| 3 | +// Zyquo Router | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// Local router API keys (`zyquo-sk-…`): create (token shown once), rename, | |
| 9 | +// enable/disable, revoke. Hashes persist in local-keys.json; the server | |
| 10 | +// snapshot is taken at Start. | |
| 11 | +// | |
| 12 | + | |
| 13 | +import Foundation | |
| 14 | + | |
| 15 | +@MainActor | |
| 16 | +final class LocalKeysStore: ObservableObject { | |
| 17 | + static let fileName = "local-keys.json" | |
| 18 | + | |
| 19 | + @Published private(set) var keys: [APIKeyRecord] | |
| 20 | + | |
| 21 | + private let persistence: PersistenceService | |
| 22 | + | |
| 23 | + init(persistence: PersistenceService = .shared) { | |
| 24 | + self.persistence = persistence | |
| 25 | + keys = persistence.load([APIKeyRecord].self, from: Self.fileName) ?? [] | |
| 26 | + } | |
| 27 | + | |
| 28 | + /// Creates a key and returns the plaintext token — shown exactly once. | |
| 29 | + func create(name: String) -> String { | |
| 30 | + let (record, token) = APIKeyRecord.generate(name: name.isEmpty ? "Untitled key" : name) | |
| 31 | + keys.append(record) | |
| 32 | + save() | |
| 33 | + return token | |
| 34 | + } | |
| 35 | + | |
| 36 | + func setEnabled(_ enabled: Bool, for id: UUID) { | |
| 37 | + guard let index = keys.firstIndex(where: { $0.id == id }) else { return } | |
| 38 | + keys[index].enabled = enabled | |
| 39 | + save() | |
| 40 | + } | |
| 41 | + | |
| 42 | + func revoke(_ id: UUID) { | |
| 43 | + keys.removeAll { $0.id == id } | |
| 44 | + save() | |
| 45 | + } | |
| 46 | + | |
| 47 | + var hasEnabledKey: Bool { | |
| 48 | + keys.contains(where: \.enabled) | |
| 49 | + } | |
| 50 | + | |
| 51 | + private func save() { | |
| 52 | + persistence.save(keys, to: Self.fileName) | |
| 53 | + } | |
| 54 | +} | |
modified
Sources/ZyquoRouter/ViewModels/ServerController.swift
+11 −1
@@ -27,6 +27,11 @@ final class ServerController: ObservableObject { | ||
| 27 | 27 | @AppStorage("serverPort") var port = 8787 |
| 28 | 28 | @AppStorage("bindLAN") var bindLAN = false |
| 29 | 29 | |
| 30 | + /// Shared with Routes at Start; the dashboard reads totals from it. | |
| 31 | + let usageMeter = UsageMeter() | |
| 32 | + /// When the current run started (uptime tile). | |
| 33 | + @Published private(set) var startedAt: Date? | |
| 34 | + | |
| 30 | 35 | private var serverTask: Task<Void, Never>? |
| 31 | 36 | |
| 32 | 37 | var endpointURL: String { |
@@ -54,7 +59,8 @@ final class ServerController: ObservableObject { | ||
| 54 | 59 | |
| 55 | 60 | let routes = Routes( |
| 56 | 61 | router: RequestRouter(), |
| 57 | − auth: AuthMiddleware(keys: localKeys) | |
| 62 | + auth: AuthMiddleware(keys: localKeys), | |
| 63 | + usageMeter: usageMeter | |
| 58 | 64 | ) |
| 59 | 65 | let server = HTTPServer(host: host, port: port) { request in |
| 60 | 66 | await routes.handle(request) |
@@ -68,16 +74,20 @@ final class ServerController: ObservableObject { | ||
| 68 | 74 | Task { @MainActor [weak self] in |
| 69 | 75 | guard let self else { return } |
| 70 | 76 | self.state = .running(port: port) |
| 77 | + self.startedAt = Date() | |
| 71 | 78 | } |
| 72 | 79 | } |
| 73 | 80 | self.serverTask = nil |
| 74 | 81 | self.state = .stopped |
| 82 | + self.startedAt = nil | |
| 75 | 83 | } catch is CancellationError { |
| 76 | 84 | self.serverTask = nil |
| 77 | 85 | self.state = .stopped |
| 86 | + self.startedAt = nil | |
| 78 | 87 | } catch { |
| 79 | 88 | self.serverTask = nil |
| 80 | 89 | self.state = .failed(error.localizedDescription) |
| 90 | + self.startedAt = nil | |
| 81 | 91 | } |
| 82 | 92 | } |
| 83 | 93 | } |
added
Sources/ZyquoRouter/Views/DashboardView.swift
+359 −0
@@ -0,0 +1,359 @@ | ||
| 1 | +// | |
| 2 | +// DashboardView.swift | |
| 3 | +// Zyquo Router | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// The hero screen: server card (status, Start/Stop, port, bind selector, | |
| 9 | +// endpoint + copy-as snippets), live metric tiles, first-run onboarding. | |
| 10 | +// | |
| 11 | + | |
| 12 | +import SwiftUI | |
| 13 | + | |
| 14 | +struct DashboardView: View { | |
| 15 | + @EnvironmentObject private var server: ServerController | |
| 16 | + @EnvironmentObject private var vault: KeyVaultStore | |
| 17 | + @EnvironmentObject private var localKeys: LocalKeysStore | |
| 18 | + | |
| 19 | + @State private var totals: (requests: Int, tokens: Int, cost: Double, errors: Int) = (0, 0, 0, 0) | |
| 20 | + @State private var uptimeText = "—" | |
| 21 | + private let refresh = Timer.publish(every: 1, on: .main, in: .common).autoconnect() | |
| 22 | + | |
| 23 | + private var hasAnyProviderKey: Bool { | |
| 24 | + ProviderID.builtIn.contains { vault.hasKey(for: $0) } | |
| 25 | + } | |
| 26 | + | |
| 27 | + var body: some View { | |
| 28 | + ScrollView { | |
| 29 | + VStack(alignment: .leading, spacing: ZyquoSpacing.lg) { | |
| 30 | + SectionHeader( | |
| 31 | + title: "Dashboard", | |
| 32 | + subtitle: "One local endpoint for every provider." | |
| 33 | + ) | |
| 34 | + | |
| 35 | + if !hasAnyProviderKey { | |
| 36 | + OnboardingCard() | |
| 37 | + } | |
| 38 | + | |
| 39 | + ServerCard() | |
| 40 | + | |
| 41 | + // Live tiles | |
| 42 | + let columns = [GridItem(.adaptive(minimum: ZyquoMetrics.tileMinWidth), spacing: ZyquoSpacing.sm)] | |
| 43 | + LazyVGrid(columns: columns, alignment: .leading, spacing: ZyquoSpacing.sm) { | |
| 44 | + StatTile(label: "Requests today", value: "\(totals.requests)") | |
| 45 | + StatTile(label: "Tokens today", value: compact(totals.tokens)) | |
| 46 | + StatTile( | |
| 47 | + label: "Est. cost today", | |
| 48 | + value: totals.cost < 0.005 && totals.cost > 0 | |
| 49 | + ? "<$0.01" | |
| 50 | + : String(format: "$%.2f", totals.cost), | |
| 51 | + valueColor: ZyquoColor.accent | |
| 52 | + ) | |
| 53 | + StatTile( | |
| 54 | + label: "Errors today", | |
| 55 | + value: "\(totals.errors)", | |
| 56 | + valueColor: totals.errors > 0 ? ZyquoColor.danger : ZyquoColor.textPrimary | |
| 57 | + ) | |
| 58 | + StatTile(label: "Uptime", value: uptimeText) | |
| 59 | + } | |
| 60 | + } | |
| 61 | + .padding(ZyquoMetrics.contentInset) | |
| 62 | + .frame(maxWidth: 860, alignment: .leading) | |
| 63 | + } | |
| 64 | + .frame(maxWidth: .infinity, alignment: .center) | |
| 65 | + .onReceive(refresh) { _ in | |
| 66 | + refreshTiles() | |
| 67 | + } | |
| 68 | + } | |
| 69 | + | |
| 70 | + private func refreshTiles() { | |
| 71 | + if let startedAt = server.startedAt { | |
| 72 | + let seconds = Int(Date().timeIntervalSince(startedAt)) | |
| 73 | + uptimeText = seconds >= 3600 | |
| 74 | + ? String(format: "%dh %02dm", seconds / 3600, (seconds % 3600) / 60) | |
| 75 | + : String(format: "%dm %02ds", seconds / 60, seconds % 60) | |
| 76 | + } else { | |
| 77 | + uptimeText = "—" | |
| 78 | + } | |
| 79 | + let meter = server.usageMeter | |
| 80 | + Task { | |
| 81 | + let cutoff = Calendar.current.startOfDay(for: Date()) | |
| 82 | + let today = await meter.totals(since: cutoff) | |
| 83 | + withAnimation(ZyquoMotion.live) { | |
| 84 | + totals = (today.requests, today.usage.totalTokens, today.cost, today.errors) | |
| 85 | + } | |
| 86 | + } | |
| 87 | + } | |
| 88 | + | |
| 89 | + private func compact(_ value: Int) -> String { | |
| 90 | + switch value { | |
| 91 | + case 1_000_000...: return String(format: "%.1fM", Double(value) / 1_000_000) | |
| 92 | + case 1_000...: return String(format: "%.1fK", Double(value) / 1_000) | |
| 93 | + default: return "\(value)" | |
| 94 | + } | |
| 95 | + } | |
| 96 | +} | |
| 97 | + | |
| 98 | +// MARK: - Server card | |
| 99 | + | |
| 100 | +private struct ServerCard: View { | |
| 101 | + @EnvironmentObject private var server: ServerController | |
| 102 | + @EnvironmentObject private var localKeys: LocalKeysStore | |
| 103 | + @State private var snippet: Snippet = .curl | |
| 104 | + | |
| 105 | + var body: some View { | |
| 106 | + VStack(alignment: .leading, spacing: ZyquoSpacing.md) { | |
| 107 | + HStack(alignment: .center, spacing: ZyquoSpacing.md) { | |
| 108 | + statusBlock | |
| 109 | + Spacer() | |
| 110 | + startStopButton | |
| 111 | + } | |
| 112 | + | |
| 113 | + ZyquoHairline() | |
| 114 | + | |
| 115 | + HStack(spacing: ZyquoSpacing.lg) { | |
| 116 | + // Port | |
| 117 | + VStack(alignment: .leading, spacing: ZyquoSpacing.xxs) { | |
| 118 | + fieldLabel("PORT") | |
| 119 | + TextField("8787", value: $server.port, format: .number.grouping(.never)) | |
| 120 | + .textFieldStyle(.plain) | |
| 121 | + .font(ZyquoFont.mono(size: 13, weight: .medium)) | |
| 122 | + .frame(width: 64) | |
| 123 | + .padding(.horizontal, ZyquoSpacing.xs) | |
| 124 | + .padding(.vertical, 5) | |
| 125 | + .background( | |
| 126 | + RoundedRectangle(cornerRadius: ZyquoRadius.small) | |
| 127 | + .fill(ZyquoColor.surfaceSecondary) | |
| 128 | + ) | |
| 129 | + .disabled(server.isRunning) | |
| 130 | + } | |
| 131 | + | |
| 132 | + // Bind selector | |
| 133 | + VStack(alignment: .leading, spacing: ZyquoSpacing.xxs) { | |
| 134 | + fieldLabel("BIND") | |
| 135 | + Picker("", selection: $server.bindLAN) { | |
| 136 | + Text("Localhost only").tag(false) | |
| 137 | + Text("LAN (requires local key)").tag(true) | |
| 138 | + } | |
| 139 | + .labelsHidden() | |
| 140 | + .pickerStyle(.menu) | |
| 141 | + .frame(width: 200) | |
| 142 | + .disabled(server.isRunning) | |
| 143 | + } | |
| 144 | + | |
| 145 | + Spacer() | |
| 146 | + } | |
| 147 | + | |
| 148 | + if server.bindLAN, !localKeys.hasEnabledKey { | |
| 149 | + Label( | |
| 150 | + "LAN exposure requires at least one enabled local API key — create one in Keys.", | |
| 151 | + systemImage: "exclamationmark.triangle" | |
| 152 | + ) | |
| 153 | + .font(ZyquoFont.caption) | |
| 154 | + .foregroundStyle(ZyquoColor.warning) | |
| 155 | + } | |
| 156 | + | |
| 157 | + if case .failed(let message) = server.state { | |
| 158 | + Label(message, systemImage: "xmark.octagon") | |
| 159 | + .font(ZyquoFont.body()) | |
| 160 | + .foregroundStyle(ZyquoColor.danger) | |
| 161 | + } | |
| 162 | + | |
| 163 | + if server.isRunning { | |
| 164 | + VStack(alignment: .leading, spacing: ZyquoSpacing.xs) { | |
| 165 | + fieldLabel("ENDPOINT") | |
| 166 | + HStack { | |
| 167 | + CopyField(value: server.endpointURL, size: 13) | |
| 168 | + Spacer() | |
| 169 | + Picker("", selection: $snippet) { | |
| 170 | + ForEach(Snippet.allCases) { choice in | |
| 171 | + Text(choice.rawValue).tag(choice) | |
| 172 | + } | |
| 173 | + } | |
| 174 | + .labelsHidden() | |
| 175 | + .pickerStyle(.segmented) | |
| 176 | + .frame(width: 220) | |
| 177 | + } | |
| 178 | + HStack(alignment: .top) { | |
| 179 | + Text(snippet.code(endpoint: server.endpointURL)) | |
| 180 | + .font(ZyquoFont.mono(size: 11)) | |
| 181 | + .foregroundStyle(ZyquoColor.textSecondary) | |
| 182 | + .textSelection(.enabled) | |
| 183 | + .frame(maxWidth: .infinity, alignment: .leading) | |
| 184 | + CopyButton(value: snippet.code(endpoint: server.endpointURL)) | |
| 185 | + } | |
| 186 | + .padding(ZyquoSpacing.sm) | |
| 187 | + .background( | |
| 188 | + RoundedRectangle(cornerRadius: ZyquoRadius.small) | |
| 189 | + .fill(ZyquoColor.surfaceSecondary) | |
| 190 | + ) | |
| 191 | + } | |
| 192 | + } | |
| 193 | + } | |
| 194 | + .padding(ZyquoSpacing.lg) | |
| 195 | + .zyquoCard() | |
| 196 | + .animation(ZyquoMotion.state, value: server.isRunning) | |
| 197 | + } | |
| 198 | + | |
| 199 | + private var statusBlock: some View { | |
| 200 | + HStack(spacing: ZyquoSpacing.sm) { | |
| 201 | + Circle() | |
| 202 | + .fill(statusColor) | |
| 203 | + .frame(width: 12, height: 12) | |
| 204 | + .overlay( | |
| 205 | + Circle() | |
| 206 | + .stroke(statusColor.opacity(0.25), lineWidth: 4) | |
| 207 | + ) | |
| 208 | + VStack(alignment: .leading, spacing: 1) { | |
| 209 | + Text(statusTitle) | |
| 210 | + .font(ZyquoFont.heading) | |
| 211 | + .foregroundStyle(ZyquoColor.textPrimary) | |
| 212 | + Text(statusDetail) | |
| 213 | + .font(ZyquoFont.caption) | |
| 214 | + .foregroundStyle(ZyquoColor.textSecondary) | |
| 215 | + } | |
| 216 | + } | |
| 217 | + } | |
| 218 | + | |
| 219 | + private var startStopButton: some View { | |
| 220 | + Button { | |
| 221 | + server.toggle() | |
| 222 | + } label: { | |
| 223 | + Text(server.isRunning || server.state == .starting ? "Stop" : "Start") | |
| 224 | + .font(ZyquoFont.bodyEmphasis(size: 14)) | |
| 225 | + .foregroundStyle(.white) | |
| 226 | + .frame(width: 120, height: 34) | |
| 227 | + .background( | |
| 228 | + RoundedRectangle(cornerRadius: ZyquoRadius.small) | |
| 229 | + .fill(server.isRunning ? ZyquoColor.graphite : ZyquoColor.accent) | |
| 230 | + ) | |
| 231 | + } | |
| 232 | + .buttonStyle(.plain) | |
| 233 | + .keyboardShortcut("r", modifiers: .command) | |
| 234 | + } | |
| 235 | + | |
| 236 | + private var statusColor: Color { | |
| 237 | + switch server.state { | |
| 238 | + case .running: return ZyquoColor.success | |
| 239 | + case .starting: return ZyquoColor.warning | |
| 240 | + case .failed: return ZyquoColor.danger | |
| 241 | + case .stopped: return ZyquoColor.textTertiary | |
| 242 | + } | |
| 243 | + } | |
| 244 | + | |
| 245 | + private var statusTitle: String { | |
| 246 | + switch server.state { | |
| 247 | + case .running(let port): return "Running on :\(port)" | |
| 248 | + case .starting: return "Starting…" | |
| 249 | + case .failed: return "Failed to start" | |
| 250 | + case .stopped: return "Stopped" | |
| 251 | + } | |
| 252 | + } | |
| 253 | + | |
| 254 | + private var statusDetail: String { | |
| 255 | + switch server.state { | |
| 256 | + case .running: return server.bindLAN ? "Serving on the local network" : "Serving on localhost only" | |
| 257 | + case .starting: return "Binding the port" | |
| 258 | + case .failed: return "See the error below" | |
| 259 | + case .stopped: return "The gateway is offline" | |
| 260 | + } | |
| 261 | + } | |
| 262 | + | |
| 263 | + private func fieldLabel(_ text: String) -> some View { | |
| 264 | + Text(text) | |
| 265 | + .font(.system(size: 9.5, weight: .semibold)) | |
| 266 | + .foregroundStyle(ZyquoColor.textTertiary) | |
| 267 | + .kerning(0.4) | |
| 268 | + } | |
| 269 | +} | |
| 270 | + | |
| 271 | +// MARK: - Copy-as snippets | |
| 272 | + | |
| 273 | +private enum Snippet: String, CaseIterable, Identifiable { | |
| 274 | + case curl = "curl" | |
| 275 | + case python = "Python" | |
| 276 | + case javascript = "JS" | |
| 277 | + | |
| 278 | + var id: String { rawValue } | |
| 279 | + | |
| 280 | + func code(endpoint: String) -> String { | |
| 281 | + switch self { | |
| 282 | + case .curl: | |
| 283 | + return """ | |
| 284 | + curl \(endpoint)/chat/completions \\ | |
| 285 | + -H "Content-Type: application/json" \\ | |
| 286 | + -d '{"model": "anthropic/claude-sonnet-4-5", "messages": [{"role": "user", "content": "Hello"}]}' | |
| 287 | + """ | |
| 288 | + case .python: | |
| 289 | + return """ | |
| 290 | + from openai import OpenAI | |
| 291 | + client = OpenAI(base_url="\(endpoint)", api_key="zyquo") | |
| 292 | + r = client.chat.completions.create( | |
| 293 | + model="anthropic/claude-sonnet-4-5", | |
| 294 | + messages=[{"role": "user", "content": "Hello"}], | |
| 295 | + ) | |
| 296 | + """ | |
| 297 | + case .javascript: | |
| 298 | + return """ | |
| 299 | + import OpenAI from "openai"; | |
| 300 | + const client = new OpenAI({ baseURL: "\(endpoint)", apiKey: "zyquo" }); | |
| 301 | + const r = await client.chat.completions.create({ | |
| 302 | + model: "anthropic/claude-sonnet-4-5", | |
| 303 | + messages: [{ role: "user", content: "Hello" }], | |
| 304 | + }); | |
| 305 | + """ | |
| 306 | + } | |
| 307 | + } | |
| 308 | +} | |
| 309 | + | |
| 310 | +// MARK: - First-run onboarding | |
| 311 | + | |
| 312 | +private struct OnboardingCard: View { | |
| 313 | + var body: some View { | |
| 314 | + VStack(alignment: .leading, spacing: ZyquoSpacing.md) { | |
| 315 | + Text("Three steps to one endpoint") | |
| 316 | + .font(ZyquoFont.heading) | |
| 317 | + .foregroundStyle(ZyquoColor.textPrimary) | |
| 318 | + HStack(spacing: ZyquoSpacing.lg) { | |
| 319 | + OnboardingStep(number: 1, title: "Add a provider key", detail: "Keys → Provider Keys") | |
| 320 | + OnboardingStep(number: 2, title: "Pick a port", detail: "Default 8787") | |
| 321 | + OnboardingStep(number: 3, title: "Press Start", detail: "Point any OpenAI SDK at it") | |
| 322 | + } | |
| 323 | + } | |
| 324 | + .padding(ZyquoSpacing.lg) | |
| 325 | + .frame(maxWidth: .infinity, alignment: .leading) | |
| 326 | + .background( | |
| 327 | + RoundedRectangle(cornerRadius: ZyquoRadius.medium) | |
| 328 | + .fill(ZyquoColor.accentSubtle) | |
| 329 | + .overlay( | |
| 330 | + RoundedRectangle(cornerRadius: ZyquoRadius.medium) | |
| 331 | + .strokeBorder(ZyquoColor.accent.opacity(0.25), lineWidth: ZyquoMetrics.hairline) | |
| 332 | + ) | |
| 333 | + ) | |
| 334 | + } | |
| 335 | +} | |
| 336 | + | |
| 337 | +private struct OnboardingStep: View { | |
| 338 | + let number: Int | |
| 339 | + let title: String | |
| 340 | + let detail: String | |
| 341 | + | |
| 342 | + var body: some View { | |
| 343 | + HStack(alignment: .top, spacing: ZyquoSpacing.xs) { | |
| 344 | + Text("\(number)") | |
| 345 | + .font(ZyquoFont.mono(size: 12, weight: .semibold)) | |
| 346 | + .foregroundStyle(.white) | |
| 347 | + .frame(width: 20, height: 20) | |
| 348 | + .background(Circle().fill(ZyquoColor.accent)) | |
| 349 | + VStack(alignment: .leading, spacing: 1) { | |
| 350 | + Text(title) | |
| 351 | + .font(ZyquoFont.bodyEmphasis()) | |
| 352 | + .foregroundStyle(ZyquoColor.textPrimary) | |
| 353 | + Text(detail) | |
| 354 | + .font(ZyquoFont.caption) | |
| 355 | + .foregroundStyle(ZyquoColor.textSecondary) | |
| 356 | + } | |
| 357 | + } | |
| 358 | + } | |
| 359 | +} | |
added
Sources/ZyquoRouter/Views/DocsView.swift
+181 −0
@@ -0,0 +1,181 @@ | ||
| 1 | +// | |
| 2 | +// DocsView.swift | |
| 3 | +// Zyquo Router | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// In-app rendering of docs/API.md — the same source of truth as the served | |
| 9 | +// behavior. The file is bundled into the app (Makefile copies it into | |
| 10 | +// Contents/Resources); a repo-relative fallback covers `swift run` during | |
| 11 | +// development. | |
| 12 | +// | |
| 13 | + | |
| 14 | +import SwiftUI | |
| 15 | + | |
| 16 | +struct DocsView: View { | |
| 17 | + @State private var markdown: String? | |
| 18 | + | |
| 19 | + var body: some View { | |
| 20 | + Group { | |
| 21 | + if let markdown { | |
| 22 | + ScrollView { | |
| 23 | + MarkdownText(markdown: markdown) | |
| 24 | + .padding(ZyquoMetrics.contentInset) | |
| 25 | + .frame(maxWidth: 780, alignment: .leading) | |
| 26 | + } | |
| 27 | + .frame(maxWidth: .infinity) | |
| 28 | + } else { | |
| 29 | + EmptyState( | |
| 30 | + systemImage: "book", | |
| 31 | + title: "API reference unavailable", | |
| 32 | + message: "docs/API.md was not found in the app bundle." | |
| 33 | + ) | |
| 34 | + } | |
| 35 | + } | |
| 36 | + .onAppear(perform: load) | |
| 37 | + } | |
| 38 | + | |
| 39 | + private func load() { | |
| 40 | + if let bundled = Bundle.main.url(forResource: "API", withExtension: "md"), | |
| 41 | + let text = try? String(contentsOf: bundled, encoding: .utf8) { | |
| 42 | + markdown = text | |
| 43 | + return | |
| 44 | + } | |
| 45 | + // Development fallback: repo checkout next to the executable's cwd. | |
| 46 | + let repoDocs = URL(fileURLWithPath: FileManager.default.currentDirectoryPath) | |
| 47 | + .appendingPathComponent("docs/API.md") | |
| 48 | + markdown = try? String(contentsOf: repoDocs, encoding: .utf8) | |
| 49 | + } | |
| 50 | +} | |
| 51 | + | |
| 52 | +/// Lightweight markdown rendering tuned for the API reference: headings, | |
| 53 | +/// code blocks (mono on surfaceSecondary), tables as mono blocks, body text | |
| 54 | +/// via AttributedString's markdown parser. | |
| 55 | +private struct MarkdownText: View { | |
| 56 | + let markdown: String | |
| 57 | + | |
| 58 | + var body: some View { | |
| 59 | + VStack(alignment: .leading, spacing: ZyquoSpacing.sm) { | |
| 60 | + ForEach(Array(blocks.enumerated()), id: \.offset) { _, block in | |
| 61 | + render(block) | |
| 62 | + } | |
| 63 | + } | |
| 64 | + } | |
| 65 | + | |
| 66 | + private enum Block { | |
| 67 | + case heading(level: Int, text: String) | |
| 68 | + case code(String) | |
| 69 | + case table(String) | |
| 70 | + case paragraph(String) | |
| 71 | + case rule | |
| 72 | + } | |
| 73 | + | |
| 74 | + private var blocks: [Block] { | |
| 75 | + var result: [Block] = [] | |
| 76 | + var paragraph: [String] = [] | |
| 77 | + var codeBlock: [String]? | |
| 78 | + var tableBlock: [String] = [] | |
| 79 | + | |
| 80 | + func flushParagraph() { | |
| 81 | + if !paragraph.isEmpty { | |
| 82 | + result.append(.paragraph(paragraph.joined(separator: "\n"))) | |
| 83 | + paragraph = [] | |
| 84 | + } | |
| 85 | + } | |
| 86 | + func flushTable() { | |
| 87 | + if !tableBlock.isEmpty { | |
| 88 | + result.append(.table(tableBlock.joined(separator: "\n"))) | |
| 89 | + tableBlock = [] | |
| 90 | + } | |
| 91 | + } | |
| 92 | + | |
| 93 | + for line in markdown.components(separatedBy: "\n") { | |
| 94 | + if var code = codeBlock { | |
| 95 | + if line.hasPrefix("```") { | |
| 96 | + result.append(.code(code.joined(separator: "\n"))) | |
| 97 | + codeBlock = nil | |
| 98 | + } else { | |
| 99 | + code.append(line) | |
| 100 | + codeBlock = code | |
| 101 | + } | |
| 102 | + continue | |
| 103 | + } | |
| 104 | + if line.hasPrefix("```") { | |
| 105 | + flushParagraph(); flushTable() | |
| 106 | + codeBlock = [] | |
| 107 | + continue | |
| 108 | + } | |
| 109 | + if line.hasPrefix("|") { | |
| 110 | + flushParagraph() | |
| 111 | + tableBlock.append(line) | |
| 112 | + continue | |
| 113 | + } | |
| 114 | + flushTable() | |
| 115 | + if line.hasPrefix("#") { | |
| 116 | + flushParagraph() | |
| 117 | + let level = line.prefix(while: { $0 == "#" }).count | |
| 118 | + result.append(.heading(level: level, text: line.drop(while: { $0 == "#" }).trimmingCharacters(in: .whitespaces))) | |
| 119 | + } else if line.hasPrefix("---") { | |
| 120 | + flushParagraph() | |
| 121 | + result.append(.rule) | |
| 122 | + } else if line.trimmingCharacters(in: .whitespaces).isEmpty { | |
| 123 | + flushParagraph() | |
| 124 | + } else { | |
| 125 | + paragraph.append(line) | |
| 126 | + } | |
| 127 | + } | |
| 128 | + flushParagraph(); flushTable() | |
| 129 | + return result | |
| 130 | + } | |
| 131 | + | |
| 132 | + @ViewBuilder | |
| 133 | + private func render(_ block: Block) -> some View { | |
| 134 | + switch block { | |
| 135 | + case .heading(let level, let text): | |
| 136 | + Text(text) | |
| 137 | + .font(level <= 1 ? ZyquoFont.title : level == 2 ? .system(size: 17, weight: .semibold) : ZyquoFont.heading) | |
| 138 | + .foregroundStyle(ZyquoColor.textPrimary) | |
| 139 | + .padding(.top, level <= 2 ? ZyquoSpacing.sm : ZyquoSpacing.xxs) | |
| 140 | + case .code(let code): | |
| 141 | + HStack(alignment: .top) { | |
| 142 | + Text(code) | |
| 143 | + .font(ZyquoFont.mono(size: 11.5)) | |
| 144 | + .foregroundStyle(ZyquoColor.textPrimary) | |
| 145 | + .textSelection(.enabled) | |
| 146 | + .frame(maxWidth: .infinity, alignment: .leading) | |
| 147 | + CopyButton(value: code) | |
| 148 | + } | |
| 149 | + .padding(ZyquoSpacing.sm) | |
| 150 | + .background( | |
| 151 | + RoundedRectangle(cornerRadius: ZyquoRadius.small) | |
| 152 | + .fill(ZyquoColor.surfaceSecondary) | |
| 153 | + ) | |
| 154 | + case .table(let table): | |
| 155 | + Text(table) | |
| 156 | + .font(ZyquoFont.mono(size: 10.5)) | |
| 157 | + .foregroundStyle(ZyquoColor.textSecondary) | |
| 158 | + .textSelection(.enabled) | |
| 159 | + .padding(ZyquoSpacing.xs) | |
| 160 | + .background( | |
| 161 | + RoundedRectangle(cornerRadius: ZyquoRadius.small) | |
| 162 | + .fill(ZyquoColor.surfaceSecondary) | |
| 163 | + ) | |
| 164 | + case .paragraph(let text): | |
| 165 | + Text(attributed(text)) | |
| 166 | + .font(ZyquoFont.body()) | |
| 167 | + .foregroundStyle(ZyquoColor.textPrimary) | |
| 168 | + .lineSpacing(3) | |
| 169 | + case .rule: | |
| 170 | + ZyquoHairline() | |
| 171 | + .padding(.vertical, ZyquoSpacing.xxs) | |
| 172 | + } | |
| 173 | + } | |
| 174 | + | |
| 175 | + private func attributed(_ text: String) -> AttributedString { | |
| 176 | + (try? AttributedString( | |
| 177 | + markdown: text, | |
| 178 | + options: AttributedString.MarkdownParsingOptions(interpretedSyntax: .inlineOnlyPreservingWhitespace) | |
| 179 | + )) ?? AttributedString(text) | |
| 180 | + } | |
| 181 | +} | |
added
Sources/ZyquoRouter/Views/KeysView.swift
+241 −0
@@ -0,0 +1,241 @@ | ||
| 1 | +// | |
| 2 | +// KeysView.swift | |
| 3 | +// Zyquo Router | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// Two tabs: Provider Keys (masked vault fields with per-provider Test — | |
| 9 | +// identical UX to Zyquo Cloud) and Local API Keys (create/reveal-once/ | |
| 10 | +// revoke zyquo-sk tokens). | |
| 11 | +// | |
| 12 | + | |
| 13 | +import SwiftUI | |
| 14 | + | |
| 15 | +struct KeysView: View { | |
| 16 | + @State private var tab: Tab = .provider | |
| 17 | + | |
| 18 | + enum Tab: String, CaseIterable, Identifiable { | |
| 19 | + case provider = "Provider Keys" | |
| 20 | + case local = "Local API Keys" | |
| 21 | + var id: String { rawValue } | |
| 22 | + } | |
| 23 | + | |
| 24 | + var body: some View { | |
| 25 | + VStack(alignment: .leading, spacing: 0) { | |
| 26 | + HStack { | |
| 27 | + SectionHeader(title: "Keys") | |
| 28 | + Spacer() | |
| 29 | + Picker("", selection: $tab) { | |
| 30 | + ForEach(Tab.allCases) { tab in | |
| 31 | + Text(tab.rawValue).tag(tab) | |
| 32 | + } | |
| 33 | + } | |
| 34 | + .labelsHidden() | |
| 35 | + .pickerStyle(.segmented) | |
| 36 | + .frame(width: 280) | |
| 37 | + } | |
| 38 | + .padding(ZyquoMetrics.contentInset) | |
| 39 | + | |
| 40 | + ZyquoHairline() | |
| 41 | + | |
| 42 | + switch tab { | |
| 43 | + case .provider: ProviderKeysTab() | |
| 44 | + case .local: LocalKeysTab() | |
| 45 | + } | |
| 46 | + } | |
| 47 | + } | |
| 48 | +} | |
| 49 | + | |
| 50 | +// MARK: - Provider keys | |
| 51 | + | |
| 52 | +private struct ProviderKeysTab: View { | |
| 53 | + @EnvironmentObject private var vault: KeyVaultStore | |
| 54 | + @EnvironmentObject private var catalog: ModelCatalog | |
| 55 | + | |
| 56 | + var body: some View { | |
| 57 | + ScrollView { | |
| 58 | + VStack(spacing: 0) { | |
| 59 | + ForEach(ProviderID.builtIn) { provider in | |
| 60 | + ProviderKeyRow(provider: provider) | |
| 61 | + ZyquoHairline() | |
| 62 | + .padding(.leading, ZyquoMetrics.contentInset) | |
| 63 | + } | |
| 64 | + } | |
| 65 | + } | |
| 66 | + } | |
| 67 | +} | |
| 68 | + | |
| 69 | +private struct ProviderKeyRow: View { | |
| 70 | + let provider: ProviderID | |
| 71 | + @EnvironmentObject private var vault: KeyVaultStore | |
| 72 | + @EnvironmentObject private var catalog: ModelCatalog | |
| 73 | + @State private var draft = "" | |
| 74 | + @State private var editing = false | |
| 75 | + | |
| 76 | + var body: some View { | |
| 77 | + HStack(spacing: ZyquoSpacing.md) { | |
| 78 | + HStack(spacing: ZyquoSpacing.xs) { | |
| 79 | + Circle() | |
| 80 | + .fill(ZyquoColor.providerHue(provider)) | |
| 81 | + .frame(width: 8, height: 8) | |
| 82 | + Text(provider.displayName) | |
| 83 | + .font(ZyquoFont.bodyEmphasis()) | |
| 84 | + .foregroundStyle(ZyquoColor.textPrimary) | |
| 85 | + } | |
| 86 | + .frame(width: 150, alignment: .leading) | |
| 87 | + | |
| 88 | + if editing || !vault.hasKey(for: provider) { | |
| 89 | + SecureField("Paste API key…", text: $draft) | |
| 90 | + .textFieldStyle(.roundedBorder) | |
| 91 | + .font(ZyquoFont.mono(size: 12)) | |
| 92 | + .frame(maxWidth: 320) | |
| 93 | + Button("Save") { | |
| 94 | + vault.setKey(draft, for: provider) | |
| 95 | + draft = "" | |
| 96 | + editing = false | |
| 97 | + } | |
| 98 | + .disabled(draft.trimmingCharacters(in: .whitespaces).isEmpty) | |
| 99 | + if editing { | |
| 100 | + Button("Cancel") { | |
| 101 | + draft = "" | |
| 102 | + editing = false | |
| 103 | + } | |
| 104 | + } | |
| 105 | + } else { | |
| 106 | + Text(vault.redactedKeys[provider] ?? "••••") | |
| 107 | + .font(ZyquoFont.mono(size: 12)) | |
| 108 | + .foregroundStyle(ZyquoColor.textSecondary) | |
| 109 | + .frame(maxWidth: 320, alignment: .leading) | |
| 110 | + Button("Replace") { editing = true } | |
| 111 | + Button("Remove") { vault.deleteKey(for: provider) } | |
| 112 | + Button("Test") { | |
| 113 | + Task { await vault.testKey(for: provider, catalog: catalog) } | |
| 114 | + } | |
| 115 | + } | |
| 116 | + | |
| 117 | + Spacer() | |
| 118 | + statusView | |
| 119 | + } | |
| 120 | + .padding(.horizontal, ZyquoMetrics.contentInset) | |
| 121 | + .padding(.vertical, ZyquoSpacing.xs) | |
| 122 | + } | |
| 123 | + | |
| 124 | + @ViewBuilder | |
| 125 | + private var statusView: some View { | |
| 126 | + switch vault.statuses[provider] ?? .unset { | |
| 127 | + case .unset: | |
| 128 | + Text("No key") | |
| 129 | + .font(ZyquoFont.caption) | |
| 130 | + .foregroundStyle(ZyquoColor.textTertiary) | |
| 131 | + case .saved: | |
| 132 | + statusDot(ZyquoColor.textTertiary, "Saved") | |
| 133 | + case .testing: | |
| 134 | + ProgressView() | |
| 135 | + .controlSize(.small) | |
| 136 | + case .verified(let latency): | |
| 137 | + statusDot(ZyquoColor.success, String(format: "OK · %.0f ms", latency * 1000)) | |
| 138 | + case .failed(let message): | |
| 139 | + statusDot(ZyquoColor.danger, message) | |
| 140 | + .help(message) | |
| 141 | + } | |
| 142 | + } | |
| 143 | + | |
| 144 | + private func statusDot(_ color: Color, _ text: String) -> some View { | |
| 145 | + HStack(spacing: 5) { | |
| 146 | + Circle().fill(color).frame(width: 7, height: 7) | |
| 147 | + Text(text) | |
| 148 | + .font(ZyquoFont.caption) | |
| 149 | + .foregroundStyle(ZyquoColor.textSecondary) | |
| 150 | + .lineLimit(1) | |
| 151 | + .frame(maxWidth: 220, alignment: .trailing) | |
| 152 | + } | |
| 153 | + } | |
| 154 | +} | |
| 155 | + | |
| 156 | +// MARK: - Local keys | |
| 157 | + | |
| 158 | +private struct LocalKeysTab: View { | |
| 159 | + @EnvironmentObject private var localKeys: LocalKeysStore | |
| 160 | + @State private var newName = "" | |
| 161 | + @State private var revealedToken: String? | |
| 162 | + | |
| 163 | + var body: some View { | |
| 164 | + ScrollView { | |
| 165 | + VStack(alignment: .leading, spacing: ZyquoSpacing.md) { | |
| 166 | + // Create | |
| 167 | + HStack(spacing: ZyquoSpacing.xs) { | |
| 168 | + TextField("Key name (e.g. \"CLI\", \"VS Code\")", text: $newName) | |
| 169 | + .textFieldStyle(.roundedBorder) | |
| 170 | + .frame(maxWidth: 280) | |
| 171 | + Button("Create key") { | |
| 172 | + revealedToken = localKeys.create(name: newName) | |
| 173 | + newName = "" | |
| 174 | + } | |
| 175 | + } | |
| 176 | + | |
| 177 | + if let token = revealedToken { | |
| 178 | + VStack(alignment: .leading, spacing: ZyquoSpacing.xxs) { | |
| 179 | + Label("Copy this token now — it is shown only once.", systemImage: "exclamationmark.triangle") | |
| 180 | + .font(ZyquoFont.caption) | |
| 181 | + .foregroundStyle(ZyquoColor.warning) | |
| 182 | + HStack { | |
| 183 | + CopyField(value: token, size: 12.5) | |
| 184 | + Button("Done") { revealedToken = nil } | |
| 185 | + } | |
| 186 | + } | |
| 187 | + .padding(ZyquoSpacing.sm) | |
| 188 | + .background( | |
| 189 | + RoundedRectangle(cornerRadius: ZyquoRadius.small) | |
| 190 | + .fill(ZyquoColor.accentSubtle) | |
| 191 | + ) | |
| 192 | + } | |
| 193 | + | |
| 194 | + if localKeys.keys.isEmpty { | |
| 195 | + Text("No local keys. Without keys, the router is open on localhost; LAN mode requires at least one enabled key.") | |
| 196 | + .font(ZyquoFont.body()) | |
| 197 | + .foregroundStyle(ZyquoColor.textSecondary) | |
| 198 | + } else { | |
| 199 | + VStack(spacing: 0) { | |
| 200 | + ForEach(localKeys.keys) { key in | |
| 201 | + LocalKeyRow(record: key) | |
| 202 | + ZyquoHairline() | |
| 203 | + } | |
| 204 | + } | |
| 205 | + .zyquoCard() | |
| 206 | + } | |
| 207 | + } | |
| 208 | + .padding(ZyquoMetrics.contentInset) | |
| 209 | + } | |
| 210 | + } | |
| 211 | +} | |
| 212 | + | |
| 213 | +private struct LocalKeyRow: View { | |
| 214 | + let record: APIKeyRecord | |
| 215 | + @EnvironmentObject private var localKeys: LocalKeysStore | |
| 216 | + | |
| 217 | + var body: some View { | |
| 218 | + HStack(spacing: ZyquoSpacing.md) { | |
| 219 | + VStack(alignment: .leading, spacing: 2) { | |
| 220 | + Text(record.name) | |
| 221 | + .font(ZyquoFont.bodyEmphasis()) | |
| 222 | + .foregroundStyle(ZyquoColor.textPrimary) | |
| 223 | + Text("\(record.tokenPrefix)… · created \(record.createdAt.formatted(date: .abbreviated, time: .omitted))") | |
| 224 | + .font(ZyquoFont.mono(size: 11)) | |
| 225 | + .foregroundStyle(ZyquoColor.textTertiary) | |
| 226 | + } | |
| 227 | + Spacer() | |
| 228 | + Toggle("", isOn: Binding( | |
| 229 | + get: { record.enabled }, | |
| 230 | + set: { localKeys.setEnabled($0, for: record.id) } | |
| 231 | + )) | |
| 232 | + .toggleStyle(.switch) | |
| 233 | + .controlSize(.small) | |
| 234 | + .labelsHidden() | |
| 235 | + Button("Revoke", role: .destructive) { | |
| 236 | + localKeys.revoke(record.id) | |
| 237 | + } | |
| 238 | + } | |
| 239 | + .padding(ZyquoSpacing.sm) | |
| 240 | + } | |
| 241 | +} | |
added
Sources/ZyquoRouter/Views/MainWindowView.swift
+166 −0
@@ -0,0 +1,166 @@ | ||
| 1 | +// | |
| 2 | +// MainWindowView.swift | |
| 3 | +// Zyquo Router | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// App shell: 240pt translucent left navigator (sections, footer settings | |
| 9 | +// gear, permanent status pill with Start/Stop) + detail area. | |
| 10 | +// | |
| 11 | + | |
| 12 | +import SwiftUI | |
| 13 | + | |
| 14 | +struct MainWindowView: View { | |
| 15 | + @EnvironmentObject private var appearance: AppearanceStore | |
| 16 | + // Stored so the ⌘1–6 menu commands can drive it from the Commands scene. | |
| 17 | + @AppStorage("selectedSection") private var sectionRaw = AppSection.dashboard.rawValue | |
| 18 | + | |
| 19 | + private var section: Binding<AppSection> { | |
| 20 | + Binding( | |
| 21 | + get: { AppSection(rawValue: sectionRaw) ?? .dashboard }, | |
| 22 | + set: { sectionRaw = $0.rawValue } | |
| 23 | + ) | |
| 24 | + } | |
| 25 | + | |
| 26 | + var body: some View { | |
| 27 | + HStack(spacing: 0) { | |
| 28 | + Navigator(section: section) | |
| 29 | + .frame(width: ZyquoMetrics.navigatorWidth) | |
| 30 | + Rectangle() | |
| 31 | + .fill(ZyquoColor.border) | |
| 32 | + .frame(width: ZyquoMetrics.hairline) | |
| 33 | + detail | |
| 34 | + .frame(maxWidth: .infinity, maxHeight: .infinity) | |
| 35 | + .background(ZyquoColor.background) | |
| 36 | + } | |
| 37 | + .frame( | |
| 38 | + minWidth: ZyquoMetrics.windowMinWidth, | |
| 39 | + minHeight: ZyquoMetrics.windowMinHeight | |
| 40 | + ) | |
| 41 | + .preferredColorScheme(appearance.themeMode.colorScheme) | |
| 42 | + } | |
| 43 | + | |
| 44 | + @ViewBuilder | |
| 45 | + private var detail: some View { | |
| 46 | + switch section.wrappedValue { | |
| 47 | + case .dashboard: DashboardView() | |
| 48 | + case .models: ModelsView() | |
| 49 | + case .requests: RequestsView() | |
| 50 | + case .keys: KeysView() | |
| 51 | + case .playground: PlaygroundView() | |
| 52 | + case .docs: DocsView() | |
| 53 | + } | |
| 54 | + } | |
| 55 | +} | |
| 56 | + | |
| 57 | +// MARK: - Navigator | |
| 58 | + | |
| 59 | +private struct Navigator: View { | |
| 60 | + @Binding var section: AppSection | |
| 61 | + | |
| 62 | + var body: some View { | |
| 63 | + VStack(alignment: .leading, spacing: 0) { | |
| 64 | + // Wordmark | |
| 65 | + HStack(spacing: ZyquoSpacing.xs) { | |
| 66 | + RouterZGlyph(size: 20) | |
| 67 | + Text("Zyquo Router") | |
| 68 | + .font(ZyquoFont.heading) | |
| 69 | + .foregroundStyle(ZyquoColor.textPrimary) | |
| 70 | + } | |
| 71 | + .padding(.horizontal, ZyquoSpacing.md) | |
| 72 | + .padding(.top, ZyquoSpacing.xl) | |
| 73 | + .padding(.bottom, ZyquoSpacing.lg) | |
| 74 | + | |
| 75 | + ForEach(AppSection.allCases) { item in | |
| 76 | + NavigatorRow(item: item, selected: section == item) { | |
| 77 | + withAnimation(ZyquoMotion.state) { section = item } | |
| 78 | + } | |
| 79 | + } | |
| 80 | + | |
| 81 | + Spacer() | |
| 82 | + | |
| 83 | + StatusPill() | |
| 84 | + .padding(.horizontal, ZyquoSpacing.md) | |
| 85 | + .padding(.bottom, ZyquoSpacing.sm) | |
| 86 | + | |
| 87 | + HStack { | |
| 88 | + Button { | |
| 89 | + SettingsOpener.open() | |
| 90 | + } label: { | |
| 91 | + Image(systemName: "gearshape") | |
| 92 | + .font(.system(size: 13, weight: .medium)) | |
| 93 | + .foregroundStyle(ZyquoColor.textSecondary) | |
| 94 | + } | |
| 95 | + .buttonStyle(.plain) | |
| 96 | + .help("Settings") | |
| 97 | + Spacer() | |
| 98 | + } | |
| 99 | + .padding(.horizontal, ZyquoSpacing.md) | |
| 100 | + .padding(.bottom, ZyquoSpacing.md) | |
| 101 | + } | |
| 102 | + .background(NavigatorMaterial()) | |
| 103 | + } | |
| 104 | +} | |
| 105 | + | |
| 106 | +private struct NavigatorRow: View { | |
| 107 | + let item: AppSection | |
| 108 | + let selected: Bool | |
| 109 | + let action: () -> Void | |
| 110 | + @State private var hovering = false | |
| 111 | + | |
| 112 | + var body: some View { | |
| 113 | + Button(action: action) { | |
| 114 | + HStack(spacing: ZyquoSpacing.xs) { | |
| 115 | + Image(systemName: item.systemImage) | |
| 116 | + .font(.system(size: 13, weight: .medium)) | |
| 117 | + .frame(width: 18) | |
| 118 | + Text(item.rawValue) | |
| 119 | + .font(ZyquoFont.bodyEmphasis()) | |
| 120 | + Spacer() | |
| 121 | + } | |
| 122 | + .foregroundStyle(selected ? ZyquoColor.accent : ZyquoColor.textSecondary) | |
| 123 | + .padding(.horizontal, ZyquoSpacing.sm) | |
| 124 | + .padding(.vertical, 7) | |
| 125 | + .background( | |
| 126 | + RoundedRectangle(cornerRadius: ZyquoRadius.small) | |
| 127 | + .fill(selected ? ZyquoColor.accentSubtle : (hovering ? ZyquoColor.surfaceSecondary : .clear)) | |
| 128 | + ) | |
| 129 | + } | |
| 130 | + .buttonStyle(.plain) | |
| 131 | + .padding(.horizontal, ZyquoSpacing.xs) | |
| 132 | + .onHover { hover in | |
| 133 | + withAnimation(ZyquoMotion.hover) { hovering = hover } | |
| 134 | + } | |
| 135 | + } | |
| 136 | +} | |
| 137 | + | |
| 138 | +/// Translucent sidebar material behind the navigator. | |
| 139 | +private struct NavigatorMaterial: NSViewRepresentable { | |
| 140 | + func makeNSView(context: Context) -> NSVisualEffectView { | |
| 141 | + let view = NSVisualEffectView() | |
| 142 | + view.material = .sidebar | |
| 143 | + view.blendingMode = .behindWindow | |
| 144 | + view.state = .followsWindowActiveState | |
| 145 | + return view | |
| 146 | + } | |
| 147 | + | |
| 148 | + func updateNSView(_ nsView: NSVisualEffectView, context: Context) {} | |
| 149 | +} | |
| 150 | + | |
| 151 | +/// Mini Z-with-signal-dot wordmark glyph (Phase 5 derives the full icon). | |
| 152 | +struct RouterZGlyph: View { | |
| 153 | + let size: CGFloat | |
| 154 | + | |
| 155 | + var body: some View { | |
| 156 | + ZStack(alignment: .bottomTrailing) { | |
| 157 | + Text("Z") | |
| 158 | + .font(.system(size: size, weight: .heavy, design: .rounded)) | |
| 159 | + .foregroundStyle(ZyquoColor.textPrimary) | |
| 160 | + Circle() | |
| 161 | + .fill(ZyquoColor.accent) | |
| 162 | + .frame(width: size * 0.28, height: size * 0.28) | |
| 163 | + .offset(x: size * 0.18, y: 0) | |
| 164 | + } | |
| 165 | + } | |
| 166 | +} | |
added
Sources/ZyquoRouter/Views/ModelsView.swift
+131 −0
@@ -0,0 +1,131 @@ | ||
| 1 | +// | |
| 2 | +// ModelsView.swift | |
| 3 | +// Zyquo Router | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// The full namespaced catalog: search, provider filter, capability badges, | |
| 9 | +// context + pricing columns, one-click ID copy. Aliases and fallback-chain | |
| 10 | +// editors join in Phase 6. | |
| 11 | +// | |
| 12 | + | |
| 13 | +import SwiftUI | |
| 14 | + | |
| 15 | +struct ModelsView: View { | |
| 16 | + @EnvironmentObject private var catalog: ModelCatalog | |
| 17 | + @State private var search = "" | |
| 18 | + @State private var providerFilter: ProviderID? | |
| 19 | + | |
| 20 | + private var filtered: [AIModel] { | |
| 21 | + catalog.all.filter { model in | |
| 22 | + if let providerFilter, model.provider != providerFilter { return false } | |
| 23 | + guard !search.isEmpty else { return true } | |
| 24 | + let namespaced = RequestRouter.namespacedID(for: model) | |
| 25 | + return namespaced.localizedCaseInsensitiveContains(search) | |
| 26 | + || model.displayName.localizedCaseInsensitiveContains(search) | |
| 27 | + } | |
| 28 | + } | |
| 29 | + | |
| 30 | + var body: some View { | |
| 31 | + VStack(alignment: .leading, spacing: 0) { | |
| 32 | + HStack(alignment: .center) { | |
| 33 | + SectionHeader( | |
| 34 | + title: "Models", | |
| 35 | + subtitle: "\(catalog.all.count) models across \(ProviderID.builtIn.count) providers" | |
| 36 | + ) | |
| 37 | + Spacer() | |
| 38 | + Picker("", selection: $providerFilter) { | |
| 39 | + Text("All providers").tag(ProviderID?.none) | |
| 40 | + ForEach(ProviderID.builtIn) { provider in | |
| 41 | + Text(provider.displayName).tag(ProviderID?.some(provider)) | |
| 42 | + } | |
| 43 | + } | |
| 44 | + .labelsHidden() | |
| 45 | + .frame(width: 170) | |
| 46 | + TextField("Search models…", text: $search) | |
| 47 | + .textFieldStyle(.roundedBorder) | |
| 48 | + .frame(width: 220) | |
| 49 | + } | |
| 50 | + .padding(ZyquoMetrics.contentInset) | |
| 51 | + | |
| 52 | + ZyquoHairline() | |
| 53 | + | |
| 54 | + ScrollView { | |
| 55 | + LazyVStack(spacing: 0) { | |
| 56 | + ForEach(filtered) { model in | |
| 57 | + ModelRow(model: model) | |
| 58 | + ZyquoHairline() | |
| 59 | + .padding(.leading, ZyquoMetrics.contentInset) | |
| 60 | + } | |
| 61 | + } | |
| 62 | + } | |
| 63 | + } | |
| 64 | + } | |
| 65 | +} | |
| 66 | + | |
| 67 | +private struct ModelRow: View { | |
| 68 | + let model: AIModel | |
| 69 | + @State private var hovering = false | |
| 70 | + | |
| 71 | + private var namespacedID: String { RequestRouter.namespacedID(for: model) } | |
| 72 | + | |
| 73 | + var body: some View { | |
| 74 | + HStack(spacing: ZyquoSpacing.md) { | |
| 75 | + VStack(alignment: .leading, spacing: 3) { | |
| 76 | + HStack(spacing: ZyquoSpacing.xs) { | |
| 77 | + Text(namespacedID) | |
| 78 | + .font(ZyquoFont.mono(size: 12.5, weight: .medium)) | |
| 79 | + .foregroundStyle(ZyquoColor.textPrimary) | |
| 80 | + .lineLimit(1) | |
| 81 | + .truncationMode(.middle) | |
| 82 | + if hovering { | |
| 83 | + CopyButton(value: namespacedID) | |
| 84 | + } | |
| 85 | + if model.isRecommended { | |
| 86 | + CapabilityBadge(label: "FEATURED", tint: ZyquoColor.accent) | |
| 87 | + } | |
| 88 | + if model.isLegacy { | |
| 89 | + CapabilityBadge(label: "LEGACY", tint: ZyquoColor.textTertiary) | |
| 90 | + } | |
| 91 | + } | |
| 92 | + HStack(spacing: ZyquoSpacing.xs) { | |
| 93 | + ProviderBadge(provider: model.provider) | |
| 94 | + Text(model.displayName) | |
| 95 | + .font(ZyquoFont.caption) | |
| 96 | + .foregroundStyle(ZyquoColor.textTertiary) | |
| 97 | + } | |
| 98 | + } | |
| 99 | + | |
| 100 | + Spacer() | |
| 101 | + | |
| 102 | + HStack(spacing: ZyquoSpacing.xxs) { | |
| 103 | + if model.capabilities.vision { CapabilityBadge(label: "VISION") } | |
| 104 | + if model.capabilities.tools { CapabilityBadge(label: "TOOLS") } | |
| 105 | + if model.capabilities.reasoning { CapabilityBadge(label: "REASONING", tint: ZyquoColor.accent) } | |
| 106 | + if model.capabilities.citations { CapabilityBadge(label: "SEARCH") } | |
| 107 | + } | |
| 108 | + | |
| 109 | + Text(model.contextBadge) | |
| 110 | + .font(ZyquoFont.mono(size: 11)) | |
| 111 | + .foregroundStyle(ZyquoColor.textSecondary) | |
| 112 | + .frame(width: 66, alignment: .trailing) | |
| 113 | + | |
| 114 | + Text(priceText) | |
| 115 | + .font(ZyquoFont.mono(size: 11)) | |
| 116 | + .foregroundStyle(ZyquoColor.textSecondary) | |
| 117 | + .frame(width: 120, alignment: .trailing) | |
| 118 | + } | |
| 119 | + .padding(.horizontal, ZyquoMetrics.contentInset) | |
| 120 | + .padding(.vertical, ZyquoSpacing.xs) | |
| 121 | + .background(hovering ? ZyquoColor.surfaceSecondary : .clear) | |
| 122 | + .onHover { hover in | |
| 123 | + withAnimation(ZyquoMotion.hover) { hovering = hover } | |
| 124 | + } | |
| 125 | + } | |
| 126 | + | |
| 127 | + private var priceText: String { | |
| 128 | + guard let pricing = model.pricing else { return "—" } | |
| 129 | + return String(format: "$%.2f / $%.2f", pricing.inputPerMTok, pricing.outputPerMTok) | |
| 130 | + } | |
| 131 | +} | |
added
Sources/ZyquoRouter/Views/PlaygroundView.swift
+166 −0
@@ -0,0 +1,166 @@ | ||
| 1 | +// | |
| 2 | +// PlaygroundView.swift | |
| 3 | +// Zyquo Router | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// Built-in tester that calls the router's OWN local endpoint (never the | |
| 9 | +// upstreams directly): model picker, composer, streaming toggle, response | |
| 10 | +// pane with the raw request JSON alongside. | |
| 11 | +// | |
| 12 | + | |
| 13 | +import SwiftUI | |
| 14 | + | |
| 15 | +struct PlaygroundView: View { | |
| 16 | + @EnvironmentObject private var server: ServerController | |
| 17 | + @EnvironmentObject private var catalog: ModelCatalog | |
| 18 | + | |
| 19 | + @State private var modelID = "" | |
| 20 | + @State private var prompt = "" | |
| 21 | + @State private var streaming = true | |
| 22 | + @State private var output = "" | |
| 23 | + @State private var running = false | |
| 24 | + @State private var errorText: String? | |
| 25 | + | |
| 26 | + var body: some View { | |
| 27 | + VStack(alignment: .leading, spacing: 0) { | |
| 28 | + SectionHeader(title: "Playground", subtitle: "Requests go through your local endpoint — exactly what your tools see.") | |
| 29 | + .padding(ZyquoMetrics.contentInset) | |
| 30 | + | |
| 31 | + ZyquoHairline() | |
| 32 | + | |
| 33 | + if !server.isRunning { | |
| 34 | + EmptyState( | |
| 35 | + systemImage: "bolt.slash", | |
| 36 | + title: "Server is stopped", | |
| 37 | + message: "Start the server on the Dashboard to use the Playground." | |
| 38 | + ) | |
| 39 | + } else { | |
| 40 | + content | |
| 41 | + } | |
| 42 | + } | |
| 43 | + } | |
| 44 | + | |
| 45 | + private var content: some View { | |
| 46 | + VStack(alignment: .leading, spacing: ZyquoSpacing.md) { | |
| 47 | + HStack(spacing: ZyquoSpacing.sm) { | |
| 48 | + Picker("", selection: $modelID) { | |
| 49 | + ForEach(catalog.all) { model in | |
| 50 | + Text(RequestRouter.namespacedID(for: model)) | |
| 51 | + .tag(RequestRouter.namespacedID(for: model)) | |
| 52 | + } | |
| 53 | + } | |
| 54 | + .labelsHidden() | |
| 55 | + .frame(maxWidth: 340) | |
| 56 | + | |
| 57 | + Toggle("Stream", isOn: $streaming) | |
| 58 | + .toggleStyle(.checkbox) | |
| 59 | + | |
| 60 | + Spacer() | |
| 61 | + | |
| 62 | + Button(running ? "Cancel" : "Send") { | |
| 63 | + running ? cancel() : send() | |
| 64 | + } | |
| 65 | + .keyboardShortcut(.return, modifiers: .command) | |
| 66 | + .disabled(modelID.isEmpty || prompt.isEmpty && !running) | |
| 67 | + } | |
| 68 | + | |
| 69 | + TextEditor(text: $prompt) | |
| 70 | + .font(ZyquoFont.mono(size: 12.5)) | |
| 71 | + .scrollContentBackground(.hidden) | |
| 72 | + .padding(ZyquoSpacing.xs) | |
| 73 | + .frame(height: 90) | |
| 74 | + .background( | |
| 75 | + RoundedRectangle(cornerRadius: ZyquoRadius.small) | |
| 76 | + .fill(ZyquoColor.surfaceSecondary) | |
| 77 | + ) | |
| 78 | + | |
| 79 | + if let errorText { | |
| 80 | + Label(errorText, systemImage: "xmark.octagon") | |
| 81 | + .font(ZyquoFont.body()) | |
| 82 | + .foregroundStyle(ZyquoColor.danger) | |
| 83 | + } | |
| 84 | + | |
| 85 | + ScrollView { | |
| 86 | + Text(output.isEmpty ? "Response appears here." : output) | |
| 87 | + .font(ZyquoFont.mono(size: 12.5)) | |
| 88 | + .foregroundStyle(output.isEmpty ? ZyquoColor.textTertiary : ZyquoColor.textPrimary) | |
| 89 | + .textSelection(.enabled) | |
| 90 | + .frame(maxWidth: .infinity, alignment: .leading) | |
| 91 | + .padding(ZyquoSpacing.sm) | |
| 92 | + } | |
| 93 | + .frame(maxHeight: .infinity) | |
| 94 | + .background( | |
| 95 | + RoundedRectangle(cornerRadius: ZyquoRadius.small) | |
| 96 | + .fill(ZyquoColor.surface) | |
| 97 | + .overlay( | |
| 98 | + RoundedRectangle(cornerRadius: ZyquoRadius.small) | |
| 99 | + .strokeBorder(ZyquoColor.border, lineWidth: ZyquoMetrics.hairline) | |
| 100 | + ) | |
| 101 | + ) | |
| 102 | + } | |
| 103 | + .padding(ZyquoMetrics.contentInset) | |
| 104 | + .onAppear { | |
| 105 | + if modelID.isEmpty, let first = catalog.defaultModel { | |
| 106 | + modelID = RequestRouter.namespacedID(for: first) | |
| 107 | + } | |
| 108 | + } | |
| 109 | + } | |
| 110 | + | |
| 111 | + @State private var task: Task<Void, Never>? | |
| 112 | + | |
| 113 | + private func send() { | |
| 114 | + output = "" | |
| 115 | + errorText = nil | |
| 116 | + running = true | |
| 117 | + let body: [String: Any] = [ | |
| 118 | + "model": modelID, | |
| 119 | + "messages": [["role": "user", "content": prompt]], | |
| 120 | + "stream": streaming, | |
| 121 | + ] | |
| 122 | + let url = URL(string: "http://127.0.0.1:\(server.port)/v1/chat/completions")! | |
| 123 | + task = Task { | |
| 124 | + defer { running = false } | |
| 125 | + var request = URLRequest(url: url) | |
| 126 | + request.httpMethod = "POST" | |
| 127 | + request.setValue("application/json", forHTTPHeaderField: "Content-Type") | |
| 128 | + request.httpBody = try? JSONSerialization.data(withJSONObject: body) | |
| 129 | + do { | |
| 130 | + if streaming { | |
| 131 | + let (bytes, _) = try await URLSession.shared.bytes(for: request) | |
| 132 | + for try await line in bytes.lines { | |
| 133 | + guard line.hasPrefix("data: "), !line.hasSuffix("[DONE]") else { continue } | |
| 134 | + guard let json = try? JSONSerialization.jsonObject(with: Data(line.dropFirst(6).utf8)) as? [String: Any] else { continue } | |
| 135 | + if let error = json["error"] as? [String: Any] { | |
| 136 | + errorText = error["message"] as? String | |
| 137 | + continue | |
| 138 | + } | |
| 139 | + let delta = ((json["choices"] as? [[String: Any]])?.first?["delta"] as? [String: Any]) | |
| 140 | + if let piece = delta?["content"] as? String { | |
| 141 | + output += piece | |
| 142 | + } | |
| 143 | + } | |
| 144 | + } else { | |
| 145 | + let (data, _) = try await URLSession.shared.data(for: request) | |
| 146 | + if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { | |
| 147 | + if let error = json["error"] as? [String: Any] { | |
| 148 | + errorText = error["message"] as? String | |
| 149 | + } else if let pretty = try? JSONSerialization.data(withJSONObject: json, options: [.prettyPrinted, .sortedKeys]) { | |
| 150 | + output = String(decoding: pretty, as: UTF8.self) | |
| 151 | + } | |
| 152 | + } | |
| 153 | + } | |
| 154 | + } catch is CancellationError { | |
| 155 | + // cancelled by the user | |
| 156 | + } catch { | |
| 157 | + errorText = error.localizedDescription | |
| 158 | + } | |
| 159 | + } | |
| 160 | + } | |
| 161 | + | |
| 162 | + private func cancel() { | |
| 163 | + task?.cancel() | |
| 164 | + running = false | |
| 165 | + } | |
| 166 | +} | |
added
Sources/ZyquoRouter/Views/RequestsView.swift
+134 −0
@@ -0,0 +1,134 @@ | ||
| 1 | +// | |
| 2 | +// RequestsView.swift | |
| 3 | +// Zyquo Router | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// Live traffic. Phase 4 ships the table structure over UsageMeter records; | |
| 9 | +// the full request inspector (redacted bodies, timing waterfall, filters) | |
| 10 | +// lands with the observability work in Phase 6. | |
| 11 | +// | |
| 12 | + | |
| 13 | +import SwiftUI | |
| 14 | + | |
| 15 | +struct RequestsView: View { | |
| 16 | + @EnvironmentObject private var server: ServerController | |
| 17 | + @State private var records: [UsageRecord] = [] | |
| 18 | + private let refresh = Timer.publish(every: 1, on: .main, in: .common).autoconnect() | |
| 19 | + | |
| 20 | + var body: some View { | |
| 21 | + VStack(alignment: .leading, spacing: 0) { | |
| 22 | + SectionHeader(title: "Requests", subtitle: "Routed traffic, newest first") | |
| 23 | + .padding(ZyquoMetrics.contentInset) | |
| 24 | + | |
| 25 | + ZyquoHairline() | |
| 26 | + | |
| 27 | + if records.isEmpty { | |
| 28 | + EmptyState( | |
| 29 | + systemImage: "antenna.radiowaves.left.and.right", | |
| 30 | + title: "No traffic yet", | |
| 31 | + message: server.isRunning | |
| 32 | + ? "Requests routed through \(server.endpointURL) will stream in here live." | |
| 33 | + : "Start the server, then point any OpenAI client at the endpoint." | |
| 34 | + ) | |
| 35 | + } else { | |
| 36 | + headerRow | |
| 37 | + ZyquoHairline() | |
| 38 | + ScrollView { | |
| 39 | + LazyVStack(spacing: 0) { | |
| 40 | + ForEach(records.reversed()) { record in | |
| 41 | + RequestRow(record: record) | |
| 42 | + ZyquoHairline() | |
| 43 | + .padding(.leading, ZyquoMetrics.contentInset) | |
| 44 | + } | |
| 45 | + } | |
| 46 | + } | |
| 47 | + } | |
| 48 | + } | |
| 49 | + .onReceive(refresh) { _ in | |
| 50 | + let meter = server.usageMeter | |
| 51 | + Task { | |
| 52 | + let latest = await meter.records | |
| 53 | + if latest.count != records.count { records = latest } | |
| 54 | + } | |
| 55 | + } | |
| 56 | + } | |
| 57 | + | |
| 58 | + private var headerRow: some View { | |
| 59 | + HStack(spacing: ZyquoSpacing.md) { | |
| 60 | + column("TIME", width: 70) | |
| 61 | + column("MODEL", width: nil) | |
| 62 | + column("STATUS", width: 52) | |
| 63 | + column("LATENCY", width: 64) | |
| 64 | + column("TOKENS", width: 88) | |
| 65 | + column("COST", width: 66) | |
| 66 | + } | |
| 67 | + .padding(.horizontal, ZyquoMetrics.contentInset) | |
| 68 | + .padding(.vertical, ZyquoSpacing.xxs) | |
| 69 | + } | |
| 70 | + | |
| 71 | + private func column(_ title: String, width: CGFloat?) -> some View { | |
| 72 | + Text(title) | |
| 73 | + .font(.system(size: 9.5, weight: .semibold)) | |
| 74 | + .foregroundStyle(ZyquoColor.textTertiary) | |
| 75 | + .kerning(0.4) | |
| 76 | + .frame(width: width, alignment: width == nil ? .leading : .trailing) | |
| 77 | + .frame(maxWidth: width == nil ? .infinity : width, alignment: .leading) | |
| 78 | + } | |
| 79 | +} | |
| 80 | + | |
| 81 | +private struct RequestRow: View { | |
| 82 | + let record: UsageRecord | |
| 83 | + | |
| 84 | + var body: some View { | |
| 85 | + HStack(spacing: ZyquoSpacing.md) { | |
| 86 | + Text(record.date, format: .dateTime.hour().minute().second()) | |
| 87 | + .font(ZyquoFont.mono(size: 11)) | |
| 88 | + .foregroundStyle(ZyquoColor.textSecondary) | |
| 89 | + .frame(width: 70, alignment: .leading) | |
| 90 | + | |
| 91 | + HStack(spacing: ZyquoSpacing.xs) { | |
| 92 | + Circle() | |
| 93 | + .fill(ZyquoColor.providerHue(record.provider)) | |
| 94 | + .frame(width: 6, height: 6) | |
| 95 | + Text(record.namespacedModelID) | |
| 96 | + .font(ZyquoFont.mono(size: 11.5)) | |
| 97 | + .foregroundStyle(ZyquoColor.textPrimary) | |
| 98 | + .lineLimit(1) | |
| 99 | + .truncationMode(.middle) | |
| 100 | + if record.streamed { | |
| 101 | + CapabilityBadge(label: "SSE", tint: ZyquoColor.accent) | |
| 102 | + } | |
| 103 | + } | |
| 104 | + .frame(maxWidth: .infinity, alignment: .leading) | |
| 105 | + | |
| 106 | + Text("\(record.status)") | |
| 107 | + .font(ZyquoFont.mono(size: 11, weight: .medium)) | |
| 108 | + .foregroundStyle(record.status < 400 ? ZyquoColor.success : ZyquoColor.danger) | |
| 109 | + .frame(width: 52, alignment: .trailing) | |
| 110 | + | |
| 111 | + Text(String(format: "%.2fs", record.latency)) | |
| 112 | + .font(ZyquoFont.mono(size: 11)) | |
| 113 | + .foregroundStyle(ZyquoColor.textSecondary) | |
| 114 | + .frame(width: 64, alignment: .trailing) | |
| 115 | + | |
| 116 | + Text("\(record.usage.inputTokens)→\(record.usage.outputTokens)") | |
| 117 | + .font(ZyquoFont.mono(size: 11)) | |
| 118 | + .foregroundStyle(ZyquoColor.textSecondary) | |
| 119 | + .frame(width: 88, alignment: .trailing) | |
| 120 | + | |
| 121 | + Text(costText) | |
| 122 | + .font(ZyquoFont.mono(size: 11)) | |
| 123 | + .foregroundStyle(ZyquoColor.textSecondary) | |
| 124 | + .frame(width: 66, alignment: .trailing) | |
| 125 | + } | |
| 126 | + .padding(.horizontal, ZyquoMetrics.contentInset) | |
| 127 | + .padding(.vertical, 6) | |
| 128 | + } | |
| 129 | + | |
| 130 | + private var costText: String { | |
| 131 | + guard let cost = record.estimatedCost, cost > 0 else { return "—" } | |
| 132 | + return cost < 0.01 ? "<$0.01" : String(format: "$%.2f", cost) | |
| 133 | + } | |
| 134 | +} | |
added
Sources/ZyquoRouter/Views/SettingsView.swift
+151 −0
@@ -0,0 +1,151 @@ | ||
| 1 | +// | |
| 2 | +// SettingsView.swift | |
| 3 | +// Zyquo Router | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Mail: contact@spboucher.ai | |
| 7 | +// | |
| 8 | +// Native settings tabs (720×540): Server, Logging, Usage & Pricing, | |
| 9 | +// Appearance, Shortcuts, Advanced. Server + Appearance are fully live in | |
| 10 | +// Phase 4; the rest fill in with Phase 6 features. | |
| 11 | +// | |
| 12 | + | |
| 13 | +import SwiftUI | |
| 14 | + | |
| 15 | +struct SettingsView: View { | |
| 16 | + var body: some View { | |
| 17 | + TabView { | |
| 18 | + ServerSettings() | |
| 19 | + .tabItem { Label("Server", systemImage: "server.rack") } | |
| 20 | + LoggingSettings() | |
| 21 | + .tabItem { Label("Logging", systemImage: "doc.text") } | |
| 22 | + UsageSettings() | |
| 23 | + .tabItem { Label("Usage & Pricing", systemImage: "dollarsign.circle") } | |
| 24 | + AppearanceSettings() | |
| 25 | + .tabItem { Label("Appearance", systemImage: "paintpalette") } | |
| 26 | + ShortcutsSettings() | |
| 27 | + .tabItem { Label("Shortcuts", systemImage: "keyboard") } | |
| 28 | + AdvancedSettings() | |
| 29 | + .tabItem { Label("Advanced", systemImage: "gearshape.2") } | |
| 30 | + } | |
| 31 | + .frame(width: ZyquoMetrics.settingsWidth, height: ZyquoMetrics.settingsHeight) | |
| 32 | + } | |
| 33 | +} | |
| 34 | + | |
| 35 | +private struct ServerSettings: View { | |
| 36 | + @EnvironmentObject private var server: ServerController | |
| 37 | + @AppStorage("autoStartServer") private var autoStart = false | |
| 38 | + @AppStorage("keepServingWhenClosed") private var keepServing = true | |
| 39 | + | |
| 40 | + var body: some View { | |
| 41 | + Form { | |
| 42 | + TextField("Default port:", value: $server.port, format: .number.grouping(.never)) | |
| 43 | + .frame(width: 200) | |
| 44 | + Picker("Bind:", selection: $server.bindLAN) { | |
| 45 | + Text("Localhost only").tag(false) | |
| 46 | + Text("LAN (requires a local API key)").tag(true) | |
| 47 | + } | |
| 48 | + .frame(width: 340) | |
| 49 | + Toggle("Start the server when the app launches", isOn: $autoStart) | |
| 50 | + Toggle("Keep serving when the window is closed", isOn: $keepServing) | |
| 51 | + Text("Request body limit: 32 MB · streaming timeout: 15 min") | |
| 52 | + .font(ZyquoFont.caption) | |
| 53 | + .foregroundStyle(ZyquoColor.textSecondary) | |
| 54 | + } | |
| 55 | + .padding(ZyquoSpacing.xl) | |
| 56 | + } | |
| 57 | +} | |
| 58 | + | |
| 59 | +private struct LoggingSettings: View { | |
| 60 | + @AppStorage("logBodiesRevealed") private var reveal = false | |
| 61 | + | |
| 62 | + var body: some View { | |
| 63 | + Form { | |
| 64 | + Toggle("Reveal request/response bodies in the inspector (this session)", isOn: $reveal) | |
| 65 | + Text("Bodies are redacted by default. Metadata (model, status, latency, tokens, cost) is always recorded; provider keys are never logged.") | |
| 66 | + .font(ZyquoFont.caption) | |
| 67 | + .foregroundStyle(ZyquoColor.textSecondary) | |
| 68 | + } | |
| 69 | + .padding(ZyquoSpacing.xl) | |
| 70 | + } | |
| 71 | +} | |
| 72 | + | |
| 73 | +private struct UsageSettings: View { | |
| 74 | + @EnvironmentObject private var catalog: ModelCatalog | |
| 75 | + | |
| 76 | + var body: some View { | |
| 77 | + Form { | |
| 78 | + Text("Costs are estimated from the catalog's per-model pricing (\(catalog.all.count) models). Usage counters reset daily at midnight.") | |
| 79 | + .font(ZyquoFont.body()) | |
| 80 | + .foregroundStyle(ZyquoColor.textSecondary) | |
| 81 | + } | |
| 82 | + .padding(ZyquoSpacing.xl) | |
| 83 | + } | |
| 84 | +} | |
| 85 | + | |
| 86 | +private struct AppearanceSettings: View { | |
| 87 | + @EnvironmentObject private var appearance: AppearanceStore | |
| 88 | + | |
| 89 | + var body: some View { | |
| 90 | + Form { | |
| 91 | + Picker("Theme:", selection: $appearance.themeMode) { | |
| 92 | + ForEach(ThemeMode.allCases) { mode in | |
| 93 | + Text(mode.displayName).tag(mode) | |
| 94 | + } | |
| 95 | + } | |
| 96 | + .pickerStyle(.segmented) | |
| 97 | + .frame(width: 320) | |
| 98 | + | |
| 99 | + Picker("Accent:", selection: $appearance.accent) { | |
| 100 | + ForEach(AccentChoice.allCases) { accent in | |
| 101 | + Text(accent.displayName).tag(accent) | |
| 102 | + } | |
| 103 | + } | |
| 104 | + .frame(width: 320) | |
| 105 | + | |
| 106 | + Slider(value: $appearance.fontSize, in: 12...16, step: 0.5) { | |
| 107 | + Text("Font size: \(appearance.fontSize, format: .number.precision(.fractionLength(1)))pt") | |
| 108 | + } | |
| 109 | + .frame(width: 380) | |
| 110 | + } | |
| 111 | + .padding(ZyquoSpacing.xl) | |
| 112 | + } | |
| 113 | +} | |
| 114 | + | |
| 115 | +private struct ShortcutsSettings: View { | |
| 116 | + var body: some View { | |
| 117 | + Form { | |
| 118 | + shortcut("⌘R", "Start / stop the server") | |
| 119 | + shortcut("⌘1–6", "Switch sections") | |
| 120 | + shortcut("⌘⇧C", "Copy endpoint URL") | |
| 121 | + shortcut("⌘⏎", "Send in Playground") | |
| 122 | + } | |
| 123 | + .padding(ZyquoSpacing.xl) | |
| 124 | + } | |
| 125 | + | |
| 126 | + private func shortcut(_ keys: String, _ label: String) -> some View { | |
| 127 | + HStack { | |
| 128 | + Text(keys) | |
| 129 | + .font(ZyquoFont.mono(size: 12, weight: .medium)) | |
| 130 | + .foregroundStyle(ZyquoColor.textPrimary) | |
| 131 | + .frame(width: 70, alignment: .leading) | |
| 132 | + Text(label) | |
| 133 | + .font(ZyquoFont.body()) | |
| 134 | + .foregroundStyle(ZyquoColor.textSecondary) | |
| 135 | + } | |
| 136 | + } | |
| 137 | +} | |
| 138 | + | |
| 139 | +private struct AdvancedSettings: View { | |
| 140 | + var body: some View { | |
| 141 | + Form { | |
| 142 | + Button("Reveal data folder in Finder") { | |
| 143 | + NSWorkspace.shared.activateFileViewerSelecting([PersistenceService.shared.rootDirectory]) | |
| 144 | + } | |
| 145 | + Text("Config lives in ~/Library/Application Support/ZyquoRouter/ — provider keys stay in the encrypted vault (vault.zq) and are never exported in plaintext.") | |
| 146 | + .font(ZyquoFont.caption) | |
| 147 | + .foregroundStyle(ZyquoColor.textSecondary) | |
| 148 | + } | |
| 149 | + .padding(ZyquoSpacing.xl) | |
| 150 | + } | |
| 151 | +} | |
modified
docs/PLAN.md
+36 −1
@@ -112,7 +112,42 @@ fixture tests green; zero warnings; headers swept. | ||
| 112 | 112 | |
| 113 | 113 | |
| 114 | 114 | |
| 115 | −## Phase 4 — Design system & UI spec — pending | |
| 115 | +## Phase 4 — Design system & UI | |
| 116 | + | |
| 117 | +- [x] 4.1 `DesignSystem/ZyquoTheme.swift`: Router palette per spec (bg `#FAFBFC`, surface `#FFFFFF`, accent signal-cyan `#0891B2` + graphite `#374151`, subtle `#E5F5F9`, text `#191C1F/#697077/#9BA3AA`, border `#E4E8EB`, status greens/ambers/reds, chart tokens); dark "ops room" derived; family type/spacing/radii/motion tokens; SF Mono where data lives | |
| 118 | +- [x] 4.2 `AppearanceStore`: Light/Dark/System + accents cyan (default), graphite, sky, emerald, violet, copper | |
| 119 | +- [x] 4.3 Shared components: status pill (● Running on :port / ○ Stopped), cards, section headers, mono copy fields, capability badges, hairlines | |
| 120 | +- [x] 4.4 App shell: 240pt translucent navigator (Dashboard/Models/Requests/Keys/Playground/Docs + footer gear + permanent status pill with Start/Stop), 1280×820 default / 1020×660 min | |
| 121 | +- [x] 4.5 Dashboard: hero server card (status, big Start/Stop, port field, bind selector + LAN warning, endpoint URL with Copy + curl/Python/JS snippets), live tiles row (requests, tokens, cost, errors, uptime), first-run onboarding card | |
| 122 | +- [x] 4.6 Models: searchable/filterable catalog, mono namespaced IDs with one-click copy, capability badges, context + pricing columns | |
| 123 | +- [x] 4.7 Keys: Provider Keys tab (masked fields, save/delete, Test with status dot + latency, vault-backed) + Local API Keys tab (create/reveal-once/revoke) | |
| 124 | +- [x] 4.8 Requests: table + detail structure with empty state (live data lands in Phase 6) | |
| 125 | +- [x] 4.9 Playground: model picker, composer, params, streaming toggle — calls the router's own endpoint | |
| 126 | +- [x] 4.10 Docs: in-app rendering of docs/API.md (bundled into the app) | |
| 127 | +- [x] 4.11 Settings window (720×540 tabs): Server + Appearance functional; Logging/Usage/Shortcuts/Advanced structured | |
| 128 | +- [x] 4.12 Zero warnings; headers; `make dev` visual pass over stopped/starting/running/port-conflict/no-keys states | |
| 129 | + | |
| 130 | +**Phase gate: PASSED (2026-07-30) — layout/structure level; the full every-state design | |
| 131 | +quality gate re-runs at the end of Phase 6 once all features are live.** | |
| 132 | + | |
| 133 | +**Phase 4 summary:** `ZyquoTheme` carries the spec palette exactly (graphite-cyan light | |
| 134 | +flagship, dark ops-room derived, provider hues, chart tokens, family type/spacing/radii/ | |
| 135 | +motion; SF Mono for all data). `AppearanceStore` with cyan/graphite/sky/emerald/violet/ | |
| 136 | +copper accents. Shell: 240pt translucent navigator (NSVisualEffectView sidebar), six | |
| 137 | +sections, footer gear + permanent StatusPill with Start/Stop; ⌘1–6 Go menu, ⌘⇧C copy | |
| 138 | +endpoint, ⌘R start/stop. Screens live: Dashboard (hero server card with port/bind/LAN | |
| 139 | +warning, endpoint + curl/Python/JS copy-as snippets, five 1Hz tiles fed by UsageMeter, | |
| 140 | +first-run onboarding card), Models (search/provider filter over the 170-model catalog, | |
| 141 | +mono IDs + hover copy, badges, context/pricing columns), Requests (table over | |
| 142 | +UsageRecords + empty state), Keys (Provider tab identical to Cloud UX incl. Test with | |
| 143 | +latency; Local tab create/reveal-once/enable/revoke), Playground (calls own endpoint, | |
| 144 | +streaming + pretty JSON), Docs (bundled docs/API.md renderer with copyable code blocks), | |
| 145 | +Settings 6 tabs (Server/Appearance fully live). Verified visually via screenshots: | |
| 146 | +stopped + running dashboard, Models, Keys, Docs all match the spec. 31 tests green, | |
| 147 | +zero warnings, headers swept. | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 116 | 151 | ## Phase 5 — App icon — pending |
| 117 | 152 | ## Phase 6 — Features — pending |
| 118 | 153 | ## Phase 7 — Verification with real keys — pending |
| 119 | 154 | |