Files
PhoneClaw/LLM/MLX/Core/GPULifecycle.swift
KellyvvandClaude Opus 4.6 779be78843 refactor: split MLX service, introduce skill type & runtime budgets
Major restructuring of the agent stack to support content-type skills,
data-driven runtime budgets, and the new translate skill.

LLM/MLX/
- Split MLXLocalLLMService.swift (1500 → 700 lines) into Core/, Gemma4/,
  Installation/ subdirectories
- New BundledModel: LinearBudgetFormula replaces tier tables, model
  parameters fully data-driven via ModelRuntimeProfile
- New RuntimeBudgets: pure functions for text/thinking/multimodal budgets,
  zero hardcoded model IDs
- Drop adjustedTextOutputTokens prepared-subtraction (chunked prefill made
  it obsolete and triggered false-positive truncation on long prompts)

Skills/
- Add SkillType (.device / .content) metadata, parsed from SKILL.md
  frontmatter
- New translate skill (type: content, no tools) with anaphora handling
- Trim translate (4563→1804), device (2357→1680), calendar (2169→1790)
- contacts SKILL.md adds multi-turn disambiguation + cancel guidance,
  semantics owned by body not framework
- SkillRegistry validates allowed-tools against ToolRegistry at load time

Agent/
- ToolCallParser becomes registry-aware: drops tool_call names not in
  ToolRegistry or protocol set (load_skill / list_skills); ToolRegistry
  is now the single white list, validation moves to parser boundary
- Router sticky routing uses semantic user-turn boundary instead of
  fixed suffix(4); correctly handles long agent loops
- AgentEngine system prompt template differentiates device vs content
  skills with explicit invocation rules
- SYSPROMPT.md auto-migration: old single-placeholder template backed up
  to .bak when new dual-placeholder is detected
- PromptBuilder.buildLoadedSkillPrompt receives availableTools list,
  explicitly tells model which tools (or none) are callable

Tools/
- ToolRegistry split into Helpers/Stores/AppPermissions/Handlers/* for
  separation of concerns
- Mark Tools/Handlers/* with explicit allowed argument signatures

UI/
- Add long-press copy contextMenu on user and assistant bubbles
- Enable .textSelection on user bubble

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 09:22:20 +08:00

91 lines
2.9 KiB
Swift

import Foundation
#if canImport(UIKit)
import UIKit
#endif
// MARK: - MLXLocalLLMService GPU + app lifecycle extension
//
// iPhone 禁止后台继续跑 Metal compute, 否则 jetsam 直接 kill。
// 这个 extension 监听 UIApplication 生命周期通知, 当 app 即将进入非活动状态时
// 立即取消当前的 generate/load task, 并把 foregroundGPUAllowed flag 置 false。
// ensureForegroundGPUExecution() 在关键路径前调用, 阻止任何后台提交。
extension MLXLocalLLMService {
func ensureForegroundGPUExecution() async throws {
#if canImport(UIKit)
let isActive = await MainActor.run {
UIApplication.shared.applicationState == .active
}
setForegroundGPUAllowed(isActive)
guard isActive else {
throw MLXError.gpuExecutionRequiresForeground
}
#endif
}
func configureLifecycleObservers() {
#if canImport(UIKit)
let center = NotificationCenter.default
lifecycleObserverTokens = [
center.addObserver(
forName: UIApplication.willResignActiveNotification,
object: nil,
queue: nil
) { [weak self] _ in
self?.handleApplicationLeavingForeground()
},
center.addObserver(
forName: UIApplication.didEnterBackgroundNotification,
object: nil,
queue: nil
) { [weak self] _ in
self?.handleApplicationLeavingForeground()
},
center.addObserver(
forName: UIApplication.willEnterForegroundNotification,
object: nil,
queue: nil
) { [weak self] _ in
self?.setForegroundGPUAllowed(true)
},
center.addObserver(
forName: UIApplication.didBecomeActiveNotification,
object: nil,
queue: nil
) { [weak self] _ in
self?.setForegroundGPUAllowed(true)
}
]
Task { [weak self] in
guard let self else { return }
let isActive = await MainActor.run {
UIApplication.shared.applicationState == .active
}
self.setForegroundGPUAllowed(isActive)
}
#endif
}
func handleApplicationLeavingForeground() {
setForegroundGPUAllowed(false)
cancelled = true
currentGenerationTask?.cancel()
currentLoadTask?.cancel()
}
func setForegroundGPUAllowed(_ allowed: Bool) {
foregroundStateLock.lock()
foregroundGPUAllowed = allowed
foregroundStateLock.unlock()
}
func isForegroundGPUAllowed() -> Bool {
foregroundStateLock.lock()
let allowed = foregroundGPUAllowed
foregroundStateLock.unlock()
return allowed
}
}