Files
PhoneClaw/LLM/MLXLocalLLMService.swift
T
Kellyvv 95553ee653 feat: major agent upgrade — multi-model, calendar/reminders/contacts skills
Agent:
- Rewrite load_skill follow-up: inject skill instructions into system
  block (buildLoadedSkillPrompt) instead of appending as user message
- Add auto tool-call shortcut for single no-arg skills
  (autoToolCallForLoadedSkills) — skips second LLM round
- Add Chinese date parsing (parseBasicChineseDate, detectDateInQuestion)
- Add heuristic argument inference (heuristicArgumentsForTool)
- Add looksLikePromptEcho / looksLikeStructuredIntermediateOutput filters
- Add renderToolResultLocally for zero-inference result formatting
- Add permission management API (AppPermissionKind, permissionStatuses,
  requestPermission)
- Add applyModelSelection() for runtime model switching

PromptBuilder:
- Replace buildFollowUp+buildForcedSkillContinuation with:
  buildLoadedSkillPrompt (skill in system block, cleaner prefill)
  buildToolAnswerPrompt (minimal tool-result answer prompt)
- Add extractSystemBlock / injectIntoSystemBlock helpers

MLXLocalLLMService:
- Add BundledModelOption struct
- Support multiple models (E2B + E4B) via availableModels array
- modelPath now dynamic based on selectedModel

ToolRegistry:
- Add calendar-create-event (EventKit, write-only access)
- Add reminders-create (EventKit, full access, EKAlarm for notifications)
  - ensureWritableReminderCalendar: auto-creates reminder list if needed
- Add contacts-upsert (Contacts, dedup by phone number)
- Fix: CNContacts .limited case treated as .granted
- Fix: reminders EKAlarm required for notification to fire

SkillLoader:
- Add calendar, reminders, contacts default SKILL.md entries

Info.plist:
- Add NSCalendarsWriteOnlyAccessUsageDescription
- Add NSCalendarsUsageDescription
- Add NSRemindersFullAccessUsageDescription
- Add NSRemindersUsageDescription
- Add NSContactsUsageDescription

ConfigurationsView:
- Add model picker (E2B / E4B)
- Add permissions panel (AppPermissionKind list with request support)
2026-04-06 03:49:21 +08:00

504 lines
18 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Foundation
import CoreImage
import MLX
import MLXLLM
import MLXLMCommon
import MLXVLM
#if canImport(UIKit)
import UIKit
#endif
// MARK: - MLX Local LLM Service
public struct BundledModelOption: Identifiable, Hashable, Sendable {
public let id: String
public let directoryName: String
public let displayName: String
}
/// MLX GPU inference service for Gemma 4.
/// Forces MLX Metal GPU path — no CPU fallback.
@Observable
public class MLXLocalLLMService: LLMEngine {
static let availableModels: [BundledModelOption] = [
.init(
id: "gemma-4-e2b-it-4bit",
directoryName: "gemma-4-e2b-it-4bit",
displayName: "Gemma 4 E2B"
),
.init(
id: "gemma-4-e4b-it-4bit",
directoryName: "gemma-4-e4b-it-4bit",
displayName: "Gemma 4 E4B"
)
]
static let defaultModel = availableModels[0]
private static let multimodalMaxOutputTokens = 4000
// MARK: - State
public private(set) var isLoaded = false
public private(set) var isLoading = false
public private(set) var isGenerating = false
public private(set) var stats = LLMStats()
public var statusMessage = "等待加载模型..."
public private(set) var selectedModel = defaultModel
public private(set) var loadedModel: BundledModelOption?
public var modelDisplayName: String { loadedModel?.displayName ?? selectedModel.displayName }
public var selectedModelID: String { selectedModel.id }
public var loadedModelID: String? { loadedModel?.id }
// MARK: - Compatibility Settings
public var useGPU = true
public var samplingTopK: Int = 40
public var samplingTopP: Float = 0.95
public var samplingTemperature: Float = 1.0
public var maxOutputTokens: Int = 4000
private var modelContainer: ModelContainer?
private var cancelled = false
private var currentLoadTask: Task<Void, Never>?
private var currentGenerationTask: Task<Void, Never>?
/// Local path to the model directory
private var modelPath: URL {
Self.resolveModelPath(for: selectedModel)
}
// MARK: - Init
public init(selectedModelID: String? = nil) {
if let selectedModelID,
let option = Self.availableModels.first(where: { $0.id == selectedModelID }) {
self.selectedModel = option
}
self.stats.backend = "mlx-gpu"
}
/// Convenience init with default model location
public convenience init() {
self.init(selectedModelID: nil)
}
public func selectModel(id: String) -> Bool {
guard let option = Self.availableModels.first(where: { $0.id == id }),
option != selectedModel else {
return false
}
selectedModel = option
statusMessage = isLoaded
? "已选择 \(option.displayName),准备重新加载..."
: "已选择 \(option.displayName),等待加载..."
return true
}
private static func resolveModelPath(for model: BundledModelOption) -> URL {
if let resourceURL = Bundle.main.resourceURL {
let directBundleDir = resourceURL.appendingPathComponent(
model.directoryName,
isDirectory: true
)
if FileManager.default.fileExists(atPath: directBundleDir.path) {
return directBundleDir
}
let nestedBundleDir = resourceURL
.appendingPathComponent("Models", isDirectory: true)
.appendingPathComponent(model.directoryName, isDirectory: true)
if FileManager.default.fileExists(atPath: nestedBundleDir.path) {
return nestedBundleDir
}
}
let documentsPath = FileManager.default.urls(
for: .documentDirectory,
in: .userDomainMask
).first!
return documentsPath.appendingPathComponent("models/\(model.directoryName)")
}
func loadModel() {
currentLoadTask?.cancel()
currentLoadTask = Task { [weak self] in
guard let self else { return }
defer { self.currentLoadTask = nil }
do {
if self.isLoading {
return
}
try await load()
try await warmup()
} catch is CancellationError {
await MainActor.run {
if self.statusMessage.hasPrefix("正在加载") || self.statusMessage.hasPrefix("正在初始化") {
self.statusMessage = "已取消模型切换"
}
}
} catch {
statusMessage = "❌ \(error.localizedDescription)"
print("[MLX] Load failed: \(error)")
}
}
}
func generateStream(
prompt: String,
images: [CIImage] = [],
onToken: @escaping (String) -> Void,
onComplete: @escaping (Result<String, Error>) -> Void
) {
Task {
var fullResponse = ""
do {
for try await token in generateStream(prompt: prompt, images: images) {
fullResponse += token
await MainActor.run {
onToken(token)
}
}
await MainActor.run {
onComplete(.success(fullResponse))
}
} catch {
await MainActor.run {
onComplete(.failure(error))
}
}
}
}
func generateStream(
chat: [Chat.Message],
onToken: @escaping (String) -> Void,
onComplete: @escaping (Result<String, Error>) -> Void
) {
Task {
var fullResponse = ""
do {
for try await token in generateStream(chat: chat) {
fullResponse += token
await MainActor.run {
onToken(token)
}
}
await MainActor.run {
onComplete(.success(fullResponse))
}
} catch {
await MainActor.run {
onComplete(.failure(error))
}
}
}
}
// MARK: - LLMEngine Protocol
public func load() async throws {
if isLoading {
return
}
let model = selectedModel
let path = Self.resolveModelPath(for: model)
isLoading = true
defer {
isLoading = false
}
statusMessage = "正在初始化模型..."
await Gemma4Registration.register()
guard FileManager.default.fileExists(atPath: path.path) else {
throw MLXError.modelDirectoryMissing(path.path)
}
statusMessage = "正在加载 \(model.displayName)..."
let loadStart = CFAbsoluteTimeGetCurrent()
// ── Memory diagnostics (read before load) ──────────────────────────────
let physMB = Double(ProcessInfo.processInfo.physicalMemory) / 1_048_576
let (footprintBefore, limitBefore) = appMemoryFootprintMB()
print("[MEM] Physical RAM: \(Int(physMB)) MB")
print("[MEM] Before load — footprint: \(Int(footprintBefore)) MB, jetsam limit: \(Int(limitBefore)) MB")
print("[MEM] MLX before — active: \(MLX.GPU.activeMemory / 1_048_576) MB, cache: \(MLX.GPU.cacheMemory / 1_048_576) MB")
let container = try await VLMModelFactory.shared.loadContainer(
from: path,
using: MLXTokenizersLoader()
)
try Task.checkCancellation()
self.modelContainer = container
self.isLoaded = true
self.loadedModel = model
// ── Memory diagnostics (read after load) ───────────────────────────────
let (footprintAfter, _) = appMemoryFootprintMB()
print("[MEM] After load — footprint: \(Int(footprintAfter)) MB")
print("[MEM] MLX after — active: \(MLX.GPU.activeMemory / 1_048_576) MB, cache: \(MLX.GPU.cacheMemory / 1_048_576) MB")
let elapsed = (CFAbsoluteTimeGetCurrent() - loadStart) * 1000
stats.loadTimeMs = elapsed
statusMessage = "模型已就绪 ✅ (\(Int(elapsed))ms)"
print("[MLX] Model loaded in \(Int(elapsed))ms — backend: mlx-gpu — model: \(model.displayName)")
}
/// Returns (footprint MB, jetsam limit MB) via task_info.
private func appMemoryFootprintMB() -> (Double, Double) {
var info = task_vm_info_data_t()
var count = mach_msg_type_number_t(MemoryLayout<task_vm_info_data_t>.size / MemoryLayout<natural_t>.size)
let kr = withUnsafeMutablePointer(to: &info) {
$0.withMemoryRebound(to: integer_t.self, capacity: Int(count)) {
task_info(mach_task_self_, task_flavor_t(TASK_VM_INFO), $0, &count)
}
}
guard kr == KERN_SUCCESS else { return (0, 0) }
let footprint = Double(info.phys_footprint) / 1_048_576
let limit = Double(info.limit_bytes_remaining) / 1_048_576 + footprint
return (footprint, limit)
}
/// 当前可用内存 headroom(MB)。Agent 用来动态调整 history 深度。
public var availableHeadroomMB: Int {
let (footprint, limit) = appMemoryFootprintMB()
return max(0, Int(limit - footprint))
}
/// 根据当前剩余内存推荐安全的 history 深度(消息条数)。
/// Gemma E2B 每 ~200 token history ≈ ~200 MB 推理峰值,保守估算:
/// headroom > 1500 MB → suffix(4) 最近 2 轮
/// headroom > 900 MB → suffix(2) 最近 1 轮
/// headroom ≤ 900 MB → suffix(0) 无历史(临界状态)
public var safeHistoryDepth: Int {
let h = availableHeadroomMB
switch h {
case 1500...: return 4
case 900..<1500: return 2
default: return 0
}
}
public func warmup() async throws {
// Warmup skipped for E2B.
//
// E2B has 26 layers (E4B has 42). Running MLXLMCommon.generate() for the first time
// triggers Metal JIT shader compilation across all unique kernel variants
// (attention, MLP, PLE, RoPE ...). This compilation adds a temporary
// memory spike on top of the already-loaded 4.9 GB weights, which pushes
// the process past the jetsam limit on iPhone 17 Pro Max.
//
// Skipping warmup means the first user inference compiles shaders lazily
// (first response is ~2-3s slower) but avoids the OOM kill on startup.
print("[MLX] Warmup skipped — shaders will compile on first inference")
statusMessage = "模型已就绪 ✅"
}
public func generateStream(prompt: String, images: [CIImage]) -> AsyncThrowingStream<String, Error> {
let input: UserInput
if images.isEmpty {
input = UserInput(prompt: prompt)
} else {
input = UserInput(
chat: [
.user(
prompt,
images: images.map { .ciImage($0) }
)
]
)
}
return generateStream(input: input, isMultimodal: !images.isEmpty)
}
public func generateStream(chat: [Chat.Message]) -> AsyncThrowingStream<String, Error> {
let hasImages = chat.contains { !$0.images.isEmpty }
let input = UserInput(chat: chat)
return generateStream(input: input, isMultimodal: hasImages)
}
private func ensureForegroundGPUExecution() async throws {
#if canImport(UIKit)
let isActive = await MainActor.run {
UIApplication.shared.applicationState == .active
}
guard isActive else {
throw MLXError.gpuExecutionRequiresForeground
}
#endif
}
private func generateStream(
input: UserInput,
isMultimodal: Bool
) -> AsyncThrowingStream<String, Error> {
AsyncThrowingStream { continuation in
let task = Task { [weak self] in
guard let self else {
continuation.finish(throwing: MLXError.modelNotLoaded)
return
}
guard let container = modelContainer else {
continuation.finish(throwing: MLXError.modelNotLoaded)
return
}
// Free Metal buffers cached from previous inference before
// allocating the new computation graph. Critical on low-headroom devices:
// the follow-up prompt is longer than the first inference,
// and without clearing, residual cache + new activations
// exceed the 6GB jetsam limit on iPhone.
MLX.GPU.clearCache()
self.isGenerating = true
self.cancelled = false
let genStart = CFAbsoluteTimeGetCurrent()
var firstTokenTime: Double? = nil
var tokenCount = 0
let (fp, _) = appMemoryFootprintMB()
print("[MEM] generateStream start — footprint: \(Int(fp)) MB, MLX active: \(MLX.GPU.activeMemory / 1_048_576) MB")
do {
try await self.ensureForegroundGPUExecution()
_ = try await container.perform { context in
try await self.ensureForegroundGPUExecution()
let effectiveMaxOutputTokens =
isMultimodal ? min(maxOutputTokens, Self.multimodalMaxOutputTokens) : maxOutputTokens
if isMultimodal {
print("[VLM] multimodal budget — maxOutputTokens=\(effectiveMaxOutputTokens)")
}
let input = try await context.processor.prepare(input: input)
if isMultimodal {
print("[VLM] prepared sequence length=\(input.text.tokens.dim(1))")
}
try await self.ensureForegroundGPUExecution()
return try MLXLMCommon.generate(
input: input,
parameters: .init(
maxTokens: effectiveMaxOutputTokens,
temperature: samplingTemperature,
topP: samplingTopP,
topK: samplingTopK
),
context: context
) { tokens in
if self.cancelled {
return .stop
}
tokenCount = tokens.count
if firstTokenTime == nil {
firstTokenTime = (CFAbsoluteTimeGetCurrent() - genStart) * 1000
}
// Stream the latest token
if let lastToken = tokens.last {
let text = context.tokenizer.decode(tokenIds: [lastToken])
continuation.yield(text)
}
// Multimodal path uses a tighter generation budget on iPhone.
return tokens.count >= effectiveMaxOutputTokens ? .stop : .more
}
}
let elapsed = CFAbsoluteTimeGetCurrent() - genStart
self.stats.ttftMs = firstTokenTime ?? 0
self.stats.tokensPerSec = elapsed > 0
? Double(tokenCount) / elapsed : 0
self.stats.totalTokens = tokenCount
print(
"[MLX] Generated \(tokenCount) tokens in \(String(format: "%.1f", elapsed))s"
)
print(
"[MLX] TTFT: \(String(format: "%.0f", self.stats.ttftMs))ms, "
+ "Speed: \(String(format: "%.1f", self.stats.tokensPerSec)) tok/s")
// 推理结束后立即释放 Metal activation 缓存,
// 确保下一轮有最大可用 headroom。
MLX.GPU.clearCache()
let (fpEnd, _) = appMemoryFootprintMB()
print("[MEM] generateStream end — footprint: \(Int(fpEnd)) MB, headroom: \(self.availableHeadroomMB) MB")
continuation.finish()
} catch {
continuation.finish(throwing: error)
}
self.isGenerating = false
self.currentGenerationTask = nil
}
currentGenerationTask = task
continuation.onTermination = { [weak self] _ in
task.cancel()
if self?.currentGenerationTask?.isCancelled == true {
self?.currentGenerationTask = nil
}
}
}
}
public func cancel() {
cancelled = true
currentGenerationTask?.cancel()
currentLoadTask?.cancel()
}
public func prepareForReload() async {
cancel()
while isGenerating || isLoading {
try? await Task.sleep(nanoseconds: 50_000_000)
}
unload()
MLX.GPU.clearCache()
try? await Task.sleep(nanoseconds: 150_000_000)
}
public func unload() {
currentGenerationTask?.cancel()
currentLoadTask?.cancel()
modelContainer = nil
isLoaded = false
isLoading = false
isGenerating = false
loadedModel = nil
cancelled = false
stats = LLMStats()
stats.backend = "mlx-gpu"
MLX.GPU.clearCache()
statusMessage = "模型已卸载"
print("[MLX] Model unloaded")
}
}
// MARK: - Errors
enum MLXError: LocalizedError {
case modelNotLoaded
case modelDirectoryMissing(String)
case gpuExecutionRequiresForeground
var errorDescription: String? {
switch self {
case .modelNotLoaded:
return "MLX model not loaded. Call load() first."
case .modelDirectoryMissing(let path):
return "MLX 模型目录不存在: \(path)"
case .gpuExecutionRequiresForeground:
return "应用进入后台时,iPhone 不允许继续提交 GPU 推理任务。"
}
}
}