157 lines
6.5 KiB
Swift
157 lines
6.5 KiB
Swift
import Foundation
|
|
import JavaScriptCore
|
|
|
|
enum ModelCatalogLoader {
|
|
static var defaultPath: String { self.resolveDefaultPath() }
|
|
private static let logger = Logger(subsystem: "com.clawdbot", category: "models")
|
|
private nonisolated static let appSupportDir: URL = {
|
|
let base = FileManager().urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
|
|
return base.appendingPathComponent("Clawdbot", isDirectory: true)
|
|
}()
|
|
|
|
private static var cachePath: URL {
|
|
self.appSupportDir.appendingPathComponent("model-catalog/models.generated.js", isDirectory: false)
|
|
}
|
|
|
|
static func load(from path: String) async throws -> [ModelChoice] {
|
|
let expanded = (path as NSString).expandingTildeInPath
|
|
guard let resolved = self.resolvePath(preferred: expanded) else {
|
|
self.logger.error("model catalog load failed: file not found")
|
|
throw NSError(
|
|
domain: "ModelCatalogLoader",
|
|
code: 1,
|
|
userInfo: [NSLocalizedDescriptionKey: "Model catalog file not found"])
|
|
}
|
|
self.logger.debug("model catalog load start file=\(URL(fileURLWithPath: resolved.path).lastPathComponent)")
|
|
let source = try String(contentsOfFile: resolved.path, encoding: .utf8)
|
|
let sanitized = self.sanitize(source: source)
|
|
|
|
let ctx = JSContext()
|
|
ctx?.exceptionHandler = { _, exception in
|
|
if let exception {
|
|
self.logger.warning("model catalog JS exception: \(exception)")
|
|
}
|
|
}
|
|
ctx?.evaluateScript(sanitized)
|
|
guard let rawModels = ctx?.objectForKeyedSubscript("MODELS")?.toDictionary() as? [String: Any] else {
|
|
self.logger.error("model catalog parse failed: MODELS missing")
|
|
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))
|
|
}
|
|
}
|
|
|
|
let sorted = choices.sorted { lhs, rhs in
|
|
if lhs.provider == rhs.provider {
|
|
return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending
|
|
}
|
|
return lhs.provider.localizedCaseInsensitiveCompare(rhs.provider) == .orderedAscending
|
|
}
|
|
self.logger.debug("model catalog loaded providers=\(rawModels.count) models=\(sorted.count)")
|
|
if resolved.shouldCache {
|
|
self.cacheCatalog(sourcePath: resolved.path)
|
|
}
|
|
return sorted
|
|
}
|
|
|
|
private static func resolveDefaultPath() -> String {
|
|
let cache = self.cachePath.path
|
|
if FileManager().isReadableFile(atPath: cache) { return cache }
|
|
if let bundlePath = self.bundleCatalogPath() { return bundlePath }
|
|
if let nodePath = self.nodeModulesCatalogPath() { return nodePath }
|
|
return cache
|
|
}
|
|
|
|
private static func resolvePath(preferred: String) -> (path: String, shouldCache: Bool)? {
|
|
if FileManager().isReadableFile(atPath: preferred) {
|
|
return (preferred, preferred != self.cachePath.path)
|
|
}
|
|
|
|
if let bundlePath = self.bundleCatalogPath(), bundlePath != preferred {
|
|
self.logger.warning("model catalog path missing; falling back to bundled catalog")
|
|
return (bundlePath, true)
|
|
}
|
|
|
|
let cache = self.cachePath.path
|
|
if cache != preferred, FileManager().isReadableFile(atPath: cache) {
|
|
self.logger.warning("model catalog path missing; falling back to cached catalog")
|
|
return (cache, false)
|
|
}
|
|
|
|
if let nodePath = self.nodeModulesCatalogPath(), nodePath != preferred {
|
|
self.logger.warning("model catalog path missing; falling back to node_modules catalog")
|
|
return (nodePath, true)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
private static func bundleCatalogPath() -> String? {
|
|
guard let url = Bundle.main.url(forResource: "models.generated", withExtension: "js") else {
|
|
return nil
|
|
}
|
|
return url.path
|
|
}
|
|
|
|
private static func nodeModulesCatalogPath() -> String? {
|
|
let roots = [
|
|
URL(fileURLWithPath: CommandResolver.projectRootPath()),
|
|
URL(fileURLWithPath: FileManager().currentDirectoryPath),
|
|
]
|
|
for root in roots {
|
|
let candidate = root
|
|
.appendingPathComponent("node_modules/@mariozechner/pi-ai/dist/models.generated.js")
|
|
if FileManager().isReadableFile(atPath: candidate.path) {
|
|
return candidate.path
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private static func cacheCatalog(sourcePath: String) {
|
|
let destination = self.cachePath
|
|
do {
|
|
try FileManager().createDirectory(
|
|
at: destination.deletingLastPathComponent(),
|
|
withIntermediateDirectories: true)
|
|
if FileManager().fileExists(atPath: destination.path) {
|
|
try FileManager().removeItem(at: destination)
|
|
}
|
|
try FileManager().copyItem(atPath: sourcePath, toPath: destination.path)
|
|
self.logger.debug("model catalog cached file=\(destination.lastPathComponent)")
|
|
} catch {
|
|
self.logger.warning("model catalog cache failed: \(error.localizedDescription)")
|
|
}
|
|
}
|
|
|
|
private static func sanitize(source: String) -> String {
|
|
guard let exportRange = source.range(of: "export const MODELS"),
|
|
let firstBrace = source[exportRange.upperBound...].firstIndex(of: "{"),
|
|
let lastBrace = source.lastIndex(of: "}")
|
|
else {
|
|
return "var MODELS = {}"
|
|
}
|
|
var body = String(source[firstBrace...lastBrace])
|
|
body = body.replacingOccurrences(
|
|
of: #"(?m)\bsatisfies\s+[^,}\n]+"#,
|
|
with: "",
|
|
options: .regularExpression)
|
|
body = body.replacingOccurrences(
|
|
of: #"(?m)\bas\s+[^;,\n]+"#,
|
|
with: "",
|
|
options: .regularExpression)
|
|
return "var MODELS = \(body);"
|
|
}
|
|
}
|