refactor(settings): split provider config tab

This commit is contained in:
baiqing committed 2026-04-29 13:41:18 +08:00
1 parent a9e42e5a32
commit 924005fe99
6 files changed
+716 -745

No files matched your search

+1
View File
@@ -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/
@@ -0,0 +1,174 @@
import SwiftUI
import OpenLessCore
/// 添加 LLM 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 }
if LLMProviderRegistry.preset(for: id) != nil { return false }
if existingIds.contains(id) { return false }
return true
}
}
@@ -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)
}
}
@@ -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 = ""
}
@@ -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<String> = []
/// 用户在草稿态下「删除」的 provider id 列表。commit 时一并清掉。
private var pendingDeletedLLMProviders: Set<String> = []
/// 加载时记录的 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")
}
}
@@ -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<String> = []
/// 用户在草稿态下「删除」的 provider id 列表。commit 时一并清掉。
private var pendingDeletedLLMProviders: Set<String> = []
@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<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 }
if LLMProviderRegistry.preset(for: id) != nil { return false }
if existingIds.contains(id) { return false }
return true
}
}