From 2517df5d2f4cf45f5cda8371a97cc1996fbb07ac Mon Sep 17 00:00:00 2001 From: baiqing Date: Wed, 29 Apr 2026 11:54:24 +0800 Subject: [PATCH] feat(settings): LLM Providers tab + rewire DictationCoordinator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增「LLM Provider」侧边栏 Tab: - 顶部 picker 切换 active provider(含已配置条目 + 一键添加) - 下方表单编辑 displayName / API Key / Base URL / Model / Temperature - 字段编辑后 0.5s debounce 自动写盘并 post `.openLessCredentialsChanged` - Base URL 与预设默认值相同时显示「默认」徽标 - 预设条目展开「怎么获取 API Key?」disclosure,附 docsURL 跳转 - 「+ 添加」sheet 列出 5 家预设 + 自定义 OpenAI 兼容;自定义需填 slug+displayName - 删除按钮在 active provider 上禁用,必须先切走再删 DictationCoordinator 不再构造 `DoubaoPolishClient`,改成: let active = vault.activeLLMProviderId guard let cfg = vault.llmProviderConfig(for: active), !cfg.apiKey.isEmpty else { return raw } // 静默插入原文 let provider = OpenAICompatibleLLMProvider(config: cfg, ...) let polished = try await provider.polish(rawText:..., mode:..., hotwords:...) `SettingsHubTab` 移除硬编码的 Ark 三字段表单,改为只显示当前 active provider 状态并把详细设置指向新 Tab。 --- .../OpenLessApp/DictationCoordinator.swift | 35 +- .../OpenLessApp/Settings/SettingsView.swift | 4 + .../Settings/Tabs/LLMProvidersTab.swift | 645 ++++++++++++++++++ .../Settings/Tabs/SettingsHubTab.swift | 53 +- 4 files changed, 700 insertions(+), 37 deletions(-) create mode 100644 Sources/OpenLessApp/Settings/Tabs/LLMProvidersTab.swift diff --git a/Sources/OpenLessApp/DictationCoordinator.swift b/Sources/OpenLessApp/DictationCoordinator.swift index 432457df..20c0e1e5 100644 --- a/Sources/OpenLessApp/DictationCoordinator.swift +++ b/Sources/OpenLessApp/DictationCoordinator.swift @@ -375,8 +375,14 @@ final class DictationCoordinator { return } - guard let arkCreds = loadArkCredentials() else { - Log.write("[polish] 缺少 Ark 凭据;跳过润色,插入 raw") + // 走 active LLM provider 通用路径(OpenAICompatibleLLMProvider)。 + // 凭据缺失(apiKey 为空 / config 构造不出来)时静默降级为"插入 raw",保持 + // "用户的原话不会丢"这条不变量。 + let vault = CredentialsVault.shared + let activeId = vault.activeLLMProviderId + guard let cfg = vault.llmProviderConfig(for: activeId), + !cfg.apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + Log.write("[polish] active provider=\(activeId) 缺少 apiKey;跳过润色,插入 raw") await insertText( text: raw.text, raw: savedRaw, @@ -388,16 +394,20 @@ final class DictationCoordinator { return } - Log.write("[polish] 调用 endpoint=\(arkCreds.endpoint.absoluteString) model=\(arkCreds.modelId)") - let polish = DoubaoPolishClient( - credentials: arkCreds, + // 词典条目里 enabled 的部分作为 hotwords 喂给 provider;hotwords 仅影响 prompt 后缀, + // 不影响热路径成本。 + let hotwords = dictionaryEntries.map(\.phrase).filter { !$0.isEmpty } + + Log.write("[polish] provider=\(activeId) baseURL=\(cfg.baseURL.absoluteString) model=\(cfg.model)") + let provider = OpenAICompatibleLLMProvider( + config: cfg, logger: { msg in Log.write(msg) } ) do { - let final = try await polish.polish(rawTranscript: raw, mode: mode, dictionaryEntries: dictionaryEntries) - Log.write("[polish] mode=\(mode.rawValue) → \(final.text)") + let final = try await provider.polish(rawText: raw.text, mode: mode, hotwords: hotwords) + Log.write("[polish] mode=\(mode.rawValue) → \(final)") await insertText( - text: final.text, + text: final, raw: savedRaw, mode: mode, durationMs: raw.durationMs, @@ -556,11 +566,6 @@ final class DictationCoordinator { ) } - private func loadArkCredentials() -> ArkCredentials? { - guard let key = credentials.arkApiKey, !key.isEmpty else { return nil } - let model = credentials.arkModelId ?? ArkCredentials.defaultModelId - let endpointStr = credentials.arkEndpoint ?? ArkCredentials.defaultEndpoint.absoluteString - let endpoint = URL(string: endpointStr) ?? ArkCredentials.defaultEndpoint - return ArkCredentials(apiKey: key, modelId: model, endpoint: endpoint) - } + // loadArkCredentials 已移除:润色路径改走 vault.llmProviderConfig(for:) + + // OpenAICompatibleLLMProvider,不再单独构造 ArkCredentials。 } diff --git a/Sources/OpenLessApp/Settings/SettingsView.swift b/Sources/OpenLessApp/Settings/SettingsView.swift index f5746155..3c14f074 100644 --- a/Sources/OpenLessApp/Settings/SettingsView.swift +++ b/Sources/OpenLessApp/Settings/SettingsView.swift @@ -15,6 +15,7 @@ enum OpenLessMainTab: String, CaseIterable, Identifiable { case history case dictionary case polish + case llm case help case settings @@ -26,6 +27,7 @@ enum OpenLessMainTab: String, CaseIterable, Identifiable { case .history: return "历史记录" case .dictionary: return "词汇表" case .polish: return "风格" + case .llm: return "LLM Provider" case .help: return "帮助中心" case .settings: return "设置" } @@ -37,6 +39,7 @@ enum OpenLessMainTab: String, CaseIterable, Identifiable { case .history: return "clock" case .dictionary: return "text.book.closed" case .polish: return "paintpalette" + case .llm: return "wand.and.stars" case .help: return "questionmark.circle" case .settings: return "gearshape" } @@ -68,6 +71,7 @@ struct SettingsView: View { case .history: HistoryTab() case .dictionary: DictionaryTab() case .polish: StyleTab() + case .llm: LLMProvidersTab() case .help: HelpTab() case .settings: SettingsHubTab() } diff --git a/Sources/OpenLessApp/Settings/Tabs/LLMProvidersTab.swift b/Sources/OpenLessApp/Settings/Tabs/LLMProvidersTab.swift new file mode 100644 index 00000000..c21e86e2 --- /dev/null +++ b/Sources/OpenLessApp/Settings/Tabs/LLMProvidersTab.swift @@ -0,0 +1,645 @@ +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/SettingsHubTab.swift b/Sources/OpenLessApp/Settings/Tabs/SettingsHubTab.swift index 2ada782b..11517804 100644 --- a/Sources/OpenLessApp/Settings/Tabs/SettingsHubTab.swift +++ b/Sources/OpenLessApp/Settings/Tabs/SettingsHubTab.swift @@ -5,9 +5,9 @@ import OpenLessHotkey import OpenLessPersistence import OpenLessRecorder import OpenLessASR -import OpenLessPolish -/// 所有可调项汇总:录音 + 凭据(Volc ASR + Ark 润色)+ 授权 + 隐私 + 关于。 +/// 所有可调项汇总:录音 + ASR 凭据(Volc)+ 授权 + 隐私 + 关于。 +/// LLM provider 配置已经搬到独立的「LLM Provider」Tab;这里只保留 ASR / 系统级开关。 struct SettingsHubTab: View { @State private var trigger: HotkeyBinding.Trigger = UserPreferences.shared.hotkeyTrigger @State private var hotkeyMode: HotkeyMode = UserPreferences.shared.hotkeyMode @@ -17,10 +17,9 @@ struct SettingsHubTab: View { @State private var volcAppKey = "" @State private var volcAccessKey = "" @State private var volcResourceId = VolcengineCredentials.defaultResourceId - @State private var arkApiKey = "" - @State private var arkModelId = ArkCredentials.defaultModelId - @State private var arkEndpoint = ArkCredentials.defaultEndpoint.absoluteString @State private var saved = false + @State private var hasLLMProvider = false + @State private var llmProviderDisplayName = "" var body: some View { SettingsPage( @@ -78,22 +77,28 @@ struct SettingsHubTab: View { } } - GlassSection(title: "Ark / DeepSeek V3.2 润色", symbol: "wand.and.stars") { - SettingsRow(title: "API Key") { - PasteableCredentialField(placeholder: "Bearer Token", secure: true, text: $arkApiKey) + GlassSection(title: "LLM 润色 Provider", symbol: "wand.and.stars") { + SettingsRow(title: "当前 Provider") { + HStack(spacing: 10) { + Label( + hasLLMProvider ? llmProviderDisplayName : "未配置", + systemImage: hasLLMProvider ? "checkmark.circle.fill" : "exclamationmark.triangle.fill" + ) + .foregroundStyle(hasLLMProvider ? .green : .orange) + Spacer() + } } DividerLine() - SettingsRow(title: "Model ID") { - PasteableCredentialField(placeholder: "Model ID", secure: false, text: $arkModelId) - } - DividerLine() - SettingsRow(title: "Endpoint") { - PasteableCredentialField(placeholder: "Endpoint", secure: false, text: $arkEndpoint) - } + Text(hasLLMProvider + ? "去「LLM Provider」Tab 切换 / 编辑详细字段。" + : "前往「LLM Provider」Tab 选择豆包 / OpenAI / DeepSeek / 自定义 OpenAI 兼容 provider。未配置时识别后会直接插入原文。") + .font(.footnote) + .foregroundStyle(.secondary) + .padding(.top, 4) } PrimaryActionRow { - Button("保存凭据") { saveCredentials() } + Button("保存 ASR 凭据") { saveCredentials() } .keyboardShortcut(.defaultAction) .buttonStyle(.borderedProminent) if saved { @@ -152,9 +157,16 @@ struct SettingsHubTab: View { volcAppKey = v.get(CredentialAccount.volcengineAppKey) ?? "" volcAccessKey = v.get(CredentialAccount.volcengineAccessKey) ?? "" volcResourceId = v.get(CredentialAccount.volcengineResourceId) ?? VolcengineCredentials.defaultResourceId - arkApiKey = v.get(CredentialAccount.arkApiKey) ?? "" - arkModelId = v.get(CredentialAccount.arkModelId) ?? ArkCredentials.defaultModelId - arkEndpoint = v.get(CredentialAccount.arkEndpoint) ?? ArkCredentials.defaultEndpoint.absoluteString + + // 当前 active LLM provider 是否填了 apiKey;LLM 设置已搬到 LLMProvidersTab。 + let activeId = v.activeLLMProviderId + if let cfg = v.llmProviderConfig(for: activeId), !cfg.apiKey.isEmpty { + hasLLMProvider = true + llmProviderDisplayName = cfg.displayName + } else { + hasLLMProvider = false + llmProviderDisplayName = LLMProviderRegistry.preset(for: activeId)?.displayName ?? activeId + } } private func saveCredentials() { @@ -162,9 +174,6 @@ struct SettingsHubTab: View { 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) - try? v.set(arkApiKey.trimmingCharacters(in: .whitespacesAndNewlines), for: CredentialAccount.arkApiKey) - try? v.set(arkModelId.trimmingCharacters(in: .whitespacesAndNewlines), for: CredentialAccount.arkModelId) - try? v.set(arkEndpoint.trimmingCharacters(in: .whitespacesAndNewlines), for: CredentialAccount.arkEndpoint) NotificationCenter.default.post(name: .openLessCredentialsChanged, object: nil) saved = true DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { saved = false }