diff --git a/README.md b/README.md index d1aa9af0..983cf6f7 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # OpenLess -OpenLess 是一个原生 macOS 菜单栏语音输入层:把光标放在任意输入框,按下全局快捷键说话,OpenLess 会录音、转写、轻度整理,并把结果插入当前输入位置。插入失败时会自动复制到剪贴板,尽量保证“用户说过的话不丢”。 +OpenLess 是一个原生 macOS 语音输入应用:把光标放在任意输入框,按下全局快捷键说话,OpenLess 会录音、转写、轻度整理,并把结果插入当前输入位置。插入失败时会自动复制到剪贴板,尽量保证“用户说过的话不丢”。 ## 当前状态 @@ -9,7 +9,7 @@ OpenLess 是一个原生 macOS 菜单栏语音输入层:把光标放在任意 - 默认是切换式录音:按一次开始,再按一次结束;录音中按 `Esc` 取消。 - 支持火山引擎流式 ASR 和 Ark / DeepSeek 兼容 Chat Completions 润色。 - 支持 4 种输出模式:原文、轻度润色、清晰结构、正式表达。 -- 支持底部微型状态胶囊、菜单栏控制、设置页、历史记录、剪贴板兜底。 +- 支持 Dock 主窗口、菜单栏控制、底部微型状态胶囊、首页报告、历史记录、词典、设置和剪贴板兜底。 ## 运行方式 @@ -25,7 +25,7 @@ open build/OpenLess.app 1. 在系统设置中授予 OpenLess 辅助功能权限和麦克风权限。 2. 退出 OpenLess。 3. 重新打开 `build/OpenLess.app`。 -4. 在菜单栏中打开设置,填写火山引擎 ASR 和 Ark 凭据。 +4. 从 Dock 打开 OpenLess 首页,在「设置」里填写火山引擎 ASR 和 Ark 凭据。 实时日志: @@ -100,7 +100,7 @@ OpenLess 的润色模型只做文本整理,不做问答、不做任务执行 - 将词典包裹后注入后期润色模型,明确告诉模型根据整句语义自动判断:如果 `Cloud` 在当前语境下明显指向 AI 产品 `Claude`,就修正为 `Claude`;如果确实是在说云服务 Cloud,则保留原词。 - 从历史输出中自动学习类似 `Claude`、`ChatGPT`、`OpenLess` 的候选正确词,后续作为 ASR 热词和后期语义判断候选。 -设置页里的“词典”标签可以添加和管理词条;“效果”标签会展示口述时长、估算节省的打字时间、平均口述字数、速度提升和词典参与记录。 +主窗口按「首页 / 历史记录 / 词典 / 设置」组织;词典页点击“新建”会弹出独立编辑窗口,首页会展示口述时长、总字数、平均每分钟字数、估算节省时间和词典参与记录。 ## 尚未完成的功能 diff --git a/Sources/OpenLessApp/AppDelegate.swift b/Sources/OpenLessApp/AppDelegate.swift index 4f8df704..d30775af 100644 --- a/Sources/OpenLessApp/AppDelegate.swift +++ b/Sources/OpenLessApp/AppDelegate.swift @@ -25,6 +25,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { coordinator.bootstrap() self.coordinator = coordinator self.menuBar = menuBar + coordinator.openHome() runLaunchActions(coordinator: coordinator) } @@ -52,6 +53,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate { false } + func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { + coordinator?.openHome() + return true + } + private func runLaunchActions(coordinator: DictationCoordinator) { let arguments = Set(CommandLine.arguments.dropFirst()) if arguments.contains("--open-settings") { diff --git a/Sources/OpenLessApp/DictationCoordinator.swift b/Sources/OpenLessApp/DictationCoordinator.swift index 05d1e205..5e9c420b 100644 --- a/Sources/OpenLessApp/DictationCoordinator.swift +++ b/Sources/OpenLessApp/DictationCoordinator.swift @@ -88,8 +88,20 @@ final class DictationCoordinator { } } + func openHome() { + settings.show(tab: .home) + } + + func openHistory() { + settings.show(tab: .history) + } + + func openDictionary() { + settings.show(tab: .dictionary) + } + func openSettings() { - settings.show() + settings.show(tab: .settings) } func toggleDictationFromMenu() { diff --git a/Sources/OpenLessApp/MenuBarController.swift b/Sources/OpenLessApp/MenuBarController.swift index 8ee58239..217e0239 100644 --- a/Sources/OpenLessApp/MenuBarController.swift +++ b/Sources/OpenLessApp/MenuBarController.swift @@ -62,6 +62,18 @@ final class MenuBarController { menu.addItem(.separator()) + let home = NSMenuItem(title: "打开首页…", action: #selector(MenuActions.openHome), keyEquivalent: "") + home.target = actions + menu.addItem(home) + + let history = NSMenuItem(title: "打开历史记录…", action: #selector(MenuActions.openHistory), keyEquivalent: "") + history.target = actions + menu.addItem(history) + + let dictionary = NSMenuItem(title: "打开词典…", action: #selector(MenuActions.openDictionary), keyEquivalent: "") + dictionary.target = actions + menu.addItem(dictionary) + let settings = NSMenuItem(title: "打开设置…", action: #selector(MenuActions.openSettings), keyEquivalent: ",") settings.target = actions menu.addItem(settings) @@ -103,6 +115,18 @@ private final class MenuActions: NSObject { coordinator?.openSettings() } + @objc func openHome() { + coordinator?.openHome() + } + + @objc func openHistory() { + coordinator?.openHistory() + } + + @objc func openDictionary() { + coordinator?.openDictionary() + } + @objc func toggleDictation() { coordinator?.toggleDictationFromMenu() } diff --git a/Sources/OpenLessApp/OpenLessApp.swift b/Sources/OpenLessApp/OpenLessApp.swift index c64385ab..4097670f 100644 --- a/Sources/OpenLessApp/OpenLessApp.swift +++ b/Sources/OpenLessApp/OpenLessApp.swift @@ -5,7 +5,7 @@ import AppKit struct OpenLessApp { static func main() { let app = NSApplication.shared - app.setActivationPolicy(.accessory) + app.setActivationPolicy(.regular) let delegate = AppDelegate() app.delegate = delegate app.run() diff --git a/Sources/OpenLessApp/Settings/SettingsView.swift b/Sources/OpenLessApp/Settings/SettingsView.swift index c66d2ba8..3b0cf647 100644 --- a/Sources/OpenLessApp/Settings/SettingsView.swift +++ b/Sources/OpenLessApp/Settings/SettingsView.swift @@ -7,65 +7,123 @@ import OpenLessRecorder import OpenLessASR import OpenLessPolish -struct SettingsView: View { - @State private var selection: Tab = .overview +enum OpenLessMainTab: String, CaseIterable, Identifiable { + case home + case history + case dictionary + case settings - enum Tab: String, CaseIterable, Identifiable { - case overview, insights, dictionary, credentials, hotkey, modes, history, privacy - var id: String { rawValue } + var id: String { rawValue } - var title: String { - switch self { - case .overview: return "概览" - case .insights: return "效果" - case .dictionary: return "词典" - case .credentials: return "凭据" - case .hotkey: return "快捷键" - case .modes: return "模式" - case .history: return "历史" - case .privacy: return "隐私" - } - } - - var symbol: String { - switch self { - case .overview: return "checklist" - case .insights: return "chart.line.uptrend.xyaxis" - case .dictionary: return "text.book.closed" - case .credentials: return "key" - case .hotkey: return "keyboard" - case .modes: return "text.badge.checkmark" - case .history: return "clock" - case .privacy: return "lock.shield" - } + 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 { - NavigationSplitView { - List(Tab.allCases, selection: $selection) { tab in - Label(tab.title, systemImage: tab.symbol).tag(tab) - .padding(.vertical, 3) - } - .listStyle(.sidebar) - .navigationSplitViewColumnWidth(min: 196, ideal: 220) - } detail: { + HStack(spacing: 0) { + FixedSidebar(selection: $navigation.selection) + Divider() Group { - switch selection { - case .overview: OverviewTab() - case .insights: InsightsTab() - case .dictionary: DictionaryTab() - case .credentials: CredentialsTab() - case .hotkey: HotkeyTab() - case .modes: ModesTab() + switch navigation.selection { + case .home: HomeTab() case .history: HistoryTab() - case .privacy: PrivacyTab() + case .dictionary: DictionaryTab() + case .settings: SettingsHubTab() } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } - .navigationSplitViewStyle(.balanced) - .frame(minWidth: 940, minHeight: 660) + .frame(minWidth: 1040, minHeight: 700) + } +} + +private struct FixedSidebar: View { + @Binding var selection: OpenLessMainTab + + var body: some View { + VStack(alignment: .leading, spacing: 18) { + VStack(alignment: .leading, spacing: 4) { + Text("OpenLess") + .font(.system(size: 24, weight: .semibold)) + Text("自然说话,完美书写") + .font(.footnote) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 18) + .padding(.top, 24) + + VStack(spacing: 6) { + ForEach(OpenLessMainTab.allCases) { tab in + Button { + selection = tab + } label: { + HStack(spacing: 11) { + Image(systemName: tab.symbol) + .symbolRenderingMode(.hierarchical) + .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) + .background( + selection == tab ? Color.accentColor.opacity(0.16) : Color.clear, + in: RoundedRectangle(cornerRadius: 12, style: .continuous) + ) + .foregroundStyle(selection == tab ? .primary : .secondary) + } + .buttonStyle(.plain) + .help(tab.title) + } + } + .padding(.horizontal, 12) + + Spacer() + + VStack(alignment: .leading, spacing: 7) { + Label("右 Option 开始录音", systemImage: "keyboard") + Label("Esc 取消", systemImage: "escape") + } + .font(.caption) + .foregroundStyle(.secondary) + .padding(.horizontal, 18) + .padding(.bottom, 20) + } + .frame(width: 224) + .background(.regularMaterial) } } @@ -206,6 +264,15 @@ private func isFilled(_ value: String?) -> Bool { 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)) @@ -282,9 +349,9 @@ private struct OverviewTab: View { } } -// MARK: - Insights +// MARK: - Home -private struct InsightsTab: View { +private struct HomeTab: View { @State private var sessions: [DictationSession] = [] @State private var dictionaryEntries: [DictionaryEntry] = [] private let history = HistoryStore() @@ -292,15 +359,15 @@ private struct InsightsTab: View { var body: some View { SettingsPage( - title: "自然说话,完美书写", - subtitle: "用实际输入记录展示口述速度、节省时间、词典建议和个人词汇积累。" + title: "首页", + subtitle: "用个人输入记录展示口述时长、总字数、平均每分钟字数和节省时间。" ) { LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 14), count: 2), spacing: 14) { - MetricTile(title: "累计口述", value: formattedDuration(totalSpeakingSeconds), symbol: "waveform") + 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: "\(Int(spokenCharsPerMinute.rounded())) 字/分钟", symbol: "speedometer") MetricTile(title: "速度提升", value: String(format: "%.1fx", speedLift), symbol: "bolt.fill") - MetricTile(title: "词典参与", value: "\(dictionaryUsageCount) 次", symbol: "text.badge.checkmark") MetricTile(title: "启用词条", value: "\(enabledDictionaryCount) 个", symbol: "text.book.closed") } @@ -443,11 +510,8 @@ private struct MetricTile: View { private struct DictionaryTab: View { @State private var entries: [DictionaryEntry] = [] - @State private var selectedID: UUID? - @State private var phrase = "" - @State private var category: DictionaryEntryCategory = .aiTool - @State private var notes = "" - @State private var enabled = true + @State private var editingEntry: DictionaryEntry? + @State private var isShowingEditor = false @State private var saved = false private let store = DictionaryStore() @@ -457,68 +521,35 @@ private struct DictionaryTab: View { subtitle: "把 Claude、OpenLess、内部项目名等正确词放进词典。ASR 会优先识别;后期模型会根据整句语义自动判断是否需要修正。" ) { GlassSection(title: "词条", symbol: "text.book.closed") { - HStack(alignment: .top, spacing: 18) { - VStack(alignment: .leading, spacing: 8) { - if entries.isEmpty { - ContentUnavailableView("还没有词条", systemImage: "text.book.closed", description: Text("先添加一个 Claude 示例。ASR 会优先识别 Claude;后期模型会根据语义判断 Cloud 是否应为 Claude。")) - .frame(maxWidth: .infinity, minHeight: 230) - } else { - ScrollView { - LazyVStack(alignment: .leading, spacing: 8) { - ForEach(entries) { entry in - DictionaryRow(entry: entry, selected: selectedID == entry.id) - .onTapGesture { select(entry) } - } - } - } - .frame(minHeight: 260, maxHeight: 330) - } - - HStack { - Button("Claude 示例") { addClaudeExample() } - Button("新建") { clearForm() } - } - .buttonStyle(.bordered) + VStack(alignment: .leading, spacing: 12) { + HStack { + Text("正确词会同时参与 ASR 热词和后期语义判断。") + .foregroundStyle(.secondary) + Spacer() + Button("Claude 示例") { addClaudeExample() } + Button("新建") { beginCreate() } + .buttonStyle(.borderedProminent) } - .frame(width: 310) - VStack(alignment: .leading, spacing: 12) { - SettingsRow(title: "标准词") { - TextField("Claude", text: $phrase) - .textFieldStyle(.roundedBorder) - } - SettingsRow(title: "分类") { - Picker("分类", selection: $category) { - ForEach(DictionaryEntryCategory.allCases, id: \.self) { item in - Text(item.displayName).tag(item) + 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) } } } - .labelsHidden() - .pickerStyle(.menu) - .frame(width: 180, alignment: .leading) - } - SettingsRow(title: "启用") { - Toggle("用于 ASR 热词和后期语义判断", isOn: $enabled) - .toggleStyle(.checkbox) - } - SettingsRow(title: "备注") { - TextField("例如:AI 产品名,模型可按语义判断是否需要修正", text: $notes, axis: .vertical) - .lineLimit(2...5) - .textFieldStyle(.roundedBorder) } + .frame(minHeight: 320, maxHeight: 430) + } - PrimaryActionRow { - Button("删除") { deleteSelected() } - .disabled(selectedID == nil) - Button("保存词条") { saveEntry() } - .buttonStyle(.borderedProminent) - .keyboardShortcut(.defaultAction) - .disabled(phrase.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) - if saved { - Label("已保存", systemImage: "checkmark.circle.fill") - .foregroundStyle(.green) - } - } + if saved { + Label("已保存", systemImage: "checkmark.circle.fill") + .foregroundStyle(.green) } } } @@ -532,6 +563,19 @@ private struct DictionaryTab: View { } } .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 { @@ -544,65 +588,36 @@ private struct DictionaryTab: View { private func reload() { entries = store.all() - if let selectedID, let selected = entries.first(where: { $0.id == selectedID }) { - select(selected) - } } - private func select(_ entry: DictionaryEntry) { - selectedID = entry.id - phrase = entry.phrase - category = entry.category - notes = entry.notes - enabled = entry.enabled + private func beginCreate() { + editingEntry = nil + isShowingEditor = true } - private func clearForm() { - selectedID = nil - phrase = "" - category = .aiTool - notes = "" - enabled = true + private func beginEdit(_ entry: DictionaryEntry) { + editingEntry = entry + isShowingEditor = true } - private func saveEntry() { + private func addClaudeExample() { let entry = DictionaryEntry( - id: selectedID ?? UUID(), - phrase: phrase, - category: category, - notes: notes, - enabled: enabled, - source: selectedID.flatMap { id in entries.first(where: { $0.id == id })?.source } ?? .manual + phrase: "Claude", + category: .aiTool, + notes: "AI 产品名;后期模型会根据整句语义判断是否需要把误识别内容修正为 Claude。", + enabled: true, + source: .manual ) store.upsert(entry) NotificationCenter.default.post(name: .openLessDictionaryChanged, object: nil) saved = true reload() - selectedID = entry.id DispatchQueue.main.asyncAfter(deadline: .now() + 1.4) { saved = false } } - - private func deleteSelected() { - guard let selectedID else { return } - store.delete(id: selectedID) - NotificationCenter.default.post(name: .openLessDictionaryChanged, object: nil) - clearForm() - reload() - } - - private func addClaudeExample() { - phrase = "Claude" - category = .aiTool - notes = "AI 产品名;后期模型会根据整句语义判断是否需要把误识别内容修正为 Claude。" - enabled = true - selectedID = nil - saveEntry() - } } private struct DictionaryRow: View { let entry: DictionaryEntry - let selected: Bool var body: some View { HStack(spacing: 10) { @@ -621,10 +636,274 @@ private struct DictionaryRow: View { .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(.horizontal, 10) .padding(.vertical, 9) - .background(selected ? Color.accentColor.opacity(0.16) : Color.clear, in: RoundedRectangle(cornerRadius: 10, style: .continuous)) } } diff --git a/Sources/OpenLessApp/Settings/SettingsWindowController.swift b/Sources/OpenLessApp/Settings/SettingsWindowController.swift index 815e0fb7..283fee65 100644 --- a/Sources/OpenLessApp/Settings/SettingsWindowController.swift +++ b/Sources/OpenLessApp/Settings/SettingsWindowController.swift @@ -4,21 +4,23 @@ import SwiftUI @MainActor final class SettingsWindowController { private var window: NSWindow? + private let navigation = SettingsNavigationModel() - func show() { + func show(tab: OpenLessMainTab = .home) { + navigation.selection = tab if let window = window { window.makeKeyAndOrderFront(nil) NSApp.activate(ignoringOtherApps: true) return } - let hosting = NSHostingController(rootView: SettingsView()) + let hosting = NSHostingController(rootView: SettingsView(navigation: navigation)) let win = NSWindow(contentViewController: hosting) - win.title = "OpenLess 设置" + win.title = "OpenLess" win.styleMask = [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView] win.titlebarAppearsTransparent = false win.toolbarStyle = .unified - win.setContentSize(NSSize(width: 760, height: 520)) - win.contentMinSize = NSSize(width: 720, height: 480) + win.setContentSize(NSSize(width: 1040, height: 700)) + win.contentMinSize = NSSize(width: 960, height: 640) win.tabbingMode = .disallowed win.center() win.isReleasedWhenClosed = false diff --git a/docs/openless-development.md b/docs/openless-development.md index 91807803..1b2ea001 100644 --- a/docs/openless-development.md +++ b/docs/openless-development.md @@ -283,7 +283,7 @@ History item selected 建议实现为一个无边框、透明背景、always-on-top 的辅助窗口: - 不抢焦点。 -- 不出现在 Dock。 +- 主窗口出现在 Dock;录音胶囊作为辅助窗口不出现在 Dock。 - 屏幕底部居中。 - 多屏时优先显示在当前活跃输入所在屏幕。 - 空闲时隐藏。 diff --git a/scripts/build-app.sh b/scripts/build-app.sh index c38ee659..cb526dd5 100755 --- a/scripts/build-app.sh +++ b/scripts/build-app.sh @@ -51,8 +51,6 @@ cat > "${APP_DIR}/Contents/Info.plist" <${BUILD_NUMBER} LSMinimumSystemVersion 15.0 - LSUIElement - NSPrincipalClass NSApplication NSMicrophoneUsageDescription @@ -85,6 +83,6 @@ echo " 1. open ${APP_DIR} # 双击启动;默认已重置权限,会重 echo " 2. 系统设置 → 隐私与安全 → 辅助功能 / 麦克风 → 勾选 OpenLess" echo " 3. 屏幕右上角菜单栏 OpenLess 图标 → 退出 OpenLess(必须先退出再重启)" echo " 4. 再次 open ${APP_DIR}" -echo " 5. 在菜单栏「打开设置…」填火山 APP ID、Access Token、Resource ID,以及 Ark API Key(保存到 ~/.openless/credentials.json,0600)" +echo " 5. 从 Dock 或菜单栏打开 OpenLess 首页;在「设置」里填火山 APP ID、Access Token、Resource ID,以及 Ark API Key(保存到 ~/.openless/credentials.json,0600)" echo " 6. 实时日志(另开终端): tail -f ~/Library/Logs/OpenLess/OpenLess.log" echo " 7. 按右 Option(或 Fn)开始录音;再按一次结束;语音文字会插入当前输入框"