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>
187 lines
5.6 KiB
Swift
187 lines
5.6 KiB
Swift
import AVFoundation
|
|
import Contacts
|
|
import EventKit
|
|
import Foundation
|
|
|
|
// MARK: - 权限模型
|
|
|
|
enum AppPermissionKind: String, CaseIterable, Identifiable {
|
|
case microphone
|
|
case calendar
|
|
case reminders
|
|
case contacts
|
|
|
|
var id: String { rawValue }
|
|
|
|
var title: String {
|
|
switch self {
|
|
case .microphone: return "麦克风"
|
|
case .calendar: return "日历"
|
|
case .reminders: return "提醒事项"
|
|
case .contacts: return "通讯录"
|
|
}
|
|
}
|
|
|
|
var description: String {
|
|
switch self {
|
|
case .microphone: return "允许录音并采集实时音频输入"
|
|
case .calendar: return "允许创建和写入日历事项"
|
|
case .reminders: return "允许创建提醒和待办"
|
|
case .contacts: return "允许查询、保存和删除联系人"
|
|
}
|
|
}
|
|
|
|
var icon: String {
|
|
switch self {
|
|
case .microphone: return "mic"
|
|
case .calendar: return "calendar"
|
|
case .reminders: return "bell"
|
|
case .contacts: return "person.crop.circle"
|
|
}
|
|
}
|
|
}
|
|
|
|
enum AppPermissionStatus: Equatable {
|
|
case notDetermined
|
|
case denied
|
|
case restricted
|
|
case granted
|
|
|
|
var label: String {
|
|
switch self {
|
|
case .notDetermined: return "未请求"
|
|
case .denied: return "已拒绝"
|
|
case .restricted: return "受限制"
|
|
case .granted: return "已授权"
|
|
}
|
|
}
|
|
|
|
var detail: String {
|
|
switch self {
|
|
case .notDetermined: return "首次使用时会弹出系统授权框"
|
|
case .denied: return "请到系统设置里手动开启权限"
|
|
case .restricted: return "当前设备限制了这项权限"
|
|
case .granted: return "可以直接执行相关 Skill"
|
|
}
|
|
}
|
|
|
|
var isGranted: Bool {
|
|
self == .granted
|
|
}
|
|
}
|
|
|
|
// MARK: - 权限查询与请求
|
|
|
|
extension ToolRegistry {
|
|
|
|
func authorizationStatus(for kind: AppPermissionKind) -> AppPermissionStatus {
|
|
switch kind {
|
|
case .microphone:
|
|
switch AVAudioSession.sharedInstance().recordPermission {
|
|
case .granted:
|
|
return .granted
|
|
case .denied:
|
|
return .denied
|
|
case .undetermined:
|
|
return .notDetermined
|
|
@unknown default:
|
|
return .restricted
|
|
}
|
|
|
|
case .calendar:
|
|
let status = EKEventStore.authorizationStatus(for: .event)
|
|
switch status {
|
|
case .fullAccess, .writeOnly, .authorized:
|
|
return .granted
|
|
case .notDetermined:
|
|
return .notDetermined
|
|
case .denied:
|
|
return .denied
|
|
case .restricted:
|
|
return .restricted
|
|
@unknown default:
|
|
return .restricted
|
|
}
|
|
|
|
case .reminders:
|
|
let status = EKEventStore.authorizationStatus(for: .reminder)
|
|
switch status {
|
|
case .fullAccess, .authorized:
|
|
return .granted
|
|
case .notDetermined:
|
|
return .notDetermined
|
|
case .denied:
|
|
return .denied
|
|
case .restricted, .writeOnly:
|
|
return .restricted
|
|
@unknown default:
|
|
return .restricted
|
|
}
|
|
|
|
case .contacts:
|
|
let status = CNContactStore.authorizationStatus(for: .contacts)
|
|
switch status {
|
|
case .authorized:
|
|
return .granted
|
|
case .limited:
|
|
return .granted
|
|
case .notDetermined:
|
|
return .notDetermined
|
|
case .denied:
|
|
return .denied
|
|
case .restricted:
|
|
return .restricted
|
|
@unknown default:
|
|
return .restricted
|
|
}
|
|
}
|
|
}
|
|
|
|
func allPermissionStatuses() -> [AppPermissionKind: AppPermissionStatus] {
|
|
Dictionary(uniqueKeysWithValues: AppPermissionKind.allCases.map {
|
|
($0, authorizationStatus(for: $0))
|
|
})
|
|
}
|
|
|
|
func requestAccess(for kind: AppPermissionKind) async throws -> Bool {
|
|
switch kind {
|
|
case .microphone:
|
|
return await withCheckedContinuation { continuation in
|
|
AVAudioSession.sharedInstance().requestRecordPermission { granted in
|
|
continuation.resume(returning: granted)
|
|
}
|
|
}
|
|
case .calendar:
|
|
return try await withCheckedThrowingContinuation { continuation in
|
|
SystemStores.event.requestWriteOnlyAccessToEvents { granted, error in
|
|
if let error {
|
|
continuation.resume(throwing: error)
|
|
} else {
|
|
continuation.resume(returning: granted)
|
|
}
|
|
}
|
|
}
|
|
case .reminders:
|
|
return try await withCheckedThrowingContinuation { continuation in
|
|
SystemStores.event.requestFullAccessToReminders { granted, error in
|
|
if let error {
|
|
continuation.resume(throwing: error)
|
|
} else {
|
|
continuation.resume(returning: granted)
|
|
}
|
|
}
|
|
}
|
|
case .contacts:
|
|
return try await withCheckedThrowingContinuation { continuation in
|
|
SystemStores.contacts.requestAccess(for: .contacts) { granted, error in
|
|
if let error {
|
|
continuation.resume(throwing: error)
|
|
} else {
|
|
continuation.resume(returning: granted)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|