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>
74 lines
2.8 KiB
Swift
74 lines
2.8 KiB
Swift
import Foundation
|
|
|
|
/// Layer 2 — local-LLM-powered rewriting / tone / "Ask" / translate.
|
|
///
|
|
/// Backed in production by **MLX** (mlx-swift) running a small instruct model
|
|
/// downloaded once from Hugging Face. The protocol isolates the rest of the app
|
|
/// from the runtime so the UI, gating, and tests don't depend on MLX being present.
|
|
protocol AIEngine: AnyObject {
|
|
/// Whether a model is loaded and ready to run locally.
|
|
var isReady: Bool { get }
|
|
|
|
/// Run an action against `text` and return the rewritten result.
|
|
/// `protectedTerms` are vocabulary the model must reproduce verbatim (the
|
|
/// personal dictionary / style-guide terms). Throws on model-not-ready or
|
|
/// generation failure. Never performs network I/O on the on-device backend.
|
|
func run(_ action: AIAction, on text: String, protectedTerms: [String]) async throws -> String
|
|
|
|
/// Best-effort local tone classification (used by tone-detection UI).
|
|
func detectTone(_ text: String) async -> Tone
|
|
|
|
/// Optionally preload the model so the first real action is instant.
|
|
func warmup() async
|
|
|
|
/// Streaming variant: yields incremental output as it generates. The default
|
|
/// implementation wraps `run` as a single chunk, so a backend that doesn't
|
|
/// natively stream still conforms.
|
|
func runStreaming(_ action: AIAction, on text: String, protectedTerms: [String])
|
|
-> AsyncThrowingStream<String, Error>
|
|
}
|
|
|
|
extension AIEngine {
|
|
/// Convenience: run with no protected terms.
|
|
func run(_ action: AIAction, on text: String) async throws -> String {
|
|
try await run(action, on: text, protectedTerms: [])
|
|
}
|
|
|
|
/// Default: no preload.
|
|
func warmup() async {}
|
|
|
|
/// Default streaming: produce the full result, then emit it as one chunk.
|
|
func runStreaming(_ action: AIAction, on text: String, protectedTerms: [String])
|
|
-> AsyncThrowingStream<String, Error> {
|
|
AsyncThrowingStream { continuation in
|
|
Task {
|
|
do {
|
|
continuation.yield(try await run(action, on: text, protectedTerms: protectedTerms))
|
|
continuation.finish()
|
|
} catch {
|
|
continuation.finish(throwing: error)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Convenience streaming overload with no protected terms.
|
|
func runStreaming(_ action: AIAction, on text: String) -> AsyncThrowingStream<String, Error> {
|
|
runStreaming(action, on: text, protectedTerms: [])
|
|
}
|
|
}
|
|
|
|
enum AIEngineError: LocalizedError {
|
|
case modelNotReady
|
|
case generationFailed(String)
|
|
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case .modelNotReady:
|
|
return "The on-device model isn't ready yet. Finish the first-run download in Settings."
|
|
case .generationFailed(let why):
|
|
return "On-device rewrite failed: \(why)"
|
|
}
|
|
}
|
|
}
|