Merge: unified 配置 tab + sidebar reorder

Merges LLM tab and ASR tab into a single 配置 tab placed above 设置 in
sidebar. Volcengine 3-field form moves out of SettingsHubTab into the
new tab's ASR section. Single prominent 保存 button at bottom commits
all pending changes (LLM provider switch + LLM provider configs + ASR
provider switch + Volcengine creds + new ASR provider creds).

ASRProviderRegistry adds aliyun-paraformer and custom-openai-whisper
presets. New ASR provider classes are added in a separate parallel
branch.
This commit is contained in:
baiqing committed 2026-04-29 13:05:01 +08:00
commit a14dac8b0a
7 files changed
+1171 -966

No files matched your search

@@ -15,10 +15,9 @@ enum OpenLessMainTab: String, CaseIterable, Identifiable {
case history
case dictionary
case polish
case asr
case llm
case help
case providersConfig
case settings
case help
var id: String { rawValue }
@@ -28,10 +27,9 @@ enum OpenLessMainTab: String, CaseIterable, Identifiable {
case .history: return "历史记录"
case .dictionary: return "词汇表"
case .polish: return "风格"
case .asr: return "ASR"
case .llm: return "LLM Provider"
case .help: return "帮助中心"
case .providersConfig: return "配置"
case .settings: return "设置"
case .help: return "帮助中心"
}
}
@@ -41,10 +39,9 @@ enum OpenLessMainTab: String, CaseIterable, Identifiable {
case .history: return "clock"
case .dictionary: return "text.book.closed"
case .polish: return "paintpalette"
case .asr: return "waveform"
case .llm: return "wand.and.stars"
case .help: return "questionmark.circle"
case .providersConfig: return "slider.horizontal.3"
case .settings: return "gearshape"
case .help: return "questionmark.circle"
}
}
}
@@ -74,10 +71,9 @@ struct SettingsView: View {
case .history: HistoryTab()
case .dictionary: DictionaryTab()
case .polish: StyleTab()
case .asr: ASRProvidersTab()
case .llm: LLMProvidersTab()
case .help: HelpTab()
case .providersConfig: ProvidersConfigTab()
case .settings: SettingsHubTab()
case .help: HelpTab()
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
@@ -1,248 +0,0 @@
import AppKit
import SwiftUI
import OpenLessCore
import OpenLessPersistence
/// ASR Provider 设置 Tab:横向 chip 切换 active provider,下方显示当前选项的提示
/// 与"凭据填在哪里"的指引;底部一个明显的主操作「保存」按钮把 selection 持久化。
///
/// 设计要点:
/// - 状态来源是 `CredentialsVault.shared`:所有读 / 写都直接打 vault,UI 是无状态视图层。
/// - 与 `LLMProvidersTab` 的差异:ASR 各家协议字段差异大(火山 3 字段 / Apple Speech 0 字段),
/// 不能像 LLM 那样共用一份表单——所以这里只承担"切 active",具体字段:
/// * 火山引擎 → 跳「设置」Tab 的 ASR 字段段
/// * Apple Speech → 无字段,仅提示权限会被系统询问
/// - 「保存」按钮采用主操作样式(borderedProminent),让用户看见明确的提交动作。
/// 切 chip 不立即写盘,避免误触;只有点击保存才会落到 credentials.json 里。
@MainActor
struct ASRProvidersTab: View {
@StateObject private var model = ASRProvidersModel()
@State private var saved = false
var body: some View {
SettingsPage(
title: "ASR Provider",
subtitle: "选择哪家语音识别引擎负责把你的语音转成文字。火山引擎更准但要 API key;macOS 本地 (Apple Speech) 完全离线、零配置,第一次切换时系统会请求语音识别权限。"
) {
GlassSection(title: "选择 ASR", symbol: "waveform") {
providerChips
DividerLine()
Text(model.helpTextForSelection)
.font(.footnote)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
.padding(.top, 4)
}
GlassSection(title: detailSectionTitle, symbol: detailSectionSymbol) {
providerDetail
}
PrimaryActionRow {
Button {
model.commitSelection()
flashSaved()
} label: {
Label("保存", systemImage: "checkmark.circle.fill")
}
.keyboardShortcut(.defaultAction)
.buttonStyle(.borderedProminent)
.controlSize(.large)
.disabled(!model.hasUnsavedChanges)
if saved {
Label("已保存", systemImage: "checkmark.circle.fill")
.foregroundStyle(.green)
}
}
}
.onAppear { model.load() }
.onReceive(NotificationCenter.default.publisher(for: .openLessCredentialsChanged)) { _ in
model.load()
}
}
// MARK: - Chip row
private var providerChips: some View {
HStack(spacing: 10) {
ForEach(ASRProviderRegistry.presets, id: \.providerId) { preset in
ASRProviderChip(
preset: preset,
isSelected: model.selectedProviderId == preset.providerId,
isActive: model.activeProviderId == preset.providerId,
onSelect: { model.selectProvider(preset.providerId) }
)
}
Spacer()
}
.padding(.vertical, 4)
}
// MARK: - Detail section
private var detailSectionTitle: String {
if let preset = ASRProviderRegistry.preset(for: model.selectedProviderId) {
return preset.displayName
}
return model.selectedProviderId
}
private var detailSectionSymbol: String {
switch model.selectedProviderId {
case "apple-speech": return "lock.shield"
case "volcengine": return "key"
default: return "info.circle"
}
}
@ViewBuilder
private var providerDetail: some View {
switch model.selectedProviderId {
case "apple-speech":
appleSpeechDetail
case "volcengine":
volcengineDetail
default:
Text("未知 ASR provider:\(model.selectedProviderId)")
.font(.callout)
.foregroundStyle(.secondary)
}
}
private var appleSpeechDetail: some View {
VStack(alignment: .leading, spacing: 12) {
detailLine("无需配置 API key —— Apple Speech 完全由 macOS 系统提供。", symbol: "checkmark.seal")
detailLine("中文 (zh-CN) 在 Apple Silicon 上支持完全离线识别;其他语言或 Intel 机器会回退到 Apple 云端。", symbol: "globe")
detailLine("第一次切换并开始录音时,系统会弹窗请求「语音识别」权限——选择允许即可。", symbol: "hand.raised.fill")
detailLine("保存后,下一次按下录音键就会通过 Apple Speech 识别。", symbol: "play.circle.fill")
}
.padding(.vertical, 4)
}
private var volcengineDetail: some View {
VStack(alignment: .leading, spacing: 12) {
detailLine("火山引擎使用云端流式识别,准确度高,需要在控制台获取 App ID / Access Token / Resource ID。", symbol: "cloud")
detailLine("具体凭据字段填在 「设置」Tab 的「火山引擎大模型流式 ASR」段。", symbol: "gearshape")
detailLine("缺少凭据时本 Tab 选中火山引擎依然能保存——但下一次录音会回落到演示模式。", symbol: "exclamationmark.triangle")
}
.padding(.vertical, 4)
}
private func detailLine(_ 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: - 提示反馈
private func flashSaved() {
saved = true
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { saved = false }
}
}
// MARK: - Chip
@MainActor
private struct ASRProviderChip: View {
let preset: ASRProviderRegistry.Preset
let isSelected: Bool
let isActive: Bool
let onSelect: () -> Void
var body: some View {
Button(action: onSelect) {
HStack(spacing: 8) {
Image(systemName: chipSymbol)
.symbolRenderingMode(.hierarchical)
Text(preset.displayName)
.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, 14)
.padding(.vertical, 9)
.background(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(isSelected ? Color.blue.opacity(0.15) : Color.primary.opacity(0.04))
)
.overlay(
RoundedRectangle(cornerRadius: 16, 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)
.help(preset.helpText)
}
private var chipSymbol: String {
switch preset.providerId {
case "apple-speech": return "applelogo"
case "volcengine": return "cloud.fill"
default: return "waveform"
}
}
}
// MARK: - Model
@MainActor
final class ASRProvidersModel: ObservableObject {
@Published private(set) var configuredIds: [String] = []
@Published private(set) var activeProviderId: String = defaultActiveASRProviderId
@Published private(set) var selectedProviderId: String = defaultActiveASRProviderId
/// 用户切了 chip 但还没点保存。保存按钮的 enable 状态依赖这个。
var hasUnsavedChanges: Bool {
selectedProviderId != activeProviderId
}
var helpTextForSelection: String {
ASRProviderRegistry.preset(for: selectedProviderId)?.helpText
?? "未知 ASR provider;请联系开发者或检查 ~/.openless/credentials.json。"
}
func load() {
let vault = CredentialsVault.shared
configuredIds = vault.configuredASRProviderIds
activeProviderId = vault.activeASRProviderId
// 第一次加载或 active 不在列表里时,selectedProviderId 跟随 active;
// 用户切换过 selected 后保留用户选择直到保存或离开。
if !configuredIds.contains(selectedProviderId) {
selectedProviderId = activeProviderId
}
}
func selectProvider(_ providerId: String) {
selectedProviderId = providerId
}
/// 把当前 selection 写回 vault;触发凭据变更通知,让 DictationCoordinator 等订阅方刷新缓存。
func commitSelection() {
let vault = CredentialsVault.shared
guard vault.activeASRProviderId != selectedProviderId else { return }
vault.activeASRProviderId = selectedProviderId
activeProviderId = selectedProviderId
NotificationCenter.default.post(name: .openLessCredentialsChanged, object: nil)
}
}
@@ -1,645 +0,0 @@
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
}
}
File diff suppressed because it is too large. Load diff
@@ -4,27 +4,23 @@ import OpenLessCore
import OpenLessHotkey
import OpenLessPersistence
import OpenLessRecorder
import OpenLessASR
/// 所有可调项汇总:录音 + ASR 凭据(Volc)+ 授权 + 隐私 + 关于。
/// LLM provider 配置已经搬到独立的「LLM Provider」Tab;这里只保留 ASR / 系统级开关。
/// 系统级开关与状态汇总:录音快捷键 + 授权 + 隐私 + 关于。
/// LLM 与 ASR 的凭据 / provider 切换全部搬到「配置」Tab。
struct SettingsHubTab: View {
@State private var trigger: HotkeyBinding.Trigger = UserPreferences.shared.hotkeyTrigger
@State private var hotkeyMode: HotkeyMode = UserPreferences.shared.hotkeyMode
@State private var hasAccessibility = false
@State private var hasMicrophone = false
@State private var volcAppKey = ""
@State private var volcAccessKey = ""
@State private var volcResourceId = VolcengineCredentials.defaultResourceId
@State private var saved = false
@State private var hasLLMProvider = false
@State private var llmProviderDisplayName = ""
@State private var activeASRDisplayName = ""
var body: some View {
SettingsPage(
title: "设置",
subtitle: "录音快捷键、凭据、授权状态、隐私和版本信息全部在这里。"
subtitle: "录音快捷键、授权状态、隐私和版本信息。LLM 模型 / ASR 语音的 API Key 和切换在「配置」Tab。"
) {
GlassSection(title: "录音", symbol: "keyboard") {
SettingsRow(title: "录音快捷键") {
@@ -63,22 +59,8 @@ struct SettingsHubTab: View {
.padding(.top, 4)
}
GlassSection(title: "火山引擎大模型流式 ASR", symbol: "waveform") {
SettingsRow(title: "APP ID") {
PasteableCredentialField(placeholder: "X-Api-App-Key", secure: false, text: $volcAppKey)
}
DividerLine()
SettingsRow(title: "Access Token") {
PasteableCredentialField(placeholder: "X-Api-Access-Key", secure: true, text: $volcAccessKey)
}
DividerLine()
SettingsRow(title: "Resource ID") {
PasteableCredentialField(placeholder: "X-Api-Resource-Id", secure: false, text: $volcResourceId)
}
}
GlassSection(title: "LLM 润色 Provider", symbol: "wand.and.stars") {
SettingsRow(title: "当前 Provider") {
GlassSection(title: "Provider 概览", symbol: "wand.and.stars") {
SettingsRow(title: "LLM 模型") {
HStack(spacing: 10) {
Label(
hasLLMProvider ? llmProviderDisplayName : "未配置",
@@ -89,24 +71,23 @@ struct SettingsHubTab: View {
}
}
DividerLine()
Text(hasLLMProvider
? "去「LLM Provider」Tab 切换 / 编辑详细字段。"
: "前往「LLM Provider」Tab 选择豆包 / OpenAI / DeepSeek / 自定义 OpenAI 兼容 provider。未配置时识别后会直接插入原文。")
SettingsRow(title: "ASR 语音") {
HStack(spacing: 10) {
Label(
activeASRDisplayName.isEmpty ? "未选择" : activeASRDisplayName,
systemImage: "waveform"
)
.foregroundStyle(.primary)
Spacer()
}
}
DividerLine()
Text("前往「配置」Tab 选择并填写 LLM / ASR 凭据。未配置 LLM 时识别后直接插入原文;ASR 缺凭据时回退到演示模式。")
.font(.footnote)
.foregroundStyle(.secondary)
.padding(.top, 4)
}
PrimaryActionRow {
Button("保存 ASR 凭据") { saveCredentials() }
.keyboardShortcut(.defaultAction)
.buttonStyle(.borderedProminent)
if saved {
Label("已保存", systemImage: "checkmark.circle.fill")
.foregroundStyle(.green)
}
}
GlassSection(title: "授权状态", symbol: "checkmark.seal") {
StatusLine(title: "辅助功能", detail: hasAccessibility ? "已授权" : "未授权", ok: hasAccessibility)
DividerLine()
@@ -120,7 +101,7 @@ struct SettingsHubTab: View {
DividerLine()
privacyRow("历史只保存原始转写和最终文本", symbol: "doc.text")
DividerLine()
privacyRow("云端 ASR 会上传音频;开启 Ark 润色时上传转写文本", symbol: "icloud")
privacyRow("云端 ASR 会上传音频;开启润色时上传转写文本", symbol: "icloud")
}
GlassSection(title: "关于", symbol: "info.circle") {
@@ -139,6 +120,9 @@ struct SettingsHubTab: View {
}
}
.onAppear { refresh() }
.onReceive(NotificationCenter.default.publisher(for: .openLessCredentialsChanged)) { _ in
refresh()
}
}
private var versionString: String {
@@ -154,29 +138,19 @@ struct SettingsHubTab: View {
hasAccessibility = AccessibilityPermission.isGranted()
hasMicrophone = MicrophonePermission.isGranted()
let v = CredentialsVault.shared
volcAppKey = v.get(CredentialAccount.volcengineAppKey) ?? ""
volcAccessKey = v.get(CredentialAccount.volcengineAccessKey) ?? ""
volcResourceId = v.get(CredentialAccount.volcengineResourceId) ?? VolcengineCredentials.defaultResourceId
// 当前 active LLM provider 是否填了 apiKey;LLM 设置已搬到 LLMProvidersTab。
let activeId = v.activeLLMProviderId
if let cfg = v.llmProviderConfig(for: activeId), !cfg.apiKey.isEmpty {
// 当前 active LLM provider 是否填了 apiKey;详细配置在「配置」Tab。
let activeLLMId = v.activeLLMProviderId
if let cfg = v.llmProviderConfig(for: activeLLMId), !cfg.apiKey.isEmpty {
hasLLMProvider = true
llmProviderDisplayName = cfg.displayName
} else {
hasLLMProvider = false
llmProviderDisplayName = LLMProviderRegistry.preset(for: activeId)?.displayName ?? activeId
llmProviderDisplayName = LLMProviderRegistry.preset(for: activeLLMId)?.displayName ?? activeLLMId
}
}
private func saveCredentials() {
let v = CredentialsVault.shared
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)
NotificationCenter.default.post(name: .openLessCredentialsChanged, object: nil)
saved = true
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { saved = false }
let activeASRId = v.activeASRProviderId
activeASRDisplayName = ASRProviderRegistry.preset(for: activeASRId)?.displayName ?? activeASRId
}
private func privacyRow(_ text: String, symbol: String) -> some View {
+14 -2
View File
@@ -35,8 +35,8 @@ public enum ASRProviderRegistry {
}
}
/// M1 内置的 ASR provider 预设。
/// 顺序即为 UI 列表的展示顺序——火山在前(旧用户默认),Apple Speech 在后。
/// 内置的 ASR provider 预设。
/// 顺序即为 UI 列表的展示顺序——火山在前(旧用户默认),Apple Speech、阿里、自定义 Whisper 依次展开。
public static let presets: [Preset] = [
Preset(
providerId: "volcengine",
@@ -50,6 +50,18 @@ public enum ASRProviderRegistry {
mode: .streaming,
helpText: "使用 macOS 内置语音识别。免费、离线(zh-CN 在 Apple Silicon 上支持纯离线),不需要 API key。首次切换时系统会请求语音识别权限。"
),
Preset(
providerId: "aliyun-paraformer",
displayName: "阿里通义 Paraformer (DashScope)",
mode: .streaming,
helpText: "DashScope paraformer-realtime-v2 流式语音识别。如果你的 LLM 已经填了阿里通义 DashScope 的 API Key,会自动复用同一个 key。"
),
Preset(
providerId: "custom-openai-whisper",
displayName: "自定义 OpenAI 兼容 (Whisper)",
mode: .batch,
helpText: "兼容 OpenAI Whisper API(multipart/form-data POST)的任意端点:Groq、DeepInfra、OpenAI、自建反代等。录完整段音频后整段上传转写,长录音会等几秒。"
),
]
/// 根据 providerId 查预设;未知 id 返回 nil。
@@ -1,10 +1,10 @@
import XCTest
@testable import OpenLessCore
/// ASRProviderRegistry 的静态健康检查:M1 应包含火山引擎 + Apple Speech 两条预设,
/// id 不重复、displayName 非空、mode 合法。
/// ASRProviderRegistry 的静态健康检查:当前应包含火山引擎 + Apple Speech +
/// 阿里 Paraformer + 自定义 Whisper 四条预设,id 不重复、displayName 非空、mode 合法。
///
/// 这些断言看起来很无聊,但 registry 直接被 ASR Tab 的 chip 列表与 vault 兜底逻辑用,
/// 这些断言看起来很无聊,但 registry 直接被「配置」Tab 的 chip 列表与 vault 兜底逻辑用,
/// 漏一条都会变成"用户切不了 ASR / picker 空白"的运行期 bug。
final class ASRProviderRegistryTests: XCTestCase {
@@ -13,12 +13,21 @@ final class ASRProviderRegistryTests: XCTestCase {
let presets = ASRProviderRegistry.presets
// Act / Assert
XCTAssertEqual(presets.count, 2, "M1 计划列出的 ASR 预设是 volcengine / apple-speech")
XCTAssertEqual(
presets.count,
4,
"ASR 预设:volcengine / apple-speech / aliyun-paraformer / custom-openai-whisper"
)
}
func test_presets_includeAllExpectedProviderIds() {
// Arrange
let expected: Set<String> = ["volcengine", "apple-speech"]
let expected: Set<String> = [
"volcengine",
"apple-speech",
"aliyun-paraformer",
"custom-openai-whisper",
]
// Act
let actualIds = Set(ASRProviderRegistry.presets.map(\.providerId))
@@ -65,6 +74,24 @@ final class ASRProviderRegistryTests: XCTestCase {
XCTAssertEqual(preset?.mode, .streaming)
}
func test_aliyunParaformerPreset_isStreaming() {
// Arrange
let preset = ASRProviderRegistry.preset(for: "aliyun-paraformer")
// Act / Assert
XCTAssertNotNil(preset)
XCTAssertEqual(preset?.mode, .streaming)
}
func test_customOpenAIWhisperPreset_isBatch() {
// Arrange
let preset = ASRProviderRegistry.preset(for: "custom-openai-whisper")
// Act / Assert
XCTAssertNotNil(preset)
XCTAssertEqual(preset?.mode, .batch, "Whisper API 是批量上传形态")
}
func test_preset_lookup_returnsRegistered() {
for preset in ASRProviderRegistry.presets {
let resolved = ASRProviderRegistry.preset(for: preset.providerId)