Files
PhoneClaw/UI/ConfigurationsView.swift

681 lines
26 KiB
Swift

import SwiftUI
#if canImport(UIKit)
import UIKit
#endif
// MARK: - Configurations 弹窗(iOS 版,适配 Theme 暖色系)
struct ConfigurationsView: View {
@Bindable var engine: AgentEngine
@Environment(\.dismiss) private var dismiss
@Environment(\.openURL) private var openURL
@State private var selectedTab = 0 // 0=Model Settings, 1=System Prompt, 2=Permissions
// 本地编辑状态(确认后才应用)
@State private var selectedModelID = MLXLocalLLMService.defaultModel.id
@State private var maxTokens: Double = 4000
@State private var topK: Double = 64
@State private var topP: Double = 0.95
@State private var temperature: Double = 1.0
@State private var systemPrompt: String = ""
@State private var permissionStatuses: [AppPermissionKind: AppPermissionStatus] = [:]
@State private var requestingPermission: AppPermissionKind?
private var isChineseSystem: Bool {
Locale.preferredLanguages.contains { $0.hasPrefix("zh") }
}
var body: some View {
NavigationStack {
VStack(spacing: 0) {
// Tab 切换
HStack(spacing: 0) {
tabButton(localized("模型设置", "Model Settings"), tag: 0)
tabButton(localized("系统提示词", "System Prompt"), tag: 1)
tabButton(localized("权限", "Permissions"), tag: 2)
}
.padding(.horizontal)
Rectangle().fill(Theme.border).frame(height: 1)
Group {
if selectedTab == 0 {
modelConfigsTab
} else if selectedTab == 1 {
systemPromptTab
} else {
permissionsTab
}
}
Rectangle().fill(Theme.border).frame(height: 1)
// 底部按钮
HStack(spacing: 20) {
Spacer()
Button(localized("取消", "Cancel")) { dismiss() }
.foregroundStyle(Theme.textSecondary)
Button(localized("确定", "OK")) {
if applySettings() {
dismiss()
}
}
.font(.body.weight(.semibold))
.foregroundStyle(Theme.accent)
.padding(.horizontal, 24)
.padding(.vertical, 8)
.background(Theme.accent.opacity(0.15), in: Capsule())
}
.padding()
}
.navigationTitle(localized("配置", "Configurations"))
.navigationBarTitleDisplayMode(.inline)
.toolbarColorScheme(.dark, for: .navigationBar)
.background(Theme.bgElevated)
}
.preferredColorScheme(.dark)
.onAppear { loadCurrentSettings() }
#if canImport(UIKit)
.onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) { _ in
engine.llm.refreshModelInstallStates()
refreshPermissionStatuses()
}
#endif
}
// MARK: - Tab 按钮
private func tabButton(_ title: String, tag: Int) -> some View {
Button {
withAnimation(.easeInOut(duration: 0.2)) { selectedTab = tag }
} label: {
VStack(spacing: 8) {
Text(title)
.font(.subheadline.weight(selectedTab == tag ? .semibold : .regular))
.foregroundStyle(selectedTab == tag ? Theme.textPrimary : Theme.textTertiary)
Rectangle()
.fill(selectedTab == tag ? Theme.accent : .clear)
.frame(height: 2)
}
}
.frame(maxWidth: .infinity)
}
// MARK: - Model Configs
private var modelConfigsTab: some View {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
modelSection
configSlider(
title: localized("最大 Token 数", "Max Tokens"),
value: $maxTokens,
range: 128...8192,
displayValue: "\(Int(maxTokens))"
)
configSlider(
title: localized("采样 TopK", "TopK"),
value: $topK,
range: 1...128,
displayValue: "\(Int(topK))"
)
configSlider(
title: localized("采样 TopP", "TopP"),
value: $topP,
range: 0...1,
displayValue: String(format: "%.2f", topP)
)
configSlider(
title: localized("温度", "Temperature"),
value: $temperature,
range: 0...2,
displayValue: String(format: "%.2f", temperature)
)
}
.padding()
}
}
// MARK: - System Prompt
private var systemPromptTab: some View {
VStack(alignment: .leading, spacing: 12) {
TextEditor(text: $systemPrompt)
.font(.system(size: 13))
.foregroundStyle(Theme.textPrimary)
.scrollContentBackground(.hidden)
.padding(12)
.background(Theme.bg, in: RoundedRectangle(cornerRadius: 8))
.overlay(
RoundedRectangle(cornerRadius: 8)
.strokeBorder(Theme.border, lineWidth: 1)
)
Button(localized("恢复默认", "Restore Default")) {
systemPrompt = engine.defaultSystemPrompt
}
.font(.subheadline)
.foregroundStyle(Theme.accent)
}
.padding()
}
private var permissionsTab: some View {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
permissionsSection
}
.padding()
}
}
// MARK: - 配置 Slider
private var modelSection: some View {
VStack(alignment: .leading, spacing: 12) {
Text(localized("模型", "Model"))
.font(.headline)
.foregroundStyle(Theme.textPrimary)
Text(engine.llm.isLoaded
? localized("当前已加载:", "Loaded: ") + engine.llm.modelDisplayName
: engine.llm.statusMessage)
.font(.subheadline)
.foregroundStyle(Theme.textSecondary)
VStack(spacing: 10) {
ForEach(engine.availableModels) { model in
let state = engine.llm.installState(for: model)
let isDownloading: Bool = {
if case .downloading = state { return true }
return false
}()
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 12) {
VStack(alignment: .leading, spacing: 4) {
Text(model.displayName)
.font(.subheadline.weight(.semibold))
.foregroundStyle(Theme.textPrimary)
Text(model.id)
.font(.caption.monospaced())
.foregroundStyle(Theme.textTertiary)
}
Spacer()
if isDownloading {
if selectedModelID == model.id {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(Theme.accent)
} else {
Image(systemName: "circle")
.foregroundStyle(Theme.textTertiary)
}
} else {
VStack(alignment: .trailing, spacing: 8) {
modelStateControl(for: model, state: state)
if selectedModelID == model.id {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(Theme.accent)
} else {
Image(systemName: "circle")
.foregroundStyle(Theme.textTertiary)
}
}
}
}
if case let .downloading(completedFiles, totalFiles, _) = state {
downloadProgressBadge(
modelID: model.id,
completedFiles: completedFiles,
totalFiles: totalFiles
)
}
if let detail = modelStateDetail(state) {
Text(detail)
.font(.caption)
.foregroundStyle(state.isFailure ? Theme.accent : Theme.textTertiary)
}
}
.padding(12)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(selectedModelID == model.id ? Theme.accentSubtle : Theme.bg)
)
.overlay(
RoundedRectangle(cornerRadius: 12)
.strokeBorder(
selectedModelID == model.id ? Theme.accent : Theme.border,
lineWidth: 1
)
)
.contentShape(RoundedRectangle(cornerRadius: 12))
.onTapGesture {
selectedModelID = model.id
}
}
}
Text(modelFooterText)
.font(.caption)
.foregroundStyle(Theme.textTertiary)
}
.padding(14)
.background(Theme.bgElevated, in: RoundedRectangle(cornerRadius: 16))
.overlay(
RoundedRectangle(cornerRadius: 16)
.strokeBorder(Theme.border, lineWidth: 1)
)
}
private var permissionsSection: some View {
VStack(alignment: .leading, spacing: 12) {
Text(localized("权限", "Permissions"))
.font(.headline)
.foregroundStyle(Theme.textPrimary)
ForEach(AppPermissionKind.allCases) { kind in
permissionRow(for: kind)
}
}
.padding(14)
.background(Theme.bgElevated, in: RoundedRectangle(cornerRadius: 16))
.overlay(
RoundedRectangle(cornerRadius: 16)
.strokeBorder(Theme.border, lineWidth: 1)
)
}
private func permissionRow(for kind: AppPermissionKind) -> some View {
let status = permissionStatuses[kind] ?? .notDetermined
return VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .top, spacing: 12) {
Image(systemName: kind.icon)
.font(.system(size: 15, weight: .medium))
.foregroundStyle(Theme.accent)
.frame(width: 28, height: 28)
.background(Theme.bg, in: RoundedRectangle(cornerRadius: 8))
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(permissionTitle(kind))
.font(.subheadline.weight(.semibold))
.foregroundStyle(Theme.textPrimary)
Spacer()
Text(permissionStatusLabel(status))
.font(.caption.weight(.semibold))
.foregroundStyle(status.isGranted ? Theme.accentGreen : Theme.accent)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(
(status.isGranted ? Theme.accentGreen : Theme.accent)
.opacity(0.14),
in: Capsule()
)
}
Text(permissionDescription(kind))
.font(.caption)
.foregroundStyle(Theme.textSecondary)
Text(permissionStatusDetail(status))
.font(.caption)
.foregroundStyle(Theme.textTertiary)
}
}
HStack(spacing: 10) {
if !status.isGranted {
Button(requestingPermission == kind
? localized("请求中...", "Requesting...")
: localized("请求权限", "Request Access")) {
requestPermission(kind)
}
.disabled(requestingPermission != nil)
.font(.caption.weight(.semibold))
.foregroundStyle(Theme.accent)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(Theme.accent.opacity(0.15), in: Capsule())
}
Button(localized("去设置", "Open Settings")) {
openAppSettings()
}
.font(.caption.weight(.semibold))
.foregroundStyle(Theme.textSecondary)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(Theme.bg, in: Capsule())
}
}
.padding(.vertical, 4)
}
private func configSlider(
title: String,
value: Binding<Double>,
range: ClosedRange<Double>,
displayValue: String
) -> some View {
VStack(alignment: .leading, spacing: 6) {
Text(title)
.font(.subheadline)
.foregroundStyle(Theme.textSecondary)
HStack(spacing: 12) {
Slider(value: value, in: range)
.tint(Theme.accent)
Text(displayValue)
.font(.body.monospaced())
.foregroundStyle(Theme.textPrimary.opacity(0.8))
.frame(width: 56)
.padding(.horizontal, 8)
.padding(.vertical, 5)
.background(Theme.bg, in: RoundedRectangle(cornerRadius: 6))
}
}
}
@ViewBuilder
private func modelStateControl(for model: BundledModelOption, state: ModelInstallState) -> some View {
switch state {
case .notInstalled:
Button(localized("下载", "Download")) {
selectedModelID = model.id
Task {
await engine.llm.downloadModel(id: model.id)
if engine.llm.isModelAvailable(model),
selectedModelID == model.id,
(!engine.llm.isLoaded || engine.llm.loadedModelID != model.id) {
engine.config.selectedModelID = model.id
engine.reloadModel()
}
}
}
.font(.caption.weight(.semibold))
.foregroundStyle(Theme.accent)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(Theme.accent.opacity(0.15), in: Capsule())
case .checkingSource:
modelBadge(localized("检查中", "Checking"))
case .downloading(let completedFiles, let totalFiles, _):
downloadProgressBadge(
modelID: model.id,
completedFiles: completedFiles,
totalFiles: totalFiles
)
case .downloaded:
modelBadge(localized("已下载", "Downloaded"), color: Theme.accentGreen)
case .bundled:
modelBadge(localized("内置", "Bundled"), color: Theme.accentGreen)
case .failed:
Button(localized("重试", "Retry")) {
selectedModelID = model.id
Task {
await engine.llm.downloadModel(id: model.id)
}
}
.font(.caption.weight(.semibold))
.foregroundStyle(Theme.accent)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(Theme.accent.opacity(0.15), in: Capsule())
}
}
private func modelBadge(_ text: String, color: Color = Theme.textTertiary) -> some View {
Text(text)
.font(.caption.weight(.semibold))
.foregroundStyle(color)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(color.opacity(0.14), in: Capsule())
}
private func downloadProgressBadge(
modelID: String,
completedFiles: Int,
totalFiles: Int
) -> some View {
let safeTotal = max(totalFiles, 1)
let value = Double(min(completedFiles, safeTotal))
let metrics = engine.llm.downloadMetrics(for: modelID)
return VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 10) {
VStack(alignment: .leading, spacing: 4) {
Text(localized(
"下载中 \(completedFiles)/\(totalFiles)",
"Downloading \(completedFiles)/\(totalFiles)"
))
.font(.caption.weight(.semibold))
.foregroundStyle(Theme.textPrimary)
.lineLimit(1)
if let metrics {
Text(downloadMetricsText(metrics))
.font(.caption2.monospacedDigit())
.foregroundStyle(Theme.textTertiary)
.lineLimit(1)
}
}
Spacer(minLength: 8)
Button(localized("取消", "Cancel")) {
engine.llm.cancelModelDownload(id: modelID)
}
.font(.caption.weight(.semibold))
.foregroundStyle(Theme.textPrimary)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(Theme.bg, in: Capsule())
.fixedSize(horizontal: true, vertical: true)
}
ProgressView(value: value, total: Double(safeTotal))
.progressViewStyle(.linear)
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background(Theme.textTertiary.opacity(0.14), in: RoundedRectangle(cornerRadius: 12))
}
private func downloadMetricsText(_ metrics: ModelDownloadMetrics) -> String {
let speedText = formattedSpeed(metrics.bytesPerSecond)
if let totalBytes = metrics.totalBytes, totalBytes > 0 {
return "\(formattedBytes(metrics.bytesReceived)) / \(formattedBytes(totalBytes)) · \(speedText)"
}
return "\(formattedBytes(metrics.bytesReceived)) · \(speedText)"
}
private func formattedBytes(_ bytes: Int64) -> String {
let formatter = ByteCountFormatter()
formatter.allowedUnits = [.useKB, .useMB, .useGB]
formatter.countStyle = .file
formatter.includesUnit = true
formatter.isAdaptive = true
return formatter.string(fromByteCount: bytes)
}
private func formattedSpeed(_ bytesPerSecond: Double?) -> String {
guard let bytesPerSecond, bytesPerSecond > 0 else {
return localized("计算中…", "Calculating…")
}
return localized("速度 ", "Speed ") + formattedBytes(Int64(bytesPerSecond)) + "/s"
}
private func modelStateDetail(_ state: ModelInstallState) -> String? {
switch state {
case .notInstalled:
return localized("未安装", "Not Installed")
case .checkingSource:
return localized("正在准备下载。", "Preparing download.")
case .downloading:
return nil
case .downloaded:
return localized("已下载到手机本地,可直接加载。", "Downloaded on device and ready to load.")
case .bundled:
return localized("模型已随 App 内置。", "This model is bundled inside the app.")
case .failed(let message):
return message
}
}
private var modelFooterText: String {
guard let selectedModel = engine.availableModels.first(where: { $0.id == selectedModelID }) else {
return localized("点右侧按钮下载模型后再点击确定。", "Download a model first, then tap OK.")
}
if !engine.llm.isModelAvailable(selectedModel) {
return localized("先下载选中的模型,再点击确定加载。", "Download the selected model first, then tap OK to load it.")
}
if selectedModelID == engine.llm.selectedModelID,
engine.llm.loadedModelID == selectedModelID,
engine.llm.isLoaded {
return localized("点击确定会保留当前模型。", "Tap OK to keep the current model.")
}
return localized("点击确定后会卸载当前模型并重新加载新模型。", "Tap OK to unload the current model and reload the new one.")
}
// MARK: - 加载 / 应用
private func localized(_ zh: String, _ en: String) -> String {
isChineseSystem ? zh : en
}
private func permissionTitle(_ kind: AppPermissionKind) -> String {
switch kind {
case .microphone:
return localized("麦克风", "Microphone")
case .calendar:
return localized("日历", "Calendar")
case .reminders:
return localized("提醒事项", "Reminders")
case .contacts:
return localized("通讯录", "Contacts")
}
}
private func permissionDescription(_ kind: AppPermissionKind) -> String {
switch kind {
case .microphone:
return localized("允许录音并采集实时音频输入", "Allow recording and capturing realtime audio input")
case .calendar:
return localized("允许创建和写入日历事项", "Allow creating and writing calendar events")
case .reminders:
return localized("允许创建提醒和待办", "Allow creating reminders and tasks")
case .contacts:
return localized("允许保存和更新联系人", "Allow saving and updating contacts")
}
}
private func permissionStatusLabel(_ status: AppPermissionStatus) -> String {
switch status {
case .notDetermined:
return localized("未请求", "Not Requested")
case .denied:
return localized("已拒绝", "Denied")
case .restricted:
return localized("受限制", "Restricted")
case .granted:
return localized("已授权", "Granted")
}
}
private func permissionStatusDetail(_ status: AppPermissionStatus) -> String {
switch status {
case .notDetermined:
return localized("首次使用时会弹出系统授权框", "The system permission dialog will appear on first use")
case .denied:
return localized("请到系统设置里手动开启权限", "Please enable this permission manually in Settings")
case .restricted:
return localized("当前设备限制了这项权限", "This permission is restricted on the current device")
case .granted:
return localized("可以直接执行相关 Skill", "Related skills can run directly")
}
}
private func loadCurrentSettings() {
engine.llm.refreshModelInstallStates()
selectedModelID = engine.llm.loadedModelID ?? engine.config.selectedModelID
maxTokens = Double(engine.config.maxTokens)
topK = Double(engine.config.topK)
topP = engine.config.topP
temperature = engine.config.temperature
systemPrompt = engine.config.systemPrompt
refreshPermissionStatuses()
}
private func applySettings() -> Bool {
let modelChanged = engine.config.selectedModelID != selectedModelID
engine.config.maxTokens = Int(maxTokens)
engine.config.topK = Int(topK)
engine.config.topP = topP
engine.config.temperature = temperature
engine.config.systemPrompt = systemPrompt
// 同步采样参数到 LLM(下次生成立即生效)
engine.applySamplingConfig()
guard let selectedModel = engine.availableModels.first(where: { $0.id == selectedModelID }),
engine.llm.isModelAvailable(selectedModel) else {
if let missingModel = engine.availableModels.first(where: { $0.id == selectedModelID }) {
engine.llm.statusMessage = localized("请先在配置中下载 ", "Please download ")
+ missingModel.displayName
+ localized(" 模型", " first")
}
return false
}
engine.config.selectedModelID = selectedModelID
let needsLoad = !engine.llm.isLoaded || engine.llm.loadedModelID != selectedModelID
if modelChanged || needsLoad {
engine.reloadModel()
}
return true
}
private func refreshPermissionStatuses() {
permissionStatuses = engine.permissionStatuses()
}
private func requestPermission(_ kind: AppPermissionKind) {
requestingPermission = kind
Task {
_ = await engine.requestPermission(kind)
await MainActor.run {
refreshPermissionStatuses()
requestingPermission = nil
}
}
}
private func openAppSettings() {
#if canImport(UIKit)
guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
openURL(url)
#endif
}
}
private extension ModelInstallState {
var isFailure: Bool {
if case .failed = self {
return true
}
return false
}
}