grammargem/Sources/GrammarGem/AI/CustomModesStore.swift
BigMarc 201fe04d8a feat: backend-neutral AI seam, Ollama backend, extension bridge + trust/correctness fixes
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>
2026-06-20 20:21:29 +02:00

80 lines
2.8 KiB
Swift

import Foundation
/// User-created Writing Modes (paid) plus per-app Mode overrides for App-Aware
/// switching. Persisted locally; the built-in modes live in `ModeRegistry`.
@MainActor
final class CustomModesStore: ObservableObject {
@Published private(set) var customModes: [WritingMode]
/// bundleID -> mode id (overrides the default App-Aware mapping).
@Published private(set) var appOverrides: [String: String]
private let modesKey = "GrammarGem.customModes"
private let mapKey = "GrammarGem.appModeOverrides"
private let d = UserDefaults.standard
init() {
if let raw = d.data(forKey: modesKey),
let decoded = try? JSONDecoder().decode([WritingMode].self, from: raw) {
customModes = decoded
} else {
customModes = []
}
appOverrides = (d.dictionary(forKey: mapKey) as? [String: String]) ?? [:]
}
/// Built-in + custom modes (custom ones are always paid).
func allModes() -> [WritingMode] { ModeRegistry.all + customModes }
/// Returns false (no-op) if the name or prompt is empty, or the name
/// collides with an existing built-in/custom mode — so an empty-prompt mode
/// can't slip through to the LLM as an empty system prompt.
@discardableResult
func addCustom(name: String, prompt: String) -> Bool {
let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedPrompt = prompt.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, !trimmedPrompt.isEmpty else { return false }
guard !allModes().contains(where: { $0.name.caseInsensitiveCompare(trimmed) == .orderedSame })
else { return false }
let mode = WritingMode(
id: "custom-\(UUID().uuidString.prefix(8))",
name: trimmed,
systemPrompt: trimmedPrompt,
isPaid: true,
lengthCap: nil,
autoFormat: "Custom"
)
customModes.append(mode)
persistModes()
return true
}
func removeCustom(id: String) {
customModes.removeAll { $0.id == id }
appOverrides = appOverrides.filter { $0.value != id }
persistModes()
persistMap()
}
func setOverride(bundleID: String, modeID: String) {
let b = bundleID.trimmingCharacters(in: .whitespaces)
guard !b.isEmpty else { return }
appOverrides[b] = modeID
persistMap()
}
func clearOverride(bundleID: String) {
appOverrides.removeValue(forKey: bundleID)
persistMap()
}
private func persistModes() {
if let raw = try? JSONEncoder().encode(customModes) {
d.set(raw, forKey: modesKey)
}
}
private func persistMap() {
d.set(appOverrides, forKey: mapKey)
}
}