From 22a9885d4463deecc11361c48c1ad81d4c352f45 Mon Sep 17 00:00:00 2001 From: baiqing Date: Wed, 29 Apr 2026 13:02:58 +0800 Subject: [PATCH] =?UTF-8?q?feat(settings):=20unified=20=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=20Tab=20+=20add=20new=20ASR=20presets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 合并 LLM Provider Tab 与 ASR Provider Tab 到同一个「配置」页: 顶部 LLM 模型 chip 行 + 表单,下方 ASR 语音 chip 行 + provider 专属字段, 底部 borderedProminent .large「保存」按钮统一提交。 - 切 chip 不再立即写盘——所有改动在内存草稿里积攒,点保存才写 vault / UserDefaults 并 post .openLessCredentialsChanged。 - 火山引擎 3 字段(APP ID / Access Token / Resource ID)从 SettingsHubTab 搬到 「配置」页 ASR 段;SettingsHubTab 简化为录音快捷键 / 授权 / 隐私 / 关于 + provider 概览。 - 侧边栏顺序调整为 首页 → 历史记录 → 词汇表 → 风格 → 配置 → 设置 → 帮助中心, 「配置」紧靠「设置」之上。 - ASRProviderRegistry 新增两条预设: * aliyun-paraformer (DashScope 流式 paraformer-realtime-v2) * custom-openai-whisper (OpenAI 兼容批量 Whisper API) 这两类 provider 的 chip 与表单已就绪;运行期具体识别由并行分支补齐。 - 删除 LLMProvidersTab.swift / ASRProvidersTab.swift;AddLLMProviderSheet 搬到 ProvidersConfigTab.swift 同文件。 --- .../OpenLessApp/Settings/SettingsView.swift | 20 +- .../Settings/Tabs/ASRProvidersTab.swift | 248 ---- .../Settings/Tabs/LLMProvidersTab.swift | 645 ---------- .../Settings/Tabs/ProvidersConfigTab.swift | 1089 +++++++++++++++++ .../Settings/Tabs/SettingsHubTab.swift | 82 +- .../OpenLessCore/ASRProviderRegistry.swift | 16 +- .../ASRProviderRegistryTests.swift | 37 +- 7 files changed, 1171 insertions(+), 966 deletions(-) delete mode 100644 Sources/OpenLessApp/Settings/Tabs/ASRProvidersTab.swift delete mode 100644 Sources/OpenLessApp/Settings/Tabs/LLMProvidersTab.swift create mode 100644 Sources/OpenLessApp/Settings/Tabs/ProvidersConfigTab.swift diff --git a/Sources/OpenLessApp/Settings/SettingsView.swift b/Sources/OpenLessApp/Settings/SettingsView.swift index b646ec8f..ef9199b1 100644 --- a/Sources/OpenLessApp/Settings/SettingsView.swift +++ b/Sources/OpenLessApp/Settings/SettingsView.swift @@ -15,10 +15,9 @@ enum OpenLessMainTab: String, CaseIterable, Identifiable { case history case dictionary case polish - case asr - case llm - case help + case providersConfig case settings + case help var id: String { rawValue } @@ -28,10 +27,9 @@ enum OpenLessMainTab: String, CaseIterable, Identifiable { case .history: return "历史记录" case .dictionary: return "词汇表" case .polish: return "风格" - case .asr: return "ASR" - case .llm: return "LLM Provider" - case .help: return "帮助中心" + case .providersConfig: return "配置" case .settings: return "设置" + case .help: return "帮助中心" } } @@ -41,10 +39,9 @@ enum OpenLessMainTab: String, CaseIterable, Identifiable { case .history: return "clock" case .dictionary: return "text.book.closed" case .polish: return "paintpalette" - case .asr: return "waveform" - case .llm: return "wand.and.stars" - case .help: return "questionmark.circle" + case .providersConfig: return "slider.horizontal.3" case .settings: return "gearshape" + case .help: return "questionmark.circle" } } } @@ -74,10 +71,9 @@ struct SettingsView: View { case .history: HistoryTab() case .dictionary: DictionaryTab() case .polish: StyleTab() - case .asr: ASRProvidersTab() - case .llm: LLMProvidersTab() - case .help: HelpTab() + case .providersConfig: ProvidersConfigTab() case .settings: SettingsHubTab() + case .help: HelpTab() } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) diff --git a/Sources/OpenLessApp/Settings/Tabs/ASRProvidersTab.swift b/Sources/OpenLessApp/Settings/Tabs/ASRProvidersTab.swift deleted file mode 100644 index 8bedca80..00000000 --- a/Sources/OpenLessApp/Settings/Tabs/ASRProvidersTab.swift +++ /dev/null @@ -1,248 +0,0 @@ -import AppKit -import SwiftUI -import OpenLessCore -import OpenLessPersistence - -/// ASR Provider 设置 Tab:横向 chip 切换 active provider,下方显示当前选项的提示 -/// 与"凭据填在哪里"的指引;底部一个明显的主操作「保存」按钮把 selection 持久化。 -/// -/// 设计要点: -/// - 状态来源是 `CredentialsVault.shared`:所有读 / 写都直接打 vault,UI 是无状态视图层。 -/// - 与 `LLMProvidersTab` 的差异:ASR 各家协议字段差异大(火山 3 字段 / Apple Speech 0 字段), -/// 不能像 LLM 那样共用一份表单——所以这里只承担"切 active",具体字段: -/// * 火山引擎 → 跳「设置」Tab 的 ASR 字段段 -/// * Apple Speech → 无字段,仅提示权限会被系统询问 -/// - 「保存」按钮采用主操作样式(borderedProminent),让用户看见明确的提交动作。 -/// 切 chip 不立即写盘,避免误触;只有点击保存才会落到 credentials.json 里。 -@MainActor -struct ASRProvidersTab: View { - @StateObject private var model = ASRProvidersModel() - @State private var saved = false - - var body: some View { - SettingsPage( - title: "ASR Provider", - subtitle: "选择哪家语音识别引擎负责把你的语音转成文字。火山引擎更准但要 API key;macOS 本地 (Apple Speech) 完全离线、零配置,第一次切换时系统会请求语音识别权限。" - ) { - GlassSection(title: "选择 ASR", symbol: "waveform") { - providerChips - DividerLine() - Text(model.helpTextForSelection) - .font(.footnote) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - .padding(.top, 4) - } - - GlassSection(title: detailSectionTitle, symbol: detailSectionSymbol) { - providerDetail - } - - PrimaryActionRow { - Button { - model.commitSelection() - flashSaved() - } label: { - Label("保存", systemImage: "checkmark.circle.fill") - } - .keyboardShortcut(.defaultAction) - .buttonStyle(.borderedProminent) - .controlSize(.large) - .disabled(!model.hasUnsavedChanges) - - if saved { - Label("已保存", systemImage: "checkmark.circle.fill") - .foregroundStyle(.green) - } - } - } - .onAppear { model.load() } - .onReceive(NotificationCenter.default.publisher(for: .openLessCredentialsChanged)) { _ in - model.load() - } - } - - // MARK: - Chip row - - private var providerChips: some View { - HStack(spacing: 10) { - ForEach(ASRProviderRegistry.presets, id: \.providerId) { preset in - ASRProviderChip( - preset: preset, - isSelected: model.selectedProviderId == preset.providerId, - isActive: model.activeProviderId == preset.providerId, - onSelect: { model.selectProvider(preset.providerId) } - ) - } - Spacer() - } - .padding(.vertical, 4) - } - - // MARK: - Detail section - - private var detailSectionTitle: String { - if let preset = ASRProviderRegistry.preset(for: model.selectedProviderId) { - return preset.displayName - } - return model.selectedProviderId - } - - private var detailSectionSymbol: String { - switch model.selectedProviderId { - case "apple-speech": return "lock.shield" - case "volcengine": return "key" - default: return "info.circle" - } - } - - @ViewBuilder - private var providerDetail: some View { - switch model.selectedProviderId { - case "apple-speech": - appleSpeechDetail - case "volcengine": - volcengineDetail - default: - Text("未知 ASR provider:\(model.selectedProviderId)") - .font(.callout) - .foregroundStyle(.secondary) - } - } - - private var appleSpeechDetail: some View { - VStack(alignment: .leading, spacing: 12) { - detailLine("无需配置 API key —— Apple Speech 完全由 macOS 系统提供。", symbol: "checkmark.seal") - detailLine("中文 (zh-CN) 在 Apple Silicon 上支持完全离线识别;其他语言或 Intel 机器会回退到 Apple 云端。", symbol: "globe") - detailLine("第一次切换并开始录音时,系统会弹窗请求「语音识别」权限——选择允许即可。", symbol: "hand.raised.fill") - detailLine("保存后,下一次按下录音键就会通过 Apple Speech 识别。", symbol: "play.circle.fill") - } - .padding(.vertical, 4) - } - - private var volcengineDetail: some View { - VStack(alignment: .leading, spacing: 12) { - detailLine("火山引擎使用云端流式识别,准确度高,需要在控制台获取 App ID / Access Token / Resource ID。", symbol: "cloud") - detailLine("具体凭据字段填在 「设置」Tab 的「火山引擎大模型流式 ASR」段。", symbol: "gearshape") - detailLine("缺少凭据时本 Tab 选中火山引擎依然能保存——但下一次录音会回落到演示模式。", symbol: "exclamationmark.triangle") - } - .padding(.vertical, 4) - } - - private func detailLine(_ text: String, symbol: String) -> some View { - HStack(alignment: .top, spacing: 10) { - Image(systemName: symbol) - .foregroundStyle(.secondary) - .frame(width: 20) - .padding(.top, 2) - Text(text) - .font(.callout) - .foregroundStyle(.primary) - .fixedSize(horizontal: false, vertical: true) - Spacer() - } - } - - // MARK: - 提示反馈 - - private func flashSaved() { - saved = true - DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { saved = false } - } -} - -// MARK: - Chip - -@MainActor -private struct ASRProviderChip: View { - let preset: ASRProviderRegistry.Preset - let isSelected: Bool - let isActive: Bool - let onSelect: () -> Void - - var body: some View { - Button(action: onSelect) { - HStack(spacing: 8) { - Image(systemName: chipSymbol) - .symbolRenderingMode(.hierarchical) - Text(preset.displayName) - .font(.system(size: 13, weight: isSelected ? .semibold : .regular)) - if isActive { - Text("当前") - .font(.caption2) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background( - RoundedRectangle(cornerRadius: 4, style: .continuous) - .fill(Color.green.opacity(0.18)) - ) - .foregroundStyle(.green) - } - } - .padding(.horizontal, 14) - .padding(.vertical, 9) - .background( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .fill(isSelected ? Color.blue.opacity(0.15) : Color.primary.opacity(0.04)) - ) - .overlay( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .strokeBorder(isSelected ? Color.blue.opacity(0.6) : Color.primary.opacity(0.12), lineWidth: isSelected ? 1.5 : 1) - ) - .foregroundStyle(isSelected ? .primary : .secondary) - } - .buttonStyle(.plain) - .help(preset.helpText) - } - - private var chipSymbol: String { - switch preset.providerId { - case "apple-speech": return "applelogo" - case "volcengine": return "cloud.fill" - default: return "waveform" - } - } -} - -// MARK: - Model - -@MainActor -final class ASRProvidersModel: ObservableObject { - @Published private(set) var configuredIds: [String] = [] - @Published private(set) var activeProviderId: String = defaultActiveASRProviderId - @Published private(set) var selectedProviderId: String = defaultActiveASRProviderId - - /// 用户切了 chip 但还没点保存。保存按钮的 enable 状态依赖这个。 - var hasUnsavedChanges: Bool { - selectedProviderId != activeProviderId - } - - var helpTextForSelection: String { - ASRProviderRegistry.preset(for: selectedProviderId)?.helpText - ?? "未知 ASR provider;请联系开发者或检查 ~/.openless/credentials.json。" - } - - func load() { - let vault = CredentialsVault.shared - configuredIds = vault.configuredASRProviderIds - activeProviderId = vault.activeASRProviderId - // 第一次加载或 active 不在列表里时,selectedProviderId 跟随 active; - // 用户切换过 selected 后保留用户选择直到保存或离开。 - if !configuredIds.contains(selectedProviderId) { - selectedProviderId = activeProviderId - } - } - - func selectProvider(_ providerId: String) { - selectedProviderId = providerId - } - - /// 把当前 selection 写回 vault;触发凭据变更通知,让 DictationCoordinator 等订阅方刷新缓存。 - func commitSelection() { - let vault = CredentialsVault.shared - guard vault.activeASRProviderId != selectedProviderId else { return } - vault.activeASRProviderId = selectedProviderId - activeProviderId = selectedProviderId - NotificationCenter.default.post(name: .openLessCredentialsChanged, object: nil) - } -} diff --git a/Sources/OpenLessApp/Settings/Tabs/LLMProvidersTab.swift b/Sources/OpenLessApp/Settings/Tabs/LLMProvidersTab.swift deleted file mode 100644 index c21e86e2..00000000 --- a/Sources/OpenLessApp/Settings/Tabs/LLMProvidersTab.swift +++ /dev/null @@ -1,645 +0,0 @@ -import AppKit -import SwiftUI -import OpenLessCore -import OpenLessPersistence - -/// LLM Provider 设置 Tab:顶部 picker 切 active provider,下方表单编辑当前 provider 字段。 -/// -/// 设计要点: -/// - 状态来源是 `CredentialsVault.shared`:所有读 / 写都直接打 vault,UI 是无状态视图层。 -/// - 表单字段编辑后用 0.5s debounce 写盘,避免每按一个键就触发文件 IO 和通知风暴。 -/// - 任何写盘成功都 post `.openLessCredentialsChanged`,让 DictationCoordinator 等订阅方刷新缓存。 -/// - 删除按钮在 active provider 上禁用;用户必须先切走才能删。 -@MainActor -struct LLMProvidersTab: View { - @StateObject private var model = LLMProvidersModel() - @State private var showingAddSheet = false - @State private var showingDeleteConfirm = false - @State private var lastSavedFlash = false - - var body: some View { - SettingsPage( - title: "LLM Provider", - subtitle: "选择哪家大模型负责把你的口述整理成最终文本。任何 OpenAI 兼容协议的供应商都能用。" - ) { - GlassSection(title: "当前 Provider", symbol: "wand.and.stars") { - SettingsRow(title: "Active") { - activePicker - } - DividerLine() - Text(activeHint) - .font(.footnote) - .foregroundStyle(.secondary) - .padding(.top, 4) - } - - providerForm - - if lastSavedFlash { - HStack { - Spacer() - Label("已保存", systemImage: "checkmark.circle.fill") - .foregroundStyle(.green) - } - } - } - .onAppear { model.load() } - .onReceive(NotificationCenter.default.publisher(for: .openLessCredentialsChanged)) { _ in - // 别的窗口 / Coordinator 改了凭据时刷新视图,避免显示过时数据。 - model.load() - } - .sheet(isPresented: $showingAddSheet) { - AddLLMProviderSheet( - isPresented: $showingAddSheet, - existingIds: Set(model.configuredIds), - onAdd: { providerId, displayName in - model.addProvider(providerId: providerId, displayName: displayName) - flashSaved() - } - ) - } - .alert("删除此 provider?", isPresented: $showingDeleteConfirm) { - Button("取消", role: .cancel) {} - Button("删除", role: .destructive) { - model.deleteSelected() - flashSaved() - } - } message: { - Text("删除后该 provider 的 API Key、baseURL、Model 都会从本机 credentials.json 移除。") - } - } - - // MARK: - Active picker - - private var activePicker: some View { - HStack(spacing: 10) { - Picker("Provider", selection: Binding( - get: { model.selectedProviderId }, - set: { newValue in - model.selectProvider(newValue) - flashSaved() - } - )) { - ForEach(model.configuredIds, id: \.self) { id in - Text(model.displayName(for: id)).tag(id) - } - } - .labelsHidden() - .pickerStyle(.menu) - .frame(width: 260, alignment: .leading) - - Button { - showingAddSheet = true - } label: { - Label("添加", systemImage: "plus") - } - .buttonStyle(.bordered) - .controlSize(.small) - .help("添加另一个 LLM provider") - } - } - - private var activeHint: String { - if let preset = LLMProviderRegistry.preset(for: model.selectedProviderId) { - return "\(preset.displayName) · \(preset.defaultBaseURL.absoluteString)" - } - return "自定义 OpenAI 兼容 provider" - } - - // MARK: - Provider form - - @ViewBuilder - private var providerForm: some View { - let preset = LLMProviderRegistry.preset(for: model.selectedProviderId) - let isCustom = preset == nil - let isActive = model.selectedProviderId == model.activeProviderId - - GlassSection(title: providerFormTitle, symbol: "key") { - // Display name:自定义可改;预设给出 read-only 提示。 - SettingsRow(title: "名称") { - if isCustom { - TextField("展示名", text: Binding( - get: { model.draft.displayName }, - set: { newValue in - model.updateDisplayName(newValue) - flashSavedDebounced() - } - )) - .textFieldStyle(.roundedBorder) - .frame(maxWidth: 390) - } else { - Text(preset?.displayName ?? model.selectedProviderId) - .foregroundStyle(.secondary) - } - } - DividerLine() - - SettingsRow(title: "API Key") { - PasteableCredentialField( - placeholder: model.draft.apiKey.isEmpty ? "请填入 API Key" : "Bearer Token", - secure: true, - text: Binding( - get: { model.draft.apiKey }, - set: { newValue in - model.updateApiKey(newValue) - flashSavedDebounced() - } - ) - ) - } - DividerLine() - - SettingsRow(title: "Base URL") { - HStack(spacing: 8) { - PasteableCredentialField( - placeholder: preset?.defaultBaseURL.absoluteString ?? "https://api.example.com/v1", - secure: false, - text: Binding( - get: { model.draft.baseURL }, - set: { newValue in - model.updateBaseURL(newValue) - flashSavedDebounced() - } - ) - ) - if let preset, model.draft.baseURL == preset.defaultBaseURL.absoluteString || model.draft.baseURL.isEmpty { - baseURLDefaultBadge - } - } - } - DividerLine() - - SettingsRow(title: "Model") { - PasteableCredentialField( - placeholder: preset?.defaultModel.isEmpty == false ? preset!.defaultModel : "endpoint id / model name", - secure: false, - text: Binding( - get: { model.draft.model }, - set: { newValue in - model.updateModel(newValue) - flashSavedDebounced() - } - ) - ) - } - DividerLine() - - SettingsRow(title: "Temperature") { - temperatureControl - } - - if let preset { - DividerLine() - presetHelpDisclosure(preset) - } - } - - // 删除按钮独占一行,用 destructive 强调;active 时禁用并提示。 - HStack { - Spacer() - Button(role: .destructive) { - showingDeleteConfirm = true - } label: { - Label("删除此 provider", systemImage: "trash") - } - .buttonStyle(.bordered) - .disabled(isActive) - .help(isActive ? "当前 active provider 不能删除——先切到别的 provider" : "从本机移除该 provider 的所有字段") - } - } - - private var providerFormTitle: String { - if let preset = LLMProviderRegistry.preset(for: model.selectedProviderId) { - return preset.displayName - } - if !model.draft.displayName.isEmpty { - return model.draft.displayName - } - return "自定义 Provider (\(model.selectedProviderId))" - } - - private var baseURLDefaultBadge: some View { - Text("默认") - .font(.caption2) - .foregroundStyle(.secondary) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background( - RoundedRectangle(cornerRadius: 4, style: .continuous) - .fill(Color.primary.opacity(0.06)) - ) - } - - private var temperatureControl: some View { - HStack(spacing: 12) { - Slider(value: Binding( - get: { model.draft.temperature }, - set: { newValue in - model.updateTemperature(newValue) - flashSavedDebounced() - } - ), in: 0.0...1.0, step: 0.05) - .frame(width: 220) - Text(String(format: "%.2f", model.draft.temperature)) - .font(.system(size: 13, design: .monospaced)) - .foregroundStyle(.secondary) - .frame(width: 44, alignment: .leading) - } - } - - private func presetHelpDisclosure(_ preset: LLMProviderRegistry.Preset) -> some View { - DisclosureGroup { - VStack(alignment: .leading, spacing: 8) { - Text(preset.helpText) - .font(.callout) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - if let docsURL = preset.docsURL { - Button { - NSWorkspace.shared.open(docsURL) - } label: { - Label("打开 \(docsURL.host ?? "API Key 页")", systemImage: "arrow.up.right.square") - } - .buttonStyle(.bordered) - .controlSize(.small) - } - } - .padding(.top, 6) - } label: { - Label("怎么获取 API Key?", systemImage: "questionmark.circle") - .font(.callout) - .foregroundStyle(.primary) - } - .padding(.vertical, 6) - } - - // MARK: - 提示反馈 - - private func flashSaved() { - lastSavedFlash = true - DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { lastSavedFlash = false } - } - - /// 防止"每按一个键就闪一次"——表单字段编辑用更短的 debounce。 - private func flashSavedDebounced() { - // 模型自身已经在 0.5s debounce 之后写盘;这里仅仅控制视觉提示节奏。 - lastSavedFlash = true - DispatchQueue.main.asyncAfter(deadline: .now() + 0.9) { lastSavedFlash = false } - } -} - -// MARK: - Model - -/// LLM Providers 视图模型:管理 picker 选择 / 草稿表单 / 保存到 vault。 -@MainActor -final class LLMProvidersModel: ObservableObject { - @Published private(set) var configuredIds: [String] = [] - @Published private(set) var activeProviderId: String = defaultActiveLLMProviderId - @Published private(set) var selectedProviderId: String = defaultActiveLLMProviderId - @Published var draft = LLMProviderDraft() - - /// 表单 debounce:防止每按一个键就写一次 credentials.json。 - private var saveTask: Task? - - func load() { - let vault = CredentialsVault.shared - configuredIds = vault.configuredLLMProviderIds - activeProviderId = vault.activeLLMProviderId - // 第一次加载或 active 不在列表里时,selectedProviderId 跟随 active; - // 用户切换过 selected 后保留用户选择。 - if selectedProviderId.isEmpty || !configuredIds.contains(selectedProviderId) { - selectedProviderId = activeProviderId - } - loadDraftFromVault() - } - - func displayName(for providerId: String) -> String { - if let preset = LLMProviderRegistry.preset(for: providerId) { - return preset.displayName - } - // 自定义 provider:从 vault 读 displayName。 - let vault = CredentialsVault.shared - if let cfg = vault.llmProviderConfig(for: providerId), !cfg.displayName.isEmpty { - return cfg.displayName - } - return providerId - } - - func selectProvider(_ providerId: String) { - flushDraftIfDirty() - selectedProviderId = providerId - loadDraftFromVault() - - // 选 active 应该立即生效——但只有当用户选择的是已存在的 provider 时。 - let vault = CredentialsVault.shared - if vault.activeLLMProviderId != providerId { - vault.activeLLMProviderId = providerId - activeProviderId = providerId - NotificationCenter.default.post(name: .openLessCredentialsChanged, object: nil) - } - configuredIds = vault.configuredLLMProviderIds - } - - func addProvider(providerId: String, displayName: String) { - let vault = CredentialsVault.shared - let preset = LLMProviderRegistry.preset(for: providerId) - let baseURL = preset?.defaultBaseURL ?? URL(string: "https://api.example.com/v1")! - let model = preset?.defaultModel ?? "" - let resolvedDisplayName = displayName.isEmpty ? (preset?.displayName ?? providerId) : displayName - - let cfg = OpenAICompatibleConfig( - providerId: providerId, - displayName: resolvedDisplayName, - baseURL: baseURL, - apiKey: "", - model: model, - extraHeaders: [:], - temperature: 0.3 - ) - vault.setLLMProviderConfig(cfg) - // 切到新加的 provider 让用户立即填字段。 - vault.activeLLMProviderId = providerId - NotificationCenter.default.post(name: .openLessCredentialsChanged, object: nil) - load() - selectedProviderId = providerId - loadDraftFromVault() - } - - func deleteSelected() { - let vault = CredentialsVault.shared - guard selectedProviderId != activeProviderId else { return } - do { - try vault.removeLLMProvider(selectedProviderId) - } catch { - // 删除失败不阻塞 UI;下一次 load 会重新拉。 - } - NotificationCenter.default.post(name: .openLessCredentialsChanged, object: nil) - load() - selectedProviderId = activeProviderId - loadDraftFromVault() - } - - // MARK: - Draft 字段更新(debounce 写盘) - - func updateDisplayName(_ value: String) { - draft.displayName = value - scheduleSave() - } - - func updateApiKey(_ value: String) { - draft.apiKey = value - scheduleSave() - } - - func updateBaseURL(_ value: String) { - draft.baseURL = value - scheduleSave() - } - - func updateModel(_ value: String) { - draft.model = value - scheduleSave() - } - - func updateTemperature(_ value: Double) { - draft.temperature = value - scheduleSave() - } - - // MARK: - 私有 - - private func loadDraftFromVault() { - let vault = CredentialsVault.shared - let cfg = vault.llmProviderConfig(for: selectedProviderId) - let preset = LLMProviderRegistry.preset(for: selectedProviderId) - - draft.displayName = cfg?.displayName ?? preset?.displayName ?? selectedProviderId - draft.apiKey = cfg?.apiKey ?? "" - draft.baseURL = cfg?.baseURL.absoluteString - ?? preset?.defaultBaseURL.absoluteString - ?? "" - draft.model = cfg?.model ?? preset?.defaultModel ?? "" - draft.temperature = cfg?.temperature ?? 0.3 - } - - private func scheduleSave() { - saveTask?.cancel() - saveTask = Task { @MainActor [weak self] in - try? await Task.sleep(nanoseconds: 500_000_000) - if Task.isCancelled { return } - self?.flushDraftIfDirty() - } - } - - private func flushDraftIfDirty() { - let vault = CredentialsVault.shared - let trimmedBaseURL = draft.baseURL.trimmingCharacters(in: .whitespacesAndNewlines) - let preset = LLMProviderRegistry.preset(for: selectedProviderId) - let baseURL: URL - if let parsed = URL(string: trimmedBaseURL), !trimmedBaseURL.isEmpty, parsed.scheme != nil { - baseURL = parsed - } else if let preset { - baseURL = preset.defaultBaseURL - } else { - // 自定义 + 无效 baseURL:暂不写盘,等用户填完。 - return - } - - let cfg = OpenAICompatibleConfig( - providerId: selectedProviderId, - displayName: draft.displayName, - baseURL: baseURL, - apiKey: draft.apiKey, - model: draft.model, - extraHeaders: [:], - temperature: draft.temperature - ) - vault.setLLMProviderConfig(cfg) - NotificationCenter.default.post(name: .openLessCredentialsChanged, object: nil) - } -} - -/// 编辑中的 provider 表单字段。`OpenAICompatibleConfig` 用 URL 不允许中间态非法字符串, -/// 所以表单层用 String 草稿;保存前再 try URL。 -struct LLMProviderDraft { - var displayName: String = "" - var apiKey: String = "" - var baseURL: String = "" - var model: String = "" - var temperature: Double = 0.3 -} - -// MARK: - Add Provider Sheet - -/// 添加 provider 的 sheet:列出所有预设 + "自定义";选 "自定义" 时多一步收集 slug + displayName。 -@MainActor -struct AddLLMProviderSheet: View { - @Binding var isPresented: Bool - let existingIds: Set - let onAdd: (_ providerId: String, _ displayName: String) -> Void - - @State private var customId: String = "" - @State private var customDisplayName: String = "" - @State private var step: Step = .pick - - enum Step { - case pick - case customDetails - } - - var body: some View { - VStack(alignment: .leading, spacing: 0) { - HStack { - Text(step == .pick ? "添加 LLM Provider" : "自定义 OpenAI 兼容 Provider") - .font(.system(size: 18, weight: .semibold)) - Spacer() - Button { - isPresented = false - } label: { - Image(systemName: "xmark.circle.fill") - .font(.system(size: 18)) - .foregroundStyle(.secondary) - } - .buttonStyle(.plain) - } - .padding(20) - - Divider() - - ScrollView { - switch step { - case .pick: - pickList - case .customDetails: - customDetailsForm - } - } - .frame(minHeight: 360, maxHeight: 480) - } - .frame(width: 520) - } - - private var pickList: some View { - VStack(alignment: .leading, spacing: 0) { - ForEach(LLMProviderRegistry.presets, id: \.providerId) { preset in - presetRow(preset) - Divider().padding(.leading, 16) - } - customRow - } - .padding(.vertical, 4) - } - - private func presetRow(_ preset: LLMProviderRegistry.Preset) -> some View { - let alreadyAdded = existingIds.contains(preset.providerId) - return Button { - guard !alreadyAdded else { return } - onAdd(preset.providerId, preset.displayName) - isPresented = false - } label: { - HStack(alignment: .top, spacing: 12) { - Image(systemName: alreadyAdded ? "checkmark.circle.fill" : "circle") - .foregroundStyle(alreadyAdded ? .green : .secondary) - .font(.system(size: 18)) - .padding(.top, 2) - VStack(alignment: .leading, spacing: 4) { - Text(preset.displayName) - .font(.system(size: 15, weight: .semibold)) - .foregroundStyle(.primary) - Text(preset.defaultBaseURL.absoluteString) - .font(.system(size: 12, design: .monospaced)) - .foregroundStyle(.secondary) - Text(preset.helpText) - .font(.callout) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - Spacer() - } - .padding(.horizontal, 18) - .padding(.vertical, 12) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .disabled(alreadyAdded) - .help(alreadyAdded ? "已添加" : "添加 \(preset.displayName)") - } - - private var customRow: some View { - Button { - step = .customDetails - } label: { - HStack(alignment: .top, spacing: 12) { - Image(systemName: "plus.circle") - .foregroundStyle(.blue) - .font(.system(size: 18)) - .padding(.top, 2) - VStack(alignment: .leading, spacing: 4) { - Text(LLMProviderRegistry.customDisplayName) - .font(.system(size: 15, weight: .semibold)) - .foregroundStyle(.primary) - Text("任何遵循 OpenAI Chat Completions 协议的供应商都能填进来——比如自建网关、私有化部署、或表里没列的云厂商。") - .font(.callout) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - Spacer() - } - .padding(.horizontal, 18) - .padding(.vertical, 12) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - } - - private var customDetailsForm: some View { - VStack(alignment: .leading, spacing: 16) { - VStack(alignment: .leading, spacing: 6) { - Text("Provider ID") - .font(.caption) - .foregroundStyle(.secondary) - TextField("custom-gateway", text: $customId) - .textFieldStyle(.roundedBorder) - Text("唯一 slug,建议小写字母 / 数字 / 短横线;不能与已存在的 id 重复。") - .font(.caption) - .foregroundStyle(.secondary) - } - VStack(alignment: .leading, spacing: 6) { - Text("展示名") - .font(.caption) - .foregroundStyle(.secondary) - TextField("公司内部网关", text: $customDisplayName) - .textFieldStyle(.roundedBorder) - } - - HStack { - Button("返回") { - step = .pick - } - .buttonStyle(.bordered) - Spacer() - Button("添加") { - let id = customId.trimmingCharacters(in: .whitespacesAndNewlines) - let name = customDisplayName.trimmingCharacters(in: .whitespacesAndNewlines) - onAdd(id, name) - isPresented = false - } - .keyboardShortcut(.defaultAction) - .buttonStyle(.borderedProminent) - .disabled(!isCustomReady) - } - .padding(.top, 6) - } - .padding(20) - } - - private var isCustomReady: Bool { - let id = customId.trimmingCharacters(in: .whitespacesAndNewlines) - let name = customDisplayName.trimmingCharacters(in: .whitespacesAndNewlines) - guard !id.isEmpty, !name.isEmpty else { return false } - // slug 不能撞预设 id 也不能撞已存在的条目。 - if LLMProviderRegistry.preset(for: id) != nil { return false } - if existingIds.contains(id) { return false } - return true - } -} diff --git a/Sources/OpenLessApp/Settings/Tabs/ProvidersConfigTab.swift b/Sources/OpenLessApp/Settings/Tabs/ProvidersConfigTab.swift new file mode 100644 index 00000000..cfb35092 --- /dev/null +++ b/Sources/OpenLessApp/Settings/Tabs/ProvidersConfigTab.swift @@ -0,0 +1,1089 @@ +import AppKit +import Foundation +import SwiftUI +import OpenLessCore +import OpenLessASR +import OpenLessPersistence + +/// 「配置」Tab:把 LLM 模型与 ASR 语音两类 provider 合并到一页编辑。 +/// +/// 与拆分前的 LLMProvidersTab / ASRProvidersTab 的关键差异: +/// - 切 chip 不立即写盘,所有改动都先放进内存草稿;底部一个统一「保存」按钮提交。 +/// - 当前 active provider 的字段编辑同样进入草稿;用户切回去就回滚。 +/// - 火山引擎 3 字段(APP ID / Access Token / Resource ID)从 SettingsHubTab 搬到这里。 +/// +/// 为什么不用嵌套 TabView:单 ScrollView 把两段(LLM + ASR)摆在同一页, +/// 用户一次能看见全部状态,无需在子 tab 之间反复跳。 +@MainActor +struct ProvidersConfigTab: View { + @StateObject private var model = ProvidersConfigModel() + @State private var savedFlash = false + + var body: some View { + SettingsPage( + title: "配置", + subtitle: "选择并填写 LLM 模型和语音识别(ASR)凭据。改完后点击底部「保存」生效。" + ) { + llmSection + asrSection + saveActionRow + } + .onAppear { model.load() } + .onReceive(NotificationCenter.default.publisher(for: .openLessCredentialsChanged)) { _ in + model.load() + } + } + + // MARK: - LLM Section + + private var llmSection: some View { + GlassSection(title: "LLM 模型", symbol: "wand.and.stars") { + VStack(alignment: .leading, spacing: 12) { + LLMProviderChipRow( + presets: LLMProviderRegistry.presets, + selectedProviderId: model.selectedLLMProviderId, + activeProviderId: model.activeLLMProviderId, + onSelect: { id in model.selectLLMProvider(id) }, + onAddCustom: { model.beginAddCustomLLM() } + ) + + Text(llmHint) + .font(.footnote) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.vertical, 4) + + DividerLine() + + llmForm + } + } + + private var llmHint: String { + if let preset = LLMProviderRegistry.preset(for: model.selectedLLMProviderId) { + return "\(preset.displayName) · \(preset.defaultBaseURL.absoluteString)" + } + return "自定义 OpenAI 兼容 provider · \(model.llmDraft.baseURL.isEmpty ? "请填写 Base URL" : model.llmDraft.baseURL)" + } + + @ViewBuilder + private var llmForm: some View { + let preset = LLMProviderRegistry.preset(for: model.selectedLLMProviderId) + let isCustom = preset == nil + + SettingsRow(title: "API Key") { + PasteableCredentialField( + placeholder: "Bearer Token", + secure: true, + text: Binding( + get: { model.llmDraft.apiKey }, + set: { model.updateLLMApiKey($0) } + ) + ) + } + DividerLine() + + SettingsRow(title: "Base URL") { + PasteableCredentialField( + placeholder: preset?.defaultBaseURL.absoluteString ?? "https://api.example.com/v1", + secure: false, + text: Binding( + get: { model.llmDraft.baseURL }, + set: { model.updateLLMBaseURL($0) } + ) + ) + } + DividerLine() + + SettingsRow(title: "Model") { + PasteableCredentialField( + placeholder: preset?.defaultModel.isEmpty == false ? preset!.defaultModel : "endpoint id / model name", + secure: false, + text: Binding( + get: { model.llmDraft.model }, + set: { model.updateLLMModel($0) } + ) + ) + } + DividerLine() + + SettingsRow(title: "Temperature") { + HStack(spacing: 12) { + Slider( + value: Binding( + get: { model.llmDraft.temperature }, + set: { model.updateLLMTemperature($0) } + ), + in: 0.0...1.0, + step: 0.05 + ) + .frame(width: 220) + Text(String(format: "%.2f", model.llmDraft.temperature)) + .font(.system(size: 13, design: .monospaced)) + .foregroundStyle(.secondary) + .frame(width: 44, alignment: .leading) + } + } + + if isCustom, !model.selectedLLMProviderId.isEmpty { + DividerLine() + HStack { + Spacer() + Button(role: .destructive) { + model.deleteSelectedLLM() + } label: { + Label("删除此 provider", systemImage: "trash") + } + .buttonStyle(.bordered) + .disabled(model.selectedLLMProviderId == model.activeLLMProviderId) + .help(model.selectedLLMProviderId == model.activeLLMProviderId ? "当前 active provider 不能删除——先切到别的 provider" : "从本机移除该 provider 的所有字段") + } + } + } + + // MARK: - ASR Section + + private var asrSection: some View { + GlassSection(title: "ASR 语音", symbol: "waveform") { + VStack(alignment: .leading, spacing: 12) { + ASRProviderChipRow( + presets: ASRProviderRegistry.presets, + selectedProviderId: model.selectedASRProviderId, + activeProviderId: model.activeASRProviderId, + onSelect: { id in model.selectASRProvider(id) } + ) + + Text(asrHint) + .font(.footnote) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.vertical, 4) + + DividerLine() + + asrForm + } + } + + private var asrHint: String { + ASRProviderRegistry.preset(for: model.selectedASRProviderId)?.helpText + ?? "未知 ASR provider;请选择左侧任一选项。" + } + + @ViewBuilder + private var asrForm: some View { + switch model.selectedASRProviderId { + case "volcengine": + volcengineForm + case "apple-speech": + appleSpeechInfo + case "aliyun-paraformer": + aliyunParaformerForm + case "custom-openai-whisper": + customWhisperForm + default: + Text("未知 ASR provider:\(model.selectedASRProviderId)") + .font(.callout) + .foregroundStyle(.secondary) + } + } + + private var volcengineForm: some View { + Group { + SettingsRow(title: "APP ID") { + PasteableCredentialField( + placeholder: "X-Api-App-Key", + secure: false, + text: Binding( + get: { model.volcDraft.appKey }, + set: { model.updateVolcAppKey($0) } + ) + ) + } + DividerLine() + SettingsRow(title: "Access Token") { + PasteableCredentialField( + placeholder: "X-Api-Access-Key", + secure: true, + text: Binding( + get: { model.volcDraft.accessKey }, + set: { model.updateVolcAccessKey($0) } + ) + ) + } + DividerLine() + SettingsRow(title: "Resource ID") { + PasteableCredentialField( + placeholder: "X-Api-Resource-Id", + secure: false, + text: Binding( + get: { model.volcDraft.resourceId }, + set: { model.updateVolcResourceId($0) } + ) + ) + } + } + } + + private var appleSpeechInfo: some View { + VStack(alignment: .leading, spacing: 12) { + asrInfoLine("无需配置 API key —— Apple Speech 完全由 macOS 系统提供。", symbol: "checkmark.seal") + asrInfoLine("中文 (zh-CN) 在 Apple Silicon 上支持完全离线识别;其他语言或 Intel 机器会回退到 Apple 云端。", symbol: "globe") + asrInfoLine("第一次切换并开始录音时,系统会弹窗请求「语音识别」权限——选择允许即可。", symbol: "hand.raised.fill") + } + .padding(.vertical, 4) + } + + private var aliyunParaformerForm: some View { + VStack(alignment: .leading, spacing: 12) { + SettingsRow(title: "API Key") { + PasteableCredentialField( + placeholder: model.aliyunDashScopeApiKeyAvailable ? "已与 LLM「阿里通义」共享" : "百炼控制台 sk-...", + secure: true, + text: Binding( + get: { model.aliyunParaformerDraft.apiKey }, + set: { model.updateAliyunParaformerApiKey($0) } + ) + ) + } + + if model.aliyunDashScopeApiKeyAvailable { + asrInfoLine("已自动从 LLM「阿里通义 (DashScope)」复用同一个 API Key;如需单独覆盖可直接编辑上方字段。", symbol: "link") + } else { + asrInfoLine("可在 LLM 区域选择并填入「阿里通义 (DashScope)」的 API Key,下次会自动复用,不必两边各填一遍。", symbol: "info.circle") + } + } + .padding(.vertical, 4) + } + + private var customWhisperForm: some View { + Group { + SettingsRow(title: "Base URL") { + PasteableCredentialField( + placeholder: "https://api.openai.com/v1", + secure: false, + text: Binding( + get: { model.customWhisperDraft.baseURL }, + set: { model.updateCustomWhisperBaseURL($0) } + ) + ) + } + DividerLine() + SettingsRow(title: "API Key") { + PasteableCredentialField( + placeholder: "sk-...", + secure: true, + text: Binding( + get: { model.customWhisperDraft.apiKey }, + set: { model.updateCustomWhisperApiKey($0) } + ) + ) + } + DividerLine() + SettingsRow(title: "Model") { + PasteableCredentialField( + placeholder: "whisper-1", + secure: false, + text: Binding( + get: { model.customWhisperDraft.model }, + set: { model.updateCustomWhisperModel($0) } + ) + ) + } + } + } + + private func asrInfoLine(_ text: String, symbol: String) -> some View { + HStack(alignment: .top, spacing: 10) { + Image(systemName: symbol) + .foregroundStyle(.secondary) + .frame(width: 20) + .padding(.top, 2) + Text(text) + .font(.callout) + .foregroundStyle(.primary) + .fixedSize(horizontal: false, vertical: true) + Spacer() + } + } + + // MARK: - Save + + private var saveActionRow: some View { + PrimaryActionRow { + Button { + model.commit() + flashSaved() + } label: { + Label(savedFlash ? "已保存" : "保存", systemImage: savedFlash ? "checkmark.circle.fill" : "tray.and.arrow.down.fill") + } + .keyboardShortcut(.defaultAction) + .buttonStyle(.borderedProminent) + .controlSize(.large) + .disabled(!model.hasUnsavedChanges) + } + .sheet(isPresented: $model.isShowingAddCustom) { + AddLLMProviderSheet( + isPresented: $model.isShowingAddCustom, + existingIds: Set(model.configuredLLMProviderIds), + onAdd: { providerId, displayName in + model.addLLMProvider(providerId: providerId, displayName: displayName) + } + ) + } + } + + private func flashSaved() { + savedFlash = true + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { savedFlash = false } + } +} + +// MARK: - Chip rows + +@MainActor +private struct LLMProviderChipRow: View { + let presets: [LLMProviderRegistry.Preset] + let selectedProviderId: String + let activeProviderId: String + let onSelect: (String) -> Void + let onAddCustom: () -> Void + + var body: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(presets, id: \.providerId) { preset in + ProviderChipButton( + title: preset.displayName, + symbol: chipSymbol(for: preset.providerId), + isSelected: selectedProviderId == preset.providerId, + isActive: activeProviderId == preset.providerId, + onSelect: { onSelect(preset.providerId) } + ) + } + // 自定义条目:当 selectedProviderId 不是预设时(用户加进来的 slug),独立画一颗 chip。 + if !selectedProviderId.isEmpty, + LLMProviderRegistry.preset(for: selectedProviderId) == nil { + ProviderChipButton( + title: "自定义 (\(selectedProviderId))", + symbol: "slider.horizontal.3", + isSelected: true, + isActive: activeProviderId == selectedProviderId, + onSelect: { onSelect(selectedProviderId) } + ) + } + Button(action: onAddCustom) { + Label("自定义", systemImage: "plus.circle") + .padding(.horizontal, 12) + .padding(.vertical, 9) + } + .buttonStyle(.bordered) + .controlSize(.small) + .help("添加自定义 OpenAI 兼容 LLM provider") + } + .padding(.vertical, 2) + } + } + + private func chipSymbol(for providerId: String) -> String { + switch providerId { + case "ark": return "sparkles" + case "openai": return "circle.hexagongrid.fill" + case "aliyun-dashscope": return "cloud.fill" + case "deepseek": return "fish" + case "moonshot": return "moon.stars" + default: return "wand.and.stars" + } + } +} + +@MainActor +private struct ASRProviderChipRow: View { + let presets: [ASRProviderRegistry.Preset] + let selectedProviderId: String + let activeProviderId: String + let onSelect: (String) -> Void + + var body: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(presets, id: \.providerId) { preset in + ProviderChipButton( + title: preset.displayName, + symbol: chipSymbol(for: preset.providerId), + isSelected: selectedProviderId == preset.providerId, + isActive: activeProviderId == preset.providerId, + onSelect: { onSelect(preset.providerId) } + ) + } + } + .padding(.vertical, 2) + } + } + + private func chipSymbol(for providerId: String) -> String { + switch providerId { + case "volcengine": return "cloud.fill" + case "apple-speech": return "applelogo" + case "aliyun-paraformer": return "waveform.badge.mic" + case "custom-openai-whisper": return "slider.horizontal.3" + default: return "waveform" + } + } +} + +@MainActor +private struct ProviderChipButton: View { + let title: String + let symbol: String + let isSelected: Bool + let isActive: Bool + let onSelect: () -> Void + + var body: some View { + Button(action: onSelect) { + HStack(spacing: 7) { + Image(systemName: symbol) + .symbolRenderingMode(.hierarchical) + Text(title) + .font(.system(size: 13, weight: isSelected ? .semibold : .regular)) + if isActive { + Text("当前") + .font(.caption2) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background( + RoundedRectangle(cornerRadius: 4, style: .continuous) + .fill(Color.green.opacity(0.18)) + ) + .foregroundStyle(.green) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 9) + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(isSelected ? Color.blue.opacity(0.15) : Color.primary.opacity(0.04)) + ) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .strokeBorder(isSelected ? Color.blue.opacity(0.6) : Color.primary.opacity(0.12), lineWidth: isSelected ? 1.5 : 1) + ) + .foregroundStyle(isSelected ? .primary : .secondary) + } + .buttonStyle(.plain) + } +} + +// MARK: - Drafts + +struct LLMConfigDraft: Equatable { + var apiKey: String = "" + var baseURL: String = "" + var model: String = "" + var temperature: Double = 0.3 + /// 自定义 provider 的展示名。预设条目从 registry 兜底,不暴露在 UI 上。 + var displayName: String = "" +} + +struct VolcengineDraft: Equatable { + var appKey: String = "" + var accessKey: String = "" + var resourceId: String = VolcengineCredentials.defaultResourceId +} + +struct AliyunParaformerDraft: Equatable { + var apiKey: String = "" +} + +struct CustomWhisperDraft: Equatable { + var baseURL: String = "" + var apiKey: String = "" + var model: String = "" +} + +// MARK: - Model + +/// 配置 Tab 的核心:把所有 LLM / ASR 改动都积攒在内存里,commit() 时一次性写盘。 +@MainActor +final class ProvidersConfigModel: ObservableObject { + // LLM + @Published private(set) var configuredLLMProviderIds: [String] = [] + @Published private(set) var activeLLMProviderId: String = defaultActiveLLMProviderId + @Published private(set) var selectedLLMProviderId: String = defaultActiveLLMProviderId + @Published var llmDraft = LLMConfigDraft() + /// 用户在草稿态下编辑过的所有 provider 配置(key 为 providerId)。 + /// 切到别的 provider 不会丢;commit 时一起写盘。 + private var pendingLLMConfigs: [String: LLMConfigDraft] = [:] + /// 用户在草稿态下"添加"的自定义 provider id 列表。commit 时若仍未填关键字段会被回滚。 + private var pendingNewLLMProviders: Set = [] + /// 用户在草稿态下「删除」的 provider id 列表。commit 时一并清掉。 + private var pendingDeletedLLMProviders: Set = [] + + @Published var isShowingAddCustom = false + + // ASR + @Published private(set) var activeASRProviderId: String = defaultActiveASRProviderId + @Published private(set) var selectedASRProviderId: String = defaultActiveASRProviderId + @Published var volcDraft = VolcengineDraft() + @Published var aliyunParaformerDraft = AliyunParaformerDraft() + @Published var customWhisperDraft = CustomWhisperDraft() + + /// 加载时记录的 ASR 三类字段值,作为「是否有未保存改动」的基线。 + /// LLM 改动直接与 vault 比较,因此不需要快照。 + private var initialSnapshot = Snapshot() + + private struct Snapshot: Equatable { + var volc: VolcengineDraft = VolcengineDraft() + var aliyunParaformer: AliyunParaformerDraft = AliyunParaformerDraft() + var customWhisper: CustomWhisperDraft = CustomWhisperDraft() + } + + /// 用户已经填好且尚未点保存的改动数。 + var hasUnsavedChanges: Bool { + // 1. active provider 切换 + if selectedLLMProviderId != activeLLMProviderId { return true } + if selectedASRProviderId != activeASRProviderId { return true } + + // 2. 当前编辑的 LLM 草稿与 vault 不一致 + if !isCurrentLLMDraftClean() { return true } + + // 3. pending 集合不空(之前切走过的草稿、新增、删除) + if !pendingDeletedLLMProviders.isEmpty { return true } + if !pendingNewLLMProviders.isEmpty { return true } + for (id, draft) in pendingLLMConfigs where id != selectedLLMProviderId { + if !isLLMDraftClean(providerId: id, draft: draft) { return true } + } + + // 4. ASR 字段改动 + if volcDraft != initialSnapshot.volc { return true } + if aliyunParaformerDraft != initialSnapshot.aliyunParaformer { return true } + if customWhisperDraft != initialSnapshot.customWhisper { return true } + + return false + } + + /// 当前 selected provider 的草稿是否与 vault 一致(无未保存改动)。 + private func isCurrentLLMDraftClean() -> Bool { + isLLMDraftClean(providerId: selectedLLMProviderId, draft: llmDraft) + } + + private func isLLMDraftClean(providerId: String, draft: LLMConfigDraft) -> Bool { + let vaultDraft = vaultLLMDraft(for: providerId) + return draft == vaultDraft + } + + /// 直接读 vault 构造 baseline 草稿;不修改任何状态。 + private func vaultLLMDraft(for providerId: String) -> LLMConfigDraft { + let vault = CredentialsVault.shared + let cfg = vault.llmProviderConfig(for: providerId) + let preset = LLMProviderRegistry.preset(for: providerId) + var d = LLMConfigDraft() + d.apiKey = cfg?.apiKey ?? "" + d.baseURL = cfg?.baseURL.absoluteString ?? preset?.defaultBaseURL.absoluteString ?? "" + d.model = cfg?.model ?? preset?.defaultModel ?? "" + d.temperature = cfg?.temperature ?? 0.3 + d.displayName = cfg?.displayName ?? preset?.displayName ?? providerId + return d + } + + var aliyunDashScopeApiKeyAvailable: Bool { + if let cfg = pendingLLMConfigs["aliyun-dashscope"] { + return !cfg.apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + let vault = CredentialsVault.shared + if let cfg = vault.llmProviderConfig(for: "aliyun-dashscope") { + return !cfg.apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + return false + } + + // MARK: - Load + + func load() { + let vault = CredentialsVault.shared + configuredLLMProviderIds = vault.configuredLLMProviderIds + activeLLMProviderId = vault.activeLLMProviderId + activeASRProviderId = vault.activeASRProviderId + + if !configuredLLMProviderIds.contains(selectedLLMProviderId) { + selectedLLMProviderId = activeLLMProviderId + } + if !ASRProviderRegistry.presets.contains(where: { $0.providerId == selectedASRProviderId }) { + selectedASRProviderId = activeASRProviderId + } + + pendingLLMConfigs = [:] + pendingNewLLMProviders = [] + pendingDeletedLLMProviders = [] + + // 把当前 selected 的草稿从 vault 拉出来。 + loadLLMDraftFromVault(for: selectedLLMProviderId) + + // ASR 三类草稿全部从 vault / UserDefaults 读出来。 + loadVolcDraftFromVault() + loadAliyunParaformerDraft() + loadCustomWhisperDraft() + + initialSnapshot = baselineSnapshot() + } + + /// 加载时记录的 ASR 字段基线。LLM 改动直接和 vault 比较。 + private func baselineSnapshot() -> Snapshot { + var s = Snapshot() + s.volc = volcDraft + s.aliyunParaformer = aliyunParaformerDraft + s.customWhisper = customWhisperDraft + return s + } + + // MARK: - LLM selection / form + + func selectLLMProvider(_ providerId: String) { + // 切走前先把当前草稿快照存起来(commit 时会用到)。 + stashCurrentLLMDraft() + selectedLLMProviderId = providerId + loadLLMDraftFromVault(for: providerId) + } + + func updateLLMApiKey(_ value: String) { + llmDraft.apiKey = value + } + + func updateLLMBaseURL(_ value: String) { + llmDraft.baseURL = value + } + + func updateLLMModel(_ value: String) { + llmDraft.model = value + } + + func updateLLMTemperature(_ value: Double) { + llmDraft.temperature = value + } + + func updateLLMDisplayName(_ value: String) { + llmDraft.displayName = value + } + + func beginAddCustomLLM() { + isShowingAddCustom = true + } + + /// AddLLMProviderSheet 回调:内存里登记一个新 provider,切到它,等待用户填字段。 + func addLLMProvider(providerId: String, displayName: String) { + let trimmedId = providerId.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedId.isEmpty else { return } + stashCurrentLLMDraft() + + var draft = LLMConfigDraft() + let preset = LLMProviderRegistry.preset(for: trimmedId) + draft.displayName = displayName.isEmpty ? (preset?.displayName ?? trimmedId) : displayName + draft.baseURL = preset?.defaultBaseURL.absoluteString ?? "" + draft.model = preset?.defaultModel ?? "" + pendingLLMConfigs[trimmedId] = draft + pendingNewLLMProviders.insert(trimmedId) + pendingDeletedLLMProviders.remove(trimmedId) + + if !configuredLLMProviderIds.contains(trimmedId) { + configuredLLMProviderIds.append(trimmedId) + configuredLLMProviderIds.sort() + } + selectedLLMProviderId = trimmedId + llmDraft = draft + } + + func deleteSelectedLLM() { + let id = selectedLLMProviderId + guard id != activeLLMProviderId else { return } + guard LLMProviderRegistry.preset(for: id) == nil else { return } // 预设不允许删 + pendingDeletedLLMProviders.insert(id) + pendingNewLLMProviders.remove(id) + pendingLLMConfigs.removeValue(forKey: id) + configuredLLMProviderIds.removeAll { $0 == id } + selectedLLMProviderId = activeLLMProviderId + loadLLMDraftFromVault(for: selectedLLMProviderId) + } + + // MARK: - ASR selection / form + + func selectASRProvider(_ providerId: String) { + selectedASRProviderId = providerId + } + + func updateVolcAppKey(_ value: String) { + volcDraft.appKey = value + } + + func updateVolcAccessKey(_ value: String) { + volcDraft.accessKey = value + } + + func updateVolcResourceId(_ value: String) { + volcDraft.resourceId = value + } + + func updateAliyunParaformerApiKey(_ value: String) { + aliyunParaformerDraft.apiKey = value + } + + func updateCustomWhisperBaseURL(_ value: String) { + customWhisperDraft.baseURL = value + } + + func updateCustomWhisperApiKey(_ value: String) { + customWhisperDraft.apiKey = value + } + + func updateCustomWhisperModel(_ value: String) { + customWhisperDraft.model = value + } + + // MARK: - Commit + + /// 一次性把所有改动写盘。 + func commit() { + stashCurrentLLMDraft() + + let vault = CredentialsVault.shared + + // LLM:先删要删的;再写所有 pending;最后切 active。 + for id in pendingDeletedLLMProviders { + try? vault.removeLLMProvider(id) + } + for (providerId, draft) in pendingLLMConfigs { + persistLLMDraft(providerId: providerId, draft: draft) + } + if vault.activeLLMProviderId != selectedLLMProviderId, + !pendingDeletedLLMProviders.contains(selectedLLMProviderId) { + vault.activeLLMProviderId = selectedLLMProviderId + } + + // ASR:火山字段写 vault;阿里 / Whisper 写 UserDefaults(vault schema 暂不支持)。 + persistVolcDraft() + persistAliyunParaformerDraft() + persistCustomWhisperDraft() + if vault.activeASRProviderId != selectedASRProviderId { + vault.activeASRProviderId = selectedASRProviderId + } + + NotificationCenter.default.post(name: .openLessCredentialsChanged, object: nil) + load() + } + + // MARK: - LLM draft 装载 / 存档 + + private func stashCurrentLLMDraft() { + pendingLLMConfigs[selectedLLMProviderId] = llmDraft + } + + private func loadLLMDraftFromVault(for providerId: String) { + if let pending = pendingLLMConfigs[providerId] { + llmDraft = pending + return + } + let vault = CredentialsVault.shared + let cfg = vault.llmProviderConfig(for: providerId) + let preset = LLMProviderRegistry.preset(for: providerId) + var draft = LLMConfigDraft() + draft.apiKey = cfg?.apiKey ?? "" + draft.baseURL = cfg?.baseURL.absoluteString ?? preset?.defaultBaseURL.absoluteString ?? "" + draft.model = cfg?.model ?? preset?.defaultModel ?? "" + draft.temperature = cfg?.temperature ?? 0.3 + draft.displayName = cfg?.displayName ?? preset?.displayName ?? providerId + llmDraft = draft + } + + private func persistLLMDraft(providerId: String, draft: LLMConfigDraft) { + let vault = CredentialsVault.shared + let preset = LLMProviderRegistry.preset(for: providerId) + + // 自定义 provider 没填 baseURL 时跳过——避免落出空配置。 + let trimmedBaseURL = draft.baseURL.trimmingCharacters(in: .whitespacesAndNewlines) + let baseURL: URL + if let parsed = URL(string: trimmedBaseURL), !trimmedBaseURL.isEmpty, parsed.scheme != nil { + baseURL = parsed + } else if let preset { + baseURL = preset.defaultBaseURL + } else { + return + } + + let displayName = draft.displayName.isEmpty + ? (preset?.displayName ?? providerId) + : draft.displayName + + let cfg = OpenAICompatibleConfig( + providerId: providerId, + displayName: displayName, + baseURL: baseURL, + apiKey: draft.apiKey, + model: draft.model, + extraHeaders: [:], + temperature: draft.temperature + ) + vault.setLLMProviderConfig(cfg) + } + + // MARK: - Volcengine draft + + private func loadVolcDraftFromVault() { + let vault = CredentialsVault.shared + var d = VolcengineDraft() + d.appKey = vault.get(CredentialAccount.volcengineAppKey) ?? "" + d.accessKey = vault.get(CredentialAccount.volcengineAccessKey) ?? "" + let resource = vault.get(CredentialAccount.volcengineResourceId) ?? "" + d.resourceId = resource.isEmpty ? VolcengineCredentials.defaultResourceId : resource + volcDraft = d + } + + private func persistVolcDraft() { + let vault = CredentialsVault.shared + try? vault.set(volcDraft.appKey.trimmingCharacters(in: .whitespacesAndNewlines), for: CredentialAccount.volcengineAppKey) + try? vault.set(volcDraft.accessKey.trimmingCharacters(in: .whitespacesAndNewlines), for: CredentialAccount.volcengineAccessKey) + try? vault.set(volcDraft.resourceId.trimmingCharacters(in: .whitespacesAndNewlines), for: CredentialAccount.volcengineResourceId) + } + + // MARK: - Aliyun Paraformer draft(暂存 UserDefaults) + + private func loadAliyunParaformerDraft() { + var d = AliyunParaformerDraft() + // 优先用 LLM 阿里通义已填的 apiKey;否则用本地 fallback。 + let vault = CredentialsVault.shared + if let llm = vault.llmProviderConfig(for: "aliyun-dashscope"), + !llm.apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + d.apiKey = llm.apiKey + } else { + d.apiKey = UserDefaults.standard.string(forKey: ProvidersConfigModel.aliyunParaformerApiKeyDefaultsKey) ?? "" + } + aliyunParaformerDraft = d + } + + private func persistAliyunParaformerDraft() { + let trimmed = aliyunParaformerDraft.apiKey.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + UserDefaults.standard.removeObject(forKey: ProvidersConfigModel.aliyunParaformerApiKeyDefaultsKey) + } else { + UserDefaults.standard.set(trimmed, forKey: ProvidersConfigModel.aliyunParaformerApiKeyDefaultsKey) + } + } + + // MARK: - Custom OpenAI Whisper draft(暂存 UserDefaults) + + private func loadCustomWhisperDraft() { + var d = CustomWhisperDraft() + let defaults = UserDefaults.standard + d.baseURL = defaults.string(forKey: ProvidersConfigModel.customWhisperBaseURLKey) ?? "" + d.apiKey = defaults.string(forKey: ProvidersConfigModel.customWhisperApiKeyKey) ?? "" + d.model = defaults.string(forKey: ProvidersConfigModel.customWhisperModelKey) ?? "whisper-1" + customWhisperDraft = d + } + + private func persistCustomWhisperDraft() { + let defaults = UserDefaults.standard + let baseURL = customWhisperDraft.baseURL.trimmingCharacters(in: .whitespacesAndNewlines) + let apiKey = customWhisperDraft.apiKey.trimmingCharacters(in: .whitespacesAndNewlines) + let model = customWhisperDraft.model.trimmingCharacters(in: .whitespacesAndNewlines) + + if baseURL.isEmpty { + defaults.removeObject(forKey: ProvidersConfigModel.customWhisperBaseURLKey) + } else { + defaults.set(baseURL, forKey: ProvidersConfigModel.customWhisperBaseURLKey) + } + if apiKey.isEmpty { + defaults.removeObject(forKey: ProvidersConfigModel.customWhisperApiKeyKey) + } else { + defaults.set(apiKey, forKey: ProvidersConfigModel.customWhisperApiKeyKey) + } + if model.isEmpty { + defaults.removeObject(forKey: ProvidersConfigModel.customWhisperModelKey) + } else { + defaults.set(model, forKey: ProvidersConfigModel.customWhisperModelKey) + } + } + + // MARK: - 常量 + + static let aliyunParaformerApiKeyDefaultsKey = "openless.asr.aliyun_paraformer.api_key" + static let customWhisperBaseURLKey = "openless.asr.custom_whisper.base_url" + static let customWhisperApiKeyKey = "openless.asr.custom_whisper.api_key" + static let customWhisperModelKey = "openless.asr.custom_whisper.model" +} + +// MARK: - Add Custom LLM Provider Sheet + +/// 添加 LLM provider 的 sheet:列出所有预设 + "自定义";选 "自定义" 时多一步收集 slug + displayName。 +/// +/// 与拆分前 LLMProvidersTab 中的同名 sheet 完全一致,搬到 ProvidersConfigTab 同文件保持邻近。 +@MainActor +struct AddLLMProviderSheet: View { + @Binding var isPresented: Bool + let existingIds: Set + let onAdd: (_ providerId: String, _ displayName: String) -> Void + + @State private var customId: String = "" + @State private var customDisplayName: String = "" + @State private var step: Step = .pick + + enum Step { + case pick + case customDetails + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack { + Text(step == .pick ? "添加 LLM Provider" : "自定义 OpenAI 兼容 Provider") + .font(.system(size: 18, weight: .semibold)) + Spacer() + Button { + isPresented = false + } label: { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 18)) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + } + .padding(20) + + Divider() + + ScrollView { + switch step { + case .pick: + pickList + case .customDetails: + customDetailsForm + } + } + .frame(minHeight: 360, maxHeight: 480) + } + .frame(width: 520) + } + + private var pickList: some View { + VStack(alignment: .leading, spacing: 0) { + ForEach(LLMProviderRegistry.presets, id: \.providerId) { preset in + presetRow(preset) + Divider().padding(.leading, 16) + } + customRow + } + .padding(.vertical, 4) + } + + private func presetRow(_ preset: LLMProviderRegistry.Preset) -> some View { + let alreadyAdded = existingIds.contains(preset.providerId) + return Button { + guard !alreadyAdded else { return } + onAdd(preset.providerId, preset.displayName) + isPresented = false + } label: { + HStack(alignment: .top, spacing: 12) { + Image(systemName: alreadyAdded ? "checkmark.circle.fill" : "circle") + .foregroundStyle(alreadyAdded ? .green : .secondary) + .font(.system(size: 18)) + .padding(.top, 2) + VStack(alignment: .leading, spacing: 4) { + Text(preset.displayName) + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(.primary) + Text(preset.defaultBaseURL.absoluteString) + .font(.system(size: 12, design: .monospaced)) + .foregroundStyle(.secondary) + Text(preset.helpText) + .font(.callout) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + Spacer() + } + .padding(.horizontal, 18) + .padding(.vertical, 12) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(alreadyAdded) + .help(alreadyAdded ? "已添加" : "添加 \(preset.displayName)") + } + + private var customRow: some View { + Button { + step = .customDetails + } label: { + HStack(alignment: .top, spacing: 12) { + Image(systemName: "plus.circle") + .foregroundStyle(.blue) + .font(.system(size: 18)) + .padding(.top, 2) + VStack(alignment: .leading, spacing: 4) { + Text(LLMProviderRegistry.customDisplayName) + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(.primary) + Text("任何遵循 OpenAI Chat Completions 协议的供应商都能填进来——比如自建网关、私有化部署、或表里没列的云厂商。") + .font(.callout) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + Spacer() + } + .padding(.horizontal, 18) + .padding(.vertical, 12) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + + private var customDetailsForm: some View { + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 6) { + Text("Provider ID") + .font(.caption) + .foregroundStyle(.secondary) + TextField("custom-gateway", text: $customId) + .textFieldStyle(.roundedBorder) + Text("唯一 slug,建议小写字母 / 数字 / 短横线;不能与已存在的 id 重复。") + .font(.caption) + .foregroundStyle(.secondary) + } + VStack(alignment: .leading, spacing: 6) { + Text("展示名") + .font(.caption) + .foregroundStyle(.secondary) + TextField("公司内部网关", text: $customDisplayName) + .textFieldStyle(.roundedBorder) + } + + HStack { + Button("返回") { + step = .pick + } + .buttonStyle(.bordered) + Spacer() + Button("添加") { + let id = customId.trimmingCharacters(in: .whitespacesAndNewlines) + let name = customDisplayName.trimmingCharacters(in: .whitespacesAndNewlines) + onAdd(id, name) + isPresented = false + } + .keyboardShortcut(.defaultAction) + .buttonStyle(.borderedProminent) + .disabled(!isCustomReady) + } + .padding(.top, 6) + } + .padding(20) + } + + private var isCustomReady: Bool { + let id = customId.trimmingCharacters(in: .whitespacesAndNewlines) + let name = customDisplayName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !id.isEmpty, !name.isEmpty else { return false } + if LLMProviderRegistry.preset(for: id) != nil { return false } + if existingIds.contains(id) { return false } + return true + } +} diff --git a/Sources/OpenLessApp/Settings/Tabs/SettingsHubTab.swift b/Sources/OpenLessApp/Settings/Tabs/SettingsHubTab.swift index 11517804..186d942b 100644 --- a/Sources/OpenLessApp/Settings/Tabs/SettingsHubTab.swift +++ b/Sources/OpenLessApp/Settings/Tabs/SettingsHubTab.swift @@ -4,27 +4,23 @@ import OpenLessCore import OpenLessHotkey import OpenLessPersistence import OpenLessRecorder -import OpenLessASR -/// 所有可调项汇总:录音 + ASR 凭据(Volc)+ 授权 + 隐私 + 关于。 -/// LLM provider 配置已经搬到独立的「LLM Provider」Tab;这里只保留 ASR / 系统级开关。 +/// 系统级开关与状态汇总:录音快捷键 + 授权 + 隐私 + 关于。 +/// LLM 与 ASR 的凭据 / provider 切换全部搬到「配置」Tab。 struct SettingsHubTab: View { @State private var trigger: HotkeyBinding.Trigger = UserPreferences.shared.hotkeyTrigger @State private var hotkeyMode: HotkeyMode = UserPreferences.shared.hotkeyMode @State private var hasAccessibility = false @State private var hasMicrophone = false - @State private var volcAppKey = "" - @State private var volcAccessKey = "" - @State private var volcResourceId = VolcengineCredentials.defaultResourceId - @State private var saved = false @State private var hasLLMProvider = false @State private var llmProviderDisplayName = "" + @State private var activeASRDisplayName = "" var body: some View { SettingsPage( title: "设置", - subtitle: "录音快捷键、凭据、授权状态、隐私和版本信息全部在这里。" + subtitle: "录音快捷键、授权状态、隐私和版本信息。LLM 模型 / ASR 语音的 API Key 和切换在「配置」Tab。" ) { GlassSection(title: "录音", symbol: "keyboard") { SettingsRow(title: "录音快捷键") { @@ -63,22 +59,8 @@ struct SettingsHubTab: View { .padding(.top, 4) } - GlassSection(title: "火山引擎大模型流式 ASR", symbol: "waveform") { - SettingsRow(title: "APP ID") { - PasteableCredentialField(placeholder: "X-Api-App-Key", secure: false, text: $volcAppKey) - } - DividerLine() - SettingsRow(title: "Access Token") { - PasteableCredentialField(placeholder: "X-Api-Access-Key", secure: true, text: $volcAccessKey) - } - DividerLine() - SettingsRow(title: "Resource ID") { - PasteableCredentialField(placeholder: "X-Api-Resource-Id", secure: false, text: $volcResourceId) - } - } - - GlassSection(title: "LLM 润色 Provider", symbol: "wand.and.stars") { - SettingsRow(title: "当前 Provider") { + GlassSection(title: "Provider 概览", symbol: "wand.and.stars") { + SettingsRow(title: "LLM 模型") { HStack(spacing: 10) { Label( hasLLMProvider ? llmProviderDisplayName : "未配置", @@ -89,24 +71,23 @@ struct SettingsHubTab: View { } } DividerLine() - Text(hasLLMProvider - ? "去「LLM Provider」Tab 切换 / 编辑详细字段。" - : "前往「LLM Provider」Tab 选择豆包 / OpenAI / DeepSeek / 自定义 OpenAI 兼容 provider。未配置时识别后会直接插入原文。") + SettingsRow(title: "ASR 语音") { + HStack(spacing: 10) { + Label( + activeASRDisplayName.isEmpty ? "未选择" : activeASRDisplayName, + systemImage: "waveform" + ) + .foregroundStyle(.primary) + Spacer() + } + } + DividerLine() + Text("前往「配置」Tab 选择并填写 LLM / ASR 凭据。未配置 LLM 时识别后直接插入原文;ASR 缺凭据时回退到演示模式。") .font(.footnote) .foregroundStyle(.secondary) .padding(.top, 4) } - PrimaryActionRow { - Button("保存 ASR 凭据") { saveCredentials() } - .keyboardShortcut(.defaultAction) - .buttonStyle(.borderedProminent) - if saved { - Label("已保存", systemImage: "checkmark.circle.fill") - .foregroundStyle(.green) - } - } - GlassSection(title: "授权状态", symbol: "checkmark.seal") { StatusLine(title: "辅助功能", detail: hasAccessibility ? "已授权" : "未授权", ok: hasAccessibility) DividerLine() @@ -120,7 +101,7 @@ struct SettingsHubTab: View { DividerLine() privacyRow("历史只保存原始转写和最终文本", symbol: "doc.text") DividerLine() - privacyRow("云端 ASR 会上传音频;开启 Ark 润色时上传转写文本", symbol: "icloud") + privacyRow("云端 ASR 会上传音频;开启润色时上传转写文本", symbol: "icloud") } GlassSection(title: "关于", symbol: "info.circle") { @@ -139,6 +120,9 @@ struct SettingsHubTab: View { } } .onAppear { refresh() } + .onReceive(NotificationCenter.default.publisher(for: .openLessCredentialsChanged)) { _ in + refresh() + } } private var versionString: String { @@ -154,29 +138,19 @@ struct SettingsHubTab: View { hasAccessibility = AccessibilityPermission.isGranted() hasMicrophone = MicrophonePermission.isGranted() let v = CredentialsVault.shared - volcAppKey = v.get(CredentialAccount.volcengineAppKey) ?? "" - volcAccessKey = v.get(CredentialAccount.volcengineAccessKey) ?? "" - volcResourceId = v.get(CredentialAccount.volcengineResourceId) ?? VolcengineCredentials.defaultResourceId - // 当前 active LLM provider 是否填了 apiKey;LLM 设置已搬到 LLMProvidersTab。 - let activeId = v.activeLLMProviderId - if let cfg = v.llmProviderConfig(for: activeId), !cfg.apiKey.isEmpty { + // 当前 active LLM provider 是否填了 apiKey;详细配置在「配置」Tab。 + let activeLLMId = v.activeLLMProviderId + if let cfg = v.llmProviderConfig(for: activeLLMId), !cfg.apiKey.isEmpty { hasLLMProvider = true llmProviderDisplayName = cfg.displayName } else { hasLLMProvider = false - llmProviderDisplayName = LLMProviderRegistry.preset(for: activeId)?.displayName ?? activeId + llmProviderDisplayName = LLMProviderRegistry.preset(for: activeLLMId)?.displayName ?? activeLLMId } - } - private func saveCredentials() { - let v = CredentialsVault.shared - try? v.set(volcAppKey.trimmingCharacters(in: .whitespacesAndNewlines), for: CredentialAccount.volcengineAppKey) - try? v.set(volcAccessKey.trimmingCharacters(in: .whitespacesAndNewlines), for: CredentialAccount.volcengineAccessKey) - try? v.set(volcResourceId.trimmingCharacters(in: .whitespacesAndNewlines), for: CredentialAccount.volcengineResourceId) - NotificationCenter.default.post(name: .openLessCredentialsChanged, object: nil) - saved = true - DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { saved = false } + let activeASRId = v.activeASRProviderId + activeASRDisplayName = ASRProviderRegistry.preset(for: activeASRId)?.displayName ?? activeASRId } private func privacyRow(_ text: String, symbol: String) -> some View { diff --git a/Sources/OpenLessCore/ASRProviderRegistry.swift b/Sources/OpenLessCore/ASRProviderRegistry.swift index 097af410..375e133d 100644 --- a/Sources/OpenLessCore/ASRProviderRegistry.swift +++ b/Sources/OpenLessCore/ASRProviderRegistry.swift @@ -35,8 +35,8 @@ public enum ASRProviderRegistry { } } - /// M1 内置的 ASR provider 预设。 - /// 顺序即为 UI 列表的展示顺序——火山在前(旧用户默认),Apple Speech 在后。 + /// 内置的 ASR provider 预设。 + /// 顺序即为 UI 列表的展示顺序——火山在前(旧用户默认),Apple Speech、阿里、自定义 Whisper 依次展开。 public static let presets: [Preset] = [ Preset( providerId: "volcengine", @@ -50,6 +50,18 @@ public enum ASRProviderRegistry { mode: .streaming, helpText: "使用 macOS 内置语音识别。免费、离线(zh-CN 在 Apple Silicon 上支持纯离线),不需要 API key。首次切换时系统会请求语音识别权限。" ), + Preset( + providerId: "aliyun-paraformer", + displayName: "阿里通义 Paraformer (DashScope)", + mode: .streaming, + helpText: "DashScope paraformer-realtime-v2 流式语音识别。如果你的 LLM 已经填了阿里通义 DashScope 的 API Key,会自动复用同一个 key。" + ), + Preset( + providerId: "custom-openai-whisper", + displayName: "自定义 OpenAI 兼容 (Whisper)", + mode: .batch, + helpText: "兼容 OpenAI Whisper API(multipart/form-data POST)的任意端点:Groq、DeepInfra、OpenAI、自建反代等。录完整段音频后整段上传转写,长录音会等几秒。" + ), ] /// 根据 providerId 查预设;未知 id 返回 nil。 diff --git a/Tests/OpenLessCoreTests/ASRProviderRegistryTests.swift b/Tests/OpenLessCoreTests/ASRProviderRegistryTests.swift index 1797a4b0..189186b6 100644 --- a/Tests/OpenLessCoreTests/ASRProviderRegistryTests.swift +++ b/Tests/OpenLessCoreTests/ASRProviderRegistryTests.swift @@ -1,10 +1,10 @@ import XCTest @testable import OpenLessCore -/// ASRProviderRegistry 的静态健康检查:M1 应包含火山引擎 + Apple Speech 两条预设, -/// id 不重复、displayName 非空、mode 合法。 +/// ASRProviderRegistry 的静态健康检查:当前应包含火山引擎 + Apple Speech + +/// 阿里 Paraformer + 自定义 Whisper 四条预设,id 不重复、displayName 非空、mode 合法。 /// -/// 这些断言看起来很无聊,但 registry 直接被 ASR Tab 的 chip 列表与 vault 兜底逻辑用, +/// 这些断言看起来很无聊,但 registry 直接被「配置」Tab 的 chip 列表与 vault 兜底逻辑用, /// 漏一条都会变成"用户切不了 ASR / picker 空白"的运行期 bug。 final class ASRProviderRegistryTests: XCTestCase { @@ -13,12 +13,21 @@ final class ASRProviderRegistryTests: XCTestCase { let presets = ASRProviderRegistry.presets // Act / Assert - XCTAssertEqual(presets.count, 2, "M1 计划列出的 ASR 预设是 volcengine / apple-speech") + XCTAssertEqual( + presets.count, + 4, + "ASR 预设:volcengine / apple-speech / aliyun-paraformer / custom-openai-whisper" + ) } func test_presets_includeAllExpectedProviderIds() { // Arrange - let expected: Set = ["volcengine", "apple-speech"] + let expected: Set = [ + "volcengine", + "apple-speech", + "aliyun-paraformer", + "custom-openai-whisper", + ] // Act let actualIds = Set(ASRProviderRegistry.presets.map(\.providerId)) @@ -65,6 +74,24 @@ final class ASRProviderRegistryTests: XCTestCase { XCTAssertEqual(preset?.mode, .streaming) } + func test_aliyunParaformerPreset_isStreaming() { + // Arrange + let preset = ASRProviderRegistry.preset(for: "aliyun-paraformer") + + // Act / Assert + XCTAssertNotNil(preset) + XCTAssertEqual(preset?.mode, .streaming) + } + + func test_customOpenAIWhisperPreset_isBatch() { + // Arrange + let preset = ASRProviderRegistry.preset(for: "custom-openai-whisper") + + // Act / Assert + XCTAssertNotNil(preset) + XCTAssertEqual(preset?.mode, .batch, "Whisper API 是批量上传形态") + } + func test_preset_lookup_returnsRegistered() { for preset in ASRProviderRegistry.presets { let resolved = ASRProviderRegistry.preset(for: preset.providerId)