Files
openless/Sources/OpenLessApp/Settings/Tabs/ProvidersConfigTab.swift
T
baiqing a9e42e5a32 fix(settings): route Aliyun + Whisper ASR drafts through vault, not UserDefaults
The unified-config-tab branch landed first and used UserDefaults as a
temporary bypass because the asr-aliyun-and-custom-whisper branch (which
extended the schema and added vault.asrProviderConfig helpers) hadn't
merged yet. Now both are merged, so route drafts through
vault.asrProviderConfig(for:) / setASRProviderConfig(_:for:) /
removeASRProvider(_:). DictationCoordinator's factory reads from vault,
so this closes the storage mismatch — saving an Aliyun apiKey in
the UI now actually reaches the provider at runtime.
2026-04-29 13:05:59 +08:00

1087 lines
40 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import AppKit
import Foundation
import SwiftUI
import OpenLessCore
import OpenLessASR
import OpenLessPersistence
/// 「配置」Tab:把 LLM 模型与 ASR 语音两类 provider 合并到一页编辑。
///
/// 与拆分前的 LLMProvidersTab / ASRProvidersTab 的关键差异:
/// - 切 chip 不立即写盘,所有改动都先放进内存草稿;底部一个统一「保存」按钮提交。
/// - 当前 active provider 的字段编辑同样进入草稿;用户切回去就回滚。
/// - 火山引擎 3 字段(APP ID / Access Token / Resource ID)从 SettingsHubTab 搬到这里。
///
/// 为什么不用嵌套 TabView:单 ScrollView 把两段(LLM + ASR)摆在同一页,
/// 用户一次能看见全部状态,无需在子 tab 之间反复跳。
@MainActor
struct ProvidersConfigTab: View {
@StateObject private var model = ProvidersConfigModel()
@State private var savedFlash = false
var body: some View {
SettingsPage(
title: "配置",
subtitle: "选择并填写 LLM 模型和语音识别(ASR)凭据。改完后点击底部「保存」生效。"
) {
llmSection
asrSection
saveActionRow
}
.onAppear { model.load() }
.onReceive(NotificationCenter.default.publisher(for: .openLessCredentialsChanged)) { _ in
model.load()
}
}
// MARK: - LLM Section
private var llmSection: some View {
GlassSection(title: "LLM 模型", symbol: "wand.and.stars") {
VStack(alignment: .leading, spacing: 12) {
LLMProviderChipRow(
presets: LLMProviderRegistry.presets,
selectedProviderId: model.selectedLLMProviderId,
activeProviderId: model.activeLLMProviderId,
onSelect: { id in model.selectLLMProvider(id) },
onAddCustom: { model.beginAddCustomLLM() }
)
Text(llmHint)
.font(.footnote)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
.padding(.vertical, 4)
DividerLine()
llmForm
}
}
private var llmHint: String {
if let preset = LLMProviderRegistry.preset(for: model.selectedLLMProviderId) {
return "\(preset.displayName) · \(preset.defaultBaseURL.absoluteString)"
}
return "自定义 OpenAI 兼容 provider · \(model.llmDraft.baseURL.isEmpty ? "请填写 Base URL" : model.llmDraft.baseURL)"
}
@ViewBuilder
private var llmForm: some View {
let preset = LLMProviderRegistry.preset(for: model.selectedLLMProviderId)
let isCustom = preset == nil
SettingsRow(title: "API Key") {
PasteableCredentialField(
placeholder: "Bearer Token",
secure: true,
text: Binding(
get: { model.llmDraft.apiKey },
set: { model.updateLLMApiKey($0) }
)
)
}
DividerLine()
SettingsRow(title: "Base URL") {
PasteableCredentialField(
placeholder: preset?.defaultBaseURL.absoluteString ?? "https://api.example.com/v1",
secure: false,
text: Binding(
get: { model.llmDraft.baseURL },
set: { model.updateLLMBaseURL($0) }
)
)
}
DividerLine()
SettingsRow(title: "Model") {
PasteableCredentialField(
placeholder: preset?.defaultModel.isEmpty == false ? preset!.defaultModel : "endpoint id / model name",
secure: false,
text: Binding(
get: { model.llmDraft.model },
set: { model.updateLLMModel($0) }
)
)
}
DividerLine()
SettingsRow(title: "Temperature") {
HStack(spacing: 12) {
Slider(
value: Binding(
get: { model.llmDraft.temperature },
set: { model.updateLLMTemperature($0) }
),
in: 0.0...1.0,
step: 0.05
)
.frame(width: 220)
Text(String(format: "%.2f", model.llmDraft.temperature))
.font(.system(size: 13, design: .monospaced))
.foregroundStyle(.secondary)
.frame(width: 44, alignment: .leading)
}
}
if isCustom, !model.selectedLLMProviderId.isEmpty {
DividerLine()
HStack {
Spacer()
Button(role: .destructive) {
model.deleteSelectedLLM()
} label: {
Label("删除此 provider", systemImage: "trash")
}
.buttonStyle(.bordered)
.disabled(model.selectedLLMProviderId == model.activeLLMProviderId)
.help(model.selectedLLMProviderId == model.activeLLMProviderId ? "当前 active provider 不能删除——先切到别的 provider" : "从本机移除该 provider 的所有字段")
}
}
}
// MARK: - ASR Section
private var asrSection: some View {
GlassSection(title: "ASR 语音", symbol: "waveform") {
VStack(alignment: .leading, spacing: 12) {
ASRProviderChipRow(
presets: ASRProviderRegistry.presets,
selectedProviderId: model.selectedASRProviderId,
activeProviderId: model.activeASRProviderId,
onSelect: { id in model.selectASRProvider(id) }
)
Text(asrHint)
.font(.footnote)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
.padding(.vertical, 4)
DividerLine()
asrForm
}
}
private var asrHint: String {
ASRProviderRegistry.preset(for: model.selectedASRProviderId)?.helpText
?? "未知 ASR provider;请选择左侧任一选项。"
}
@ViewBuilder
private var asrForm: some View {
switch model.selectedASRProviderId {
case "volcengine":
volcengineForm
case "apple-speech":
appleSpeechInfo
case "aliyun-paraformer":
aliyunParaformerForm
case "custom-openai-whisper":
customWhisperForm
default:
Text("未知 ASR provider:\(model.selectedASRProviderId)")
.font(.callout)
.foregroundStyle(.secondary)
}
}
private var volcengineForm: some View {
Group {
SettingsRow(title: "APP ID") {
PasteableCredentialField(
placeholder: "X-Api-App-Key",
secure: false,
text: Binding(
get: { model.volcDraft.appKey },
set: { model.updateVolcAppKey($0) }
)
)
}
DividerLine()
SettingsRow(title: "Access Token") {
PasteableCredentialField(
placeholder: "X-Api-Access-Key",
secure: true,
text: Binding(
get: { model.volcDraft.accessKey },
set: { model.updateVolcAccessKey($0) }
)
)
}
DividerLine()
SettingsRow(title: "Resource ID") {
PasteableCredentialField(
placeholder: "X-Api-Resource-Id",
secure: false,
text: Binding(
get: { model.volcDraft.resourceId },
set: { model.updateVolcResourceId($0) }
)
)
}
}
}
private var appleSpeechInfo: some View {
VStack(alignment: .leading, spacing: 12) {
asrInfoLine("无需配置 API key —— Apple Speech 完全由 macOS 系统提供。", symbol: "checkmark.seal")
asrInfoLine("中文 (zh-CN) 在 Apple Silicon 上支持完全离线识别;其他语言或 Intel 机器会回退到 Apple 云端。", symbol: "globe")
asrInfoLine("第一次切换并开始录音时,系统会弹窗请求「语音识别」权限——选择允许即可。", symbol: "hand.raised.fill")
}
.padding(.vertical, 4)
}
private var aliyunParaformerForm: some View {
VStack(alignment: .leading, spacing: 12) {
SettingsRow(title: "API Key") {
PasteableCredentialField(
placeholder: model.aliyunDashScopeApiKeyAvailable ? "已与 LLM「阿里通义」共享" : "百炼控制台 sk-...",
secure: true,
text: Binding(
get: { model.aliyunParaformerDraft.apiKey },
set: { model.updateAliyunParaformerApiKey($0) }
)
)
}
if model.aliyunDashScopeApiKeyAvailable {
asrInfoLine("已自动从 LLM「阿里通义 (DashScope)」复用同一个 API Key;如需单独覆盖可直接编辑上方字段。", symbol: "link")
} else {
asrInfoLine("可在 LLM 区域选择并填入「阿里通义 (DashScope)」的 API Key,下次会自动复用,不必两边各填一遍。", symbol: "info.circle")
}
}
.padding(.vertical, 4)
}
private var customWhisperForm: some View {
Group {
SettingsRow(title: "Base URL") {
PasteableCredentialField(
placeholder: "https://api.openai.com/v1",
secure: false,
text: Binding(
get: { model.customWhisperDraft.baseURL },
set: { model.updateCustomWhisperBaseURL($0) }
)
)
}
DividerLine()
SettingsRow(title: "API Key") {
PasteableCredentialField(
placeholder: "sk-...",
secure: true,
text: Binding(
get: { model.customWhisperDraft.apiKey },
set: { model.updateCustomWhisperApiKey($0) }
)
)
}
DividerLine()
SettingsRow(title: "Model") {
PasteableCredentialField(
placeholder: "whisper-1",
secure: false,
text: Binding(
get: { model.customWhisperDraft.model },
set: { model.updateCustomWhisperModel($0) }
)
)
}
}
}
private func asrInfoLine(_ text: String, symbol: String) -> some View {
HStack(alignment: .top, spacing: 10) {
Image(systemName: symbol)
.foregroundStyle(.secondary)
.frame(width: 20)
.padding(.top, 2)
Text(text)
.font(.callout)
.foregroundStyle(.primary)
.fixedSize(horizontal: false, vertical: true)
Spacer()
}
}
// MARK: - Save
private var saveActionRow: some View {
PrimaryActionRow {
Button {
model.commit()
flashSaved()
} label: {
Label(savedFlash ? "已保存" : "保存", systemImage: savedFlash ? "checkmark.circle.fill" : "tray.and.arrow.down.fill")
}
.keyboardShortcut(.defaultAction)
.buttonStyle(.borderedProminent)
.controlSize(.large)
.disabled(!model.hasUnsavedChanges)
}
.sheet(isPresented: $model.isShowingAddCustom) {
AddLLMProviderSheet(
isPresented: $model.isShowingAddCustom,
existingIds: Set(model.configuredLLMProviderIds),
onAdd: { providerId, displayName in
model.addLLMProvider(providerId: providerId, displayName: displayName)
}
)
}
}
private func flashSaved() {
savedFlash = true
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
}
}