import AppKit import SwiftUI import OpenLessCore import OpenLessHotkey import OpenLessPersistence import OpenLessRecorder import OpenLessASR import OpenLessPolish enum OpenLessMainTab: String, CaseIterable, Identifiable { case home case history case dictionary case settings var id: String { rawValue } var title: String { switch self { case .home: return "首页" case .history: return "历史记录" case .dictionary: return "词典" case .settings: return "设置" } } var symbol: String { switch self { case .home: return "chart.line.uptrend.xyaxis" case .history: return "clock" case .dictionary: return "text.book.closed" case .settings: return "gearshape" } } } @MainActor final class SettingsNavigationModel: ObservableObject { @Published var selection: OpenLessMainTab init(selection: OpenLessMainTab = .home) { self.selection = selection } } struct SettingsView: View { @ObservedObject private var navigation: SettingsNavigationModel init(navigation: SettingsNavigationModel) { self.navigation = navigation } var body: some View { HStack(spacing: 18) { FixedSidebar(selection: $navigation.selection) Group { switch navigation.selection { case .home: HomeTab() case .history: HistoryTab() case .dictionary: DictionaryTab() case .settings: SettingsHubTab() } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } .padding(18) .background(WindowCanvasBackground()) .frame(minWidth: 1040, minHeight: 700) } } private struct FixedSidebar: View { @Binding var selection: OpenLessMainTab @State private var stats = SidebarStatsSnapshot.load() private let sidebarShape = RoundedRectangle(cornerRadius: 32, style: .continuous) var body: some View { VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 7) { Text("OpenLess") .font(.system(size: 23, weight: .semibold)) .foregroundStyle(.primary) Text("自然说话,完美书写") .font(.caption) .foregroundStyle(.secondary) } .padding(.horizontal, 14) .padding(.top, 50) .padding(.bottom, 12) .frame(maxWidth: .infinity, alignment: .leading) .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 20, style: .continuous)) .padding(.horizontal, 12) .padding(.top, 12) .padding(.bottom, 8) ScrollView(.vertical) { VStack(alignment: .leading, spacing: 12) { VStack(spacing: 8) { ForEach(OpenLessMainTab.allCases) { tab in Button { selection = tab } label: { HStack(spacing: 11) { Image(systemName: tab.symbol) .symbolRenderingMode(.hierarchical) .foregroundStyle(selection == tab ? Color.blue : .secondary) .frame(width: 22) Text(tab.title) .font(.system(size: 14, weight: selection == tab ? .semibold : .regular)) Spacer() } .padding(.horizontal, 14) .padding(.vertical, 10) .frame(maxWidth: .infinity, alignment: .leading) .foregroundStyle(selection == tab ? .primary : .secondary) .background( selection == tab ? Color.blue.opacity(0.10) : Color.primary.opacity(0.035), in: RoundedRectangle(cornerRadius: 15, style: .continuous) ) } .buttonStyle(.plain) .focusable(false) .contentShape(RoundedRectangle(cornerRadius: 15, style: .continuous)) .help(tab.title) } } SidebarUsageCard(stats: stats) SidebarConnectionCard(stats: stats) VStack(alignment: .leading, spacing: 7) { Label("右 Option 开始录音", systemImage: "keyboard") Label("Esc 取消", systemImage: "escape") } .font(.caption) .foregroundStyle(.secondary) .padding(.horizontal, 14) .padding(.vertical, 12) .frame(maxWidth: .infinity, alignment: .leading) .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 18, style: .continuous)) } .padding(.horizontal, 12) .padding(.bottom, 12) } .scrollIndicators(.hidden) } .frame(width: 264) .frame(maxHeight: .infinity, alignment: .top) .clipShape(sidebarShape) .glassPanel(cornerRadius: 32) .contentShape(sidebarShape) .onAppear { refresh() } .onReceive(NotificationCenter.default.publisher(for: .openLessHistoryChanged)) { _ in refresh() } .onReceive(NotificationCenter.default.publisher(for: .openLessDictionaryChanged)) { _ in refresh() } .onReceive(NotificationCenter.default.publisher(for: .openLessCredentialsChanged)) { _ in refresh() } } private func refresh() { stats = SidebarStatsSnapshot.load() } } private struct WindowCanvasBackground: View { var body: some View { ZStack { Color(nsColor: .windowBackgroundColor) LinearGradient( colors: [ Color.primary.opacity(0.035), Color.clear, Color.accentColor.opacity(0.035), ], startPoint: .topLeading, endPoint: .bottomTrailing ) } .ignoresSafeArea() } } private struct SidebarUsageCard: View { let stats: SidebarStatsSnapshot var body: some View { VStack(alignment: .leading, spacing: 11) { HStack { Label("今日概览", systemImage: "chart.bar.xaxis") .font(.caption) .foregroundStyle(.secondary) Spacer() Text(stats.sessionCountText) .font(.caption2) .foregroundStyle(.secondary) } MiniUsageChart(values: stats.chartValues) LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 8) { SidebarMetricBox(title: "时长", value: stats.durationText, symbol: "waveform") SidebarMetricBox(title: "总字数", value: "\(stats.totalCharacters)", symbol: "number") SidebarMetricBox(title: "每分钟", value: "\(stats.charactersPerMinute)", symbol: "speedometer") SidebarMetricBox(title: "词条", value: "\(stats.dictionaryCount)", symbol: "text.book.closed") } } .padding(13) .glassPanel(cornerRadius: 20) } } private struct SidebarConnectionCard: View { let stats: SidebarStatsSnapshot var body: some View { VStack(alignment: .leading, spacing: 9) { SidebarConnectionRow(title: "ASR", detail: stats.hasVolcCredentials ? "已配置" : "待配置", ok: stats.hasVolcCredentials) SidebarConnectionRow(title: "润色", detail: stats.hasArkCredentials ? "已配置" : "原文兜底", ok: stats.hasArkCredentials) } .padding(13) .glassPanel(cornerRadius: 20) } } private struct SidebarMetricBox: View { let title: String let value: String let symbol: String var body: some View { VStack(alignment: .leading, spacing: 5) { Image(systemName: symbol) .font(.caption) .foregroundStyle(.secondary) Text(value) .font(.system(size: 17, weight: .semibold, design: .rounded)) .lineLimit(1) .minimumScaleFactor(0.75) Text(title) .font(.caption2) .foregroundStyle(.secondary) } .padding(10) .frame(maxWidth: .infinity, minHeight: 82, alignment: .leading) .glassPanel(cornerRadius: 15) } } private struct SidebarConnectionRow: View { let title: String let detail: String let ok: Bool var body: some View { HStack(spacing: 8) { Circle() .fill(ok ? Color.green : Color.orange) .frame(width: 7, height: 7) Text(title) .font(.caption) .foregroundStyle(.secondary) Spacer() Text(detail) .font(.caption) .foregroundStyle(ok ? .primary : .secondary) } .padding(.horizontal, 10) .padding(.vertical, 8) .glassPanel(cornerRadius: 13) } } private struct MiniUsageChart: View { let values: [Double] var body: some View { HStack(alignment: .bottom, spacing: 5) { ForEach(Array(values.enumerated()), id: \.offset) { _, value in RoundedRectangle(cornerRadius: 3, style: .continuous) .fill(Color.accentColor.opacity(0.72)) .frame(height: max(8, 44 * value)) .frame(maxWidth: .infinity) } } .padding(10) .frame(height: 66) .glassPanel(cornerRadius: 15) } } private struct SidebarStatsSnapshot { let totalSeconds: Double let totalCharacters: Int let charactersPerMinute: Int let dictionaryCount: Int let hasVolcCredentials: Bool let hasArkCredentials: Bool let chartValues: [Double] let sessionCount: Int var durationText: String { if totalSeconds < 60 { return "\(Int(totalSeconds.rounded())) 秒" } return String(format: "%.1f 分", totalSeconds / 60) } var sessionCountText: String { "\(sessionCount) 次" } static func load() -> SidebarStatsSnapshot { let sessions = HistoryStore().recent(limit: 100) let totalCharacters = sessions.reduce(0) { $0 + $1.finalText.count } let actualMs = sessions.compactMap(\.durationMs).reduce(0, +) let totalSeconds = actualMs > 0 ? Double(actualMs) / 1000 : Double(totalCharacters) / 240 * 60 let charactersPerMinute = totalSeconds > 0 ? Int((Double(totalCharacters) / totalSeconds * 60).rounded()) : 0 let recentCounts = Array(sessions.prefix(7).reversed()).map { Double(max($0.finalText.count, 1)) } let maxCount = recentCounts.max() ?? 1 let chartValues = recentCounts.isEmpty ? Array(repeating: 0.18, count: 7) : recentCounts.map { $0 / maxCount } let vault = CredentialsVault.shared return SidebarStatsSnapshot( totalSeconds: totalSeconds, totalCharacters: totalCharacters, charactersPerMinute: charactersPerMinute, dictionaryCount: DictionaryStore().enabledEntries().count, hasVolcCredentials: isFilled(vault.get(CredentialAccount.volcengineAppKey)) && isFilled(vault.get(CredentialAccount.volcengineAccessKey)), hasArkCredentials: isFilled(vault.get(CredentialAccount.arkApiKey)), chartValues: chartValues, sessionCount: sessions.count ) } } // MARK: - Shared private struct SettingsPage: View { let title: String let subtitle: String @ViewBuilder var content: Content var body: some View { ScrollView { VStack(alignment: .leading, spacing: 22) { VStack(alignment: .leading, spacing: 5) { Text(title) .font(.system(size: 30, weight: .semibold)) Text(subtitle) .font(.callout) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } .padding(.bottom, 2) content } .frame(maxWidth: 780, alignment: .leading) .padding(.horizontal, 34) .padding(.vertical, 30) } .background(Color.clear) } } private struct GlassSection: View { let title: String let symbol: String @ViewBuilder var content: Content var body: some View { VStack(alignment: .leading, spacing: 16) { Label(title, systemImage: symbol) .font(.headline) .symbolRenderingMode(.hierarchical) .foregroundStyle(.primary) VStack(spacing: 0) { content } } .padding(20) .frame(maxWidth: .infinity, alignment: .leading) .glassPanel(cornerRadius: 24) } } private struct SettingsRow: View { let title: String @ViewBuilder var content: Content var body: some View { HStack(alignment: .firstTextBaseline, spacing: 16) { Text(title) .foregroundStyle(.secondary) .frame(width: 138, alignment: .leading) content .frame(maxWidth: .infinity, alignment: .leading) } .padding(.vertical, 9) } } private struct DividerLine: View { var body: some View { Divider() .padding(.leading, 154) } } private struct StatusLine: View { let title: String let detail: String let ok: Bool var body: some View { SettingsRow(title: title) { Label(detail, systemImage: ok ? "checkmark.circle.fill" : "exclamationmark.triangle.fill") .foregroundStyle(ok ? .green : .orange) } } } private struct PasteableCredentialField: View { let placeholder: String let secure: Bool @Binding var text: String var body: some View { HStack(spacing: 8) { Group { if secure { SecureField(placeholder, text: $text) } else { TextField(placeholder, text: $text) } } .textFieldStyle(.roundedBorder) .frame(maxWidth: 390) Button { if let value = NSPasteboard.general.string(forType: .string) { text = value.trimmingCharacters(in: .whitespacesAndNewlines) } } label: { Label("粘贴", systemImage: "doc.on.clipboard") .labelStyle(.iconOnly) } .buttonStyle(.bordered) .controlSize(.small) .help("从剪贴板粘贴") } } } private struct PrimaryActionRow: View { @ViewBuilder var content: Content var body: some View { HStack { Spacer() content } .padding(.top, 4) } } private func isFilled(_ value: String?) -> Bool { guard let value else { return false } return !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } private func polishModeHint(_ mode: PolishMode) -> String { switch mode { case .raw: return "尽量忠实转写,只做基础标点和必要分句。" case .light: return "去掉明显口癖和重复,尽量保留原句式和语气。" case .structured: return "整理句子、段落和列表,适合 prompt 与笔记。" case .formal: return "适合邮件、工作沟通和正式文档。" } } private extension View { func glassPanel(cornerRadius: CGFloat) -> some View { modifier(GlassPanelModifier(cornerRadius: cornerRadius)) } } private struct GlassPanelModifier: ViewModifier { let cornerRadius: CGFloat func body(content: Content) -> some View { let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) if #available(macOS 26.0, *) { content .glassEffect(.regular.interactive(), in: shape) .overlay(shape.strokeBorder(Color.primary.opacity(0.08), lineWidth: 0.7)) } else { content .background(.regularMaterial, in: shape) .overlay(shape.strokeBorder(Color.primary.opacity(0.08), lineWidth: 0.7)) .shadow(color: .black.opacity(0.08), radius: 16, y: 6) } } } // MARK: - Overview private struct OverviewTab: View { @State private var hasVolcCreds = false @State private var hasArkCreds = false @State private var hasAccessibility = false @State private var hasMicrophone = false @State private var dictionaryCount = 0 var body: some View { SettingsPage( title: "OpenLess", subtitle: "本地优先、低打扰、可控润色的 macOS 语音输入层。" ) { GlassSection(title: "状态", symbol: "checkmark.seal") { StatusLine(title: "火山引擎 ASR", detail: hasVolcCreds ? "已配置" : "缺少 App ID 或 Access Token", ok: hasVolcCreds) DividerLine() StatusLine(title: "Ark 润色", detail: hasArkCreds ? "已配置" : "未配置,识别后会直接插入原文", ok: hasArkCreds) DividerLine() StatusLine(title: "辅助功能", detail: hasAccessibility ? "已授权" : "未授权", ok: hasAccessibility) DividerLine() StatusLine(title: "麦克风", detail: hasMicrophone ? "已授权" : "未授权", ok: hasMicrophone) } GlassSection(title: "当前设置", symbol: "slider.horizontal.3") { SettingsRow(title: "录音快捷键") { Text(UserPreferences.shared.hotkeyTrigger.displayName) } DividerLine() SettingsRow(title: "默认输出模式") { Text(UserPreferences.shared.polishMode.displayName) } DividerLine() SettingsRow(title: "启用词典") { Text("\(dictionaryCount) 个词条") } } } .onAppear { refresh() } } private func refresh() { let v = CredentialsVault.shared hasVolcCreds = isFilled(v.get(CredentialAccount.volcengineAppKey)) && isFilled(v.get(CredentialAccount.volcengineAccessKey)) hasArkCreds = isFilled(v.get(CredentialAccount.arkApiKey)) hasAccessibility = AccessibilityPermission.isGranted() hasMicrophone = MicrophonePermission.isGranted() dictionaryCount = DictionaryStore().enabledEntries().count } } // MARK: - Home private struct HomeTab: View { @State private var sessions: [DictationSession] = [] @State private var dictionaryEntries: [DictionaryEntry] = [] private let history = HistoryStore() private let dictionary = DictionaryStore() var body: some View { SettingsPage( title: "首页", subtitle: "用个人输入记录展示口述时长、总字数、平均每分钟字数和节省时间。" ) { LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 14), count: 2), spacing: 14) { MetricTile(title: "口述时长", value: formattedDuration(totalSpeakingSeconds), symbol: "waveform") MetricTile(title: "总字数", value: "\(totalCharacters) 字", symbol: "number") MetricTile(title: "平均每分钟", value: "\(Int(spokenCharsPerMinute.rounded())) 字", symbol: "speedometer") MetricTile(title: "估算节省", value: formattedDuration(savedTypingSeconds), symbol: "keyboard.badge.clock") MetricTile(title: "速度提升", value: String(format: "%.1fx", speedLift), symbol: "bolt.fill") MetricTile(title: "启用词条", value: "\(enabledDictionaryCount) 个", symbol: "text.book.closed") } GlassSection(title: "最近效果", symbol: "sparkles") { if sessions.isEmpty { Text("完成几次语音输入后,这里会展示口述速度、节省打字时间和词典建议记录。") .foregroundStyle(.secondary) .padding(.vertical, 8) } else { ForEach(Array(sessions.prefix(4).enumerated()), id: \.element.id) { index, session in if index > 0 { DividerLine() } VStack(alignment: .leading, spacing: 6) { HStack { Text(session.createdAt, style: .time) Text(session.mode.displayName) .foregroundStyle(.secondary) Spacer() if let durationMs = session.durationMs { Text(formattedDuration(Double(durationMs) / 1000)) .font(.caption) .foregroundStyle(.secondary) } } Text(session.finalText) .lineLimit(2) if (session.dictionaryEntryCount ?? 0) > 0 { Text("后期模型已参考 \(session.dictionaryEntryCount ?? 0) 个词典词条进行语义判断") .font(.footnote) .foregroundStyle(.blue) } } .padding(.vertical, 9) } } } GlassSection(title: "词典展示", symbol: "text.book.closed") { if dictionaryEntries.isEmpty { Text("添加 Claude、OpenLess、内部项目名等正确词后,OpenLess 会把它们注入 ASR 热词和后期模型上下文,由模型根据整句语义自动判断是否需要修正。") .foregroundStyle(.secondary) .padding(.vertical, 8) } else { ForEach(Array(dictionaryEntries.prefix(5).enumerated()), id: \.element.id) { index, entry in if index > 0 { DividerLine() } HStack { VStack(alignment: .leading, spacing: 3) { Text(entry.phrase) .font(.headline) Text(entry.source.displayName) .font(.footnote) .foregroundStyle(.secondary) } Spacer() Text(entry.category.displayName) .font(.caption) .foregroundStyle(.secondary) } .padding(.vertical, 9) } } } } .onAppear { reload() } } private var totalCharacters: Int { sessions.reduce(0) { $0 + $1.finalText.count } } private var totalSpeakingSeconds: Double { let actual = sessions.compactMap(\.durationMs).reduce(0, +) if actual > 0 { return Double(actual) / 1000 } return Double(totalCharacters) / 240 * 60 } private var savedTypingSeconds: Double { max(0, estimatedTypingSeconds - totalSpeakingSeconds) } private var estimatedTypingSeconds: Double { Double(totalCharacters) / 90 * 60 } private var spokenCharsPerMinute: Double { guard totalSpeakingSeconds > 0 else { return 0 } return Double(totalCharacters) / totalSpeakingSeconds * 60 } private var speedLift: Double { guard totalSpeakingSeconds > 0 else { return 0 } return estimatedTypingSeconds / totalSpeakingSeconds } private var dictionaryUsageCount: Int { sessions.reduce(0) { total, session in total + ((session.dictionaryEntryCount ?? 0) > 0 ? 1 : 0) } } private var enabledDictionaryCount: Int { dictionaryEntries.filter(\.enabled).count } private func reload() { sessions = history.recent(limit: 100) dictionaryEntries = dictionary.all() } private func formattedDuration(_ seconds: Double) -> String { if seconds < 60 { return "\(Int(seconds.rounded())) 秒" } return String(format: "%.1f 分钟", seconds / 60) } } private struct MetricTile: View { let title: String let value: String let symbol: String var body: some View { VStack(alignment: .leading, spacing: 10) { Label(title, systemImage: symbol) .font(.caption) .foregroundStyle(.secondary) .symbolRenderingMode(.hierarchical) Text(value) .font(.system(size: 28, weight: .semibold, design: .rounded)) .lineLimit(1) .minimumScaleFactor(0.75) } .padding(18) .frame(maxWidth: .infinity, minHeight: 104, alignment: .leading) .glassPanel(cornerRadius: 22) } } // MARK: - Dictionary private struct DictionaryTab: View { @State private var entries: [DictionaryEntry] = [] @State private var editingEntry: DictionaryEntry? @State private var isShowingEditor = false @State private var saved = false private let store = DictionaryStore() var body: some View { SettingsPage( title: "词典", subtitle: "把 Claude、OpenLess、内部项目名等正确词放进词典。ASR 会优先识别;后期模型会根据整句语义自动判断是否需要修正。" ) { GlassSection(title: "词条", symbol: "text.book.closed") { VStack(alignment: .leading, spacing: 12) { HStack { Text("正确词会同时参与 ASR 热词和后期语义判断。") .foregroundStyle(.secondary) Spacer() Button("Claude 示例") { addClaudeExample() } Button("新建") { beginCreate() } .buttonStyle(.borderedProminent) } if entries.isEmpty { ContentUnavailableView("还没有词条", systemImage: "text.book.closed", description: Text("先添加一个 Claude 示例。ASR 会优先识别 Claude;后期模型会根据语义判断 Cloud 是否应为 Claude。")) .frame(maxWidth: .infinity, minHeight: 260) } else { ScrollView { LazyVStack(alignment: .leading, spacing: 8) { ForEach(entries) { entry in DictionaryRow(entry: entry) .contentShape(Rectangle()) .onTapGesture { beginEdit(entry) } } } } .frame(minHeight: 320, maxHeight: 430) } if saved { Label("已保存", systemImage: "checkmark.circle.fill") .foregroundStyle(.green) } } } GlassSection(title: "工作方式", symbol: "arrow.triangle.2.circlepath") { dictionaryStep("ASR 阶段", "把启用词条的标准词传入火山 ASR context.hotwords,优先让识别结果靠近正确专有名词。") DividerLine() dictionaryStep("后期模型", "把同一批正确词包裹进模型上下文,要求模型根据整句语义自动判断:明确是误识别就修正,明确是其他真实概念就保留。") DividerLine() dictionaryStep("自动学习", "每次完成输入后,OpenLess 会从结果中学习像 Claude、ChatGPT、OpenLess 这样的专有词,后续作为候选正确词参与识别。") } } .onAppear { reload() } .sheet(isPresented: $isShowingEditor) { DictionaryEditorSheet(entry: editingEntry) { entry in store.upsert(entry) NotificationCenter.default.post(name: .openLessDictionaryChanged, object: nil) saved = true reload() DispatchQueue.main.asyncAfter(deadline: .now() + 1.4) { saved = false } } onDelete: { id in store.delete(id: id) NotificationCenter.default.post(name: .openLessDictionaryChanged, object: nil) reload() } } } private func dictionaryStep(_ title: String, _ body: String) -> some View { SettingsRow(title: title) { Text(body) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } } private func reload() { entries = store.all() } private func beginCreate() { editingEntry = nil isShowingEditor = true } private func beginEdit(_ entry: DictionaryEntry) { editingEntry = entry isShowingEditor = true } private func addClaudeExample() { let entry = DictionaryEntry( phrase: "Claude", category: .aiTool, notes: "AI 产品名;后期模型会根据整句语义判断是否需要把误识别内容修正为 Claude。", enabled: true, source: .manual ) store.upsert(entry) NotificationCenter.default.post(name: .openLessDictionaryChanged, object: nil) saved = true reload() DispatchQueue.main.asyncAfter(deadline: .now() + 1.4) { saved = false } } } private struct DictionaryRow: View { let entry: DictionaryEntry var body: some View { HStack(spacing: 10) { Image(systemName: entry.enabled ? "checkmark.circle.fill" : "circle") .foregroundStyle(entry.enabled ? .green : .secondary) .frame(width: 18) VStack(alignment: .leading, spacing: 3) { Text(entry.phrase) .font(.headline) .lineLimit(1) HStack(spacing: 6) { Text(entry.category.displayName) Text(entry.source.displayName) } .font(.caption) .foregroundStyle(.secondary) } Spacer() Image(systemName: "chevron.right") .font(.caption) .foregroundStyle(.tertiary) } .padding(.horizontal, 12) .padding(.vertical, 11) .background(Color.primary.opacity(0.035), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) } } private struct DictionaryEditorSheet: View { @Environment(\.dismiss) private var dismiss let entry: DictionaryEntry? let onSave: (DictionaryEntry) -> Void let onDelete: (UUID) -> Void @State private var phrase: String @State private var category: DictionaryEntryCategory @State private var notes: String @State private var enabled: Bool init( entry: DictionaryEntry?, onSave: @escaping (DictionaryEntry) -> Void, onDelete: @escaping (UUID) -> Void ) { self.entry = entry self.onSave = onSave self.onDelete = onDelete _phrase = State(initialValue: entry?.phrase ?? "") _category = State(initialValue: entry?.category ?? .aiTool) _notes = State(initialValue: entry?.notes ?? "") _enabled = State(initialValue: entry?.enabled ?? true) } var body: some View { VStack(alignment: .leading, spacing: 20) { VStack(alignment: .leading, spacing: 5) { Text(entry == nil ? "新建词条" : "编辑词条") .font(.system(size: 24, weight: .semibold)) Text("只添加正确词。易错词暂时不需要手动维护,交给 ASR 热词和后期模型按语义判断。") .font(.callout) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } VStack(spacing: 0) { SettingsRow(title: "标准词") { TextField("Claude", text: $phrase) .textFieldStyle(.roundedBorder) } DividerLine() SettingsRow(title: "分类") { Picker("分类", selection: $category) { ForEach(DictionaryEntryCategory.allCases, id: \.self) { item in Text(item.displayName).tag(item) } } .labelsHidden() .pickerStyle(.menu) .frame(width: 180, alignment: .leading) } DividerLine() SettingsRow(title: "启用") { Toggle("用于 ASR 热词和后期语义判断", isOn: $enabled) .toggleStyle(.checkbox) } DividerLine() SettingsRow(title: "备注") { TextField("例如:AI 产品名,模型可按语义判断是否需要修正", text: $notes, axis: .vertical) .lineLimit(2...5) .textFieldStyle(.roundedBorder) } } .glassPanel(cornerRadius: 20) HStack { if let entry { Button("删除") { onDelete(entry.id) dismiss() } .foregroundStyle(.red) } Spacer() Button("取消") { dismiss() } Button("保存") { let trimmed = phrase.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return } onSave(DictionaryEntry( id: entry?.id ?? UUID(), phrase: trimmed, category: category, notes: notes, enabled: enabled, source: entry?.source ?? .manual, createdAt: entry?.createdAt ?? Date() )) dismiss() } .buttonStyle(.borderedProminent) .keyboardShortcut(.defaultAction) .disabled(phrase.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } } .padding(26) .frame(width: 560) } } // MARK: - Settings Hub private 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 trigger: HotkeyBinding.Trigger = UserPreferences.shared.hotkeyTrigger @State private var mode: PolishMode = UserPreferences.shared.polishMode @State private var hasAccessibility = false @State private var hasMicrophone = false @State private var saved = false var body: some View { SettingsPage( title: "设置", subtitle: "凭据、快捷键、输出模式和隐私控制统一放在这里。" ) { GlassSection(title: "运行状态", symbol: "checkmark.seal") { StatusLine(title: "火山引擎 ASR", detail: hasVolcCredentials ? "已配置" : "缺少 App ID 或 Access Token", ok: hasVolcCredentials) DividerLine() StatusLine(title: "Ark 润色", detail: hasArkCredentials ? "已配置" : "未配置,识别后会直接插入原文", ok: hasArkCredentials) DividerLine() StatusLine(title: "辅助功能", detail: hasAccessibility ? "已授权" : "未授权", ok: hasAccessibility) DividerLine() StatusLine(title: "麦克风", detail: hasMicrophone ? "已授权" : "未授权", ok: hasMicrophone) } GlassSection(title: "凭据", symbol: "key") { SettingsRow(title: "火山 APP ID") { PasteableCredentialField(placeholder: "X-Api-App-Key", secure: false, text: $volcAppKey) } DividerLine() SettingsRow(title: "火山 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) } DividerLine() SettingsRow(title: "Ark API Key") { PasteableCredentialField(placeholder: "Bearer Token", secure: true, text: $arkApiKey) } DividerLine() SettingsRow(title: "Ark Model") { PasteableCredentialField(placeholder: "Model ID", secure: false, text: $arkModelId) } DividerLine() SettingsRow(title: "Ark Endpoint") { PasteableCredentialField(placeholder: "Endpoint", secure: false, text: $arkEndpoint) } } GlassSection(title: "输入与输出", symbol: "slider.horizontal.3") { SettingsRow(title: "录音快捷键") { Picker("触发键", selection: $trigger) { ForEach(HotkeyBinding.Trigger.allCases, id: \.self) { item in Text(item.displayName).tag(item) } } .labelsHidden() .pickerStyle(.menu) .frame(width: 180, alignment: .leading) .onChange(of: trigger) { _, newValue in UserPreferences.shared.hotkeyTrigger = newValue NotificationCenter.default.post(name: .openLessHotkeyChanged, object: nil) } } DividerLine() SettingsRow(title: "默认模式") { Picker("模式", selection: $mode) { ForEach(PolishMode.allCases, id: \.self) { item in Text(item.displayName).tag(item) } } .pickerStyle(.radioGroup) .onChange(of: mode) { _, newValue in UserPreferences.shared.polishMode = newValue } } DividerLine() Text(polishModeHint(mode)) .font(.footnote) .foregroundStyle(.secondary) .padding(.top, 9) } GlassSection(title: "隐私", symbol: "lock.shield") { privacyRow("音频默认不保存到磁盘", symbol: "mic.slash") DividerLine() privacyRow("API Key 仅存本机 0600 权限文件", symbol: "key") DividerLine() privacyRow("历史只保存原始转写和最终文本", symbol: "doc.text") DividerLine() privacyRow("使用云端 ASR 时,音频会发送给火山引擎;开启 Ark 润色时,转写文本会发送给 Ark", symbol: "icloud") } PrimaryActionRow { Button("保存凭据") { saveCredentials() } .keyboardShortcut(.defaultAction) .buttonStyle(.borderedProminent) if saved { Label("已保存", systemImage: "checkmark.circle.fill") .foregroundStyle(.green) } } } .onAppear { load() } } private var hasVolcCredentials: Bool { isFilled(volcAppKey) && isFilled(volcAccessKey) } private var hasArkCredentials: Bool { isFilled(arkApiKey) } private func load() { let v = CredentialsVault.shared 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 trigger = UserPreferences.shared.hotkeyTrigger mode = UserPreferences.shared.polishMode hasAccessibility = AccessibilityPermission.isGranted() hasMicrophone = MicrophonePermission.isGranted() } 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) 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 load() DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { saved = false } } private func privacyRow(_ text: String, symbol: String) -> some View { HStack(spacing: 10) { Image(systemName: symbol) .foregroundStyle(.secondary) .frame(width: 20) Text(text) .fixedSize(horizontal: false, vertical: true) Spacer() } .padding(.vertical, 9) } } // MARK: - Credentials private struct CredentialsTab: 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 var body: some View { SettingsPage( title: "凭据", subtitle: "凭据只写入本机受保护文件,不写入仓库、日志或公开配置。" ) { 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) } DividerLine() Text("当前接口使用 APP ID、Access Token 和 Resource ID。Secret Key 只在 HMAC 或旧接口场景中使用,OpenLess 不会发送它。") .font(.footnote) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) .padding(.top, 9) } GlassSection(title: "Ark / DeepSeek V3.2 润色", symbol: "wand.and.stars") { SettingsRow(title: "API Key") { PasteableCredentialField(placeholder: "Bearer Token", secure: true, text: $arkApiKey) } DividerLine() SettingsRow(title: "Model ID") { PasteableCredentialField(placeholder: "Model ID", secure: false, text: $arkModelId) } DividerLine() SettingsRow(title: "Endpoint") { PasteableCredentialField(placeholder: "Endpoint", secure: false, text: $arkEndpoint) } } PrimaryActionRow { Button("保存") { save() } .keyboardShortcut(.defaultAction) .buttonStyle(.borderedProminent) if saved { Label("已保存", systemImage: "checkmark.circle.fill") .foregroundStyle(.green) } } } .onAppear { load() } } private func load() { let v = CredentialsVault.shared 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 } private func save() { 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) 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 } } } // MARK: - Hotkey private struct HotkeyTab: View { @State private var trigger: HotkeyBinding.Trigger = UserPreferences.shared.hotkeyTrigger var body: some View { SettingsPage( title: "快捷键", subtitle: "按一次开始录音,再按一次结束;录音中按 Esc 取消。" ) { GlassSection(title: "录音", symbol: "keyboard") { SettingsRow(title: "触发键") { Picker("触发键", selection: $trigger) { ForEach(HotkeyBinding.Trigger.allCases, id: \.self) { t in Text(t.displayName).tag(t) } } .labelsHidden() .pickerStyle(.menu) .frame(width: 180, alignment: .leading) .onChange(of: trigger) { _, newValue in UserPreferences.shared.hotkeyTrigger = newValue NotificationCenter.default.post(name: .openLessHotkeyChanged, object: nil) } } DividerLine() Text("现在按下触发键时会立即弹出录音状态,不再等到松开后才显示。") .font(.footnote) .foregroundStyle(.secondary) .padding(.top, 9) if trigger == .fn { Label("Fn / Globe 可能被系统听写或表情面板占用;如果冲突,建议改用右 Option。", systemImage: "info.circle") .font(.footnote) .foregroundStyle(.orange) .padding(.top, 6) } } } } } // MARK: - Modes private struct ModesTab: View { @State private var current: PolishMode = UserPreferences.shared.polishMode var body: some View { SettingsPage( title: "输出模式", subtitle: "选择识别后默认使用的文本整理方式。" ) { GlassSection(title: "默认模式", symbol: "text.badge.checkmark") { Picker("模式", selection: $current) { ForEach(PolishMode.allCases, id: \.self) { mode in Text(mode.displayName).tag(mode) } } .pickerStyle(.radioGroup) .onChange(of: current) { _, newValue in UserPreferences.shared.polishMode = newValue } DividerLine() Text(modeHint(current)) .font(.footnote) .foregroundStyle(.secondary) .padding(.top, 10) } } } private func modeHint(_ mode: PolishMode) -> String { switch mode { case .raw: return "尽量忠实转写,只做基础标点和必要分句。" case .light: return "去掉明显口癖和重复,尽量保留原句式和语气。" case .structured: return "整理句子、段落和列表,适合 prompt 与笔记。" case .formal: return "适合邮件、工作沟通和正式文档。" } } } // MARK: - History private struct HistoryTab: View { @State private var sessions: [DictationSession] = [] private let store = HistoryStore() var body: some View { SettingsPage( title: "历史", subtitle: "最近的识别结果只保存在本机。" ) { PrimaryActionRow { Button("刷新") { reload() } Button("清空") { store.clear(); reload() } } if sessions.isEmpty { ContentUnavailableView("还没有历史记录", systemImage: "clock", description: Text("完成一次语音输入后会显示在这里。")) .frame(maxWidth: .infinity, minHeight: 260) } else { GlassSection(title: "最近记录", symbol: "clock") { ForEach(Array(sessions.enumerated()), id: \.element.id) { index, session in if index > 0 { DividerLine() } VStack(alignment: .leading, spacing: 4) { HStack { Text(session.createdAt, style: .time) Text(session.mode.displayName) .foregroundStyle(.secondary) Spacer() Text(session.insertStatus.rawValue) .font(.caption) .foregroundStyle(.secondary) } Text(session.finalText) .lineLimit(3) } .padding(.vertical, 9) } } } } .onAppear { reload() } } private func reload() { sessions = store.recent(limit: 100) } } // MARK: - Privacy private struct PrivacyTab: View { var body: some View { SettingsPage( title: "隐私", subtitle: "OpenLess 默认只保存必要的文本历史、词典和本机受保护凭据文件。" ) { GlassSection(title: "本机", symbol: "lock.shield") { privacyRow("音频默认不保存到磁盘", symbol: "mic.slash") DividerLine() privacyRow("API Key 仅存本机 0600 权限文件", symbol: "key") DividerLine() privacyRow("历史只保存原始转写和最终文本", symbol: "doc.text") } GlassSection(title: "云端", symbol: "icloud") { privacyRow("使用云端 ASR 时,音频会发送给火山引擎", symbol: "waveform") DividerLine() privacyRow("开启 Ark 润色时,转写文本会发送给 Ark", symbol: "wand.and.stars") } } } private func privacyRow(_ text: String, symbol: String) -> some View { HStack(spacing: 10) { Image(systemName: symbol) .foregroundStyle(.secondary) .frame(width: 20) Text(text) Spacer() } .padding(.vertical, 9) } } extension Notification.Name { static let openLessHistoryChanged = Notification.Name("openless.history_changed") static let openLessHotkeyChanged = Notification.Name("openless.hotkey_changed") static let openLessCredentialsChanged = Notification.Name("openless.credentials_changed") static let openLessDictionaryChanged = Notification.Name("openless.dictionary_changed") }