From 1916e688a6920049d313706fbf55ed866696de3a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 6 Dec 2025 05:10:21 +0100 Subject: [PATCH] feat: load PI model catalog and add dropdown in Config tab --- apps/macos/Sources/Clawdis/AppMain.swift | 189 ++++++++++++++++++++++- 1 file changed, 184 insertions(+), 5 deletions(-) diff --git a/apps/macos/Sources/Clawdis/AppMain.swift b/apps/macos/Sources/Clawdis/AppMain.swift index 2c588f8c5a..a8a1b4a8cf 100644 --- a/apps/macos/Sources/Clawdis/AppMain.swift +++ b/apps/macos/Sources/Clawdis/AppMain.swift @@ -5,6 +5,7 @@ import AVFoundation import ClawdisIPC import CoreGraphics import Foundation +import JavaScriptCore import MenuBarExtraAccess import OSLog @preconcurrency import ScreenCaptureKit @@ -26,6 +27,7 @@ private let defaultVoiceWakeTriggers = ["clawd", "claude"] private let voiceWakeMicKey = "clawdis.voiceWakeMicID" private let voiceWakeLocaleKey = "clawdis.voiceWakeLocaleID" private let voiceWakeAdditionalLocalesKey = "clawdis.voiceWakeAdditionalLocaleIDs" +private let modelCatalogPathKey = "clawdis.modelCatalogPath" private let voiceWakeSupported: Bool = ProcessInfo.processInfo.operatingSystemVersion.majorVersion >= 26 // MARK: - App model @@ -892,6 +894,27 @@ private struct SessionDefaults { let contextTokens: Int } +private struct ModelChoice: Identifiable, Hashable { + let id: String + let name: String + let provider: String + let contextWindow: Int? +} + +extension [String] { + fileprivate func dedupedPreserveOrder() -> [String] { + var seen = Set() + var result: [String] = [] + for item in self { + if !seen.contains(item) { + seen.insert(item) + result.append(item) + } + } + return result + } +} + private struct SessionConfigHints { let storePath: String? let model: String? @@ -964,6 +987,17 @@ private enum SessionLoader { return preferred } + static func availableModels(storeOverride: String?) -> [String] { + let path = self.resolveStorePath(override: storeOverride) + guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)), + let decoded = try? JSONDecoder().decode([String: SessionEntryRecord].self, from: data) + else { + return [self.fallbackModel] + } + let models = decoded.values.compactMap(\.model) + return ([self.fallbackModel] + models).dedupedPreserveOrder() + } + static func loadRows(at path: String, defaults: SessionDefaults) async throws -> [SessionRow] { try await Task.detached(priority: .utility) { guard FileManager.default.fileExists(atPath: path) else { @@ -1012,6 +1046,59 @@ private enum SessionLoader { } } +enum ModelCatalogLoader { + static let defaultPath: String = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Projects/pi-mono/packages/ai/src/models.generated.ts").path + + static func load(from path: String) async throws -> [ModelChoice] { + let expanded = (path as NSString).expandingTildeInPath + let source = try String(contentsOfFile: expanded, encoding: .utf8) + let sanitized = self.sanitize(source: source) + + let ctx = JSContext() + ctx?.exceptionHandler = { _, exception in + if let exception { print("JS exception: \(exception)") } + } + ctx?.evaluateScript(sanitized) + guard let rawModels = ctx?.objectForKeyedSubscript("MODELS")?.toDictionary() as? [String: Any] else { + throw NSError( + domain: "ModelCatalogLoader", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Failed to parse models.generated.ts"]) + } + + var choices: [ModelChoice] = [] + for (provider, value) in rawModels { + guard let models = value as? [String: Any] else { continue } + for (id, payload) in models { + guard let dict = payload as? [String: Any] else { continue } + let name = dict["name"] as? String ?? id + let ctxWindow = dict["contextWindow"] as? Int + choices.append(ModelChoice(id: id, name: name, provider: provider, contextWindow: ctxWindow)) + } + } + + return choices.sorted { lhs, rhs in + if lhs.provider == rhs.provider { + return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending + } + return lhs.provider.localizedCaseInsensitiveCompare(rhs.provider) == .orderedAscending + } + } + + private static func sanitize(source: String) -> String { + var text = source + text = text.replacingOccurrences(of: #"(?m)^import[^\n]*\n"#, with: "", options: .regularExpression) + text = text.replacingOccurrences( + of: #"export\s+const\s+MODELS"#, + with: "var MODELS", + options: .regularExpression) + text = text.replacingOccurrences(of: #"satisfies\s+Model<[^>]+>"#, with: "", options: .regularExpression) + text = text.replacingOccurrences(of: #"as\s+Model<[^>]+>"#, with: "", options: .regularExpression) + return text + } +} + private func relativeAge(from date: Date?) -> String { guard let date else { return "unknown" } let delta = Date().timeIntervalSince(date) @@ -1204,11 +1291,17 @@ struct SessionsSettings: View { @MainActor struct ConfigSettings: View { @State private var configModel: String = "" + @State private var customModel: String = "" @State private var configStorePath: String = SessionLoader.defaultStorePath @State private var configContextTokens: String = "" @State private var configStatus: String? @State private var configSaving = false @State private var hasLoaded = false + @State private var models: [ModelChoice] = [] + @State private var modelsLoading = false + @State private var modelError: String? + @State private var modelCatalogPath: String = UserDefaults.standard + .string(forKey: modelCatalogPathKey) ?? ModelCatalogLoader.defaultPath var body: some View { VStack(alignment: .leading, spacing: 14) { @@ -1219,9 +1312,53 @@ struct ConfigSettings: View { .foregroundStyle(.secondary) LabeledContent("Model") { - TextField("e.g. claude-3.5-sonnet", text: self.$configModel) - .textFieldStyle(.roundedBorder) - .frame(width: 260) + VStack(alignment: .leading, spacing: 6) { + Picker("Model", selection: self.$configModel) { + ForEach(self.models) { choice in + Text( + "\(choice.name) — \(choice.provider.uppercased())\(choice.contextWindow.map { " \($0 / 1000)k ctx" } ?? "")") + .tag(choice.id) + } + Text("Manual entry…").tag("__custom__") + } + .labelsHidden() + .frame(width: 360) + .disabled(self.modelsLoading || (!self.modelError.isNilOrEmpty && self.models.isEmpty)) + + if self.configModel == "__custom__" { + TextField("Enter model ID", text: self.$customModel) + .textFieldStyle(.roundedBorder) + .frame(width: 320) + .onChange(of: self.customModel) { _, newValue in + self.configModel = newValue + } + } + + HStack(spacing: 10) { + Button { + Task { await self.loadModels() } + } label: { + Label(self.modelsLoading ? "Loading…" : "Reload models", systemImage: "arrow.clockwise") + } + .disabled(self.modelsLoading) + + Button { + self.chooseCatalogFile() + } label: { + Label("Choose file…", systemImage: "folder") + } + + if let modelError { + Text(modelError) + .font(.footnote) + .foregroundStyle(.secondary) + } else if !self.models.isEmpty { + Text("Loaded \(self.models.count) models") + .font(.footnote) + .foregroundStyle(.secondary) + } + } + } } LabeledContent("Session store") { @@ -1267,6 +1404,7 @@ struct ConfigSettings: View { guard !self.hasLoaded else { return } self.hasLoaded = true self.loadConfig() + await self.loadModels() } } @@ -1297,7 +1435,14 @@ struct ConfigSettings: View { let session = reply["session"] as? [String: Any] let agent = reply["agent"] as? [String: Any] self.configStorePath = (session?["store"] as? String) ?? SessionLoader.defaultStorePath - self.configModel = (agent?["model"] as? String) ?? "" + let loadedModel = (agent?["model"] as? String) ?? "" + if !loadedModel.isEmpty { + self.configModel = loadedModel + self.customModel = loadedModel + } else { + self.configModel = "" + self.customModel = "" + } if let ctx = (agent?["contextTokens"] as? NSNumber)?.intValue { self.configContextTokens = "\(ctx)" } else { @@ -1318,7 +1463,9 @@ struct ConfigSettings: View { let trimmedStore = self.configStorePath.trimmingCharacters(in: .whitespacesAndNewlines) if !trimmedStore.isEmpty { session["store"] = trimmedStore } - let trimmedModel = self.configModel.trimmingCharacters(in: .whitespacesAndNewlines) + let chosenModel = (self.configModel == "__custom__" ? self.customModel : self.configModel) + .trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedModel = chosenModel if !trimmedModel.isEmpty { agent["model"] = trimmedModel } if let ctxTokens { agent["contextTokens"] = ctxTokens } @@ -1341,6 +1488,38 @@ struct ConfigSettings: View { self.configStatus = "Save failed: \(error.localizedDescription)" } } + + private func loadModels() async { + guard !self.modelsLoading else { return } + self.modelsLoading = true + self.modelError = nil + do { + let loaded = try await ModelCatalogLoader.load(from: self.modelCatalogPath) + self.models = loaded + // if current model not in list, switch to custom to keep value visible + if !self.configModel.isEmpty, !loaded.contains(where: { $0.id == self.configModel }) { + self.customModel = self.configModel + self.configModel = "__custom__" + } + } catch { + self.modelError = error.localizedDescription + self.models = [] + } + self.modelsLoading = false + } + + private func chooseCatalogFile() { + let panel = NSOpenPanel() + panel.title = "Select models.generated.ts" + panel.allowedFileTypes = ["ts"] + panel.allowsMultipleSelection = false + panel.directoryURL = URL(fileURLWithPath: self.modelCatalogPath).deletingLastPathComponent() + if panel.runModal() == .OK, let url = panel.url { + self.modelCatalogPath = url.path + UserDefaults.standard.set(url.path, forKey: modelCatalogPathKey) + Task { await self.loadModels() } + } + } } private struct SessionRowView: View {