grammargem/Sources/GrammaGem/App/AppState.swift
BigMarc 8a9ffb55af Add real on-device LLM (MLX) for rewrite / tone / Ask
Replace the MLXEngine stub with real Qwen2.5-3B-4bit inference via
mlx-swift-examples (2.25.9): loads the local model snapshot on disk (no
network), chat-templated generation per AIAction, and tone classification.
Wired behind the existing AIEngine seam; AppState resolves the model dir
through ModelManager.

Build notes:
- arm64-only now (MLX is Metal / Apple-Silicon only; matches the spec).
- swift build (CLI) cannot compile mlx-swift's .metal kernels, so the
  metallib is generated once via scripts/build-metallib.sh (xcodebuild) and
  vendored at AppSupport/mlx.metallib; scripts/build.sh ships it as
  Contents/Resources/mlx-swift_Cmlx.bundle/Contents/Resources/default.metallib
  (the path mlx resolves, and it codesigns cleanly).
- A hidden --mlx-selftest entrypoint runs one headless generation to verify
  the runtime end-to-end.

Verified: the signed app's self-test produces a real rewrite; full test
suite green (the MLX integration test is gated behind GRAMMAGEM_MLX_TEST).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 07:34:02 +02:00

395 lines
15 KiB
Swift

import Foundation
import AppKit
import SwiftUI
import Combine
/// Root application model. Owns the engines, managers, and the system-wide
/// coordinator, wires the global hotkeys, and presents the on-demand windows.
@MainActor
final class AppState: ObservableObject {
static let shared = AppState()
// Engines (Layer 1 + Layer 2)
let grammar: GrammarEngine
let ai: AIEngine
// Managers
let permissions: Permissions
let model: ModelManager
let license: LicenseManager
let gate: FeatureGate
let detector: AppDetector
let hotkeys: HotkeyManager
let dictionary: PersonalDictionary
let preferences: Preferences
let usage: UsageStats
let customModes: CustomModesStore
let snippets: SnippetStore
let exclusions: Exclusions
let liveMonitor: LiveMonitor
private let capture: TextCapture
let coordinator: TextReplacementCoordinator
// UI state
@Published var lastStatus: String = "Ready"
@Published var askText: String = ""
@Published var appAwareEnabled: Bool = false
@Published var isPaused: Bool = false
private var onboardingWindow: NSWindow?
private var askWindow: NSWindow?
private var mainWindow: NSWindow?
private var liveCheckWindow: NSWindow?
private var cancellables = Set<AnyCancellable>()
private init() {
let engine = SystemGrammarEngine()
grammar = engine
// The on-device LLM loads weights from ModelManager's local snapshot (if a
// complete download is present) — never the network. Resolved lazily on use.
ai = MLXEngine(modelDirectoryProvider: {
ModelManager.completedModelDirectory(repo: AppConfig.Model.defaultRepo)
})
permissions = Permissions()
model = ModelManager()
license = LicenseManager()
gate = FeatureGate(license: license)
detector = AppDetector()
capture = TextCapture()
hotkeys = HotkeyManager()
dictionary = PersonalDictionary()
preferences = Preferences()
usage = UsageStats()
customModes = CustomModesStore()
snippets = SnippetStore()
let excl = Exclusions()
exclusions = excl
liveMonitor = LiveMonitor(grammar: engine, detector: detector, exclusions: excl, capture: capture)
coordinator = TextReplacementCoordinator(
capture: capture, grammar: engine, ai: ai, gate: gate, detector: detector)
}
/// Called once at launch (from the AppDelegate).
func start() {
// Menu-bar app: no Dock icon (works even without LSUIElement in Info.plist).
NSApp.setActivationPolicy(.accessory)
hotkeys.onAction = { [weak self] action in
Task { await self?.handle(action) }
}
reregisterHotkeys()
// Live-apply preference changes.
capture.restoreClipboard = preferences.restoreClipboard
preferences.$restoreClipboard
.sink { [weak self] value in self?.capture.restoreClipboard = value }
.store(in: &cancellables)
preferences.$fixHotkey.dropFirst()
.sink { [weak self] _ in self?.reregisterHotkeys() }
.store(in: &cancellables)
preferences.$askHotkey.dropFirst()
.sink { [weak self] _ in self?.reregisterHotkeys() }
.store(in: &cancellables)
// Always-on live monitoring (menu-bar resident), driven by the preference.
liveMonitor.setEnabled(preferences.liveUnderlines)
preferences.$liveUnderlines.dropFirst()
.sink { [weak self] on in self?.liveMonitor.setEnabled(on) }
.store(in: &cancellables)
// Auto-install the on-device AI model in the background on first use, so
// the AI features are ready without the user hunting for a button. The
// download is resumable/cancelable from the AI Model screen.
if !model.isModelPresent(repo: model.selectedRepo) {
model.startDownload()
}
permissions.refresh()
appAwareEnabled = gate.appAwareEnabled
syncIgnoreList()
if !permissions.accessibilityTrusted {
showOnboarding()
}
Task { await license.validateIfNeeded() }
Log.app.info("GrammaGem started. Licensed: \(self.license.isLicensed, privacy: .public)")
}
// MARK: - Hotkey handling
/// (Re)register the global hotkeys from the current preferences.
func reregisterHotkeys() {
hotkeys.unregisterAll()
if !hotkeys.register(id: 1, keyCode: preferences.fixHotkey.keyCode,
modifiers: preferences.fixHotkey.carbonModifiers, action: .fix) {
// The chosen combo was rejected (already claimed) — keep a working
// shortcut by falling back to the default rather than leaving none.
hotkeys.register(id: 1, keyCode: AppConfig.Hotkey.defaultKeyCode,
modifiers: AppConfig.Hotkey.defaultModifiers, action: .fix)
lastStatus = "That Fix shortcut is already in use — kept the default (⌘;)."
}
if !hotkeys.register(id: 2, keyCode: preferences.askHotkey.keyCode,
modifiers: preferences.askHotkey.carbonModifiers, action: .ask) {
hotkeys.register(id: 2, keyCode: AppConfig.Hotkey.askKeyCode,
modifiers: AppConfig.Hotkey.askModifiers, action: .ask)
lastStatus = "That Ask shortcut is already in use — kept the default (⌘')."
}
}
func handle(_ action: HotkeyManager.Action) async {
switch action {
case .fix:
await runFix()
case .ask:
showAsk()
}
}
func runFix() async {
if let reason = blockReason() { lastStatus = reason; return }
let outcome = await coordinator.handleFix()
if case .replaced(let text) = outcome { usage.recordCorrection(words: wordCount(text)) }
present(outcome)
}
/// Fix every issue in the focused field (the live monitor's "Fix all").
func fixFocusedField() async {
if let reason = blockReason() { lastStatus = reason; return }
let outcome = await coordinator.handleFixFocusedField()
if case .replaced(let text) = outcome { usage.recordCorrection(words: wordCount(text)) }
present(outcome)
liveMonitor.refreshSoon()
}
// MARK: - Live multi-field fixes (from the Live Check panel)
func fixAllDetected() {
if let reason = blockReason() { lastStatus = reason; return }
let n = liveMonitor.fixAll()
if n > 0 { usage.recordCorrection(words: n) }
lastStatus = n > 0 ? "Fixed \(n) issue\(n == 1 ? "" : "s")" : "Nothing to fix"
}
func fixDetectedField(_ field: DetectedField) {
if let reason = blockReason() { lastStatus = reason; return }
let n = liveMonitor.fix(field)
if n > 0 { usage.recordCorrection(words: n) }
}
func applySuggestion(_ suggestion: Suggestion, in field: DetectedField) {
if let reason = blockReason() { lastStatus = reason; return }
liveMonitor.apply(suggestion, in: field)
usage.recordCorrection(words: 1)
}
/// Hide a single suggestion without changing the user's text.
func dismissSuggestion(_ suggestion: Suggestion, in field: DetectedField) {
liveMonitor.dismiss(suggestion, in: field)
}
/// Add a flagged word to the personal dictionary so it's never corrected
/// again (gated by the free cap), then re-scan so it disappears now.
@discardableResult
func addWordToDictionary(_ word: String) -> FeatureGate.Decision {
let decision = addDictionaryEntry(word)
switch decision {
case .allowed:
lastStatus = "Added “\(word)” to your dictionary"
liveMonitor.refreshSoon()
case .denied(let reason):
lastStatus = reason
}
return decision
}
/// Pause/resume all of GrammaGem (hotkeys + live monitoring).
func togglePause() {
isPaused.toggle()
liveMonitor.setPaused(isPaused)
lastStatus = isPaused ? "Paused" : "Ready"
}
/// A reason GrammaGem should stay out of the way right now, or nil.
private func blockReason() -> String? {
if isPaused { return "GrammaGem is paused." }
let front = detector.frontmost()
if exclusions.isBlocked(bundleID: front?.bundleID, domain: detector.frontmostDomain()) {
return "GrammaGem is off for \(front?.name ?? "this app")."
}
return nil
}
func runAsk() async {
let instruction = askText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !instruction.isEmpty else { return }
if let reason = blockReason() { lastStatus = reason; askWindow?.close(); return }
let outcome = await coordinator.handleAsk(instruction)
if case .replaced(let text) = outcome { usage.recordAIAction(words: wordCount(text)) }
present(outcome)
askWindow?.close()
}
private func wordCount(_ s: String) -> Int {
s.split(whereSeparator: { $0.isWhitespace || $0.isNewline }).count
}
/// Apply the Mode suggested by the frontmost app (App-Aware, paid).
func runAppAwareRewrite() async {
if let reason = blockReason() { lastStatus = reason; return }
guard gate.appAwareEnabled else {
present(.blockedByEntitlement("App-aware switching is part of the lifetime upgrade."))
return
}
guard let mode = detector.suggestedMode() else {
lastStatus = "No mode mapped for the current app."
return
}
let outcome = await coordinator.handleApplyMode(mode)
if case .replaced(let text) = outcome { usage.recordAIAction(words: wordCount(text)) }
present(outcome)
}
private func present(_ outcome: ProcessOutcome) {
switch outcome {
case .replaced:
lastStatus = "Fixed ✓"
case .noSelection:
lastStatus = "Select some text first."
case .blockedByEntitlement(let reason):
lastStatus = reason
notify(title: "Upgrade required", body: reason)
case .failed(let message):
lastStatus = message
}
}
private func notify(title: String, body: String) {
// Lightweight, content-free alert (no user text ever leaves the device).
let alert = NSAlert()
alert.messageText = title
alert.informativeText = body
alert.addButton(withTitle: "OK")
NSApp.activate(ignoringOtherApps: true)
alert.runModal()
}
// MARK: - On-demand windows (AppKit-hosted SwiftUI; reliable for menu-bar apps)
func showOnboarding() {
if onboardingWindow == nil {
let host = NSHostingController(rootView: OnboardingView().environmentObject(self))
let win = NSWindow(contentViewController: host)
win.title = "Welcome to GrammaGem"
win.styleMask = [.titled, .closable, .fullSizeContentView]
win.setContentSize(NSSize(width: 560, height: 720))
win.isReleasedWhenClosed = false
win.center()
onboardingWindow = win
}
NSApp.activate(ignoringOtherApps: true)
onboardingWindow?.makeKeyAndOrderFront(nil)
}
/// The full management window (devices, modes, settings, insights).
func showMainWindow(select section: MainSection? = nil) {
if mainWindow == nil {
let root = MainWindowView(initialSection: section ?? .dashboard)
.environmentObject(self)
.environmentObject(license)
.environmentObject(gate)
.environmentObject(model)
.environmentObject(permissions)
.environmentObject(dictionary)
.environmentObject(preferences)
.environmentObject(usage)
.environmentObject(customModes)
.environmentObject(snippets)
.environmentObject(exclusions)
.environmentObject(liveMonitor)
let host = NSHostingController(rootView: root)
let win = NSWindow(contentViewController: host)
win.title = "GrammaGem"
win.styleMask = [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView]
win.setContentSize(NSSize(width: 940, height: 660))
win.minSize = NSSize(width: 820, height: 560)
win.isReleasedWhenClosed = false
win.center()
mainWindow = win
}
NSApp.activate(ignoringOtherApps: true)
mainWindow?.makeKeyAndOrderFront(nil)
}
/// The Live Check panel: every on-screen text area with grammar issues.
func showLiveCheck() {
if liveCheckWindow == nil {
let root = LiveCheckView()
.environmentObject(self)
.environmentObject(liveMonitor)
let host = NSHostingController(rootView: root)
let win = NSWindow(contentViewController: host)
win.title = "Live Check"
win.styleMask = [.titled, .closable, .miniaturizable, .resizable]
win.setContentSize(NSSize(width: 560, height: 620))
win.minSize = NSSize(width: 460, height: 420)
win.isReleasedWhenClosed = false
win.center()
liveCheckWindow = win
}
liveMonitor.refreshSoon()
NSApp.activate(ignoringOtherApps: true)
liveCheckWindow?.makeKeyAndOrderFront(nil)
}
func showAsk() {
askText = ""
if askWindow == nil {
let host = NSHostingController(rootView: AskView().environmentObject(self))
let win = NSWindow(contentViewController: host)
win.title = "Ask GrammaGem"
win.styleMask = [.titled, .closable]
win.setContentSize(NSSize(width: 460, height: 220))
win.isReleasedWhenClosed = false
win.center()
win.level = .floating
askWindow = win
}
NSApp.activate(ignoringOtherApps: true)
askWindow?.makeKeyAndOrderFront(nil)
}
func showOnboardingDismiss() {
onboardingWindow?.close()
}
func showAskDismiss() {
askWindow?.close()
}
// MARK: - Personal dictionary (gated adds)
@discardableResult
func addDictionaryEntry(_ word: String) -> FeatureGate.Decision {
let decision = gate.canAddDictionaryEntry(currentCount: dictionary.entries.count)
if case .allowed = decision {
dictionary.add(word)
syncIgnoreList()
}
return decision
}
func removeDictionaryEntry(_ word: String) {
dictionary.remove(word)
syncIgnoreList()
}
private func syncIgnoreList() {
grammar.ignoreList = Set(dictionary.entries.map { $0.lowercased() })
}
func quit() {
NSApp.terminate(nil)
}
}