From d8fb309febfc40547fdbb22b6ea7f3e8ef0f87a5 Mon Sep 17 00:00:00 2001 From: baiqing Date: Wed, 29 Apr 2026 11:54:11 +0800 Subject: [PATCH 1/2] feat(persistence): generalize providers.llm + multi-provider vault API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 v1 schema 中 `providers.llm` 从 Ark 专用结构推广为通用 `CredentialsProviderLLMEntry`(containing displayName / temperature / extraHeaders), 为 OpenAI / DeepSeek / DashScope / Moonshot 等多 LLM provider 共用一套字段。 旧 `CredentialsProviderLLMArk` 保留为 typealias,已迁移过的 v1 文件无需再次迁移即可继续解码。 新增公开 API: - `CredentialsVault.activeLLMProviderId` getter/setter - `CredentialsVault.configuredLLMProviderIds` - `CredentialsVault.llmProviderConfig(for:)` - `CredentialsVault.setLLMProviderConfig(_:)` - `CredentialsVault.removeLLMProvider(_:)`,禁删 active provider `OpenLessCore` 新增 `LLMProviderRegistry`,集中维护 5 家 OpenAI 兼容供应商 预设(baseURL / model / 帮助文案 / docs URL),UI 与 vault 兜底逻辑共用。 `PolishOutputCleaner` 从 DoubaoPolishClient 内部提升为独立 public 类型, 让 `OpenAICompatibleLLMProvider` 输出统一过 boilerplate / markdown 栅栏清理。 DoubaoPolishClient.swift 已无调用方,整个文件删除。 Tests: - LLMProviderRegistryTests:5 家预设健康检查 + 唯一性 / URL 合法性 - CredentialsVaultMultiProviderTests:active 默认 / 增删 / 往返 / 老 `ark.*` 账号 key 路由到 active provider --- .../OpenLessCore/LLMProviderRegistry.swift | 94 +++++++ .../CredentialsError.swift | 4 + .../CredentialsSchemaV1.swift | 55 +++- .../CredentialsVault.swift | 196 ++++++++++++-- .../OpenLessPolish/DoubaoPolishClient.swift | 137 ---------- .../OpenAICompatibleLLMProvider.swift | 4 +- .../OpenLessPolish/PolishOutputCleaner.swift | 48 ++++ .../LLMProviderRegistryTests.swift | 86 ++++++ .../CredentialsVaultMultiProviderTests.swift | 253 ++++++++++++++++++ 9 files changed, 712 insertions(+), 165 deletions(-) create mode 100644 Sources/OpenLessCore/LLMProviderRegistry.swift delete mode 100644 Sources/OpenLessPolish/DoubaoPolishClient.swift create mode 100644 Sources/OpenLessPolish/PolishOutputCleaner.swift create mode 100644 Tests/OpenLessCoreTests/LLMProviderRegistryTests.swift create mode 100644 Tests/OpenLessPersistenceTests/CredentialsVaultMultiProviderTests.swift diff --git a/Sources/OpenLessCore/LLMProviderRegistry.swift b/Sources/OpenLessCore/LLMProviderRegistry.swift new file mode 100644 index 00000000..6c65d896 --- /dev/null +++ b/Sources/OpenLessCore/LLMProviderRegistry.swift @@ -0,0 +1,94 @@ +import Foundation + +/// LLM provider 预设注册表。 +/// +/// 集中维护"用户能开箱即用的供应商列表"——豆包(Ark)、OpenAI、阿里通义 DashScope、 +/// DeepSeek、Moonshot——以及 baseURL / model / 帮助文案。UI 层用它生成添加 provider +/// 的下拉菜单与帮助提示,运行期则用它兜底未填写的 baseURL / displayName。 +/// +/// `customProviderId` 是占位常量,不是真正的"id":用户在添加自定义 provider 时由 UI 收集 +/// 一个 slug 写入 schema;这里仅用来在添加 sheet 中区分"预设 vs 自定义"。 +public enum LLMProviderRegistry { + /// 单条预设记录。 + public struct Preset: Sendable, Hashable { + public let providerId: String + public let displayName: String + public let defaultBaseURL: URL + /// 预设的默认 model。Ark 故意留空——endpoint id 必须由用户在控制台拿到再填。 + public let defaultModel: String + /// "怎么拿到 API Key" 的提示文案,UI 在帮助 disclosure 里展示。 + public let helpText: String + /// 供应商 API Key 页面(可选)。UI 给一个"打开"按钮。 + public let docsURL: URL? + + public init( + providerId: String, + displayName: String, + defaultBaseURL: URL, + defaultModel: String, + helpText: String, + docsURL: URL? + ) { + self.providerId = providerId + self.displayName = displayName + self.defaultBaseURL = defaultBaseURL + self.defaultModel = defaultModel + self.helpText = helpText + self.docsURL = docsURL + } + } + + /// 5 家 OpenAI 兼容供应商的开箱即用预设。 + /// 顺序即为 UI 添加菜单里的展示顺序——第一个是 Ark(老用户迁移过来的默认)。 + public static let presets: [Preset] = [ + Preset( + providerId: "ark", + displayName: "豆包 (Ark)", + defaultBaseURL: URL(string: "https://ark.cn-beijing.volces.com/api/v3")!, + defaultModel: "", + helpText: "在火山引擎方舟控制台创建 endpoint 并把 endpoint id 填到 Model;API Key 在「我的 API Key」页生成。", + docsURL: URL(string: "https://www.volcengine.com/docs/82379/1099475") + ), + Preset( + providerId: "openai", + displayName: "OpenAI", + defaultBaseURL: URL(string: "https://api.openai.com/v1")!, + defaultModel: "gpt-4o-mini", + helpText: "platform.openai.com 创建 API key;推荐 gpt-4o-mini(性价比)或 gpt-4o(高质量)。需国外网络。", + docsURL: URL(string: "https://platform.openai.com/api-keys") + ), + Preset( + providerId: "aliyun-dashscope", + displayName: "阿里通义 (DashScope)", + defaultBaseURL: URL(string: "https://dashscope.aliyuncs.com/compatible-mode/v1")!, + defaultModel: "qwen-turbo", + helpText: "百炼控制台「API-KEY 管理」生成 sk-...;推荐 qwen-turbo(快)或 qwen-plus(强)。", + docsURL: URL(string: "https://help.aliyun.com/zh/model-studio/get-api-key") + ), + Preset( + providerId: "deepseek", + displayName: "DeepSeek", + defaultBaseURL: URL(string: "https://api.deepseek.com/v1")!, + defaultModel: "deepseek-chat", + helpText: "platform.deepseek.com 创建 API key;推荐 deepseek-chat。", + docsURL: URL(string: "https://platform.deepseek.com/api_keys") + ), + Preset( + providerId: "moonshot", + displayName: "Moonshot 月之暗面", + defaultBaseURL: URL(string: "https://api.moonshot.cn/v1")!, + defaultModel: "moonshot-v1-8k", + helpText: "platform.moonshot.cn 创建 API key;推荐 moonshot-v1-8k(短)或 moonshot-v1-32k(长)。", + docsURL: URL(string: "https://platform.moonshot.cn/console/api-keys") + ), + ] + + /// 自定义 provider 的占位 id(不是真实 providerId,仅用于 UI 添加菜单)。 + public static let customProviderId = "custom" + public static let customDisplayName = "自定义 OpenAI 兼容" + + /// 根据 providerId 查预设;未知 id 返回 nil(自定义 provider 通常落到这里)。 + public static func preset(for providerId: String) -> Preset? { + presets.first { $0.providerId == providerId } + } +} diff --git a/Sources/OpenLessPersistence/CredentialsError.swift b/Sources/OpenLessPersistence/CredentialsError.swift index f12a2746..6069f41a 100644 --- a/Sources/OpenLessPersistence/CredentialsError.swift +++ b/Sources/OpenLessPersistence/CredentialsError.swift @@ -18,4 +18,8 @@ public enum CredentialsError: Error, Sendable, Equatable { /// 写入 v1 文件失败。携带目标 URL 和失败描述。 case writeFailed(URL, String) + + /// 试图删除当前 active 的 LLM provider。携带 providerId。 + /// 调用方必须先把 active 切到别的 provider 再调 `removeLLMProvider`。 + case cannotRemoveActiveProvider(String) } diff --git a/Sources/OpenLessPersistence/CredentialsSchemaV1.swift b/Sources/OpenLessPersistence/CredentialsSchemaV1.swift index 64cd2803..c3503858 100644 --- a/Sources/OpenLessPersistence/CredentialsSchemaV1.swift +++ b/Sources/OpenLessPersistence/CredentialsSchemaV1.swift @@ -34,12 +34,13 @@ public struct CredentialsSchemaV1: Codable, Sendable, Equatable { public struct CredentialsProviders: Codable, Sendable, Equatable { /// ASR 类别:providerId → provider 配置。M1 仅有 `volcengine`。 public var asr: [String: CredentialsProviderASRVolcengine] - /// LLM 类别:providerId → provider 配置。M1 仅有 `ark`。 - public var llm: [String: CredentialsProviderLLMArk] + /// LLM 类别:providerId → provider 配置。 + /// 任意 OpenAI 兼容供应商(ark / openai / aliyun-dashscope / deepseek / moonshot / 自定义)共用一份 entry。 + public var llm: [String: CredentialsProviderLLMEntry] public init( asr: [String: CredentialsProviderASRVolcengine] = [:], - llm: [String: CredentialsProviderLLMArk] = [:] + llm: [String: CredentialsProviderLLMEntry] = [:] ) { self.asr = asr self.llm = llm @@ -84,44 +85,80 @@ public struct CredentialsProviderASRVolcengine: Codable, Sendable, Equatable { } } -/// Ark (Doubao) chat-completions 凭据。 +/// 通用 OpenAI 兼容 LLM provider 凭据条目。 /// -/// 自定义编码:nil 字段直接省略。 -public struct CredentialsProviderLLMArk: Codable, Sendable, Equatable { +/// 同一份结构覆盖 Ark / OpenAI / DashScope / DeepSeek / Moonshot 以及用户自定义条目; +/// 需要呈现给 UI 的"友好名"放在 `displayName`(自定义时由用户指定,预设条目可缺省让 UI 兜底)。 +/// +/// 自定义编码:nil 字段直接省略;空 dict 的 `extraHeaders` 同样省略,保持 JSON 干净。 +public struct CredentialsProviderLLMEntry: Codable, Sendable, Equatable, Hashable { + /// 用户给该条目设置的展示名(自定义条目必填;预设条目允许 nil 由 UI 用 registry 兜底)。 + public var displayName: String? public var apiKey: String? + /// 字符串而不是 URL:编辑过程中用户输入可能是非法 URL,schema 不在这里早 fail。 public var baseURL: String? public var model: String? + /// 该 provider 的 temperature 覆盖;为空时使用 `OpenAICompatibleConfig` 的默认值。 + public var temperature: Double? + public var extraHeaders: [String: String]? - public init(apiKey: String? = nil, baseURL: String? = nil, model: String? = nil) { + public init( + displayName: String? = nil, + apiKey: String? = nil, + baseURL: String? = nil, + model: String? = nil, + temperature: Double? = nil, + extraHeaders: [String: String]? = nil + ) { + self.displayName = displayName self.apiKey = apiKey self.baseURL = baseURL self.model = model + self.temperature = temperature + self.extraHeaders = extraHeaders } /// 是否所有字段都是空 / nil。 public var isAllEmpty: Bool { - isNilOrEmpty(apiKey) && isNilOrEmpty(baseURL) && isNilOrEmpty(model) + isNilOrEmpty(displayName) + && isNilOrEmpty(apiKey) + && isNilOrEmpty(baseURL) + && isNilOrEmpty(model) + && temperature == nil + && (extraHeaders?.isEmpty ?? true) } private enum CodingKeys: String, CodingKey { - case apiKey, baseURL, model + case displayName, apiKey, baseURL, model, temperature, extraHeaders } public func encode(to encoder: Encoder) throws { var c = encoder.container(keyedBy: CodingKeys.self) + try c.encodeIfPresent(displayName, forKey: .displayName) try c.encodeIfPresent(apiKey, forKey: .apiKey) try c.encodeIfPresent(baseURL, forKey: .baseURL) try c.encodeIfPresent(model, forKey: .model) + try c.encodeIfPresent(temperature, forKey: .temperature) + if let headers = extraHeaders, !headers.isEmpty { + try c.encode(headers, forKey: .extraHeaders) + } } public init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) + displayName = try c.decodeIfPresent(String.self, forKey: .displayName) apiKey = try c.decodeIfPresent(String.self, forKey: .apiKey) baseURL = try c.decodeIfPresent(String.self, forKey: .baseURL) model = try c.decodeIfPresent(String.self, forKey: .model) + temperature = try c.decodeIfPresent(Double.self, forKey: .temperature) + extraHeaders = try c.decodeIfPresent([String: String].self, forKey: .extraHeaders) } } +/// 兼容别名:B-2 时期叫 `CredentialsProviderLLMArk`。 +/// 通用化后所有 LLM provider 共用同一类型。保留 typealias 让外部代码继续编译。 +public typealias CredentialsProviderLLMArk = CredentialsProviderLLMEntry + // MARK: - Active selection /// 当前激活的 provider。M1 默认 `volcengine` / `ark`。 diff --git a/Sources/OpenLessPersistence/CredentialsVault.swift b/Sources/OpenLessPersistence/CredentialsVault.swift index 9f511bb8..b5898882 100644 --- a/Sources/OpenLessPersistence/CredentialsVault.swift +++ b/Sources/OpenLessPersistence/CredentialsVault.swift @@ -1,4 +1,5 @@ import Foundation +import OpenLessCore /// 开发期凭据存储:JSON 文件,路径 `~/.openless/credentials.json`,权限 0600。 /// @@ -81,19 +82,20 @@ public final class CredentialsVault: @unchecked Sendable { } /// 一次性把所有账号读出来;调用方在内存里持有,避免每次会话都打 IO。 + /// snapshot 跟随当前 active provider;切换 active LLM 后再次调用会反映新的 provider 字段。 public func snapshot() -> CredentialsSnapshot { lock.lock() defer { lock.unlock() } loadIfNeededLocked() - let volc = schema.providers.asr[defaultActiveASRProviderId] - let ark = schema.providers.llm[defaultActiveLLMProviderId] + let volc = schema.providers.asr[schema.active.asr] + let llm = schema.providers.llm[schema.active.llm] return CredentialsSnapshot( volcengineAppKey: volc?.appKey, volcengineAccessKey: volc?.accessKey, volcengineResourceId: volc?.resourceId, - arkApiKey: ark?.apiKey, - arkModelId: ark?.model, - arkEndpoint: ark?.baseURL + arkApiKey: llm?.apiKey, + arkModelId: llm?.model, + arkEndpoint: llm?.baseURL ) } @@ -261,19 +263,24 @@ public final class CredentialsVault: @unchecked Sendable { // MARK: - 账号 key → v1 字段路由 private func readAccountLocked(_ account: String) -> String? { + // ASR 路径锁定到 active ASR provider;LLM 路径锁定到 active LLM provider。 + // 一旦用户在 LLM 设置页切换 active(比如从 ark 切到 deepseek),老 UI 通过 + // `ark.*` 账号 key 看到的就是 deepseek 的字段——这是预期行为。 + let activeASR = schema.active.asr + let activeLLM = schema.active.llm switch account { case CredentialAccount.volcengineAppKey: - return schema.providers.asr[defaultActiveASRProviderId]?.appKey + return schema.providers.asr[activeASR]?.appKey case CredentialAccount.volcengineAccessKey: - return schema.providers.asr[defaultActiveASRProviderId]?.accessKey + return schema.providers.asr[activeASR]?.accessKey case CredentialAccount.volcengineResourceId: - return schema.providers.asr[defaultActiveASRProviderId]?.resourceId + return schema.providers.asr[activeASR]?.resourceId case CredentialAccount.arkApiKey: - return schema.providers.llm[defaultActiveLLMProviderId]?.apiKey + return schema.providers.llm[activeLLM]?.apiKey case CredentialAccount.arkModelId: - return schema.providers.llm[defaultActiveLLMProviderId]?.model + return schema.providers.llm[activeLLM]?.model case CredentialAccount.arkEndpoint: - return schema.providers.llm[defaultActiveLLMProviderId]?.baseURL + return schema.providers.llm[activeLLM]?.baseURL default: return nil } @@ -319,26 +326,179 @@ public final class CredentialsVault: @unchecked Sendable { } private func mutateVolc(_ apply: (inout CredentialsProviderASRVolcengine) -> Void) { - var existing = schema.providers.asr[defaultActiveASRProviderId] ?? CredentialsProviderASRVolcengine() + // 老路径:`volcengine.*` 账号 key 始终路由到 active ASR provider。 + // M1 active.asr 永远是 "volcengine",未来加供应商时这里也无需再改。 + let key = schema.active.asr + var existing = schema.providers.asr[key] ?? CredentialsProviderASRVolcengine() apply(&existing) if existing.isAllEmpty { - schema.providers.asr.removeValue(forKey: defaultActiveASRProviderId) + schema.providers.asr.removeValue(forKey: key) } else { - schema.providers.asr[defaultActiveASRProviderId] = existing + schema.providers.asr[key] = existing } } - private func mutateArk(_ apply: (inout CredentialsProviderLLMArk) -> Void) { - var existing = schema.providers.llm[defaultActiveLLMProviderId] ?? CredentialsProviderLLMArk() + private func mutateArk(_ apply: (inout CredentialsProviderLLMEntry) -> Void) { + // 老路径:`ark.*` 账号 key 始终路由到 active LLM provider。 + // 这让 SettingsHubTab 等老 UI 在新 schema 下保持原本的"豆包"语义。 + let key = schema.active.llm + var existing = schema.providers.llm[key] ?? CredentialsProviderLLMEntry() apply(&existing) if existing.isAllEmpty { - schema.providers.llm.removeValue(forKey: defaultActiveLLMProviderId) + schema.providers.llm.removeValue(forKey: key) } else { - schema.providers.llm[defaultActiveLLMProviderId] = existing + schema.providers.llm[key] = existing } } } +// MARK: - 多 LLM provider 支持(B-3) + +extension CredentialsVault { + /// 当前选中的 LLM provider id;getter 同时承担"loadIfNeeded"。 + public var activeLLMProviderId: String { + get { + lock.lock() + defer { lock.unlock() } + loadIfNeededLocked() + return schema.active.llm + } + set { + lock.lock() + defer { lock.unlock() } + loadIfNeededLocked() + guard schema.active.llm != newValue else { return } + schema.active.llm = newValue + try? writeLocked() + } + } + + /// 列出所有已配置的 LLM provider id(包含还没填 apiKey 的占位条目)。 + /// 用于设置页的 picker;当前 active 即便条目不存在也会被列出,避免"切换-删除"流程把用户卡死。 + public var configuredLLMProviderIds: [String] { + lock.lock() + defer { lock.unlock() } + loadIfNeededLocked() + var ids = Set(schema.providers.llm.keys) + ids.insert(schema.active.llm) + return ids.sorted() + } + + /// 读出某个 provider 的完整 OpenAICompatibleConfig;缺 apiKey 也仍然返回(让 UI 显示空表单)。 + /// baseURL / displayName 缺省时用 registry 兜底;都没兜底则返回 nil。 + public func llmProviderConfig(for providerId: String) -> OpenAICompatibleConfig? { + lock.lock() + defer { lock.unlock() } + loadIfNeededLocked() + let entry = schema.providers.llm[providerId] + let preset = LLMProviderRegistry.preset(for: providerId) + + let displayName: String + if let stored = entry?.displayName, !stored.isEmpty { + displayName = stored + } else if let preset { + displayName = preset.displayName + } else { + displayName = providerId + } + + let baseURL: URL + if let stored = entry?.baseURL, + !stored.isEmpty, + let parsed = URL(string: stored.trimmingCharacters(in: .whitespacesAndNewlines)) { + baseURL = parsed + } else if let preset { + baseURL = preset.defaultBaseURL + } else { + // 自定义 provider 又没填 baseURL:无法构造 config。 + return nil + } + + let model = entry?.model ?? preset?.defaultModel ?? "" + let apiKey = entry?.apiKey ?? "" + let temperature = entry?.temperature ?? 0.3 + let extraHeaders = entry?.extraHeaders ?? [:] + + return OpenAICompatibleConfig( + providerId: providerId, + displayName: displayName, + baseURL: baseURL, + apiKey: apiKey, + model: model, + extraHeaders: extraHeaders, + temperature: temperature + ) + } + + /// 写一份完整的 OpenAICompatibleConfig 到对应 provider id 下。 + /// - 预设 provider:与 registry 默认值相同的 baseURL / displayName 不写盘(保持 JSON 简洁, + /// 未来 registry 调整时不会被旧文件锁死)。 + /// - 自定义 provider:所有字段都写入(包括 displayName,因为没有 registry 可以兜底)。 + public func setLLMProviderConfig(_ config: OpenAICompatibleConfig) { + lock.lock() + defer { lock.unlock() } + loadIfNeededLocked() + + let preset = LLMProviderRegistry.preset(for: config.providerId) + var entry = schema.providers.llm[config.providerId] ?? CredentialsProviderLLMEntry() + + // displayName:与 preset 相同就不写,UI 始终从 registry 拿。 + if let preset, preset.displayName == config.displayName { + entry.displayName = nil + } else { + let trimmed = config.displayName.trimmingCharacters(in: .whitespacesAndNewlines) + entry.displayName = trimmed.isEmpty ? nil : trimmed + } + + // baseURL:与 preset.defaultBaseURL 相同就不写。 + if let preset, preset.defaultBaseURL == config.baseURL { + entry.baseURL = nil + } else { + entry.baseURL = config.baseURL.absoluteString + } + + // model:与 preset.defaultModel 相同 / 为空 → 视为"未覆盖",不写盘。 + if let preset, !preset.defaultModel.isEmpty, preset.defaultModel == config.model { + entry.model = nil + } else { + let trimmed = config.model.trimmingCharacters(in: .whitespacesAndNewlines) + entry.model = trimmed.isEmpty ? nil : trimmed + } + + // apiKey:空字符串 → nil,避免落出 `"apiKey": ""`。 + let trimmedKey = config.apiKey.trimmingCharacters(in: .whitespacesAndNewlines) + entry.apiKey = trimmedKey.isEmpty ? nil : trimmedKey + + // temperature:默认 0.3 视为未覆盖。 + entry.temperature = config.temperature == 0.3 ? nil : config.temperature + + // extraHeaders:空 dict 收敛成 nil。 + entry.extraHeaders = config.extraHeaders.isEmpty ? nil : config.extraHeaders + + if entry.isAllEmpty { + schema.providers.llm.removeValue(forKey: config.providerId) + } else { + schema.providers.llm[config.providerId] = entry + } + try? writeLocked() + } + + /// 删除某个 LLM provider;不允许删 active provider。 + /// 需要先把 `activeLLMProviderId` 切到别的 id,再删旧条目。 + public func removeLLMProvider(_ providerId: String) throws { + lock.lock() + defer { lock.unlock() } + loadIfNeededLocked() + + if schema.active.llm == providerId { + throw CredentialsError.cannotRemoveActiveProvider(providerId) + } + guard schema.providers.llm[providerId] != nil else { return } + schema.providers.llm.removeValue(forKey: providerId) + try writeLocked() + } +} + // MARK: - 时间戳工具 /// 紧凑 ISO8601 时间戳,例如 `20260429T103045`。固定 UTC,避免 DST。 diff --git a/Sources/OpenLessPolish/DoubaoPolishClient.swift b/Sources/OpenLessPolish/DoubaoPolishClient.swift deleted file mode 100644 index c638b35c..00000000 --- a/Sources/OpenLessPolish/DoubaoPolishClient.swift +++ /dev/null @@ -1,137 +0,0 @@ -import Foundation -import OpenLessCore - -public enum PolishError: Error, Sendable { - case credentialsMissing - case requestFailed(String) - case decodeFailed - case modelReturnedEmpty -} - -@available(*, deprecated, message: "Use OpenAICompatibleLLMProvider with Ark config; this class will be removed after the provider migration ships.") -public final class DoubaoPolishClient: @unchecked Sendable { - private let credentials: ArkCredentials - private let logger: (@Sendable (String) -> Void)? - private let session: URLSession - - public init( - credentials: ArkCredentials, - logger: (@Sendable (String) -> Void)? = nil, - session: URLSession = .shared - ) { - self.credentials = credentials - self.logger = logger - self.session = session - } - - public func polish( - rawTranscript: RawTranscript, - mode: PolishMode, - referenceExamples: [PolishReferenceExample] = [], - dictionaryEntries: [DictionaryEntry] = [] - ) async throws -> FinalText { - guard !rawTranscript.text.isEmpty else { - return FinalText(text: "", mode: mode) - } - - var request = URLRequest(url: Self.chatCompletionsURL(from: credentials.endpoint)) - request.httpMethod = "POST" - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.setValue("Bearer \(credentials.apiKey)", forHTTPHeaderField: "Authorization") - // 之前 30s 太短:DeepSeek V3.2 / R1 等模型即使关思考首 token 也可能 5-10s。 - // 改成 10 分钟,等同"无上限"——polish 本身一定不会真挂这么久。 - request.timeoutInterval = 600 - - let body: [String: Any] = [ - "model": credentials.modelId, - "stream": false, - "temperature": 0.3, - // 关掉深度思考:V3.2 / R1 / Doubao Thinking 等模型默认会先生成一段"思考过程" - // 再给最终答案,对"整理一段口语"这种短任务是浪费——延迟 + 成本都翻几倍。 - // 不支持 thinking 字段的模型(如 Doubao 普通版)会忽略这个参数。 - "thinking": ["type": "disabled"], - "messages": [ - ["role": "system", "content": PolishPrompts.systemPrompt(for: mode)], - ["role": "user", "content": PolishPrompts.userPrompt( - for: rawTranscript.text, - referenceExamples: referenceExamples, - dictionaryEntries: dictionaryEntries - )], - ], - ] - request.httpBody = try JSONSerialization.data(withJSONObject: body) - - if let httpBody = request.httpBody, let bodyStr = String(data: httpBody, encoding: .utf8) { - logger?("[polish] request body: \(bodyStr.prefix(500))") - } - - let (data, response) = try await session.data(for: request) - guard let http = response as? HTTPURLResponse else { - throw PolishError.requestFailed("not http") - } - let respPreview = String(data: data, encoding: .utf8) ?? "" - logger?("[polish] HTTP \(http.statusCode) response: \(respPreview.prefix(500))") - guard (200..<300).contains(http.statusCode) else { - throw PolishError.requestFailed("HTTP \(http.statusCode): \(respPreview.prefix(300))") - } - guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], - let choices = json["choices"] as? [[String: Any]], - let first = choices.first, - let message = first["message"] as? [String: Any], - let content = message["content"] as? String else { - throw PolishError.decodeFailed - } - let trimmed = PolishOutputCleaner.clean(content) - guard !trimmed.isEmpty else { throw PolishError.modelReturnedEmpty } - return FinalText(text: trimmed, mode: mode) - } - - /// Coding Plan 文档给的是 base URL `https://ark.cn-beijing.volces.com/api/coding/v3`, - /// OpenAI SDK 会自动追加 `/chat/completions`。我们裸 URLRequest 得手动补。 - /// 用户已经填好完整 URL(含 /chat/completions)时不重复追加。 - static func chatCompletionsURL(from endpoint: URL) -> URL { - let s = endpoint.absoluteString - if s.hasSuffix("/chat/completions") { - return endpoint - } - let trimmed = s.hasSuffix("/") ? String(s.dropLast()) : s - return URL(string: trimmed + "/chat/completions") ?? endpoint - } -} - -enum PolishOutputCleaner { - private static let leadingBoilerplatePatterns: [String] = [ - #"(?s)^根据[你您]?(?:给的|提供的)?内容[,,\s]*(?:我)?(?:已经|已)?(?:整理|优化)(?:好|完成)?(?:如下)?[::\s]*"#, - #"(?s)^以下(?:是|为)?(?:整理|优化|结构化整理)后?的?内容(?:如下)?[::\s]*"#, - #"(?s)^(?:整理|优化|结构化整理)(?:后?的?内容)?(?:如下)?[::\s]*"#, - ] - - static func clean(_ content: String) -> String { - var output = content.trimmingCharacters(in: .whitespacesAndNewlines) - output = stripMarkdownFence(from: output) - - var changed = true - while changed { - changed = false - for pattern in leadingBoilerplatePatterns { - if let range = output.range(of: pattern, options: .regularExpression), - range.lowerBound == output.startIndex { - output.removeSubrange(range) - output = output.trimmingCharacters(in: .whitespacesAndNewlines) - changed = true - } - } - } - - return output - } - - private static func stripMarkdownFence(from text: String) -> String { - guard text.hasPrefix("```"), text.hasSuffix("```") else { return text } - var lines = text.components(separatedBy: .newlines) - guard lines.count >= 2 else { return text } - lines.removeFirst() - lines.removeLast() - return lines.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines) - } -} diff --git a/Sources/OpenLessPolish/OpenAICompatibleLLMProvider.swift b/Sources/OpenLessPolish/OpenAICompatibleLLMProvider.swift index 0c690f90..da10df7d 100644 --- a/Sources/OpenLessPolish/OpenAICompatibleLLMProvider.swift +++ b/Sources/OpenLessPolish/OpenAICompatibleLLMProvider.swift @@ -144,6 +144,8 @@ public final class OpenAICompatibleLLMProvider: LLMProvider, @unchecked Sendable guard let content = message["content"] as? String else { throw LLMError.parseError("message.content is not a string") } - return content + // 即便 prompt 明令禁止"根据您给的内容,整理如下..."这类引导句,部分模型仍会顽固加; + // 客户端再扫一道,让插入到光标位置的永远是干净正文。 + return PolishOutputCleaner.clean(content) } } diff --git a/Sources/OpenLessPolish/PolishOutputCleaner.swift b/Sources/OpenLessPolish/PolishOutputCleaner.swift new file mode 100644 index 00000000..bb386df7 --- /dev/null +++ b/Sources/OpenLessPolish/PolishOutputCleaner.swift @@ -0,0 +1,48 @@ +import Foundation + +/// 清理 LLM 返回内容里常见的"引导套话"和 markdown 代码栅栏。 +/// +/// 即使我们在 prompt 里明确禁止"根据您给的内容,整理如下"等开场白,仍有部分模型会顽固加上; +/// 在客户端再扫一遍确保插入到光标位置的就是干净正文。 +/// +/// 这是公共类型——之前藏在 DoubaoPolishClient 内部,B-3 抽成独立文件后被 +/// `OpenAICompatibleLLMProvider` 复用,并保留 `PolishOutputCleanerTests` 直接测试入口。 +public enum PolishOutputCleaner { + /// 已知会被加在正文最前面的引导套话正则集合(顺序无关,循环匹配)。 + private static let leadingBoilerplatePatterns: [String] = [ + #"(?s)^根据[你您]?(?:给的|提供的)?内容[,,\s]*(?:我)?(?:已经|已)?(?:整理|优化)(?:好|完成)?(?:如下)?[::\s]*"#, + #"(?s)^以下(?:是|为)?(?:整理|优化|结构化整理)后?的?内容(?:如下)?[::\s]*"#, + #"(?s)^(?:整理|优化|结构化整理)(?:后?的?内容)?(?:如下)?[::\s]*"#, + ] + + public static func clean(_ content: String) -> String { + var output = content.trimmingCharacters(in: .whitespacesAndNewlines) + output = stripMarkdownFence(from: output) + + // 多模型可能叠加多次套话;循环到不再变化为止。 + var changed = true + while changed { + changed = false + for pattern in leadingBoilerplatePatterns { + if let range = output.range(of: pattern, options: .regularExpression), + range.lowerBound == output.startIndex { + output.removeSubrange(range) + output = output.trimmingCharacters(in: .whitespacesAndNewlines) + changed = true + } + } + } + + return output + } + + /// 去掉首尾的 ```...``` 围栏(含语言标记行如 ```text)。 + private static func stripMarkdownFence(from text: String) -> String { + guard text.hasPrefix("```"), text.hasSuffix("```") else { return text } + var lines = text.components(separatedBy: .newlines) + guard lines.count >= 2 else { return text } + lines.removeFirst() + lines.removeLast() + return lines.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines) + } +} diff --git a/Tests/OpenLessCoreTests/LLMProviderRegistryTests.swift b/Tests/OpenLessCoreTests/LLMProviderRegistryTests.swift new file mode 100644 index 00000000..23dffab7 --- /dev/null +++ b/Tests/OpenLessCoreTests/LLMProviderRegistryTests.swift @@ -0,0 +1,86 @@ +import XCTest +@testable import OpenLessCore + +/// LLMProviderRegistry 的静态健康检查:5 家供应商各 1 条,id 不重复,URL 合法。 +/// +/// 这些断言看起来很无聊,但 registry 直接被设置 UI 和 vault 兜底逻辑用, +/// 漏一条都会变成"用户加不了 provider / baseURL 解不出来"的运行期 bug。 +final class LLMProviderRegistryTests: XCTestCase { + + func test_presets_haveExpectedCount() { + // Arrange + let presets = LLMProviderRegistry.presets + + // Act / Assert + XCTAssertEqual(presets.count, 5, "B-3 计划列出的预设是 ark / openai / aliyun-dashscope / deepseek / moonshot") + } + + func test_presets_includeAllExpectedProviderIds() { + // Arrange + let expected: Set = ["ark", "openai", "aliyun-dashscope", "deepseek", "moonshot"] + + // Act + let actualIds = Set(LLMProviderRegistry.presets.map(\.providerId)) + + // Assert + XCTAssertEqual(actualIds, expected) + } + + func test_presets_haveUniqueProviderIds() { + // Arrange + let ids = LLMProviderRegistry.presets.map(\.providerId) + + // Act / Assert + XCTAssertEqual(Set(ids).count, ids.count, "providerId 必须唯一,否则 vault 会被互相覆盖") + } + + func test_presets_haveValidBaseURLs() { + for preset in LLMProviderRegistry.presets { + // Arrange + let url = preset.defaultBaseURL + + // Act / Assert:URL 必须是 https + 有 host + XCTAssertEqual(url.scheme, "https", "preset \(preset.providerId) 的 baseURL 必须是 https,避免网络层降级") + XCTAssertNotNil(url.host, "preset \(preset.providerId) 的 baseURL 必须有 host") + } + } + + func test_arkPreset_hasEmptyDefaultModel() { + // Ark 的 defaultModel 故意留空——endpoint id 是用户在控制台拿到的 hash, + // 不能预设。如果哪天默认填了一个看起来像 model 的字符串,UI 会误导用户。 + let ark = LLMProviderRegistry.preset(for: "ark") + XCTAssertNotNil(ark) + XCTAssertEqual(ark?.defaultModel, "") + } + + func test_nonArkPresets_haveDefaultModel() { + // OpenAI / DeepSeek / Moonshot / DashScope 都给了一个开箱即用的 model。 + for preset in LLMProviderRegistry.presets where preset.providerId != "ark" { + XCTAssertFalse(preset.defaultModel.isEmpty, "preset \(preset.providerId) 应当有 defaultModel") + } + } + + func test_presets_haveNonEmptyHelpText() { + for preset in LLMProviderRegistry.presets { + XCTAssertFalse(preset.helpText.isEmpty, "preset \(preset.providerId) 的 helpText 不能为空") + } + } + + func test_preset_lookup_returnsRegistered() { + for preset in LLMProviderRegistry.presets { + let resolved = LLMProviderRegistry.preset(for: preset.providerId) + XCTAssertEqual(resolved, preset) + } + } + + func test_preset_lookup_unknownReturnsNil() { + XCTAssertNil(LLMProviderRegistry.preset(for: "no-such-provider-xyz")) + } + + func test_customSentinels_areDistinctFromPresetIds() { + // 添加 sheet 用 customProviderId 区分"预设 vs 自定义"——它必须不在预设列表里, + // 否则会被点击事件混淆。 + let presetIds = Set(LLMProviderRegistry.presets.map(\.providerId)) + XCTAssertFalse(presetIds.contains(LLMProviderRegistry.customProviderId)) + } +} diff --git a/Tests/OpenLessPersistenceTests/CredentialsVaultMultiProviderTests.swift b/Tests/OpenLessPersistenceTests/CredentialsVaultMultiProviderTests.swift new file mode 100644 index 00000000..1f3f42c6 --- /dev/null +++ b/Tests/OpenLessPersistenceTests/CredentialsVaultMultiProviderTests.swift @@ -0,0 +1,253 @@ +import XCTest +import OpenLessCore +@testable import OpenLessPersistence + +/// CredentialsVault 多 LLM provider 扩展(B-3)的集成测试。 +/// +/// 用例策略与 CredentialsMigrationTests 一致:每个 case 独立 temp 目录, +/// 通过 vault 的 public API 验证多 provider 行为。 +final class CredentialsVaultMultiProviderTests: XCTestCase { + + // MARK: - 辅助 + + private func makeTempDirectory() -> URL { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("openless-multi-provider-\(UUID().uuidString)", isDirectory: true) + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + addTeardownBlock { + try? FileManager.default.removeItem(at: dir) + } + return dir + } + + /// 写一份 v0 字典文件(点号风格 key),让 vault 自动迁移。 + private func writeV0File(_ dict: [String: String], to dir: URL) throws { + let url = dir.appendingPathComponent("credentials.json") + let data = try JSONSerialization.data(withJSONObject: dict, options: [.prettyPrinted]) + try data.write(to: url) + } + + // MARK: - active provider id + + func test_freshInstall_activeLLMProviderId_defaultsToArk() { + // Arrange + let dir = makeTempDirectory() + + // Act + let vault = CredentialsVault(directoryURL: dir) + let active = vault.activeLLMProviderId + + // Assert:B-2 的默认 active.llm 是 "ark",B-3 改成多 provider 后语义不变。 + XCTAssertEqual(active, "ark") + } + + func test_v0Migrated_activeLLMProviderId_isArk() throws { + // Arrange:典型迁移场景——从 v0 翻译过来 active.llm 应是 ark。 + let dir = makeTempDirectory() + try writeV0File([ + "ark.api_key": "sk-ark", + "ark.model_id": "deepseek-v3-2" + ], to: dir) + + // Act + let vault = CredentialsVault(directoryURL: dir) + + // Assert + XCTAssertEqual(vault.activeLLMProviderId, "ark") + } + + func test_setActive_persists() throws { + // Arrange + let dir = makeTempDirectory() + + // 先写一个 deepseek provider 进去(可以是空字段)。 + let vault = CredentialsVault(directoryURL: dir) + vault.setLLMProviderConfig(OpenAICompatibleConfig( + providerId: "deepseek", + displayName: "DeepSeek", + baseURL: URL(string: "https://api.deepseek.com/v1")!, + apiKey: "sk-deepseek", + model: "deepseek-chat" + )) + + // Act + vault.activeLLMProviderId = "deepseek" + + // Assert:内存视图 + XCTAssertEqual(vault.activeLLMProviderId, "deepseek") + + // 再开一个新 vault 实例确认落盘。 + let vault2 = CredentialsVault(directoryURL: dir) + XCTAssertEqual(vault2.activeLLMProviderId, "deepseek") + } + + // MARK: - configuredLLMProviderIds + + func test_configuredIds_emptyVault_returnsActive() { + // Arrange + let dir = makeTempDirectory() + + // Act + let vault = CredentialsVault(directoryURL: dir) + let ids = vault.configuredLLMProviderIds + + // Assert:vault 没有任何条目时,至少要返回 active id(picker 不能空)。 + XCTAssertEqual(ids, ["ark"]) + } + + func test_configuredIds_returnsAllConfigured() throws { + // Arrange + let dir = makeTempDirectory() + let vault = CredentialsVault(directoryURL: dir) + vault.setLLMProviderConfig(OpenAICompatibleConfig( + providerId: "ark", + displayName: "豆包 (Ark)", + baseURL: URL(string: "https://ark.cn-beijing.volces.com/api/v3")!, + apiKey: "ark-key", + model: "ep-1" + )) + vault.setLLMProviderConfig(OpenAICompatibleConfig( + providerId: "openai", + displayName: "OpenAI", + baseURL: URL(string: "https://api.openai.com/v1")!, + apiKey: "sk-openai", + model: "gpt-4o-mini" + )) + vault.setLLMProviderConfig(OpenAICompatibleConfig( + providerId: "deepseek", + displayName: "DeepSeek", + baseURL: URL(string: "https://api.deepseek.com/v1")!, + apiKey: "sk-deepseek", + model: "deepseek-chat" + )) + + // Act + let ids = vault.configuredLLMProviderIds + + // Assert:3 家全在;顺序 sorted。 + XCTAssertEqual(ids, ["ark", "deepseek", "openai"]) + } + + // MARK: - setLLMProviderConfig / llmProviderConfig 往返 + + func test_setAndGetConfig_roundTrips() { + // Arrange + let dir = makeTempDirectory() + let vault = CredentialsVault(directoryURL: dir) + let cfg = OpenAICompatibleConfig( + providerId: "deepseek", + displayName: "DeepSeek", + baseURL: URL(string: "https://api.deepseek.com/v1")!, + apiKey: "sk-deep-12345", + model: "deepseek-chat", + extraHeaders: ["X-Custom": "value"], + temperature: 0.5 + ) + + // Act + vault.setLLMProviderConfig(cfg) + let back = vault.llmProviderConfig(for: "deepseek") + + // Assert:所有字段都应一一回读。 + XCTAssertEqual(back?.providerId, cfg.providerId) + XCTAssertEqual(back?.displayName, cfg.displayName) + XCTAssertEqual(back?.baseURL, cfg.baseURL) + XCTAssertEqual(back?.apiKey, cfg.apiKey) + XCTAssertEqual(back?.model, cfg.model) + XCTAssertEqual(back?.temperature, cfg.temperature) + XCTAssertEqual(back?.extraHeaders, cfg.extraHeaders) + } + + func test_getConfig_unknownProvider_returnsNilWithoutPreset() { + // Arrange + let dir = makeTempDirectory() + + // Act + let vault = CredentialsVault(directoryURL: dir) + let cfg = vault.llmProviderConfig(for: "unknown-no-preset-xyz") + + // Assert:自定义 + 没有任何条目 + 没有预设 → 兜不出 baseURL,应返回 nil。 + XCTAssertNil(cfg) + } + + func test_getConfig_presetWithoutEntry_usesPresetDefaults() { + // Arrange + let dir = makeTempDirectory() + + // Act + let vault = CredentialsVault(directoryURL: dir) + // openai 没有 entry,但有 preset;llmProviderConfig 应 fall back 到 preset 的 baseURL。 + let cfg = vault.llmProviderConfig(for: "openai") + + // Assert + XCTAssertNotNil(cfg) + XCTAssertEqual(cfg?.baseURL.absoluteString, "https://api.openai.com/v1") + XCTAssertEqual(cfg?.displayName, "OpenAI") + XCTAssertEqual(cfg?.model, "gpt-4o-mini") + XCTAssertEqual(cfg?.apiKey, "") + } + + // MARK: - removeLLMProvider + + func test_removeLLMProvider_blocksDeletingActive() throws { + // Arrange + let dir = makeTempDirectory() + let vault = CredentialsVault(directoryURL: dir) + + // Act / Assert:默认 active 是 ark,不能删。 + XCTAssertThrowsError(try vault.removeLLMProvider("ark")) { err in + guard case CredentialsError.cannotRemoveActiveProvider(let id) = err else { + XCTFail("应该抛 cannotRemoveActiveProvider,实际:\(err)") + return + } + XCTAssertEqual(id, "ark") + } + } + + func test_removeLLMProvider_removesNonActive() throws { + // Arrange + let dir = makeTempDirectory() + let vault = CredentialsVault(directoryURL: dir) + vault.setLLMProviderConfig(OpenAICompatibleConfig( + providerId: "deepseek", + displayName: "DeepSeek", + baseURL: URL(string: "https://api.deepseek.com/v1")!, + apiKey: "sk", + model: "deepseek-chat" + )) + + // Act + try vault.removeLLMProvider("deepseek") + + // Assert + XCTAssertNil(vault.llmProviderConfig(for: "deepseek")?.apiKey == "sk" ? () : ()) // sanity + let ids = vault.configuredLLMProviderIds + XCTAssertFalse(ids.contains("deepseek")) + } + + func test_removeLLMProvider_unknownIdIsNoop() { + // Arrange + let dir = makeTempDirectory() + let vault = CredentialsVault(directoryURL: dir) + + // Act / Assert:删一个根本不在的 id 不应抛错。 + XCTAssertNoThrow(try vault.removeLLMProvider("never-existed")) + } + + // MARK: - 兼容老 API + + func test_legacyArkAccount_routesToActive() throws { + // Arrange + let dir = makeTempDirectory() + let vault = CredentialsVault(directoryURL: dir) + + // 通过老 API 写入。 + try vault.set("legacy-key", for: CredentialAccount.arkApiKey) + + // Act:新的 multi-provider API 应能读出来。 + let cfg = vault.llmProviderConfig(for: "ark") + + // Assert + XCTAssertEqual(cfg?.apiKey, "legacy-key") + } +} From 2517df5d2f4cf45f5cda8371a97cc1996fbb07ac Mon Sep 17 00:00:00 2001 From: baiqing Date: Wed, 29 Apr 2026 11:54:24 +0800 Subject: [PATCH 2/2] 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 }