mirror of
https://github.com/BigMarc/grammargem.git
synced 2026-10-13 17:01:55 +02:00
Wave 1 (correctness/trust): - Fix paid large-model dead path: engine reads the persisted selected repo via a thread-safe ModelManager.activeRepo(), so download/load/isReady now agree. - Lemon Squeezy store/product check fails closed in release builds — the placeholder-ID 'any key unlocks paid' bypass can no longer ship. - Replace force-unwrapped Hugging Face URLs with safe URLComponents construction. - Never read/replace/scan secure (password) fields via AX.isSecureTextField. Wave 2 (compounding seam): - Extract AIPromptKit (prompts/temperature/maxTokens/cleanup) out of MLXEngine so every backend produces identical prompts and output. - AIEngine gains protectedTerms (personal dictionary now actually injected into the LLM), warmup(), and runStreaming(); add makeAIEngine() factory. - Honor WritingMode.lengthCap for maxTokens; warm the model on launch/after download. - Guard custom modes against empty prompts and duplicate names. Wave 3: OllamaEngine — a URLSession-only AIEngine backend (cross-platform, opt-in via GRAMMARGEM_AI_BACKEND=ollama). Wave 4: LocalEngineServer — loopback-only, token-authed HTTP bridge (Network.framework, no new deps), off by default, exposing Harper + the LLM + the entitlement gate to a browser extension. Tests: 6 new unit tests for the AIPrompts seam and custom-mode guard (19 pass). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
150 lines
6.5 KiB
Swift
150 lines
6.5 KiB
Swift
import Foundation
|
|
import MLXLMCommon
|
|
import MLXLLM
|
|
|
|
/// Layer 2 — the real on-device LLM (rewrite / tone / "Ask" / translate), backed
|
|
/// by **MLX** via mlx-swift-examples (verified against tag 2.25.9; SPM package
|
|
/// "mlx-libraries", products MLXLLM + MLXLMCommon).
|
|
///
|
|
/// Loads weights ONLY from a local on-disk snapshot (ModelManager's download
|
|
/// directory) — `ModelConfiguration(directory:)` never contacts the network, so
|
|
/// inference is fully on-device, honoring the privacy promise.
|
|
///
|
|
/// API surface used (all confirmed in the 2.25.9 source):
|
|
/// ModelConfiguration(directory:) MLXLMCommon/ModelConfiguration.swift
|
|
/// LLMModelFactory.shared.loadContainer(...) MLXLLM/LLMModelFactory.swift
|
|
/// ModelContainer.perform { context in ... } MLXLMCommon/ModelContainer.swift
|
|
/// context.processor.prepare(input: UserInput(chat:)) MLXLMCommon/UserInput.swift
|
|
/// MLXLMCommon.generate(...) -> AsyncStream<Generation> MLXLMCommon/Evaluate.swift
|
|
final class MLXEngine: AIEngine {
|
|
|
|
/// Returns the local model snapshot directory if a complete model is present,
|
|
/// else nil. Owned by ModelManager; MLXEngine only loads + infers.
|
|
private let modelDirectoryProvider: () -> URL?
|
|
|
|
/// Serializes the (expensive) first load and caches the container.
|
|
private let loader = ContainerLoader()
|
|
|
|
init(modelDirectoryProvider: @escaping () -> URL?) {
|
|
self.modelDirectoryProvider = modelDirectoryProvider
|
|
}
|
|
|
|
var isReady: Bool { modelDirectoryProvider() != nil }
|
|
|
|
// MARK: - AIEngine
|
|
|
|
func run(_ action: AIAction, on text: String, protectedTerms: [String]) async throws -> String {
|
|
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else { return text }
|
|
|
|
let container = try await container()
|
|
let params = GenerateParameters(temperature: AIPrompts.temperature(for: action))
|
|
let raw = try await complete(
|
|
system: AIPrompts.systemPrompt(for: action, protectedTerms: protectedTerms),
|
|
user: trimmed,
|
|
maxTokens: AIPrompts.maxTokens(for: action),
|
|
parameters: params, container: container)
|
|
return AIPrompts.clean(raw)
|
|
}
|
|
|
|
/// Preload the model container so the first real action doesn't pay the
|
|
/// multi-second load. Safe to call repeatedly (the loader caches).
|
|
func warmup() async {
|
|
_ = try? await container()
|
|
}
|
|
|
|
func runStreaming(_ action: AIAction, on text: String, protectedTerms: [String])
|
|
-> AsyncThrowingStream<String, Error> {
|
|
AsyncThrowingStream { continuation in
|
|
Task {
|
|
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else { continuation.yield(text); continuation.finish(); return }
|
|
do {
|
|
let container = try await container()
|
|
var params = GenerateParameters(temperature: AIPrompts.temperature(for: action))
|
|
params.maxTokens = AIPrompts.maxTokens(for: action)
|
|
let system = AIPrompts.systemPrompt(for: action, protectedTerms: protectedTerms)
|
|
try await container.perform { context in
|
|
let input = try await context.processor.prepare(
|
|
input: UserInput(chat: [.system(system), .user(trimmed)]))
|
|
let stream = try MLXLMCommon.generate(
|
|
input: input, parameters: params, context: context)
|
|
for await item in stream {
|
|
if let chunk = item.chunk { continuation.yield(chunk) }
|
|
}
|
|
}
|
|
continuation.finish()
|
|
} catch {
|
|
continuation.finish(throwing: error)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func detectTone(_ text: String) async -> Tone {
|
|
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else { return .professional }
|
|
do {
|
|
let container = try await container()
|
|
let raw = try await complete(
|
|
system: AIPrompts.toneClassifierPrompt(), user: trimmed, maxTokens: 8,
|
|
parameters: GenerateParameters(temperature: 0.0), container: container)
|
|
return AIPrompts.parseTone(raw)
|
|
} catch {
|
|
return .professional
|
|
}
|
|
}
|
|
|
|
// MARK: - Loading
|
|
|
|
private func container() async throws -> ModelContainer {
|
|
guard let dir = modelDirectoryProvider() else { throw AIEngineError.modelNotReady }
|
|
return try await loader.container(at: dir)
|
|
}
|
|
|
|
// MARK: - Generation
|
|
|
|
private func complete(system: String, user: String, maxTokens: Int,
|
|
parameters: GenerateParameters,
|
|
container: ModelContainer) async throws -> String {
|
|
do {
|
|
return try await container.perform { context in
|
|
let input = try await context.processor.prepare(
|
|
input: UserInput(chat: [.system(system), .user(user)]))
|
|
var params = parameters
|
|
params.maxTokens = maxTokens
|
|
var out = ""
|
|
let stream = try MLXLMCommon.generate(
|
|
input: input, parameters: params, context: context)
|
|
for await item in stream {
|
|
if let chunk = item.chunk { out += chunk }
|
|
}
|
|
return out
|
|
}
|
|
} catch let error as AIEngineError {
|
|
throw error
|
|
} catch {
|
|
throw AIEngineError.generationFailed(error.localizedDescription)
|
|
}
|
|
}
|
|
|
|
// Prompt construction, temperature, token budget, and output cleanup now live
|
|
// in `AIPrompts` (backend-neutral; see AIPromptKit.swift) so every AIEngine
|
|
// backend produces identical prompts and identical output trimming.
|
|
}
|
|
|
|
/// Caches one loaded `ModelContainer` (keyed by directory) and coalesces
|
|
/// concurrent first-loads. `ModelContainer` is itself an actor, so generation
|
|
/// calls through it are serialized safely.
|
|
private actor ContainerLoader {
|
|
private var cached: (url: URL, container: ModelContainer)?
|
|
|
|
func container(at directory: URL) async throws -> ModelContainer {
|
|
if let cached, cached.url == directory { return cached.container }
|
|
let configuration = ModelConfiguration(directory: directory)
|
|
let container = try await LLMModelFactory.shared.loadContainer(configuration: configuration)
|
|
cached = (directory, container)
|
|
return container
|
|
}
|
|
}
|