Merge B-3: LLM Settings UI + DictationCoordinator rewire
Phase B-3 of provider abstraction. Adds LLMProvidersTab Settings UI with 5 OpenAI-compat presets (豆包/OpenAI/阿里通义/DeepSeek/Moonshot) + custom OpenAI-compatible entry. Generalizes providers.llm to [String: CredentialsProviderLLMEntry] (backward compat via typealias). Rewires DictationCoordinator to use OpenAICompatibleLLMProvider against the active LLM provider config, preserving silent fallback to raw transcript when creds missing. PolishOutputCleaner extracted from old DoubaoPolishClient (now deleted) so OpenAICompatibleLLMProvider keeps the boilerplate-stripping behavior. 21 new tests across LLMProviderRegistryTests + CredentialsVaultMultiProviderTests. Branch: feature/settings-ui-llm
This commit is contained in:
commit
8d39490176
13 files changed
+1412
-202
No files matched your search
@@ -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。
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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<Void, Never>?
|
||||
|
||||
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<String>
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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`。
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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) ?? "<no body>"
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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<String> = ["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))
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user