Skip to content

Commit c4bfc6b

Browse files
author
lucasliu
committed
release: v1.0.3
- Chat template processor architecture with registry routing - Cloud auth gate + promotional card UI - NOVA_DIR env var support - Downloads page view - ThinkingParser improvements - FusedBatchScheduler optimizations - Auth URL static let fix (no more log spam)
1 parent de025a6 commit c4bfc6b

38 files changed

Lines changed: 4562 additions & 941 deletions

Sources/NovaMLXAPI/APIServer.swift

Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -504,8 +504,9 @@ public final class NovaMLXAPIServer: @unchecked Sendable {
504504
: result.completionTokens
505505

506506
var content: [AnthropicContentBlock] = []
507-
// Parse thinking tags from raw output
508-
let thinkingParser = ThinkingParser()
507+
// Parse thinking tags from raw output — match streaming's model-aware detection
508+
let isAnthropicImplicit = ModelContainer.isImplicitThinkingModel(for: anthropicReq.model)
509+
let thinkingParser = ThinkingParser(expectImplicitThinking: isAnthropicImplicit)
509510
_ = thinkingParser.feed(result.text)
510511
let finalResult = thinkingParser.finalize()
511512
if !finalResult.thinking.isEmpty {
@@ -1682,8 +1683,9 @@ public final class NovaMLXAPIServer: @unchecked Sendable {
16821683
let finishReason: String
16831684
let message: OpenAIChatMessage
16841685

1685-
// Parse thinking tags from raw output
1686-
let thinkingParser = ThinkingParser()
1686+
// Parse thinking tags from raw output — match streaming's model-aware detection
1687+
let isImplicitModel = ModelContainer.isImplicitThinkingModel(for: openAIReq.model)
1688+
let thinkingParser = ThinkingParser(expectImplicitThinking: isImplicitModel)
16871689
_ = thinkingParser.feed(result.text)
16881690
let finalResult = thinkingParser.finalize()
16891691
let thinkingText = finalResult.thinking.isEmpty ? nil : finalResult.thinking
@@ -1794,9 +1796,8 @@ public final class NovaMLXAPIServer: @unchecked Sendable {
17941796
try await writer.write(ByteBuffer(string: "data: \(String(data: roleData, encoding: .utf8) ?? "")\n\n"))
17951797

17961798
var completionTokenCount = 0
1797-
let modelId = openAIReq.model.lowercased()
1798-
let isThinkingModel = ModelContainer.detectThinkingModel(for: openAIReq.model)
1799-
let thinkingParser = ThinkingParser(expectImplicitThinking: isThinkingModel)
1799+
let isImplicitModel = ModelContainer.isImplicitThinkingModel(for: openAIReq.model)
1800+
let thinkingParser = ThinkingParser(expectImplicitThinking: isImplicitModel)
18001801
for try await event in keepAliveStream {
18011802
switch event {
18021803
case .token(let token):
@@ -1816,6 +1817,15 @@ public final class NovaMLXAPIServer: @unchecked Sendable {
18161817
let data = try JSONEncoder().encode(chunk)
18171818
try await writer.write(ByteBuffer(string: "data: \(String(data: data, encoding: .utf8) ?? "")\n\n"))
18181819
} else if let finish = token.finishReason {
1820+
// Flush ThinkingParser before emitting stop chunk
1821+
let finalParsed = thinkingParser.finalize()
1822+
if !finalParsed.response.isEmpty {
1823+
completionTokenCount += 1
1824+
let respDelta = OpenAIDelta(content: finalParsed.response)
1825+
let respChunk = OpenAIStreamChunk(id: chunkId, model: openAIReq.model, choices: [OpenAIStreamChoice(index: 0, delta: respDelta)])
1826+
let respData = try JSONEncoder().encode(respChunk)
1827+
try await writer.write(ByteBuffer(string: "data: \(String(data: respData, encoding: .utf8) ?? "")\n\n"))
1828+
}
18191829
let finalChunk = OpenAIStreamChunk(
18201830
id: chunkId,
18211831
model: openAIReq.model,
@@ -1932,9 +1942,8 @@ public final class NovaMLXAPIServer: @unchecked Sendable {
19321942

19331943
NovaMLXLog.info("[SSE:\(reqTag)] Waiting for first token from inference stream...")
19341944

1935-
let anthropicModelId = (anthropicReq.model).lowercased()
1936-
let isAnthropicThinkingModel = ModelContainer.detectThinkingModel(for: anthropicReq.model)
1937-
let thinkingParser = ThinkingParser(expectImplicitThinking: isAnthropicThinkingModel)
1945+
let isAnthropicImplicitModel = ModelContainer.isImplicitThinkingModel(for: anthropicReq.model)
1946+
let thinkingParser = ThinkingParser(expectImplicitThinking: isAnthropicImplicitModel)
19381947
var currentBlockIndex = 0
19391948
var isInThinkingBlock = false
19401949
var hasStartedTextBlock = false
@@ -1964,6 +1973,20 @@ public final class NovaMLXAPIServer: @unchecked Sendable {
19641973
switch event {
19651974
case .token(let token):
19661975
if token.finishReason != nil {
1976+
// Flush ThinkingParser before closing blocks
1977+
let finalParsed = thinkingParser.finalize()
1978+
if !finalParsed.response.isEmpty {
1979+
if isInThinkingBlock {
1980+
try await endCurrentBlock()
1981+
}
1982+
if !hasStartedTextBlock {
1983+
try await startTextBlock()
1984+
}
1985+
tokenCount += 1
1986+
let deltaEvent = AnthropicStreamEvent.textDelta(finalParsed.response)
1987+
let deltaData = try JSONEncoder().encode(deltaEvent)
1988+
try await writer.write(ByteBuffer(string: "event: content_block_delta\ndata: \(String(data: deltaData, encoding: .utf8) ?? "{}")\n\n"))
1989+
}
19671990
// Close current block if open
19681991
if isInThinkingBlock || hasStartedTextBlock {
19691992
try await writer.write(ByteBuffer(string: "event: content_block_stop\ndata: {}\n\n"))

Sources/NovaMLXAPI/OCROptimizer.swift

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,11 @@ enum OCROptimizer {
114114
) -> SamplingOverrides {
115115
guard let ocrType = ocrType(for: modelName),
116116
let defaults = samplingDefaults[ocrType] else {
117-
return SamplingOverrides()
117+
return SamplingOverrides(
118+
temperature: userTemperature,
119+
maxTokens: userMaxTokens,
120+
repetitionPenalty: userRepetitionPenalty
121+
)
118122
}
119123
return SamplingOverrides(
120124
temperature: userTemperature ?? defaults.temperature,

Sources/NovaMLXApp/main.swift

Lines changed: 27 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -63,10 +63,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
6363
let config = NovaMLXConfiguration.shared
6464
var mainWindow: NSWindow?
6565

66+
/// Path errors captured in init() BEFORE ModelManager creates any directories
67+
private let pathValidationErrors: [String]
68+
6669
override init() {
67-
let homeDir = FileManager.default.homeDirectoryForCurrentUser
68-
let baseDir = homeDir.appendingPathComponent(".nova", isDirectory: true)
69-
let modelsDir = baseDir.appendingPathComponent("models", isDirectory: true)
70+
// Validate BEFORE creating ModelManager (which auto-creates dirs)
71+
let errors = NovaMLXPaths.validateConfiguredPaths()
72+
self.pathValidationErrors = errors
73+
74+
let baseDir = NovaMLXPaths.baseDir
75+
let modelsDir = NovaMLXPaths.modelsDir
7076

7177
// Auto-migrate from old Application Support path if needed
7278
Self.migrateFromApplicationSupport(to: baseDir)
@@ -111,9 +117,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
111117
var json = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
112118
else { return }
113119

114-
guard let encoded = oldPrefix.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) else { return }
115-
_ = "file://" + encoded
116120
var changed = false
121+
let newModelsPrefix = "file://" + NovaMLXPaths.modelsDir.path
117122

118123
for (key, value) in json {
119124
guard var record = value as? [String: Any],
@@ -122,12 +127,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
122127
else { continue }
123128
record["localURL"] = localURL.replacingOccurrences(
124129
of: "file:///Users/lucas/Library/Application%20Support/NovaMLX/models/",
125-
with: "file:///Users/lucas/.nova/models/"
130+
with: newModelsPrefix
126131
)
127-
// Also handle non-percent-encoded variant
128132
record["localURL"] = (record["localURL"] as? String ?? localURL).replacingOccurrences(
129133
of: "file:///Users/lucas/Library/Application Support/NovaMLX/models/",
130-
with: "file:///Users/lucas/.nova/models/"
134+
with: newModelsPrefix
131135
)
132136
json[key] = record
133137
changed = true
@@ -136,7 +140,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
136140
if changed {
137141
let newData = try? JSONSerialization.data(withJSONObject: json, options: [.prettyPrinted, .sortedKeys])
138142
try? newData?.write(to: registryPath, options: .atomic)
139-
NovaMLXLog.info("Fixed registry paths to point to ~/.nova")
143+
NovaMLXLog.info("Fixed registry paths to point to \(NovaMLXPaths.modelsDir.path)")
140144
}
141145
}
142146

@@ -157,14 +161,25 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
157161
object: nil
158162
)
159163

164+
// Validate configured paths — fatal if configured but inaccessible
165+
if !pathValidationErrors.isEmpty {
166+
let alert = NSAlert()
167+
alert.messageText = "NovaMLX: Configuration Error"
168+
alert.informativeText = pathValidationErrors.joined(separator: "\n\n")
169+
alert.alertStyle = .critical
170+
alert.addButton(withTitle: "Quit")
171+
NSApp.activate(ignoringOtherApps: true)
172+
_ = alert.runModal()
173+
NSApp.terminate(nil)
174+
return
175+
}
176+
160177
Task {
161178
NovaMLXLog.rotateLogFile()
162179

163180
try? await config.initializeDirectories()
164181

165-
let configFile = await config.modelsDirectory
166-
.deletingLastPathComponent()
167-
.appendingPathComponent("config.json")
182+
let configFile = NovaMLXPaths.configFile
168183
if FileManager.default.fileExists(atPath: configFile.path) {
169184
do {
170185
try await config.loadFromFile(configFile)

Sources/NovaMLXCLI/CLIClient.swift

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
11
import Foundation
2+
import NovaMLXCore
23

34
enum CLIClient {
45
private static let baseURL = "http://127.0.0.1:8080"
56
private static let adminURL = "http://127.0.0.1:8081"
67

7-
// Read server config from ~/.nova/config.json (same file the server uses)
88
static func configFromFile() -> [String: Any]? {
9-
let homeDir = FileManager.default.homeDirectoryForCurrentUser
10-
let configURL = homeDir.appendingPathComponent(".nova/config.json")
9+
let configURL = NovaMLXPaths.configFile
1110
guard let data = try? Data(contentsOf: configURL),
1211
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
1312
else { return nil }

Sources/NovaMLXCore/AuthClient.swift

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,30 +19,26 @@ public struct AuthClient: Sendable {
1919
self.baseURL = baseURL
2020
}
2121

22-
public static var defaultBaseURL: String {
22+
/// Cached auth URL — computed once, logs once.
23+
public static let defaultBaseURL: String = {
2324
// 1. Environment variable (CLI or dev override)
2425
if let env = ProcessInfo.processInfo.environment["NOVA_AUTH_URL"], !env.isEmpty {
2526
authLog("Auth URL from env: \(env)")
2627
return env
2728
}
2829
// 2. Config file (~/.nova/config.json → auth.authURL)
2930
if let configData = FileManager.default.contents(atPath: NovaMLXPaths.configFile.path) {
30-
authLog("Config file size: \(configData.count) bytes")
3131
if let json = try? JSONSerialization.jsonObject(with: configData) as? [String: Any],
3232
let auth = json["auth"] as? [String: Any],
3333
let url = auth["authURL"] as? String, !url.isEmpty {
3434
authLog("Auth URL from config: \(url)")
3535
return url
36-
} else {
37-
authLog("Config parsed but no auth.authURL found")
3836
}
39-
} else {
40-
authLog("Config file not found at \(NovaMLXPaths.configFile.path)")
4137
}
4238
// 3. Production default
4339
authLog("Using production default: https://novamlx.ai")
4440
return "https://novamlx.ai"
45-
}
41+
}()
4642

4743
public func login(email: String, password: String) async throws -> LoginResponse {
4844
var request = URLRequest(url: URL(string: "\(baseURL)/api/v1/auth/login")!)

Sources/NovaMLXCore/Configuration.swift

Lines changed: 1 addition & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,24 +4,18 @@ public actor NovaMLXConfiguration {
44
public static let shared = NovaMLXConfiguration()
55

66
private var _modelsDirectory: URL
7-
private var _cacheDirectory: URL
87
private var _serverConfig: ServerConfig
98
private var _defaultModel: String?
109

1110
private init() {
1211
_modelsDirectory = NovaMLXPaths.modelsDir
13-
_cacheDirectory = NovaMLXPaths.cacheDir
1412
_serverConfig = ServerConfig()
1513
}
1614

1715
public var modelsDirectory: URL {
1816
get async { _modelsDirectory }
1917
}
2018

21-
public var cacheDirectory: URL {
22-
get async { _cacheDirectory }
23-
}
24-
2519
public var serverConfig: ServerConfig {
2620
get async { _serverConfig }
2721
}
@@ -34,10 +28,6 @@ public actor NovaMLXConfiguration {
3428
_modelsDirectory = url
3529
}
3630

37-
public func setCacheDirectory(_ url: URL) {
38-
_cacheDirectory = url
39-
}
40-
4131
public func setServerConfig(_ config: ServerConfig) {
4232
_serverConfig = config
4333
}
@@ -49,7 +39,6 @@ public actor NovaMLXConfiguration {
4939
public func initializeDirectories() throws {
5040
let fm = FileManager.default
5141
try fm.createDirectory(at: _modelsDirectory, withIntermediateDirectories: true)
52-
try fm.createDirectory(at: _cacheDirectory, withIntermediateDirectories: true)
5342
}
5443

5544
public func loadFromFile(_ url: URL) throws {
@@ -73,7 +62,6 @@ public actor NovaMLXConfiguration {
7362
try data.write(to: url, options: .atomic)
7463
}
7564

76-
/// Update apiKeys in the server config and persist to file
7765
public func updateApiKeys(_ keys: [String], file url: URL) throws {
7866
_serverConfig = ServerConfig(
7967
host: _serverConfig.host,
@@ -92,9 +80,8 @@ public actor NovaMLXConfiguration {
9280
try saveToFile(url)
9381
}
9482

95-
/// Convenience: get the config file URL (sibling of models directory)
9683
public var configFileURL: URL {
97-
_modelsDirectory.deletingLastPathComponent().appendingPathComponent("config.json")
84+
NovaMLXPaths.configFile
9885
}
9986
}
10087

Sources/NovaMLXCore/Localization.swift

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -85,8 +85,7 @@ public final class L10n: ObservableObject {
8585
// MARK: - Persistence
8686

8787
private func persistLanguage() {
88-
let home = FileManager.default.homeDirectoryForCurrentUser
89-
let configURL = home.appendingPathComponent(".nova/config.json")
88+
let configURL = NovaMLXPaths.configFile
9089

9190
guard var config = Self.readJSON(at: configURL) else { return }
9291
config["language"] = currentLanguage.rawValue
@@ -97,8 +96,7 @@ public final class L10n: ObservableObject {
9796
}
9897

9998
private static func loadSavedLanguage() -> String? {
100-
let home = FileManager.default.homeDirectoryForCurrentUser
101-
let configURL = home.appendingPathComponent(".nova/config.json")
99+
let configURL = NovaMLXPaths.configFile
102100
guard let config = readJSON(at: configURL) else { return nil }
103101
return config["language"] as? String
104102
}

Sources/NovaMLXCore/LocalizationStrings.swift

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@ enum LocalizationStrings {
2020
// App / Navigation
2121
"app.status": "Status",
2222
"app.models": "Models",
23-
"app.chat": "Chat",
23+
"app.downloads": "Downloads",
24+
"app.chat": "Playground",
2425
"app.agents": "Agents",
2526
"app.settings": "Settings",
2627
"app.running": "Running",
@@ -272,7 +273,7 @@ enum LocalizationStrings {
272273
private static let zhHans: [String: String] = [
273274
"app.status": "状态",
274275
"app.models": "模型",
275-
"app.chat": "聊天",
276+
"app.chat": "Playground",
276277
"app.agents": "智能体",
277278
"app.settings": "设置",
278279
"app.running": "运行中",
@@ -516,7 +517,7 @@ enum LocalizationStrings {
516517
private static let zhHantHK: [String: String] = [
517518
"app.status": "狀態",
518519
"app.models": "模型",
519-
"app.chat": "聊天",
520+
"app.chat": "Playground",
520521
"app.agents": "智能體",
521522
"app.settings": "設定",
522523
"app.running": "運行中",
@@ -760,7 +761,7 @@ enum LocalizationStrings {
760761
private static let zhHantTW: [String: String] = [
761762
"app.status": "狀態",
762763
"app.models": "模型",
763-
"app.chat": "聊天",
764+
"app.chat": "Playground",
764765
"app.agents": "智慧代理",
765766
"app.settings": "設定",
766767
"app.running": "執行中",
@@ -1004,7 +1005,7 @@ enum LocalizationStrings {
10041005
private static let ja: [String: String] = [
10051006
"app.status": "ステータス",
10061007
"app.models": "モデル",
1007-
"app.chat": "チャット",
1008+
"app.chat": "Playground",
10081009
"app.agents": "エージェント",
10091010
"app.settings": "設定",
10101011
"app.running": "実行中",
@@ -1248,7 +1249,7 @@ enum LocalizationStrings {
12481249
private static let ko: [String: String] = [
12491250
"app.status": "상태",
12501251
"app.models": "모델",
1251-
"app.chat": "채팅",
1252+
"app.chat": "Playground",
12521253
"app.agents": "에이전트",
12531254
"app.settings": "설정",
12541255
"app.running": "실행 중",
@@ -1492,7 +1493,7 @@ enum LocalizationStrings {
14921493
private static let fr: [String: String] = [
14931494
"app.status": "Statut",
14941495
"app.models": "Modèles",
1495-
"app.chat": "Chat",
1496+
"app.chat": "Playground",
14961497
"app.agents": "Agents",
14971498
"app.settings": "Paramètres",
14981499
"app.running": "En cours",
@@ -1736,7 +1737,7 @@ enum LocalizationStrings {
17361737
private static let de: [String: String] = [
17371738
"app.status": "Status",
17381739
"app.models": "Modelle",
1739-
"app.chat": "Chat",
1740+
"app.chat": "Playground",
17401741
"app.agents": "Agenten",
17411742
"app.settings": "Einstellungen",
17421743
"app.running": "Aktiv",
@@ -1980,7 +1981,7 @@ enum LocalizationStrings {
19801981
private static let ru: [String: String] = [
19811982
"app.status": "Статус",
19821983
"app.models": "Модели",
1983-
"app.chat": "Чат",
1984+
"app.chat": "Playground",
19841985
"app.agents": "Агенты",
19851986
"app.settings": "Настройки",
19861987
"app.running": "Работает",

0 commit comments

Comments
 (0)