feat(settings): LLM Providers tab + rewire DictationCoordinator
新增「LLM Provider」侧边栏 Tab:
- 顶部 picker 切换 active provider(含已配置条目 + 一键添加)
- 下方表单编辑 displayName / API Key / Base URL / Model / Temperature
- 字段编辑后 0.5s debounce 自动写盘并 post `.openLessCredentialsChanged`
- Base URL 与预设默认值相同时显示「默认」徽标
- 预设条目展开「怎么获取 API Key?」disclosure,附 docsURL 跳转
- 「+ 添加」sheet 列出 5 家预设 + 自定义 OpenAI 兼容;自定义需填 slug+displayName
- 删除按钮在 active provider 上禁用,必须先切走再删
DictationCoordinator 不再构造 `DoubaoPolishClient`,改成:
let active = vault.activeLLMProviderId
guard let cfg = vault.llmProviderConfig(for: active),
!cfg.apiKey.isEmpty else { return raw } // 静默插入原文
let provider = OpenAICompatibleLLMProvider(config: cfg, ...)
let polished = try await provider.polish(rawText:..., mode:..., hotwords:...)
`SettingsHubTab` 移除硬编码的 Ark 三字段表单,改为只显示当前 active provider
状态并把详细设置指向新 Tab。
This commit is contained in:
1 parent
d8fb309feb
commit
2517df5d2f
4 files changed
+700
-37
No files matched your search
@@ -375,8 +375,14 @@ final class DictationCoordinator {
|
||||
return
|
||||
}
|
||||
|
||||
guard let arkCreds = loadArkCredentials() else {
|
||||
Log.write("[polish] 缺少 Ark 凭据;跳过润色,插入 raw")
|
||||
// 走 active LLM provider 通用路径(OpenAICompatibleLLMProvider)。
|
||||
// 凭据缺失(apiKey 为空 / config 构造不出来)时静默降级为"插入 raw",保持
|
||||
// "用户的原话不会丢"这条不变量。
|
||||
let vault = CredentialsVault.shared
|
||||
let activeId = vault.activeLLMProviderId
|
||||
guard let cfg = vault.llmProviderConfig(for: activeId),
|
||||
!cfg.apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||
Log.write("[polish] active provider=\(activeId) 缺少 apiKey;跳过润色,插入 raw")
|
||||
await insertText(
|
||||
text: raw.text,
|
||||
raw: savedRaw,
|
||||
@@ -388,16 +394,20 @@ final class DictationCoordinator {
|
||||
return
|
||||
}
|
||||
|
||||
Log.write("[polish] 调用 endpoint=\(arkCreds.endpoint.absoluteString) model=\(arkCreds.modelId)")
|
||||
let polish = DoubaoPolishClient(
|
||||
credentials: arkCreds,
|
||||
// 词典条目里 enabled 的部分作为 hotwords 喂给 provider;hotwords 仅影响 prompt 后缀,
|
||||
// 不影响热路径成本。
|
||||
let hotwords = dictionaryEntries.map(\.phrase).filter { !$0.isEmpty }
|
||||
|
||||
Log.write("[polish] provider=\(activeId) baseURL=\(cfg.baseURL.absoluteString) model=\(cfg.model)")
|
||||
let provider = OpenAICompatibleLLMProvider(
|
||||
config: cfg,
|
||||
logger: { msg in Log.write(msg) }
|
||||
)
|
||||
do {
|
||||
let final = try await polish.polish(rawTranscript: raw, mode: mode, dictionaryEntries: dictionaryEntries)
|
||||
Log.write("[polish] mode=\(mode.rawValue) → \(final.text)")
|
||||
let final = try await provider.polish(rawText: raw.text, mode: mode, hotwords: hotwords)
|
||||
Log.write("[polish] mode=\(mode.rawValue) → \(final)")
|
||||
await insertText(
|
||||
text: final.text,
|
||||
text: final,
|
||||
raw: savedRaw,
|
||||
mode: mode,
|
||||
durationMs: raw.durationMs,
|
||||
@@ -556,11 +566,6 @@ final class DictationCoordinator {
|
||||
)
|
||||
}
|
||||
|
||||
private func loadArkCredentials() -> ArkCredentials? {
|
||||
guard let key = credentials.arkApiKey, !key.isEmpty else { return nil }
|
||||
let model = credentials.arkModelId ?? ArkCredentials.defaultModelId
|
||||
let endpointStr = credentials.arkEndpoint ?? ArkCredentials.defaultEndpoint.absoluteString
|
||||
let endpoint = URL(string: endpointStr) ?? ArkCredentials.defaultEndpoint
|
||||
return ArkCredentials(apiKey: key, modelId: model, endpoint: endpoint)
|
||||
}
|
||||
// loadArkCredentials 已移除:润色路径改走 vault.llmProviderConfig(for:) +
|
||||
// OpenAICompatibleLLMProvider,不再单独构造 ArkCredentials。
|
||||
}
|
||||
@@ -15,6 +15,7 @@ enum OpenLessMainTab: String, CaseIterable, Identifiable {
|
||||
case history
|
||||
case dictionary
|
||||
case polish
|
||||
case llm
|
||||
case help
|
||||
case settings
|
||||
|
||||
@@ -26,6 +27,7 @@ enum OpenLessMainTab: String, CaseIterable, Identifiable {
|
||||
case .history: return "历史记录"
|
||||
case .dictionary: return "词汇表"
|
||||
case .polish: return "风格"
|
||||
case .llm: return "LLM Provider"
|
||||
case .help: return "帮助中心"
|
||||
case .settings: return "设置"
|
||||
}
|
||||
@@ -37,6 +39,7 @@ enum OpenLessMainTab: String, CaseIterable, Identifiable {
|
||||
case .history: return "clock"
|
||||
case .dictionary: return "text.book.closed"
|
||||
case .polish: return "paintpalette"
|
||||
case .llm: return "wand.and.stars"
|
||||
case .help: return "questionmark.circle"
|
||||
case .settings: return "gearshape"
|
||||
}
|
||||
@@ -68,6 +71,7 @@ struct SettingsView: View {
|
||||
case .history: HistoryTab()
|
||||
case .dictionary: DictionaryTab()
|
||||
case .polish: StyleTab()
|
||||
case .llm: LLMProvidersTab()
|
||||
case .help: HelpTab()
|
||||
case .settings: SettingsHubTab()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,645 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
import OpenLessCore
|
||||
import OpenLessPersistence
|
||||
|
||||
/// LLM Provider 设置 Tab:顶部 picker 切 active provider,下方表单编辑当前 provider 字段。
|
||||
///
|
||||
/// 设计要点:
|
||||
/// - 状态来源是 `CredentialsVault.shared`:所有读 / 写都直接打 vault,UI 是无状态视图层。
|
||||
/// - 表单字段编辑后用 0.5s debounce 写盘,避免每按一个键就触发文件 IO 和通知风暴。
|
||||
/// - 任何写盘成功都 post `.openLessCredentialsChanged`,让 DictationCoordinator 等订阅方刷新缓存。
|
||||
/// - 删除按钮在 active provider 上禁用;用户必须先切走才能删。
|
||||
@MainActor
|
||||
struct LLMProvidersTab: View {
|
||||
@StateObject private var model = LLMProvidersModel()
|
||||
@State private var showingAddSheet = false
|
||||
@State private var showingDeleteConfirm = false
|
||||
@State private var lastSavedFlash = false
|
||||
|
||||
var body: some View {
|
||||
SettingsPage(
|
||||
title: "LLM Provider",
|
||||
subtitle: "选择哪家大模型负责把你的口述整理成最终文本。任何 OpenAI 兼容协议的供应商都能用。"
|
||||
) {
|
||||
GlassSection(title: "当前 Provider", symbol: "wand.and.stars") {
|
||||
SettingsRow(title: "Active") {
|
||||
activePicker
|
||||
}
|
||||
DividerLine()
|
||||
Text(activeHint)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
|
||||
providerForm
|
||||
|
||||
if lastSavedFlash {
|
||||
HStack {
|
||||
Spacer()
|
||||
Label("已保存", systemImage: "checkmark.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear { model.load() }
|
||||
.onReceive(NotificationCenter.default.publisher(for: .openLessCredentialsChanged)) { _ in
|
||||
// 别的窗口 / Coordinator 改了凭据时刷新视图,避免显示过时数据。
|
||||
model.load()
|
||||
}
|
||||
.sheet(isPresented: $showingAddSheet) {
|
||||
AddLLMProviderSheet(
|
||||
isPresented: $showingAddSheet,
|
||||
existingIds: Set(model.configuredIds),
|
||||
onAdd: { providerId, displayName in
|
||||
model.addProvider(providerId: providerId, displayName: displayName)
|
||||
flashSaved()
|
||||
}
|
||||
)
|
||||
}
|
||||
.alert("删除此 provider?", isPresented: $showingDeleteConfirm) {
|
||||
Button("取消", role: .cancel) {}
|
||||
Button("删除", role: .destructive) {
|
||||
model.deleteSelected()
|
||||
flashSaved()
|
||||
}
|
||||
} message: {
|
||||
Text("删除后该 provider 的 API Key、baseURL、Model 都会从本机 credentials.json 移除。")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Active picker
|
||||
|
||||
private var activePicker: some View {
|
||||
HStack(spacing: 10) {
|
||||
Picker("Provider", selection: Binding(
|
||||
get: { model.selectedProviderId },
|
||||
set: { newValue in
|
||||
model.selectProvider(newValue)
|
||||
flashSaved()
|
||||
}
|
||||
)) {
|
||||
ForEach(model.configuredIds, id: \.self) { id in
|
||||
Text(model.displayName(for: id)).tag(id)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.pickerStyle(.menu)
|
||||
.frame(width: 260, alignment: .leading)
|
||||
|
||||
Button {
|
||||
showingAddSheet = true
|
||||
} label: {
|
||||
Label("添加", systemImage: "plus")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
.help("添加另一个 LLM provider")
|
||||
}
|
||||
}
|
||||
|
||||
private var activeHint: String {
|
||||
if let preset = LLMProviderRegistry.preset(for: model.selectedProviderId) {
|
||||
return "\(preset.displayName) · \(preset.defaultBaseURL.absoluteString)"
|
||||
}
|
||||
return "自定义 OpenAI 兼容 provider"
|
||||
}
|
||||
|
||||
// MARK: - Provider form
|
||||
|
||||
@ViewBuilder
|
||||
private var providerForm: some View {
|
||||
let preset = LLMProviderRegistry.preset(for: model.selectedProviderId)
|
||||
let isCustom = preset == nil
|
||||
let isActive = model.selectedProviderId == model.activeProviderId
|
||||
|
||||
GlassSection(title: providerFormTitle, symbol: "key") {
|
||||
// Display name:自定义可改;预设给出 read-only 提示。
|
||||
SettingsRow(title: "名称") {
|
||||
if isCustom {
|
||||
TextField("展示名", text: Binding(
|
||||
get: { model.draft.displayName },
|
||||
set: { newValue in
|
||||
model.updateDisplayName(newValue)
|
||||
flashSavedDebounced()
|
||||
}
|
||||
))
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: 390)
|
||||
} else {
|
||||
Text(preset?.displayName ?? model.selectedProviderId)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
DividerLine()
|
||||
|
||||
SettingsRow(title: "API Key") {
|
||||
PasteableCredentialField(
|
||||
placeholder: model.draft.apiKey.isEmpty ? "请填入 API Key" : "Bearer Token",
|
||||
secure: true,
|
||||
text: Binding(
|
||||
get: { model.draft.apiKey },
|
||||
set: { newValue in
|
||||
model.updateApiKey(newValue)
|
||||
flashSavedDebounced()
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
DividerLine()
|
||||
|
||||
SettingsRow(title: "Base URL") {
|
||||
HStack(spacing: 8) {
|
||||
PasteableCredentialField(
|
||||
placeholder: preset?.defaultBaseURL.absoluteString ?? "https://api.example.com/v1",
|
||||
secure: false,
|
||||
text: Binding(
|
||||
get: { model.draft.baseURL },
|
||||
set: { newValue in
|
||||
model.updateBaseURL(newValue)
|
||||
flashSavedDebounced()
|
||||
}
|
||||
)
|
||||
)
|
||||
if let preset, model.draft.baseURL == preset.defaultBaseURL.absoluteString || model.draft.baseURL.isEmpty {
|
||||
baseURLDefaultBadge
|
||||
}
|
||||
}
|
||||
}
|
||||
DividerLine()
|
||||
|
||||
SettingsRow(title: "Model") {
|
||||
PasteableCredentialField(
|
||||
placeholder: preset?.defaultModel.isEmpty == false ? preset!.defaultModel : "endpoint id / model name",
|
||||
secure: false,
|
||||
text: Binding(
|
||||
get: { model.draft.model },
|
||||
set: { newValue in
|
||||
model.updateModel(newValue)
|
||||
flashSavedDebounced()
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
DividerLine()
|
||||
|
||||
SettingsRow(title: "Temperature") {
|
||||
temperatureControl
|
||||
}
|
||||
|
||||
if let preset {
|
||||
DividerLine()
|
||||
presetHelpDisclosure(preset)
|
||||
}
|
||||
}
|
||||
|
||||
// 删除按钮独占一行,用 destructive 强调;active 时禁用并提示。
|
||||
HStack {
|
||||
Spacer()
|
||||
Button(role: .destructive) {
|
||||
showingDeleteConfirm = true
|
||||
} label: {
|
||||
Label("删除此 provider", systemImage: "trash")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(isActive)
|
||||
.help(isActive ? "当前 active provider 不能删除——先切到别的 provider" : "从本机移除该 provider 的所有字段")
|
||||
}
|
||||
}
|
||||
|
||||
private var providerFormTitle: String {
|
||||
if let preset = LLMProviderRegistry.preset(for: model.selectedProviderId) {
|
||||
return preset.displayName
|
||||
}
|
||||
if !model.draft.displayName.isEmpty {
|
||||
return model.draft.displayName
|
||||
}
|
||||
return "自定义 Provider (\(model.selectedProviderId))"
|
||||
}
|
||||
|
||||
private var baseURLDefaultBadge: some View {
|
||||
Text("默认")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 4, style: .continuous)
|
||||
.fill(Color.primary.opacity(0.06))
|
||||
)
|
||||
}
|
||||
|
||||
private var temperatureControl: some View {
|
||||
HStack(spacing: 12) {
|
||||
Slider(value: Binding(
|
||||
get: { model.draft.temperature },
|
||||
set: { newValue in
|
||||
model.updateTemperature(newValue)
|
||||
flashSavedDebounced()
|
||||
}
|
||||
), in: 0.0...1.0, step: 0.05)
|
||||
.frame(width: 220)
|
||||
Text(String(format: "%.2f", model.draft.temperature))
|
||||
.font(.system(size: 13, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 44, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
private func presetHelpDisclosure(_ preset: LLMProviderRegistry.Preset) -> some View {
|
||||
DisclosureGroup {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(preset.helpText)
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
if let docsURL = preset.docsURL {
|
||||
Button {
|
||||
NSWorkspace.shared.open(docsURL)
|
||||
} label: {
|
||||
Label("打开 \(docsURL.host ?? "API Key 页")", systemImage: "arrow.up.right.square")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
.padding(.top, 6)
|
||||
} label: {
|
||||
Label("怎么获取 API Key?", systemImage: "questionmark.circle")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
|
||||
// MARK: - 提示反馈
|
||||
|
||||
private func flashSaved() {
|
||||
lastSavedFlash = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { lastSavedFlash = false }
|
||||
}
|
||||
|
||||
/// 防止"每按一个键就闪一次"——表单字段编辑用更短的 debounce。
|
||||
private func flashSavedDebounced() {
|
||||
// 模型自身已经在 0.5s debounce 之后写盘;这里仅仅控制视觉提示节奏。
|
||||
lastSavedFlash = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.9) { lastSavedFlash = false }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Model
|
||||
|
||||
/// LLM Providers 视图模型:管理 picker 选择 / 草稿表单 / 保存到 vault。
|
||||
@MainActor
|
||||
final class LLMProvidersModel: ObservableObject {
|
||||
@Published private(set) var configuredIds: [String] = []
|
||||
@Published private(set) var activeProviderId: String = defaultActiveLLMProviderId
|
||||
@Published private(set) var selectedProviderId: String = defaultActiveLLMProviderId
|
||||
@Published var draft = LLMProviderDraft()
|
||||
|
||||
/// 表单 debounce:防止每按一个键就写一次 credentials.json。
|
||||
private var saveTask: Task<Void, Never>?
|
||||
|
||||
func load() {
|
||||
let vault = CredentialsVault.shared
|
||||
configuredIds = vault.configuredLLMProviderIds
|
||||
activeProviderId = vault.activeLLMProviderId
|
||||
// 第一次加载或 active 不在列表里时,selectedProviderId 跟随 active;
|
||||
// 用户切换过 selected 后保留用户选择。
|
||||
if selectedProviderId.isEmpty || !configuredIds.contains(selectedProviderId) {
|
||||
selectedProviderId = activeProviderId
|
||||
}
|
||||
loadDraftFromVault()
|
||||
}
|
||||
|
||||
func displayName(for providerId: String) -> String {
|
||||
if let preset = LLMProviderRegistry.preset(for: providerId) {
|
||||
return preset.displayName
|
||||
}
|
||||
// 自定义 provider:从 vault 读 displayName。
|
||||
let vault = CredentialsVault.shared
|
||||
if let cfg = vault.llmProviderConfig(for: providerId), !cfg.displayName.isEmpty {
|
||||
return cfg.displayName
|
||||
}
|
||||
return providerId
|
||||
}
|
||||
|
||||
func selectProvider(_ providerId: String) {
|
||||
flushDraftIfDirty()
|
||||
selectedProviderId = providerId
|
||||
loadDraftFromVault()
|
||||
|
||||
// 选 active 应该立即生效——但只有当用户选择的是已存在的 provider 时。
|
||||
let vault = CredentialsVault.shared
|
||||
if vault.activeLLMProviderId != providerId {
|
||||
vault.activeLLMProviderId = providerId
|
||||
activeProviderId = providerId
|
||||
NotificationCenter.default.post(name: .openLessCredentialsChanged, object: nil)
|
||||
}
|
||||
configuredIds = vault.configuredLLMProviderIds
|
||||
}
|
||||
|
||||
func addProvider(providerId: String, displayName: String) {
|
||||
let vault = CredentialsVault.shared
|
||||
let preset = LLMProviderRegistry.preset(for: providerId)
|
||||
let baseURL = preset?.defaultBaseURL ?? URL(string: "https://api.example.com/v1")!
|
||||
let model = preset?.defaultModel ?? ""
|
||||
let resolvedDisplayName = displayName.isEmpty ? (preset?.displayName ?? providerId) : displayName
|
||||
|
||||
let cfg = OpenAICompatibleConfig(
|
||||
providerId: providerId,
|
||||
displayName: resolvedDisplayName,
|
||||
baseURL: baseURL,
|
||||
apiKey: "",
|
||||
model: model,
|
||||
extraHeaders: [:],
|
||||
temperature: 0.3
|
||||
)
|
||||
vault.setLLMProviderConfig(cfg)
|
||||
// 切到新加的 provider 让用户立即填字段。
|
||||
vault.activeLLMProviderId = providerId
|
||||
NotificationCenter.default.post(name: .openLessCredentialsChanged, object: nil)
|
||||
load()
|
||||
selectedProviderId = providerId
|
||||
loadDraftFromVault()
|
||||
}
|
||||
|
||||
func deleteSelected() {
|
||||
let vault = CredentialsVault.shared
|
||||
guard selectedProviderId != activeProviderId else { return }
|
||||
do {
|
||||
try vault.removeLLMProvider(selectedProviderId)
|
||||
} catch {
|
||||
// 删除失败不阻塞 UI;下一次 load 会重新拉。
|
||||
}
|
||||
NotificationCenter.default.post(name: .openLessCredentialsChanged, object: nil)
|
||||
load()
|
||||
selectedProviderId = activeProviderId
|
||||
loadDraftFromVault()
|
||||
}
|
||||
|
||||
// MARK: - Draft 字段更新(debounce 写盘)
|
||||
|
||||
func updateDisplayName(_ value: String) {
|
||||
draft.displayName = value
|
||||
scheduleSave()
|
||||
}
|
||||
|
||||
func updateApiKey(_ value: String) {
|
||||
draft.apiKey = value
|
||||
scheduleSave()
|
||||
}
|
||||
|
||||
func updateBaseURL(_ value: String) {
|
||||
draft.baseURL = value
|
||||
scheduleSave()
|
||||
}
|
||||
|
||||
func updateModel(_ value: String) {
|
||||
draft.model = value
|
||||
scheduleSave()
|
||||
}
|
||||
|
||||
func updateTemperature(_ value: Double) {
|
||||
draft.temperature = value
|
||||
scheduleSave()
|
||||
}
|
||||
|
||||
// MARK: - 私有
|
||||
|
||||
private func loadDraftFromVault() {
|
||||
let vault = CredentialsVault.shared
|
||||
let cfg = vault.llmProviderConfig(for: selectedProviderId)
|
||||
let preset = LLMProviderRegistry.preset(for: selectedProviderId)
|
||||
|
||||
draft.displayName = cfg?.displayName ?? preset?.displayName ?? selectedProviderId
|
||||
draft.apiKey = cfg?.apiKey ?? ""
|
||||
draft.baseURL = cfg?.baseURL.absoluteString
|
||||
?? preset?.defaultBaseURL.absoluteString
|
||||
?? ""
|
||||
draft.model = cfg?.model ?? preset?.defaultModel ?? ""
|
||||
draft.temperature = cfg?.temperature ?? 0.3
|
||||
}
|
||||
|
||||
private func scheduleSave() {
|
||||
saveTask?.cancel()
|
||||
saveTask = Task { @MainActor [weak self] in
|
||||
try? await Task.sleep(nanoseconds: 500_000_000)
|
||||
if Task.isCancelled { return }
|
||||
self?.flushDraftIfDirty()
|
||||
}
|
||||
}
|
||||
|
||||
private func flushDraftIfDirty() {
|
||||
let vault = CredentialsVault.shared
|
||||
let trimmedBaseURL = draft.baseURL.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let preset = LLMProviderRegistry.preset(for: selectedProviderId)
|
||||
let baseURL: URL
|
||||
if let parsed = URL(string: trimmedBaseURL), !trimmedBaseURL.isEmpty, parsed.scheme != nil {
|
||||
baseURL = parsed
|
||||
} else if let preset {
|
||||
baseURL = preset.defaultBaseURL
|
||||
} else {
|
||||
// 自定义 + 无效 baseURL:暂不写盘,等用户填完。
|
||||
return
|
||||
}
|
||||
|
||||
let cfg = OpenAICompatibleConfig(
|
||||
providerId: selectedProviderId,
|
||||
displayName: draft.displayName,
|
||||
baseURL: baseURL,
|
||||
apiKey: draft.apiKey,
|
||||
model: draft.model,
|
||||
extraHeaders: [:],
|
||||
temperature: draft.temperature
|
||||
)
|
||||
vault.setLLMProviderConfig(cfg)
|
||||
NotificationCenter.default.post(name: .openLessCredentialsChanged, object: nil)
|
||||
}
|
||||
}
|
||||
|
||||
/// 编辑中的 provider 表单字段。`OpenAICompatibleConfig` 用 URL 不允许中间态非法字符串,
|
||||
/// 所以表单层用 String 草稿;保存前再 try URL。
|
||||
struct LLMProviderDraft {
|
||||
var displayName: String = ""
|
||||
var apiKey: String = ""
|
||||
var baseURL: String = ""
|
||||
var model: String = ""
|
||||
var temperature: Double = 0.3
|
||||
}
|
||||
|
||||
// MARK: - Add Provider Sheet
|
||||
|
||||
/// 添加 provider 的 sheet:列出所有预设 + "自定义";选 "自定义" 时多一步收集 slug + displayName。
|
||||
@MainActor
|
||||
struct AddLLMProviderSheet: View {
|
||||
@Binding var isPresented: Bool
|
||||
let existingIds: Set<String>
|
||||
let onAdd: (_ providerId: String, _ displayName: String) -> Void
|
||||
|
||||
@State private var customId: String = ""
|
||||
@State private var customDisplayName: String = ""
|
||||
@State private var step: Step = .pick
|
||||
|
||||
enum Step {
|
||||
case pick
|
||||
case customDetails
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack {
|
||||
Text(step == .pick ? "添加 LLM Provider" : "自定义 OpenAI 兼容 Provider")
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
Spacer()
|
||||
Button {
|
||||
isPresented = false
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.font(.system(size: 18))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(20)
|
||||
|
||||
Divider()
|
||||
|
||||
ScrollView {
|
||||
switch step {
|
||||
case .pick:
|
||||
pickList
|
||||
case .customDetails:
|
||||
customDetailsForm
|
||||
}
|
||||
}
|
||||
.frame(minHeight: 360, maxHeight: 480)
|
||||
}
|
||||
.frame(width: 520)
|
||||
}
|
||||
|
||||
private var pickList: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
ForEach(LLMProviderRegistry.presets, id: \.providerId) { preset in
|
||||
presetRow(preset)
|
||||
Divider().padding(.leading, 16)
|
||||
}
|
||||
customRow
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
private func presetRow(_ preset: LLMProviderRegistry.Preset) -> some View {
|
||||
let alreadyAdded = existingIds.contains(preset.providerId)
|
||||
return Button {
|
||||
guard !alreadyAdded else { return }
|
||||
onAdd(preset.providerId, preset.displayName)
|
||||
isPresented = false
|
||||
} label: {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: alreadyAdded ? "checkmark.circle.fill" : "circle")
|
||||
.foregroundStyle(alreadyAdded ? .green : .secondary)
|
||||
.font(.system(size: 18))
|
||||
.padding(.top, 2)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(preset.displayName)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(.primary)
|
||||
Text(preset.defaultBaseURL.absoluteString)
|
||||
.font(.system(size: 12, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
Text(preset.helpText)
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 18)
|
||||
.padding(.vertical, 12)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(alreadyAdded)
|
||||
.help(alreadyAdded ? "已添加" : "添加 \(preset.displayName)")
|
||||
}
|
||||
|
||||
private var customRow: some View {
|
||||
Button {
|
||||
step = .customDetails
|
||||
} label: {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: "plus.circle")
|
||||
.foregroundStyle(.blue)
|
||||
.font(.system(size: 18))
|
||||
.padding(.top, 2)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(LLMProviderRegistry.customDisplayName)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(.primary)
|
||||
Text("任何遵循 OpenAI Chat Completions 协议的供应商都能填进来——比如自建网关、私有化部署、或表里没列的云厂商。")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 18)
|
||||
.padding(.vertical, 12)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private var customDetailsForm: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Provider ID")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
TextField("custom-gateway", text: $customId)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
Text("唯一 slug,建议小写字母 / 数字 / 短横线;不能与已存在的 id 重复。")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("展示名")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
TextField("公司内部网关", text: $customDisplayName)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Button("返回") {
|
||||
step = .pick
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
Spacer()
|
||||
Button("添加") {
|
||||
let id = customId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let name = customDisplayName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
onAdd(id, name)
|
||||
isPresented = false
|
||||
}
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(!isCustomReady)
|
||||
}
|
||||
.padding(.top, 6)
|
||||
}
|
||||
.padding(20)
|
||||
}
|
||||
|
||||
private var isCustomReady: Bool {
|
||||
let id = customId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let name = customDisplayName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !id.isEmpty, !name.isEmpty else { return false }
|
||||
// slug 不能撞预设 id 也不能撞已存在的条目。
|
||||
if LLMProviderRegistry.preset(for: id) != nil { return false }
|
||||
if existingIds.contains(id) { return false }
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,9 @@ import OpenLessHotkey
|
||||
import OpenLessPersistence
|
||||
import OpenLessRecorder
|
||||
import OpenLessASR
|
||||
import OpenLessPolish
|
||||
|
||||
/// 所有可调项汇总:录音 + 凭据(Volc ASR + Ark 润色)+ 授权 + 隐私 + 关于。
|
||||
/// 所有可调项汇总:录音 + ASR 凭据(Volc)+ 授权 + 隐私 + 关于。
|
||||
/// LLM provider 配置已经搬到独立的「LLM Provider」Tab;这里只保留 ASR / 系统级开关。
|
||||
struct SettingsHubTab: View {
|
||||
@State private var trigger: HotkeyBinding.Trigger = UserPreferences.shared.hotkeyTrigger
|
||||
@State private var hotkeyMode: HotkeyMode = UserPreferences.shared.hotkeyMode
|
||||
@@ -17,10 +17,9 @@ struct SettingsHubTab: View {
|
||||
@State private var volcAppKey = ""
|
||||
@State private var volcAccessKey = ""
|
||||
@State private var volcResourceId = VolcengineCredentials.defaultResourceId
|
||||
@State private var arkApiKey = ""
|
||||
@State private var arkModelId = ArkCredentials.defaultModelId
|
||||
@State private var arkEndpoint = ArkCredentials.defaultEndpoint.absoluteString
|
||||
@State private var saved = false
|
||||
@State private var hasLLMProvider = false
|
||||
@State private var llmProviderDisplayName = ""
|
||||
|
||||
var body: some View {
|
||||
SettingsPage(
|
||||
@@ -78,22 +77,28 @@ struct SettingsHubTab: View {
|
||||
}
|
||||
}
|
||||
|
||||
GlassSection(title: "Ark / DeepSeek V3.2 润色", symbol: "wand.and.stars") {
|
||||
SettingsRow(title: "API Key") {
|
||||
PasteableCredentialField(placeholder: "Bearer Token", secure: true, text: $arkApiKey)
|
||||
GlassSection(title: "LLM 润色 Provider", symbol: "wand.and.stars") {
|
||||
SettingsRow(title: "当前 Provider") {
|
||||
HStack(spacing: 10) {
|
||||
Label(
|
||||
hasLLMProvider ? llmProviderDisplayName : "未配置",
|
||||
systemImage: hasLLMProvider ? "checkmark.circle.fill" : "exclamationmark.triangle.fill"
|
||||
)
|
||||
.foregroundStyle(hasLLMProvider ? .green : .orange)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
DividerLine()
|
||||
SettingsRow(title: "Model ID") {
|
||||
PasteableCredentialField(placeholder: "Model ID", secure: false, text: $arkModelId)
|
||||
}
|
||||
DividerLine()
|
||||
SettingsRow(title: "Endpoint") {
|
||||
PasteableCredentialField(placeholder: "Endpoint", secure: false, text: $arkEndpoint)
|
||||
}
|
||||
Text(hasLLMProvider
|
||||
? "去「LLM Provider」Tab 切换 / 编辑详细字段。"
|
||||
: "前往「LLM Provider」Tab 选择豆包 / OpenAI / DeepSeek / 自定义 OpenAI 兼容 provider。未配置时识别后会直接插入原文。")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
|
||||
PrimaryActionRow {
|
||||
Button("保存凭据") { saveCredentials() }
|
||||
Button("保存 ASR 凭据") { saveCredentials() }
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.buttonStyle(.borderedProminent)
|
||||
if saved {
|
||||
@@ -152,9 +157,16 @@ struct SettingsHubTab: View {
|
||||
volcAppKey = v.get(CredentialAccount.volcengineAppKey) ?? ""
|
||||
volcAccessKey = v.get(CredentialAccount.volcengineAccessKey) ?? ""
|
||||
volcResourceId = v.get(CredentialAccount.volcengineResourceId) ?? VolcengineCredentials.defaultResourceId
|
||||
arkApiKey = v.get(CredentialAccount.arkApiKey) ?? ""
|
||||
arkModelId = v.get(CredentialAccount.arkModelId) ?? ArkCredentials.defaultModelId
|
||||
arkEndpoint = v.get(CredentialAccount.arkEndpoint) ?? ArkCredentials.defaultEndpoint.absoluteString
|
||||
|
||||
// 当前 active LLM provider 是否填了 apiKey;LLM 设置已搬到 LLMProvidersTab。
|
||||
let activeId = v.activeLLMProviderId
|
||||
if let cfg = v.llmProviderConfig(for: activeId), !cfg.apiKey.isEmpty {
|
||||
hasLLMProvider = true
|
||||
llmProviderDisplayName = cfg.displayName
|
||||
} else {
|
||||
hasLLMProvider = false
|
||||
llmProviderDisplayName = LLMProviderRegistry.preset(for: activeId)?.displayName ?? activeId
|
||||
}
|
||||
}
|
||||
|
||||
private func saveCredentials() {
|
||||
@@ -162,9 +174,6 @@ struct SettingsHubTab: View {
|
||||
try? v.set(volcAppKey.trimmingCharacters(in: .whitespacesAndNewlines), for: CredentialAccount.volcengineAppKey)
|
||||
try? v.set(volcAccessKey.trimmingCharacters(in: .whitespacesAndNewlines), for: CredentialAccount.volcengineAccessKey)
|
||||
try? v.set(volcResourceId.trimmingCharacters(in: .whitespacesAndNewlines), for: CredentialAccount.volcengineResourceId)
|
||||
try? v.set(arkApiKey.trimmingCharacters(in: .whitespacesAndNewlines), for: CredentialAccount.arkApiKey)
|
||||
try? v.set(arkModelId.trimmingCharacters(in: .whitespacesAndNewlines), for: CredentialAccount.arkModelId)
|
||||
try? v.set(arkEndpoint.trimmingCharacters(in: .whitespacesAndNewlines), for: CredentialAccount.arkEndpoint)
|
||||
NotificationCenter.default.post(name: .openLessCredentialsChanged, object: nil)
|
||||
saved = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { saved = false }
|
||||
|
||||
Reference in new issue
Block a user