diff --git a/.gitignore b/.gitignore index 0ebb7ed9..c6208f8a 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ config.local.json # Local working dirs that don't belong in the repo SC/ +apps/ promo/ promo-openless/ docs/old-promo/ diff --git a/Sources/OpenLessApp/Settings/Tabs/Providers/AddLLMProviderSheet.swift b/Sources/OpenLessApp/Settings/Tabs/Providers/AddLLMProviderSheet.swift new file mode 100644 index 00000000..4e5d0dad --- /dev/null +++ b/Sources/OpenLessApp/Settings/Tabs/Providers/AddLLMProviderSheet.swift @@ -0,0 +1,174 @@ +import SwiftUI +import OpenLessCore + +/// 添加 LLM 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 } + if LLMProviderRegistry.preset(for: id) != nil { return false } + if existingIds.contains(id) { return false } + return true + } +} diff --git a/Sources/OpenLessApp/Settings/Tabs/Providers/ProviderChipRows.swift b/Sources/OpenLessApp/Settings/Tabs/Providers/ProviderChipRows.swift new file mode 100644 index 00000000..68cfeddc --- /dev/null +++ b/Sources/OpenLessApp/Settings/Tabs/Providers/ProviderChipRows.swift @@ -0,0 +1,143 @@ +import SwiftUI +import OpenLessCore +import OpenLessASR + +@MainActor +struct LLMProviderChipRow: View { + let presets: [LLMProviderRegistry.Preset] + let selectedProviderId: String + let activeProviderId: String + let onSelect: (String) -> Void + let onAddCustom: () -> Void + + var body: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(presets, id: \.providerId) { preset in + ProviderChipButton( + title: preset.displayName, + symbol: chipSymbol(for: preset.providerId), + isSelected: selectedProviderId == preset.providerId, + isActive: activeProviderId == preset.providerId, + onSelect: { onSelect(preset.providerId) } + ) + } + + customProviderChip + + Button(action: onAddCustom) { + Label("自定义", systemImage: "plus.circle") + .padding(.horizontal, 12) + .padding(.vertical, 9) + } + .buttonStyle(.bordered) + .controlSize(.small) + .help("添加自定义 OpenAI 兼容 LLM provider") + } + .padding(.vertical, 2) + } + } + + @ViewBuilder + private var customProviderChip: some View { + if !selectedProviderId.isEmpty, + LLMProviderRegistry.preset(for: selectedProviderId) == nil { + ProviderChipButton( + title: "自定义 (\(selectedProviderId))", + symbol: "slider.horizontal.3", + isSelected: true, + isActive: activeProviderId == selectedProviderId, + onSelect: { onSelect(selectedProviderId) } + ) + } + } + + private func chipSymbol(for providerId: String) -> String { + switch providerId { + case "ark": return "sparkles" + case "openai": return "circle.hexagongrid.fill" + case "aliyun-dashscope": return "cloud.fill" + case "deepseek": return "fish" + case "moonshot": return "moon.stars" + default: return "wand.and.stars" + } + } +} + +@MainActor +struct ASRProviderChipRow: View { + let presets: [ASRProviderRegistry.Preset] + let selectedProviderId: String + let activeProviderId: String + let onSelect: (String) -> Void + + var body: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(presets, id: \.providerId) { preset in + ProviderChipButton( + title: preset.displayName, + symbol: chipSymbol(for: preset.providerId), + isSelected: selectedProviderId == preset.providerId, + isActive: activeProviderId == preset.providerId, + onSelect: { onSelect(preset.providerId) } + ) + } + } + .padding(.vertical, 2) + } + } + + private func chipSymbol(for providerId: String) -> String { + switch providerId { + case "volcengine": return "cloud.fill" + case "apple-speech": return "applelogo" + case "aliyun-paraformer": return "waveform.badge.mic" + case "custom-openai-whisper": return "slider.horizontal.3" + default: return "waveform" + } + } +} + +@MainActor +private struct ProviderChipButton: View { + let title: String + let symbol: String + let isSelected: Bool + let isActive: Bool + let onSelect: () -> Void + + var body: some View { + Button(action: onSelect) { + HStack(spacing: 7) { + Image(systemName: symbol) + .symbolRenderingMode(.hierarchical) + Text(title) + .font(.system(size: 13, weight: isSelected ? .semibold : .regular)) + if isActive { + Text("当前") + .font(.caption2) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background( + RoundedRectangle(cornerRadius: 4, style: .continuous) + .fill(Color.green.opacity(0.18)) + ) + .foregroundStyle(.green) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 9) + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(isSelected ? Color.blue.opacity(0.15) : Color.primary.opacity(0.04)) + ) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .strokeBorder(isSelected ? Color.blue.opacity(0.6) : Color.primary.opacity(0.12), lineWidth: isSelected ? 1.5 : 1) + ) + .foregroundStyle(isSelected ? .primary : .secondary) + } + .buttonStyle(.plain) + } +} diff --git a/Sources/OpenLessApp/Settings/Tabs/Providers/ProvidersConfigDrafts.swift b/Sources/OpenLessApp/Settings/Tabs/Providers/ProvidersConfigDrafts.swift new file mode 100644 index 00000000..b71a9cb1 --- /dev/null +++ b/Sources/OpenLessApp/Settings/Tabs/Providers/ProvidersConfigDrafts.swift @@ -0,0 +1,26 @@ +import OpenLessASR + +struct LLMConfigDraft: Equatable { + var apiKey: String = "" + var baseURL: String = "" + var model: String = "" + var temperature: Double = 0.3 + /// 自定义 provider 的展示名。预设条目从 registry 兜底,不暴露在 UI 上。 + var displayName: String = "" +} + +struct VolcengineDraft: Equatable { + var appKey: String = "" + var accessKey: String = "" + var resourceId: String = VolcengineCredentials.defaultResourceId +} + +struct AliyunParaformerDraft: Equatable { + var apiKey: String = "" +} + +struct CustomWhisperDraft: Equatable { + var baseURL: String = "" + var apiKey: String = "" + var model: String = "" +} diff --git a/Sources/OpenLessApp/Settings/Tabs/Providers/ProvidersConfigModel.swift b/Sources/OpenLessApp/Settings/Tabs/Providers/ProvidersConfigModel.swift new file mode 100644 index 00000000..97248319 --- /dev/null +++ b/Sources/OpenLessApp/Settings/Tabs/Providers/ProvidersConfigModel.swift @@ -0,0 +1,372 @@ +import Foundation +import SwiftUI +import OpenLessCore +import OpenLessASR +import OpenLessPersistence + +/// 配置 Tab 的核心:把所有 LLM / ASR 改动都积攒在内存里,commit() 时一次性写盘。 +@MainActor +final class ProvidersConfigModel: ObservableObject { + @Published private(set) var configuredLLMProviderIds: [String] = [] + @Published private(set) var activeLLMProviderId: String = defaultActiveLLMProviderId + @Published private(set) var selectedLLMProviderId: String = defaultActiveLLMProviderId + @Published var llmDraft = LLMConfigDraft() + @Published var isShowingAddCustom = false + + @Published private(set) var activeASRProviderId: String = defaultActiveASRProviderId + @Published private(set) var selectedASRProviderId: String = defaultActiveASRProviderId + @Published var volcDraft = VolcengineDraft() + @Published var aliyunParaformerDraft = AliyunParaformerDraft() + @Published var customWhisperDraft = CustomWhisperDraft() + + /// 用户在草稿态下编辑过的所有 provider 配置(key 为 providerId)。 + /// 切到别的 provider 不会丢;commit 时一起写盘。 + private var pendingLLMConfigs: [String: LLMConfigDraft] = [:] + /// 用户在草稿态下"添加"的自定义 provider id 列表。commit 时若仍未填关键字段会被回滚。 + private var pendingNewLLMProviders: Set = [] + /// 用户在草稿态下「删除」的 provider id 列表。commit 时一并清掉。 + private var pendingDeletedLLMProviders: Set = [] + + /// 加载时记录的 ASR 三类字段值,作为「是否有未保存改动」的基线。 + /// LLM 改动直接与 vault 比较,因此不需要快照。 + private var initialSnapshot = Snapshot() + + private struct Snapshot: Equatable { + var volc: VolcengineDraft = VolcengineDraft() + var aliyunParaformer: AliyunParaformerDraft = AliyunParaformerDraft() + var customWhisper: CustomWhisperDraft = CustomWhisperDraft() + } + + var hasUnsavedChanges: Bool { + if selectedLLMProviderId != activeLLMProviderId { return true } + if selectedASRProviderId != activeASRProviderId { return true } + if !isCurrentLLMDraftClean() { return true } + + if !pendingDeletedLLMProviders.isEmpty { return true } + if !pendingNewLLMProviders.isEmpty { return true } + for (id, draft) in pendingLLMConfigs where id != selectedLLMProviderId { + if !isLLMDraftClean(providerId: id, draft: draft) { return true } + } + + if volcDraft != initialSnapshot.volc { return true } + if aliyunParaformerDraft != initialSnapshot.aliyunParaformer { return true } + if customWhisperDraft != initialSnapshot.customWhisper { return true } + + return false + } + + var aliyunDashScopeApiKeyAvailable: Bool { + if let cfg = pendingLLMConfigs["aliyun-dashscope"] { + return !cfg.apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + if let cfg = CredentialsVault.shared.llmProviderConfig(for: "aliyun-dashscope") { + return !cfg.apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + return false + } + + // MARK: - Load + + func load() { + let vault = CredentialsVault.shared + configuredLLMProviderIds = vault.configuredLLMProviderIds + activeLLMProviderId = vault.activeLLMProviderId + activeASRProviderId = vault.activeASRProviderId + + if !configuredLLMProviderIds.contains(selectedLLMProviderId) { + selectedLLMProviderId = activeLLMProviderId + } + if !ASRProviderRegistry.presets.contains(where: { $0.providerId == selectedASRProviderId }) { + selectedASRProviderId = activeASRProviderId + } + + pendingLLMConfigs = [:] + pendingNewLLMProviders = [] + pendingDeletedLLMProviders = [] + + loadLLMDraftFromVault(for: selectedLLMProviderId) + loadVolcDraftFromVault() + loadAliyunParaformerDraft() + loadCustomWhisperDraft() + + initialSnapshot = baselineSnapshot() + } + + private func baselineSnapshot() -> Snapshot { + Snapshot( + volc: volcDraft, + aliyunParaformer: aliyunParaformerDraft, + customWhisper: customWhisperDraft + ) + } + + // MARK: - LLM Selection + + func selectLLMProvider(_ providerId: String) { + stashCurrentLLMDraft() + selectedLLMProviderId = providerId + loadLLMDraftFromVault(for: providerId) + } + + func updateLLMApiKey(_ value: String) { + llmDraft.apiKey = value + } + + func updateLLMBaseURL(_ value: String) { + llmDraft.baseURL = value + } + + func updateLLMModel(_ value: String) { + llmDraft.model = value + } + + func updateLLMTemperature(_ value: Double) { + llmDraft.temperature = value + } + + func beginAddCustomLLM() { + isShowingAddCustom = true + } + + func addLLMProvider(providerId: String, displayName: String) { + let trimmedId = providerId.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedId.isEmpty else { return } + stashCurrentLLMDraft() + + var draft = LLMConfigDraft() + let preset = LLMProviderRegistry.preset(for: trimmedId) + draft.displayName = displayName.isEmpty ? (preset?.displayName ?? trimmedId) : displayName + draft.baseURL = preset?.defaultBaseURL.absoluteString ?? "" + draft.model = preset?.defaultModel ?? "" + pendingLLMConfigs[trimmedId] = draft + pendingNewLLMProviders.insert(trimmedId) + pendingDeletedLLMProviders.remove(trimmedId) + + if !configuredLLMProviderIds.contains(trimmedId) { + configuredLLMProviderIds.append(trimmedId) + configuredLLMProviderIds.sort() + } + selectedLLMProviderId = trimmedId + llmDraft = draft + } + + func deleteSelectedLLM() { + let id = selectedLLMProviderId + guard id != activeLLMProviderId else { return } + guard LLMProviderRegistry.preset(for: id) == nil else { return } + + pendingDeletedLLMProviders.insert(id) + pendingNewLLMProviders.remove(id) + pendingLLMConfigs.removeValue(forKey: id) + configuredLLMProviderIds.removeAll { $0 == id } + selectedLLMProviderId = activeLLMProviderId + loadLLMDraftFromVault(for: selectedLLMProviderId) + } + + // MARK: - ASR Selection + + func selectASRProvider(_ providerId: String) { + selectedASRProviderId = providerId + } + + func updateVolcAppKey(_ value: String) { + volcDraft.appKey = value + } + + func updateVolcAccessKey(_ value: String) { + volcDraft.accessKey = value + } + + func updateVolcResourceId(_ value: String) { + volcDraft.resourceId = value + } + + func updateAliyunParaformerApiKey(_ value: String) { + aliyunParaformerDraft.apiKey = value + } + + func updateCustomWhisperBaseURL(_ value: String) { + customWhisperDraft.baseURL = value + } + + func updateCustomWhisperApiKey(_ value: String) { + customWhisperDraft.apiKey = value + } + + func updateCustomWhisperModel(_ value: String) { + customWhisperDraft.model = value + } + + // MARK: - Commit + + func commit() { + stashCurrentLLMDraft() + + let vault = CredentialsVault.shared + + for id in pendingDeletedLLMProviders { + try? vault.removeLLMProvider(id) + } + for (providerId, draft) in pendingLLMConfigs { + persistLLMDraft(providerId: providerId, draft: draft) + } + if vault.activeLLMProviderId != selectedLLMProviderId, + !pendingDeletedLLMProviders.contains(selectedLLMProviderId) { + vault.activeLLMProviderId = selectedLLMProviderId + } + + persistVolcDraft() + persistAliyunParaformerDraft() + persistCustomWhisperDraft() + if vault.activeASRProviderId != selectedASRProviderId { + vault.activeASRProviderId = selectedASRProviderId + } + + NotificationCenter.default.post(name: .openLessCredentialsChanged, object: nil) + load() + } + + // MARK: - LLM Drafts + + private func isCurrentLLMDraftClean() -> Bool { + isLLMDraftClean(providerId: selectedLLMProviderId, draft: llmDraft) + } + + private func isLLMDraftClean(providerId: String, draft: LLMConfigDraft) -> Bool { + draft == vaultLLMDraft(for: providerId) + } + + private func vaultLLMDraft(for providerId: String) -> LLMConfigDraft { + let vault = CredentialsVault.shared + let cfg = vault.llmProviderConfig(for: providerId) + let preset = LLMProviderRegistry.preset(for: providerId) + var draft = LLMConfigDraft() + draft.apiKey = cfg?.apiKey ?? "" + draft.baseURL = cfg?.baseURL.absoluteString ?? preset?.defaultBaseURL.absoluteString ?? "" + draft.model = cfg?.model ?? preset?.defaultModel ?? "" + draft.temperature = cfg?.temperature ?? 0.3 + draft.displayName = cfg?.displayName ?? preset?.displayName ?? providerId + return draft + } + + private func stashCurrentLLMDraft() { + pendingLLMConfigs[selectedLLMProviderId] = llmDraft + } + + private func loadLLMDraftFromVault(for providerId: String) { + if let pending = pendingLLMConfigs[providerId] { + llmDraft = pending + return + } + llmDraft = vaultLLMDraft(for: providerId) + } + + private func persistLLMDraft(providerId: String, draft: LLMConfigDraft) { + let vault = CredentialsVault.shared + let preset = LLMProviderRegistry.preset(for: providerId) + + let trimmedBaseURL = draft.baseURL.trimmingCharacters(in: .whitespacesAndNewlines) + let baseURL: URL + if let parsed = URL(string: trimmedBaseURL), !trimmedBaseURL.isEmpty, parsed.scheme != nil { + baseURL = parsed + } else if let preset { + baseURL = preset.defaultBaseURL + } else { + return + } + + let displayName = draft.displayName.isEmpty + ? (preset?.displayName ?? providerId) + : draft.displayName + + let config = OpenAICompatibleConfig( + providerId: providerId, + displayName: displayName, + baseURL: baseURL, + apiKey: draft.apiKey, + model: draft.model, + extraHeaders: [:], + temperature: draft.temperature + ) + vault.setLLMProviderConfig(config) + } + + // MARK: - Volcengine Draft + + private func loadVolcDraftFromVault() { + let vault = CredentialsVault.shared + var draft = VolcengineDraft() + draft.appKey = vault.get(CredentialAccount.volcengineAppKey) ?? "" + draft.accessKey = vault.get(CredentialAccount.volcengineAccessKey) ?? "" + let resource = vault.get(CredentialAccount.volcengineResourceId) ?? "" + draft.resourceId = resource.isEmpty ? VolcengineCredentials.defaultResourceId : resource + volcDraft = draft + } + + private func persistVolcDraft() { + let vault = CredentialsVault.shared + try? vault.set(volcDraft.appKey.trimmingCharacters(in: .whitespacesAndNewlines), for: CredentialAccount.volcengineAppKey) + try? vault.set(volcDraft.accessKey.trimmingCharacters(in: .whitespacesAndNewlines), for: CredentialAccount.volcengineAccessKey) + try? vault.set(volcDraft.resourceId.trimmingCharacters(in: .whitespacesAndNewlines), for: CredentialAccount.volcengineResourceId) + } + + // MARK: - Aliyun Paraformer Draft + + private func loadAliyunParaformerDraft() { + var draft = AliyunParaformerDraft() + let vault = CredentialsVault.shared + if let asr = vault.asrProviderConfig(for: "aliyun-paraformer"), + let key = asr.apiKey, !key.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + draft.apiKey = key + } else if let llm = vault.llmProviderConfig(for: "aliyun-dashscope"), + !llm.apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + draft.apiKey = llm.apiKey + } + aliyunParaformerDraft = draft + } + + private func persistAliyunParaformerDraft() { + let vault = CredentialsVault.shared + let trimmed = aliyunParaformerDraft.apiKey.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + try? vault.removeASRProvider("aliyun-paraformer") + } else { + var entry = vault.asrProviderConfig(for: "aliyun-paraformer") ?? CredentialsProviderASREntry() + entry.apiKey = trimmed + vault.setASRProviderConfig(entry, for: "aliyun-paraformer") + } + } + + // MARK: - Custom OpenAI Whisper Draft + + private func loadCustomWhisperDraft() { + var draft = CustomWhisperDraft() + let vault = CredentialsVault.shared + if let asr = vault.asrProviderConfig(for: "custom-openai-whisper") { + draft.baseURL = asr.baseURL ?? "" + draft.apiKey = asr.apiKey ?? "" + draft.model = (asr.model?.isEmpty == false ? asr.model! : "whisper-1") + } else { + draft.model = "whisper-1" + } + customWhisperDraft = draft + } + + private func persistCustomWhisperDraft() { + let vault = CredentialsVault.shared + let baseURL = customWhisperDraft.baseURL.trimmingCharacters(in: .whitespacesAndNewlines) + let apiKey = customWhisperDraft.apiKey.trimmingCharacters(in: .whitespacesAndNewlines) + let model = customWhisperDraft.model.trimmingCharacters(in: .whitespacesAndNewlines) + + if baseURL.isEmpty && apiKey.isEmpty && model.isEmpty { + try? vault.removeASRProvider("custom-openai-whisper") + return + } + + var entry = vault.asrProviderConfig(for: "custom-openai-whisper") ?? CredentialsProviderASREntry() + entry.baseURL = baseURL.isEmpty ? nil : baseURL + entry.apiKey = apiKey.isEmpty ? nil : apiKey + entry.model = model.isEmpty ? nil : model + vault.setASRProviderConfig(entry, for: "custom-openai-whisper") + } +} diff --git a/Sources/OpenLessApp/Settings/Tabs/ProvidersConfigTab.swift b/Sources/OpenLessApp/Settings/Tabs/ProvidersConfigTab.swift index 362e08bb..4a123025 100644 --- a/Sources/OpenLessApp/Settings/Tabs/ProvidersConfigTab.swift +++ b/Sources/OpenLessApp/Settings/Tabs/ProvidersConfigTab.swift @@ -1,4 +1,3 @@ -import AppKit import Foundation import SwiftUI import OpenLessCore @@ -340,747 +339,3 @@ struct ProvidersConfigTab: View { DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { savedFlash = false } } } - -// MARK: - Chip rows - -@MainActor -private struct LLMProviderChipRow: View { - let presets: [LLMProviderRegistry.Preset] - let selectedProviderId: String - let activeProviderId: String - let onSelect: (String) -> Void - let onAddCustom: () -> Void - - var body: some View { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 8) { - ForEach(presets, id: \.providerId) { preset in - ProviderChipButton( - title: preset.displayName, - symbol: chipSymbol(for: preset.providerId), - isSelected: selectedProviderId == preset.providerId, - isActive: activeProviderId == preset.providerId, - onSelect: { onSelect(preset.providerId) } - ) - } - // 自定义条目:当 selectedProviderId 不是预设时(用户加进来的 slug),独立画一颗 chip。 - if !selectedProviderId.isEmpty, - LLMProviderRegistry.preset(for: selectedProviderId) == nil { - ProviderChipButton( - title: "自定义 (\(selectedProviderId))", - symbol: "slider.horizontal.3", - isSelected: true, - isActive: activeProviderId == selectedProviderId, - onSelect: { onSelect(selectedProviderId) } - ) - } - Button(action: onAddCustom) { - Label("自定义", systemImage: "plus.circle") - .padding(.horizontal, 12) - .padding(.vertical, 9) - } - .buttonStyle(.bordered) - .controlSize(.small) - .help("添加自定义 OpenAI 兼容 LLM provider") - } - .padding(.vertical, 2) - } - } - - private func chipSymbol(for providerId: String) -> String { - switch providerId { - case "ark": return "sparkles" - case "openai": return "circle.hexagongrid.fill" - case "aliyun-dashscope": return "cloud.fill" - case "deepseek": return "fish" - case "moonshot": return "moon.stars" - default: return "wand.and.stars" - } - } -} - -@MainActor -private struct ASRProviderChipRow: View { - let presets: [ASRProviderRegistry.Preset] - let selectedProviderId: String - let activeProviderId: String - let onSelect: (String) -> Void - - var body: some View { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 8) { - ForEach(presets, id: \.providerId) { preset in - ProviderChipButton( - title: preset.displayName, - symbol: chipSymbol(for: preset.providerId), - isSelected: selectedProviderId == preset.providerId, - isActive: activeProviderId == preset.providerId, - onSelect: { onSelect(preset.providerId) } - ) - } - } - .padding(.vertical, 2) - } - } - - private func chipSymbol(for providerId: String) -> String { - switch providerId { - case "volcengine": return "cloud.fill" - case "apple-speech": return "applelogo" - case "aliyun-paraformer": return "waveform.badge.mic" - case "custom-openai-whisper": return "slider.horizontal.3" - default: return "waveform" - } - } -} - -@MainActor -private struct ProviderChipButton: View { - let title: String - let symbol: String - let isSelected: Bool - let isActive: Bool - let onSelect: () -> Void - - var body: some View { - Button(action: onSelect) { - HStack(spacing: 7) { - Image(systemName: symbol) - .symbolRenderingMode(.hierarchical) - Text(title) - .font(.system(size: 13, weight: isSelected ? .semibold : .regular)) - if isActive { - Text("当前") - .font(.caption2) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background( - RoundedRectangle(cornerRadius: 4, style: .continuous) - .fill(Color.green.opacity(0.18)) - ) - .foregroundStyle(.green) - } - } - .padding(.horizontal, 12) - .padding(.vertical, 9) - .background( - RoundedRectangle(cornerRadius: 14, style: .continuous) - .fill(isSelected ? Color.blue.opacity(0.15) : Color.primary.opacity(0.04)) - ) - .overlay( - RoundedRectangle(cornerRadius: 14, style: .continuous) - .strokeBorder(isSelected ? Color.blue.opacity(0.6) : Color.primary.opacity(0.12), lineWidth: isSelected ? 1.5 : 1) - ) - .foregroundStyle(isSelected ? .primary : .secondary) - } - .buttonStyle(.plain) - } -} - -// MARK: - Drafts - -struct LLMConfigDraft: Equatable { - var apiKey: String = "" - var baseURL: String = "" - var model: String = "" - var temperature: Double = 0.3 - /// 自定义 provider 的展示名。预设条目从 registry 兜底,不暴露在 UI 上。 - var displayName: String = "" -} - -struct VolcengineDraft: Equatable { - var appKey: String = "" - var accessKey: String = "" - var resourceId: String = VolcengineCredentials.defaultResourceId -} - -struct AliyunParaformerDraft: Equatable { - var apiKey: String = "" -} - -struct CustomWhisperDraft: Equatable { - var baseURL: String = "" - var apiKey: String = "" - var model: String = "" -} - -// MARK: - Model - -/// 配置 Tab 的核心:把所有 LLM / ASR 改动都积攒在内存里,commit() 时一次性写盘。 -@MainActor -final class ProvidersConfigModel: ObservableObject { - // LLM - @Published private(set) var configuredLLMProviderIds: [String] = [] - @Published private(set) var activeLLMProviderId: String = defaultActiveLLMProviderId - @Published private(set) var selectedLLMProviderId: String = defaultActiveLLMProviderId - @Published var llmDraft = LLMConfigDraft() - /// 用户在草稿态下编辑过的所有 provider 配置(key 为 providerId)。 - /// 切到别的 provider 不会丢;commit 时一起写盘。 - private var pendingLLMConfigs: [String: LLMConfigDraft] = [:] - /// 用户在草稿态下"添加"的自定义 provider id 列表。commit 时若仍未填关键字段会被回滚。 - private var pendingNewLLMProviders: Set = [] - /// 用户在草稿态下「删除」的 provider id 列表。commit 时一并清掉。 - private var pendingDeletedLLMProviders: Set = [] - - @Published var isShowingAddCustom = false - - // ASR - @Published private(set) var activeASRProviderId: String = defaultActiveASRProviderId - @Published private(set) var selectedASRProviderId: String = defaultActiveASRProviderId - @Published var volcDraft = VolcengineDraft() - @Published var aliyunParaformerDraft = AliyunParaformerDraft() - @Published var customWhisperDraft = CustomWhisperDraft() - - /// 加载时记录的 ASR 三类字段值,作为「是否有未保存改动」的基线。 - /// LLM 改动直接与 vault 比较,因此不需要快照。 - private var initialSnapshot = Snapshot() - - private struct Snapshot: Equatable { - var volc: VolcengineDraft = VolcengineDraft() - var aliyunParaformer: AliyunParaformerDraft = AliyunParaformerDraft() - var customWhisper: CustomWhisperDraft = CustomWhisperDraft() - } - - /// 用户已经填好且尚未点保存的改动数。 - var hasUnsavedChanges: Bool { - // 1. active provider 切换 - if selectedLLMProviderId != activeLLMProviderId { return true } - if selectedASRProviderId != activeASRProviderId { return true } - - // 2. 当前编辑的 LLM 草稿与 vault 不一致 - if !isCurrentLLMDraftClean() { return true } - - // 3. pending 集合不空(之前切走过的草稿、新增、删除) - if !pendingDeletedLLMProviders.isEmpty { return true } - if !pendingNewLLMProviders.isEmpty { return true } - for (id, draft) in pendingLLMConfigs where id != selectedLLMProviderId { - if !isLLMDraftClean(providerId: id, draft: draft) { return true } - } - - // 4. ASR 字段改动 - if volcDraft != initialSnapshot.volc { return true } - if aliyunParaformerDraft != initialSnapshot.aliyunParaformer { return true } - if customWhisperDraft != initialSnapshot.customWhisper { return true } - - return false - } - - /// 当前 selected provider 的草稿是否与 vault 一致(无未保存改动)。 - private func isCurrentLLMDraftClean() -> Bool { - isLLMDraftClean(providerId: selectedLLMProviderId, draft: llmDraft) - } - - private func isLLMDraftClean(providerId: String, draft: LLMConfigDraft) -> Bool { - let vaultDraft = vaultLLMDraft(for: providerId) - return draft == vaultDraft - } - - /// 直接读 vault 构造 baseline 草稿;不修改任何状态。 - private func vaultLLMDraft(for providerId: String) -> LLMConfigDraft { - let vault = CredentialsVault.shared - let cfg = vault.llmProviderConfig(for: providerId) - let preset = LLMProviderRegistry.preset(for: providerId) - var d = LLMConfigDraft() - d.apiKey = cfg?.apiKey ?? "" - d.baseURL = cfg?.baseURL.absoluteString ?? preset?.defaultBaseURL.absoluteString ?? "" - d.model = cfg?.model ?? preset?.defaultModel ?? "" - d.temperature = cfg?.temperature ?? 0.3 - d.displayName = cfg?.displayName ?? preset?.displayName ?? providerId - return d - } - - var aliyunDashScopeApiKeyAvailable: Bool { - if let cfg = pendingLLMConfigs["aliyun-dashscope"] { - return !cfg.apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - } - let vault = CredentialsVault.shared - if let cfg = vault.llmProviderConfig(for: "aliyun-dashscope") { - return !cfg.apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - } - return false - } - - // MARK: - Load - - func load() { - let vault = CredentialsVault.shared - configuredLLMProviderIds = vault.configuredLLMProviderIds - activeLLMProviderId = vault.activeLLMProviderId - activeASRProviderId = vault.activeASRProviderId - - if !configuredLLMProviderIds.contains(selectedLLMProviderId) { - selectedLLMProviderId = activeLLMProviderId - } - if !ASRProviderRegistry.presets.contains(where: { $0.providerId == selectedASRProviderId }) { - selectedASRProviderId = activeASRProviderId - } - - pendingLLMConfigs = [:] - pendingNewLLMProviders = [] - pendingDeletedLLMProviders = [] - - // 把当前 selected 的草稿从 vault 拉出来。 - loadLLMDraftFromVault(for: selectedLLMProviderId) - - // ASR 三类草稿全部从 vault / UserDefaults 读出来。 - loadVolcDraftFromVault() - loadAliyunParaformerDraft() - loadCustomWhisperDraft() - - initialSnapshot = baselineSnapshot() - } - - /// 加载时记录的 ASR 字段基线。LLM 改动直接和 vault 比较。 - private func baselineSnapshot() -> Snapshot { - var s = Snapshot() - s.volc = volcDraft - s.aliyunParaformer = aliyunParaformerDraft - s.customWhisper = customWhisperDraft - return s - } - - // MARK: - LLM selection / form - - func selectLLMProvider(_ providerId: String) { - // 切走前先把当前草稿快照存起来(commit 时会用到)。 - stashCurrentLLMDraft() - selectedLLMProviderId = providerId - loadLLMDraftFromVault(for: providerId) - } - - func updateLLMApiKey(_ value: String) { - llmDraft.apiKey = value - } - - func updateLLMBaseURL(_ value: String) { - llmDraft.baseURL = value - } - - func updateLLMModel(_ value: String) { - llmDraft.model = value - } - - func updateLLMTemperature(_ value: Double) { - llmDraft.temperature = value - } - - func updateLLMDisplayName(_ value: String) { - llmDraft.displayName = value - } - - func beginAddCustomLLM() { - isShowingAddCustom = true - } - - /// AddLLMProviderSheet 回调:内存里登记一个新 provider,切到它,等待用户填字段。 - func addLLMProvider(providerId: String, displayName: String) { - let trimmedId = providerId.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmedId.isEmpty else { return } - stashCurrentLLMDraft() - - var draft = LLMConfigDraft() - let preset = LLMProviderRegistry.preset(for: trimmedId) - draft.displayName = displayName.isEmpty ? (preset?.displayName ?? trimmedId) : displayName - draft.baseURL = preset?.defaultBaseURL.absoluteString ?? "" - draft.model = preset?.defaultModel ?? "" - pendingLLMConfigs[trimmedId] = draft - pendingNewLLMProviders.insert(trimmedId) - pendingDeletedLLMProviders.remove(trimmedId) - - if !configuredLLMProviderIds.contains(trimmedId) { - configuredLLMProviderIds.append(trimmedId) - configuredLLMProviderIds.sort() - } - selectedLLMProviderId = trimmedId - llmDraft = draft - } - - func deleteSelectedLLM() { - let id = selectedLLMProviderId - guard id != activeLLMProviderId else { return } - guard LLMProviderRegistry.preset(for: id) == nil else { return } // 预设不允许删 - pendingDeletedLLMProviders.insert(id) - pendingNewLLMProviders.remove(id) - pendingLLMConfigs.removeValue(forKey: id) - configuredLLMProviderIds.removeAll { $0 == id } - selectedLLMProviderId = activeLLMProviderId - loadLLMDraftFromVault(for: selectedLLMProviderId) - } - - // MARK: - ASR selection / form - - func selectASRProvider(_ providerId: String) { - selectedASRProviderId = providerId - } - - func updateVolcAppKey(_ value: String) { - volcDraft.appKey = value - } - - func updateVolcAccessKey(_ value: String) { - volcDraft.accessKey = value - } - - func updateVolcResourceId(_ value: String) { - volcDraft.resourceId = value - } - - func updateAliyunParaformerApiKey(_ value: String) { - aliyunParaformerDraft.apiKey = value - } - - func updateCustomWhisperBaseURL(_ value: String) { - customWhisperDraft.baseURL = value - } - - func updateCustomWhisperApiKey(_ value: String) { - customWhisperDraft.apiKey = value - } - - func updateCustomWhisperModel(_ value: String) { - customWhisperDraft.model = value - } - - // MARK: - Commit - - /// 一次性把所有改动写盘。 - func commit() { - stashCurrentLLMDraft() - - let vault = CredentialsVault.shared - - // LLM:先删要删的;再写所有 pending;最后切 active。 - for id in pendingDeletedLLMProviders { - try? vault.removeLLMProvider(id) - } - for (providerId, draft) in pendingLLMConfigs { - persistLLMDraft(providerId: providerId, draft: draft) - } - if vault.activeLLMProviderId != selectedLLMProviderId, - !pendingDeletedLLMProviders.contains(selectedLLMProviderId) { - vault.activeLLMProviderId = selectedLLMProviderId - } - - // ASR:火山字段写 vault;阿里 / Whisper 写 UserDefaults(vault schema 暂不支持)。 - persistVolcDraft() - persistAliyunParaformerDraft() - persistCustomWhisperDraft() - if vault.activeASRProviderId != selectedASRProviderId { - vault.activeASRProviderId = selectedASRProviderId - } - - NotificationCenter.default.post(name: .openLessCredentialsChanged, object: nil) - load() - } - - // MARK: - LLM draft 装载 / 存档 - - private func stashCurrentLLMDraft() { - pendingLLMConfigs[selectedLLMProviderId] = llmDraft - } - - private func loadLLMDraftFromVault(for providerId: String) { - if let pending = pendingLLMConfigs[providerId] { - llmDraft = pending - return - } - let vault = CredentialsVault.shared - let cfg = vault.llmProviderConfig(for: providerId) - let preset = LLMProviderRegistry.preset(for: providerId) - var draft = LLMConfigDraft() - draft.apiKey = cfg?.apiKey ?? "" - draft.baseURL = cfg?.baseURL.absoluteString ?? preset?.defaultBaseURL.absoluteString ?? "" - draft.model = cfg?.model ?? preset?.defaultModel ?? "" - draft.temperature = cfg?.temperature ?? 0.3 - draft.displayName = cfg?.displayName ?? preset?.displayName ?? providerId - llmDraft = draft - } - - private func persistLLMDraft(providerId: String, draft: LLMConfigDraft) { - let vault = CredentialsVault.shared - let preset = LLMProviderRegistry.preset(for: providerId) - - // 自定义 provider 没填 baseURL 时跳过——避免落出空配置。 - let trimmedBaseURL = draft.baseURL.trimmingCharacters(in: .whitespacesAndNewlines) - let baseURL: URL - if let parsed = URL(string: trimmedBaseURL), !trimmedBaseURL.isEmpty, parsed.scheme != nil { - baseURL = parsed - } else if let preset { - baseURL = preset.defaultBaseURL - } else { - return - } - - let displayName = draft.displayName.isEmpty - ? (preset?.displayName ?? providerId) - : draft.displayName - - let cfg = OpenAICompatibleConfig( - providerId: providerId, - displayName: displayName, - baseURL: baseURL, - apiKey: draft.apiKey, - model: draft.model, - extraHeaders: [:], - temperature: draft.temperature - ) - vault.setLLMProviderConfig(cfg) - } - - // MARK: - Volcengine draft - - private func loadVolcDraftFromVault() { - let vault = CredentialsVault.shared - var d = VolcengineDraft() - d.appKey = vault.get(CredentialAccount.volcengineAppKey) ?? "" - d.accessKey = vault.get(CredentialAccount.volcengineAccessKey) ?? "" - let resource = vault.get(CredentialAccount.volcengineResourceId) ?? "" - d.resourceId = resource.isEmpty ? VolcengineCredentials.defaultResourceId : resource - volcDraft = d - } - - private func persistVolcDraft() { - let vault = CredentialsVault.shared - try? vault.set(volcDraft.appKey.trimmingCharacters(in: .whitespacesAndNewlines), for: CredentialAccount.volcengineAppKey) - try? vault.set(volcDraft.accessKey.trimmingCharacters(in: .whitespacesAndNewlines), for: CredentialAccount.volcengineAccessKey) - try? vault.set(volcDraft.resourceId.trimmingCharacters(in: .whitespacesAndNewlines), for: CredentialAccount.volcengineResourceId) - } - - // MARK: - Aliyun Paraformer draft(vault providers.asr.aliyun-paraformer) - - private func loadAliyunParaformerDraft() { - var d = AliyunParaformerDraft() - let vault = CredentialsVault.shared - // 优先用本 provider 自己已存的 apiKey;fallback 用 LLM aliyun-dashscope 的(同一把 DashScope key)。 - if let asr = vault.asrProviderConfig(for: "aliyun-paraformer"), - let key = asr.apiKey, !key.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - d.apiKey = key - } else if let llm = vault.llmProviderConfig(for: "aliyun-dashscope"), - !llm.apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - d.apiKey = llm.apiKey - } - aliyunParaformerDraft = d - } - - private func persistAliyunParaformerDraft() { - let vault = CredentialsVault.shared - let trimmed = aliyunParaformerDraft.apiKey.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed.isEmpty { - try? vault.removeASRProvider("aliyun-paraformer") - } else { - var entry = vault.asrProviderConfig(for: "aliyun-paraformer") ?? CredentialsProviderASREntry() - entry.apiKey = trimmed - vault.setASRProviderConfig(entry, for: "aliyun-paraformer") - } - } - - // MARK: - Custom OpenAI Whisper draft(vault providers.asr.custom-openai-whisper) - - private func loadCustomWhisperDraft() { - var d = CustomWhisperDraft() - let vault = CredentialsVault.shared - if let asr = vault.asrProviderConfig(for: "custom-openai-whisper") { - d.baseURL = asr.baseURL ?? "" - d.apiKey = asr.apiKey ?? "" - d.model = (asr.model?.isEmpty == false ? asr.model! : "whisper-1") - } else { - d.model = "whisper-1" - } - customWhisperDraft = d - } - - private func persistCustomWhisperDraft() { - let vault = CredentialsVault.shared - let baseURL = customWhisperDraft.baseURL.trimmingCharacters(in: .whitespacesAndNewlines) - let apiKey = customWhisperDraft.apiKey.trimmingCharacters(in: .whitespacesAndNewlines) - let model = customWhisperDraft.model.trimmingCharacters(in: .whitespacesAndNewlines) - - // 三个字段全空才认为这一项被清空 → 从 vault 删除 - if baseURL.isEmpty && apiKey.isEmpty && model.isEmpty { - try? vault.removeASRProvider("custom-openai-whisper") - return - } - - var entry = vault.asrProviderConfig(for: "custom-openai-whisper") ?? CredentialsProviderASREntry() - entry.baseURL = baseURL.isEmpty ? nil : baseURL - entry.apiKey = apiKey.isEmpty ? nil : apiKey - entry.model = model.isEmpty ? nil : model - vault.setASRProviderConfig(entry, for: "custom-openai-whisper") - } -} - -// MARK: - Add Custom LLM Provider Sheet - -/// 添加 LLM provider 的 sheet:列出所有预设 + "自定义";选 "自定义" 时多一步收集 slug + displayName。 -/// -/// 与拆分前 LLMProvidersTab 中的同名 sheet 完全一致,搬到 ProvidersConfigTab 同文件保持邻近。 -@MainActor -struct AddLLMProviderSheet: View { - @Binding var isPresented: Bool - let existingIds: Set - let onAdd: (_ providerId: String, _ displayName: String) -> Void - - @State private var customId: String = "" - @State private var customDisplayName: String = "" - @State private var step: Step = .pick - - enum Step { - case pick - case customDetails - } - - var body: some View { - VStack(alignment: .leading, spacing: 0) { - HStack { - Text(step == .pick ? "添加 LLM Provider" : "自定义 OpenAI 兼容 Provider") - .font(.system(size: 18, weight: .semibold)) - Spacer() - Button { - isPresented = false - } label: { - Image(systemName: "xmark.circle.fill") - .font(.system(size: 18)) - .foregroundStyle(.secondary) - } - .buttonStyle(.plain) - } - .padding(20) - - Divider() - - ScrollView { - switch step { - case .pick: - pickList - case .customDetails: - customDetailsForm - } - } - .frame(minHeight: 360, maxHeight: 480) - } - .frame(width: 520) - } - - private var pickList: some View { - VStack(alignment: .leading, spacing: 0) { - ForEach(LLMProviderRegistry.presets, id: \.providerId) { preset in - presetRow(preset) - Divider().padding(.leading, 16) - } - customRow - } - .padding(.vertical, 4) - } - - private func presetRow(_ preset: LLMProviderRegistry.Preset) -> some View { - let alreadyAdded = existingIds.contains(preset.providerId) - return Button { - guard !alreadyAdded else { return } - onAdd(preset.providerId, preset.displayName) - isPresented = false - } label: { - HStack(alignment: .top, spacing: 12) { - Image(systemName: alreadyAdded ? "checkmark.circle.fill" : "circle") - .foregroundStyle(alreadyAdded ? .green : .secondary) - .font(.system(size: 18)) - .padding(.top, 2) - VStack(alignment: .leading, spacing: 4) { - Text(preset.displayName) - .font(.system(size: 15, weight: .semibold)) - .foregroundStyle(.primary) - Text(preset.defaultBaseURL.absoluteString) - .font(.system(size: 12, design: .monospaced)) - .foregroundStyle(.secondary) - Text(preset.helpText) - .font(.callout) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - Spacer() - } - .padding(.horizontal, 18) - .padding(.vertical, 12) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .disabled(alreadyAdded) - .help(alreadyAdded ? "已添加" : "添加 \(preset.displayName)") - } - - private var customRow: some View { - Button { - step = .customDetails - } label: { - HStack(alignment: .top, spacing: 12) { - Image(systemName: "plus.circle") - .foregroundStyle(.blue) - .font(.system(size: 18)) - .padding(.top, 2) - VStack(alignment: .leading, spacing: 4) { - Text(LLMProviderRegistry.customDisplayName) - .font(.system(size: 15, weight: .semibold)) - .foregroundStyle(.primary) - Text("任何遵循 OpenAI Chat Completions 协议的供应商都能填进来——比如自建网关、私有化部署、或表里没列的云厂商。") - .font(.callout) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - Spacer() - } - .padding(.horizontal, 18) - .padding(.vertical, 12) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - } - - private var customDetailsForm: some View { - VStack(alignment: .leading, spacing: 16) { - VStack(alignment: .leading, spacing: 6) { - Text("Provider ID") - .font(.caption) - .foregroundStyle(.secondary) - TextField("custom-gateway", text: $customId) - .textFieldStyle(.roundedBorder) - Text("唯一 slug,建议小写字母 / 数字 / 短横线;不能与已存在的 id 重复。") - .font(.caption) - .foregroundStyle(.secondary) - } - VStack(alignment: .leading, spacing: 6) { - Text("展示名") - .font(.caption) - .foregroundStyle(.secondary) - TextField("公司内部网关", text: $customDisplayName) - .textFieldStyle(.roundedBorder) - } - - HStack { - Button("返回") { - step = .pick - } - .buttonStyle(.bordered) - Spacer() - Button("添加") { - let id = customId.trimmingCharacters(in: .whitespacesAndNewlines) - let name = customDisplayName.trimmingCharacters(in: .whitespacesAndNewlines) - onAdd(id, name) - isPresented = false - } - .keyboardShortcut(.defaultAction) - .buttonStyle(.borderedProminent) - .disabled(!isCustomReady) - } - .padding(.top, 6) - } - .padding(20) - } - - private var isCustomReady: Bool { - let id = customId.trimmingCharacters(in: .whitespacesAndNewlines) - let name = customDisplayName.trimmingCharacters(in: .whitespacesAndNewlines) - guard !id.isEmpty, !name.isEmpty else { return false } - if LLMProviderRegistry.preset(for: id) != nil { return false } - if existingIds.contains(id) { return false } - return true - } -}