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:
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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user