mirror of
https://github.com/BigMarc/grammargem.git
synced 2026-10-13 17:01:55 +02:00
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>
This commit is contained in:
parent
143620e44e
commit
8a9ffb55af
3
.gitignore
vendored
3
.gitignore
vendored
@ -8,3 +8,6 @@ dist/
|
|||||||
# Cargo.lock IS committed (binary crate) for reproducible builds.
|
# Cargo.lock IS committed (binary crate) for reproducible builds.
|
||||||
harper-ffi/target/
|
harper-ffi/target/
|
||||||
harper-ffi/lib/
|
harper-ffi/lib/
|
||||||
|
|
||||||
|
# xcodebuild DerivedData (used only to generate the MLX metallib)
|
||||||
|
.xcode-build/
|
||||||
|
|||||||
BIN
AppSupport/mlx.metallib
Normal file
BIN
AppSupport/mlx.metallib
Normal file
Binary file not shown.
69
Package.resolved
Normal file
69
Package.resolved
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
{
|
||||||
|
"originHash" : "1aab35282a23815fc7c2ac7484414660e78b19f68cfbf14f87004cf55bb3ac6b",
|
||||||
|
"pins" : [
|
||||||
|
{
|
||||||
|
"identity" : "gzipswift",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/1024jp/GzipSwift",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "731037f6cc2be2ec01562f6597c1d0aa3fe6fd05",
|
||||||
|
"version" : "6.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "mlx-swift",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/ml-explore/mlx-swift",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "96c0f69cf15f38abd6248bb71fca908171dab1d8",
|
||||||
|
"version" : "0.25.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "mlx-swift-examples",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/ml-explore/mlx-swift-examples.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "8775112da257e8b0a5bf45e3f84fb80222592c89",
|
||||||
|
"version" : "2.25.9"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-collections",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/apple/swift-collections.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "a0cb0954ecb21e4e31b0070e6ed5674e8556685a",
|
||||||
|
"version" : "1.6.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-jinja",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/huggingface/swift-jinja.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "0b67ecb79139f6addef8699eff3622808aa6c7dc",
|
||||||
|
"version" : "2.3.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-numerics",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/apple/swift-numerics",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "0c0290ff6b24942dadb83a929ffaaa1481df04a2",
|
||||||
|
"version" : "1.1.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-transformers",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/huggingface/swift-transformers",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "a2e184dddb4757bc943e77fbe99ac6786c53f0b2",
|
||||||
|
"version" : "1.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"version" : 3
|
||||||
|
}
|
||||||
@ -6,9 +6,12 @@ import Foundation
|
|||||||
//
|
//
|
||||||
// Layer-1 grammar is the real **Harper** core (Apache-2.0), embedded as a Rust
|
// Layer-1 grammar is the real **Harper** core (Apache-2.0), embedded as a Rust
|
||||||
// C-FFI static library (see `harper-ffi/`). Build the lib first with
|
// C-FFI static library (see `harper-ffi/`). Build the lib first with
|
||||||
// `harper-ffi/build.sh` (universal arm64+x86_64 -> harper-ffi/lib/libharper_ffi.a);
|
// `harper-ffi/build.sh`; `scripts/build.sh` does this automatically.
|
||||||
// `scripts/build.sh` does this automatically. The remaining heavyweight pieces
|
//
|
||||||
// (MLX local LLM, Sparkle, KeyboardShortcuts) are still behind protocol seams.
|
// Layer-2 AI (rewrite / tone / Ask) is the real on-device LLM via MLX
|
||||||
|
// (mlx-swift-examples). MLX is **Apple-Silicon / Metal only**, so the app is
|
||||||
|
// arm64-only (matches the product spec: Apple Silicon, macOS 14+). The Harper
|
||||||
|
// static lib stays universal; the linker just uses its arm64 slice.
|
||||||
|
|
||||||
// Absolute path to the prebuilt Harper static lib, independent of build cwd.
|
// Absolute path to the prebuilt Harper static lib, independent of build cwd.
|
||||||
let packageRoot = URL(fileURLWithPath: #filePath).deletingLastPathComponent().path
|
let packageRoot = URL(fileURLWithPath: #filePath).deletingLastPathComponent().path
|
||||||
@ -22,15 +25,24 @@ let package = Package(
|
|||||||
products: [
|
products: [
|
||||||
.executable(name: "GrammaGem", targets: ["GrammaGem"])
|
.executable(name: "GrammaGem", targets: ["GrammaGem"])
|
||||||
],
|
],
|
||||||
|
dependencies: [
|
||||||
|
// On-device LLM runtime. Pinned to an exact tag (the LLM libraries still
|
||||||
|
// ship from this repo at 2.25.9; the package name is "mlx-libraries").
|
||||||
|
.package(url: "https://github.com/ml-explore/mlx-swift-examples.git", exact: "2.25.9"),
|
||||||
|
],
|
||||||
targets: [
|
targets: [
|
||||||
// C shim exposing libharper_ffi's C ABI (harper-ffi/include/harper.h) to Swift.
|
// C shim exposing libharper_ffi's C ABI (harper-ffi/include/harper.h) to Swift.
|
||||||
.target(name: "CHarper", path: "Sources/CHarper"),
|
.target(name: "CHarper", path: "Sources/CHarper"),
|
||||||
.executableTarget(
|
.executableTarget(
|
||||||
name: "GrammaGem",
|
name: "GrammaGem",
|
||||||
dependencies: ["CHarper"],
|
dependencies: [
|
||||||
|
"CHarper",
|
||||||
|
.product(name: "MLXLLM", package: "mlx-swift-examples"),
|
||||||
|
.product(name: "MLXLMCommon", package: "mlx-swift-examples"),
|
||||||
|
],
|
||||||
path: "Sources/GrammaGem",
|
path: "Sources/GrammaGem",
|
||||||
linkerSettings: [
|
linkerSettings: [
|
||||||
// Link the prebuilt universal Harper static library.
|
// Link the prebuilt Harper static library (arm64 slice).
|
||||||
.unsafeFlags(["-L\(harperLibDir)", "-lharper_ffi"])
|
.unsafeFlags(["-L\(harperLibDir)", "-lharper_ffi"])
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
|
|||||||
@ -1,91 +1,174 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import MLXLMCommon
|
||||||
|
import MLXLLM
|
||||||
|
|
||||||
/// STUB standing in for the real MLX-backed local LLM.
|
/// 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).
|
||||||
///
|
///
|
||||||
/// TODO(real-integration): load the model via mlx-swift from
|
/// Loads weights ONLY from a local on-disk snapshot (ModelManager's download
|
||||||
/// `ModelManager.modelsDirectory`, build the prompt from the action's system
|
/// directory) — `ModelConfiguration(directory:)` never contacts the network, so
|
||||||
/// prompt + the user's text, and stream tokens (target: first token < 1s on
|
/// inference is fully on-device, honoring the privacy promise.
|
||||||
/// M-series). Everything stays on-device — this method must never hit the network.
|
|
||||||
///
|
///
|
||||||
/// The stub produces deterministic, plausible transformations so the capture →
|
/// API surface used (all confirmed in the 2.25.9 source):
|
||||||
/// engine → replace loop and the entitlement gating can be exercised end-to-end
|
/// ModelConfiguration(directory:) MLXLMCommon/ModelConfiguration.swift
|
||||||
/// without the multi-gigabyte model present.
|
/// 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 {
|
final class MLXEngine: AIEngine {
|
||||||
private let ready: Bool
|
|
||||||
|
|
||||||
init(ready: Bool = true) {
|
/// Returns the local model snapshot directory if a complete model is present,
|
||||||
self.ready = ready
|
/// 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 { ready }
|
var isReady: Bool { modelDirectoryProvider() != nil }
|
||||||
|
|
||||||
|
// MARK: - AIEngine
|
||||||
|
|
||||||
func run(_ action: AIAction, on text: String) async throws -> String {
|
func run(_ action: AIAction, on text: String) async throws -> String {
|
||||||
guard ready else { throw AIEngineError.modelNotReady }
|
|
||||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !trimmed.isEmpty else { return text }
|
guard !trimmed.isEmpty else { return text }
|
||||||
|
|
||||||
switch action {
|
let container = try await container()
|
||||||
case .rewriteClarity, .rewrite:
|
let params = GenerateParameters(temperature: temperature(for: action))
|
||||||
return tidy(trimmed)
|
let raw = try await complete(
|
||||||
case .adjustTone(let tone):
|
system: systemPrompt(for: action), user: trimmed,
|
||||||
return apply(tone: tone, to: tidy(trimmed))
|
maxTokens: 512, parameters: params, container: container)
|
||||||
case .ask(let instruction):
|
return clean(raw)
|
||||||
return follow(instruction: instruction, on: trimmed)
|
|
||||||
case .translate(let language):
|
|
||||||
return "[\(language)] " + tidy(trimmed)
|
|
||||||
case .applyMode(let mode):
|
|
||||||
return applyMode(mode, to: trimmed)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func detectTone(_ text: String) async -> Tone {
|
func detectTone(_ text: String) async -> Tone {
|
||||||
let lower = text.lowercased()
|
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
if lower.contains("!") || lower.contains("🚀") { return .punchy }
|
guard !trimmed.isEmpty else { return .professional }
|
||||||
if lower.contains("therefore") || lower.contains("hereby") { return .academic }
|
let options = Tone.allCases.map(\.rawValue).joined(separator: ", ")
|
||||||
if lower.contains("hey") || lower.contains("lol") { return .friendly }
|
let system = """
|
||||||
|
You are a tone classifier. Read the user's text and respond with exactly ONE \
|
||||||
|
word — the single best-matching tone from this list: \(options). \
|
||||||
|
Output only that word, lowercase, no punctuation or explanation.
|
||||||
|
"""
|
||||||
|
do {
|
||||||
|
let container = try await container()
|
||||||
|
let raw = try await complete(
|
||||||
|
system: system, user: trimmed, maxTokens: 8,
|
||||||
|
parameters: GenerateParameters(temperature: 0.0), container: container)
|
||||||
|
return parseTone(raw)
|
||||||
|
} catch {
|
||||||
return .professional
|
return .professional
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Deterministic stand-in transforms
|
|
||||||
|
|
||||||
private func tidy(_ s: String) -> String {
|
|
||||||
var out = s
|
|
||||||
out = out.replacingOccurrences(of: " i ", with: " I ")
|
|
||||||
out = out.replacingOccurrences(of: "lmk", with: "let me know")
|
|
||||||
out = out.replacingOccurrences(of: "u ", with: "you ")
|
|
||||||
if let first = out.first, first.isLowercase {
|
|
||||||
out.replaceSubrange(out.startIndex...out.startIndex, with: String(first).uppercased())
|
|
||||||
}
|
}
|
||||||
if let last = out.last, !".!?".contains(last) { out.append(".") }
|
|
||||||
|
// 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
|
return out
|
||||||
}
|
}
|
||||||
|
} catch let error as AIEngineError {
|
||||||
private func apply(tone: Tone, to s: String) -> String {
|
throw error
|
||||||
switch tone {
|
} catch {
|
||||||
case .professional: return s
|
throw AIEngineError.generationFailed(error.localizedDescription)
|
||||||
case .friendly: return "Hi! " + s
|
|
||||||
case .confident: return s.replacingOccurrences(of: "I think ", with: "")
|
|
||||||
case .academic: return "It is worth noting that " + s.prefix(1).lowercased() + s.dropFirst()
|
|
||||||
case .punchy: return s.replacingOccurrences(of: ". ", with: ".\n")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func follow(instruction: String, on s: String) -> String {
|
// MARK: - Prompting
|
||||||
let i = instruction.lowercased()
|
|
||||||
if i.contains("short") { return String(s.split(separator: " ").prefix(8).joined(separator: " ")) + "." }
|
private func systemPrompt(for action: AIAction) -> String {
|
||||||
if i.contains("formal") { return apply(tone: .academic, to: tidy(s)) }
|
switch action {
|
||||||
if i.contains("translate") { return "[translated] " + tidy(s) }
|
case .rewriteClarity:
|
||||||
return tidy(s)
|
return "Rewrite the user's text to be clearer and more concise. Preserve the "
|
||||||
|
+ "original meaning and the author's voice. Output only the rewritten text."
|
||||||
|
case .rewrite:
|
||||||
|
return "Paraphrase the user's text. Keep the meaning but vary the wording and "
|
||||||
|
+ "structure. Output only the rewritten text."
|
||||||
|
case .adjustTone(let tone):
|
||||||
|
return "Rewrite the user's text in a \(tone.rawValue) tone. Preserve the meaning. "
|
||||||
|
+ "Output only the rewritten text."
|
||||||
|
case .ask(let instruction):
|
||||||
|
return "Apply this instruction to the user's text: \(instruction). "
|
||||||
|
+ "Output only the resulting text."
|
||||||
|
case .translate(let language):
|
||||||
|
return "Translate the user's text into \(language). Output only the translation."
|
||||||
|
case .applyMode(let mode):
|
||||||
|
return mode.systemPrompt
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func applyMode(_ mode: WritingMode, to s: String) -> String {
|
/// Low temperature for faithful rewrites; a touch higher for free paraphrase.
|
||||||
switch mode.id {
|
private func temperature(for action: AIAction) -> Float {
|
||||||
case "email": return "Hi,\n\n\(tidy(s))\n\nThanks!"
|
switch action {
|
||||||
case "post": return apply(tone: .punchy, to: tidy(s))
|
case .rewrite: return 0.5
|
||||||
case "academic": return apply(tone: .academic, to: tidy(s))
|
case .ask: return 0.4
|
||||||
case "slack": return s.lowercased()
|
default: return 0.2
|
||||||
case "code": return "// " + tidy(s)
|
|
||||||
default: return tidy(s)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Output cleanup
|
||||||
|
|
||||||
|
private func clean(_ s: String) -> String {
|
||||||
|
var out = s.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let lower = out.lowercased()
|
||||||
|
for prefix in ["sure, here", "sure! here", "here is", "here's", "certainly"] {
|
||||||
|
if lower.hasPrefix(prefix), let colon = out.firstIndex(of: ":") {
|
||||||
|
let after = out[out.index(after: colon)...]
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if !after.isEmpty { out = after }
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if out.count >= 2, out.first == "\"", out.last == "\"" {
|
||||||
|
out = String(out.dropFirst().dropLast())
|
||||||
|
}
|
||||||
|
return out.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func parseTone(_ raw: String) -> Tone {
|
||||||
|
let token = raw.lowercased().trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
|
||||||
|
if let exact = Tone(rawValue: token) { return exact }
|
||||||
|
let lower = raw.lowercased()
|
||||||
|
for tone in Tone.allCases where lower.contains(tone.rawValue) { return tone }
|
||||||
|
return .professional
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -23,20 +23,38 @@ final class ModelManager: ObservableObject {
|
|||||||
if isModelPresent(repo: selectedRepo) { state = .ready }
|
if isModelPresent(repo: selectedRepo) { state = .ready }
|
||||||
}
|
}
|
||||||
|
|
||||||
var modelsDirectory: URL {
|
var modelsDirectory: URL { Self.modelsDirectory() }
|
||||||
|
|
||||||
|
func modelDir(_ repo: String) -> URL { Self.modelDir(repo) }
|
||||||
|
|
||||||
|
/// Considered present once the completion marker is written.
|
||||||
|
func isModelPresent(repo: String) -> Bool { Self.isModelPresent(repo: repo) }
|
||||||
|
|
||||||
|
// MARK: - Nonisolated path helpers
|
||||||
|
//
|
||||||
|
// The AI engine loads weights off the main actor, so the directory lookup must
|
||||||
|
// be callable from any thread. These are pure FileManager/URL ops (no actor
|
||||||
|
// state, no network) and back the @MainActor instance accessors above.
|
||||||
|
|
||||||
|
nonisolated static func modelsDirectory() -> URL {
|
||||||
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
||||||
return base.appendingPathComponent("GrammaGem/Models", isDirectory: true)
|
return base.appendingPathComponent("GrammaGem/Models", isDirectory: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
func modelDir(_ repo: String) -> URL {
|
nonisolated static func modelDir(_ repo: String) -> URL {
|
||||||
modelsDirectory.appendingPathComponent(repo.replacingOccurrences(of: "/", with: "_"), isDirectory: true)
|
modelsDirectory().appendingPathComponent(repo.replacingOccurrences(of: "/", with: "_"), isDirectory: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Considered present once the completion marker is written.
|
nonisolated static func isModelPresent(repo: String) -> Bool {
|
||||||
func isModelPresent(repo: String) -> Bool {
|
|
||||||
FileManager.default.fileExists(atPath: modelDir(repo).appendingPathComponent(".complete").path)
|
FileManager.default.fileExists(atPath: modelDir(repo).appendingPathComponent(".complete").path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The local model directory iff a *complete* model is on disk, else nil —
|
||||||
|
/// fed to the AI engine as its network-free model source.
|
||||||
|
nonisolated static func completedModelDirectory(repo: String) -> URL? {
|
||||||
|
isModelPresent(repo: repo) ? modelDir(repo) : nil
|
||||||
|
}
|
||||||
|
|
||||||
/// Kick off (or resume to) a real download. Safe to call from the UI.
|
/// Kick off (or resume to) a real download. Safe to call from the UI.
|
||||||
func startDownload(repo: String? = nil) {
|
func startDownload(repo: String? = nil) {
|
||||||
let target = repo ?? selectedRepo
|
let target = repo ?? selectedRepo
|
||||||
|
|||||||
@ -45,7 +45,11 @@ final class AppState: ObservableObject {
|
|||||||
private init() {
|
private init() {
|
||||||
let engine = SystemGrammarEngine()
|
let engine = SystemGrammarEngine()
|
||||||
grammar = engine
|
grammar = engine
|
||||||
ai = MLXEngine(ready: true)
|
// 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()
|
permissions = Permissions()
|
||||||
model = ModelManager()
|
model = ModelManager()
|
||||||
license = LicenseManager()
|
license = LicenseManager()
|
||||||
|
|||||||
@ -1,10 +1,47 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import AppKit
|
import AppKit
|
||||||
|
|
||||||
|
/// Process entry point. A hidden `--mlx-selftest` flag loads the on-device model
|
||||||
|
/// and runs one real generation (verifies the MLX runtime + metallib headlessly),
|
||||||
|
/// then exits. Otherwise the normal menu-bar app launches.
|
||||||
|
@main
|
||||||
|
enum GrammaGemMain {
|
||||||
|
static func main() {
|
||||||
|
if CommandLine.arguments.contains("--mlx-selftest") {
|
||||||
|
MLXSelfTest.run()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
GrammaGemApp.main()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Headless verification of the on-device LLM (used by CI / the build pipeline).
|
||||||
|
enum MLXSelfTest {
|
||||||
|
static func run() {
|
||||||
|
let sample = "me and him was going to the store for to buy some milks"
|
||||||
|
let engine = MLXEngine(modelDirectoryProvider: {
|
||||||
|
ModelManager.completedModelDirectory(repo: AppConfig.Model.defaultRepo)
|
||||||
|
})
|
||||||
|
guard engine.isReady else {
|
||||||
|
FileHandle.standardError.write(Data("SELFTEST_FAIL: model not present\n".utf8))
|
||||||
|
exit(2)
|
||||||
|
}
|
||||||
|
let sem = DispatchSemaphore(value: 0)
|
||||||
|
var line = "SELFTEST_FAIL: unknown"
|
||||||
|
Task.detached {
|
||||||
|
do { line = "SELFTEST_OK: " + (try await engine.run(.rewriteClarity, on: sample)) }
|
||||||
|
catch { line = "SELFTEST_FAIL: \(error.localizedDescription)" }
|
||||||
|
sem.signal()
|
||||||
|
}
|
||||||
|
sem.wait()
|
||||||
|
print(line)
|
||||||
|
exit(line.hasPrefix("SELFTEST_OK") ? 0 : 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// App entry point. A menu-bar (`LSUIElement`) SwiftUI app: the only persistent
|
/// App entry point. A menu-bar (`LSUIElement`) SwiftUI app: the only persistent
|
||||||
/// scene is the `MenuBarExtra`; Settings uses the standard scene; Onboarding and
|
/// scene is the `MenuBarExtra`; Settings uses the standard scene; Onboarding and
|
||||||
/// Ask are AppKit-hosted windows opened on demand (see `AppState`).
|
/// Ask are AppKit-hosted windows opened on demand (see `AppState`).
|
||||||
@main
|
|
||||||
struct GrammaGemApp: App {
|
struct GrammaGemApp: App {
|
||||||
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
||||||
@StateObject private var app = AppState.shared
|
@StateObject private var app = AppState.shared
|
||||||
|
|||||||
@ -110,6 +110,29 @@ final class GrammaGemTests: XCTestCase {
|
|||||||
XCTAssertEqual(UsageStats.nextMilestone(corrections: 100).target, 500)
|
XCTAssertEqual(UsageStats.nextMilestone(corrections: 100).target, 500)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - MLX on-device LLM (integration; skipped unless the model is present)
|
||||||
|
|
||||||
|
/// Loads the real downloaded model and runs one generation. Skipped in CI /
|
||||||
|
/// on machines without the weights. Run with the model present to verify the
|
||||||
|
/// MLX integration end-to-end.
|
||||||
|
func testMLXRealGenerationIfModelPresent() async throws {
|
||||||
|
// Opt-in: `swift test` (CLI) can't place mlx-swift's metallib, so this is
|
||||||
|
// gated. Run it via the app's `--mlx-selftest`, or set GRAMMAGEM_MLX_TEST=1
|
||||||
|
// with the metallib colocated. See scripts/build-metallib.sh.
|
||||||
|
guard ProcessInfo.processInfo.environment["GRAMMAGEM_MLX_TEST"] == "1" else {
|
||||||
|
throw XCTSkip("set GRAMMAGEM_MLX_TEST=1 (metallib colocated) to run the MLX integration test")
|
||||||
|
}
|
||||||
|
guard let dir = ModelManager.completedModelDirectory(repo: AppConfig.Model.defaultRepo) else {
|
||||||
|
throw XCTSkip("on-device model not downloaded; skipping MLX integration test")
|
||||||
|
}
|
||||||
|
let engine = MLXEngine(modelDirectoryProvider: { dir })
|
||||||
|
XCTAssertTrue(engine.isReady)
|
||||||
|
let out = try await engine.run(.rewriteClarity,
|
||||||
|
on: "me and him was going to the store for to buy some milks")
|
||||||
|
print("MLX rewriteClarity -> \(out)")
|
||||||
|
XCTAssertFalse(out.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Modes
|
// MARK: - Modes
|
||||||
|
|
||||||
func testFreeTierGetsOnlyPolishMode() {
|
func testFreeTierGetsOnlyPolishMode() {
|
||||||
|
|||||||
25
scripts/build-metallib.sh
Executable file
25
scripts/build-metallib.sh
Executable file
@ -0,0 +1,25 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Regenerate AppSupport/mlx.metallib — the MLX on-device LLM's compiled Metal
|
||||||
|
# kernels.
|
||||||
|
#
|
||||||
|
# `swift build` (SwiftPM CLI) does NOT compile mlx-swift's .metal sources into a
|
||||||
|
# metallib, but Xcode's build system does. So we build the package once via
|
||||||
|
# xcodebuild, then vendor the resulting metallib (shipped colocated with the app
|
||||||
|
# executable; see scripts/build.sh). Run this when bumping the mlx-swift version.
|
||||||
|
#
|
||||||
|
# Requires the Metal Toolchain: xcodebuild -downloadComponent MetalToolchain
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
cd "$ROOT"
|
||||||
|
DD=".xcode-build"
|
||||||
|
|
||||||
|
echo "==> xcodebuild (compiles mlx-swift metal kernels — a few minutes)"
|
||||||
|
xcodebuild build -scheme GrammaGem -configuration Release \
|
||||||
|
-destination 'platform=macOS,arch=arm64' \
|
||||||
|
-derivedDataPath "$DD" -skipMacroValidation >/dev/null
|
||||||
|
|
||||||
|
SRC="$DD/Build/Products/Release/mlx-swift_Cmlx.bundle/Contents/Resources/default.metallib"
|
||||||
|
[ -f "$SRC" ] || { echo "ERROR: metallib not produced at $SRC" >&2; exit 1; }
|
||||||
|
cp "$SRC" AppSupport/mlx.metallib
|
||||||
|
echo "==> vendored AppSupport/mlx.metallib ($(du -h AppSupport/mlx.metallib | cut -f1))"
|
||||||
@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Build a runnable, UNIVERSAL (Apple Silicon + Intel) GrammaGem.app bundle.
|
# Build a runnable GrammaGem.app bundle (arm64 / Apple Silicon — the MLX
|
||||||
|
# on-device LLM runtime is Metal-only, so Intel is not supported).
|
||||||
# Usage: ./scripts/build.sh (run from the mac/ directory)
|
# Usage: ./scripts/build.sh (run from the mac/ directory)
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
@ -14,9 +15,10 @@ echo "==> Building Harper static lib (universal)"
|
|||||||
chmod +x harper-ffi/build.sh
|
chmod +x harper-ffi/build.sh
|
||||||
./harper-ffi/build.sh
|
./harper-ffi/build.sh
|
||||||
|
|
||||||
echo "==> Compiling universal (arm64 + x86_64, ${CONFIG})"
|
echo "==> Compiling (arm64 / Apple Silicon, ${CONFIG})"
|
||||||
swift build -c "${CONFIG}" --arch arm64 --arch x86_64
|
# arm64-only: the MLX on-device LLM runtime is Apple-Silicon / Metal only.
|
||||||
BIN="$(swift build -c "${CONFIG}" --arch arm64 --arch x86_64 --show-bin-path)"
|
swift build -c "${CONFIG}" --arch arm64
|
||||||
|
BIN="$(swift build -c "${CONFIG}" --arch arm64 --show-bin-path)"
|
||||||
|
|
||||||
echo "==> Assembling ${APP}"
|
echo "==> Assembling ${APP}"
|
||||||
rm -rf "${DIST}/${APP}"
|
rm -rf "${DIST}/${APP}"
|
||||||
@ -26,6 +28,23 @@ cp "${BIN}/GrammaGem" "${DIST}/${APP}/Contents/MacOS/GrammaGem"
|
|||||||
cp "AppSupport/Info.plist" "${DIST}/${APP}/Contents/Info.plist"
|
cp "AppSupport/Info.plist" "${DIST}/${APP}/Contents/Info.plist"
|
||||||
cp "AppSupport/AppIcon.icns" "${DIST}/${APP}/Contents/Resources/AppIcon.icns"
|
cp "AppSupport/AppIcon.icns" "${DIST}/${APP}/Contents/Resources/AppIcon.icns"
|
||||||
|
|
||||||
|
# SwiftPM resource bundles (tokenizer data, etc.) go in Contents/Resources so
|
||||||
|
# Bundle.module resolves via the main bundle and the signature stays valid.
|
||||||
|
shopt -s nullglob
|
||||||
|
for b in "${BIN}"/*.bundle; do
|
||||||
|
cp -R "$b" "${DIST}/${APP}/Contents/Resources/"
|
||||||
|
done
|
||||||
|
shopt -u nullglob
|
||||||
|
|
||||||
|
# MLX Metal kernels: `swift build` (CLI) does NOT compile mlx-swift's .metal
|
||||||
|
# sources, so we ship a prebuilt metallib in the exact bundle mlx looks for —
|
||||||
|
# Contents/Resources/mlx-swift_Cmlx.bundle/Contents/Resources/default.metallib —
|
||||||
|
# which mlx finds via the main bundle's resources and which codesigns cleanly.
|
||||||
|
# Regenerate AppSupport/mlx.metallib with: ./scripts/build-metallib.sh
|
||||||
|
MLX_RES="${DIST}/${APP}/Contents/Resources/mlx-swift_Cmlx.bundle/Contents/Resources"
|
||||||
|
mkdir -p "${MLX_RES}"
|
||||||
|
cp "AppSupport/mlx.metallib" "${MLX_RES}/default.metallib"
|
||||||
|
|
||||||
echo "==> Built ${DIST}/${APP}"
|
echo "==> Built ${DIST}/${APP}"
|
||||||
lipo -info "${DIST}/${APP}/Contents/MacOS/GrammaGem"
|
lipo -info "${DIST}/${APP}/Contents/MacOS/GrammaGem"
|
||||||
echo " Run with: open \"${DIST}/${APP}\" (grant Accessibility on first launch)"
|
echo " Run with: open \"${DIST}/${APP}\" (grant Accessibility on first launch)"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user