Files
PhoneClaw/Tools/AppPermissions.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

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)
}
}
}
}
}
}