Merge pull request #78 from appergb/develop

release(v1.2.0): i18n + ASR 数据不丢失 + 状态机修复 + 单实例 + UI 动画补齐
This commit is contained in:
TRIP authored and GitHub committed 2026-04-30 14:05:24 +08:00
commit 94c8507b5e
41 files changed
+951 -4458

No files matched your search

-138
View File
@@ -1,138 +0,0 @@
# Codex 对抗性审查报告
## 1. 总体判断
当前架构能支撑 v1 主链路,但不能保证 6 个未来功能“无需主流程级重构”地引入。核心问题不是单云 provider,而是缺少一条独立于录音/ASR/润色/插入的“策略与学习平面”:模式与 prompt 没有持久身份,ASR 前没有识别提示注入点,LLM 前没有用户规则/记忆/词本/易错字注入点,Persistence 也没有可追溯的 prompt、词本、修正和长期记忆数据模型。若不在 v1 留出这些接口,未来会同时改 `DictationCoordinator`、`VolcengineStreamingASR.openSession()`、`DoubaoPolishClient.polish()`、`PolishMode`、`DictationSession` 和 SQLite schema。
## 2. CRITICAL 问题
### 2.1 模式与 prompt 被硬编码为 enum/内置模板,无法承载可编辑、可切换、可实验的 prompt
- 问题:`PolishMode: String, CaseIterable, Codable` 把“模式”当成固定枚举,`PolishPromptTemplates.swift` 把 prompt 当成模块内常量,`DictationSession.mode` 只记录枚举值;§10 又建议给 `PolishMode` 加 `.custom(id, name, prompt)`,这与 `String` raw value 和自动 `CaseIterable` 直接冲突,且历史无法知道当时使用的是哪一版 prompt。
- 触发场景(哪个未来功能暴露的):2 自定义 prompt/mode/行为规则;4 switchable prompts 与 A/B test;1 个性化风格记忆;5 词本注入 LLM prompt;6 易错字修正规则注入 prompt。
- 现有架构文档位置:§4.1 `PolishMode`、`FinalText.mode`、`DictationSession.mode`;§4.4 `PolishPromptTemplates.swift`;§10「用户自定义润色模式」。
- 修订建议:把固定 enum 改为稳定 ID 模型:`ModeID`、`ModeDefinition`、`PromptTemplate`、`PromptRevision`、`PromptSet`。4 个 v1 模式作为 seeded records,不作为不可扩展 enum。新增表 `mode_definition`、`prompt_template`、`prompt_revision`、`prompt_experiment_assignment`;`dictation_session` 增加 `mode_id`、`prompt_revision_id`;`DoubaoPolishClient.polish` 改接收 `PolishRequest(modeID:promptRevisionID:prompt:)`,而不是从模块常量按 enum 查找。
### 2.2 缺少 DictationContext / PolicyResolver,未来能力没有统一注入点
- 问题:主链路是 `recording → streaming ASR → LLM polish → AX insertion → fallback clipboard → history`,但上下文只在 `PolishContext(appBundleId, appName)` 中出现,ASR 的 `openSession()` 没有 options 参数。未来个性化、词本、易错字、行为规则都需要在 ASR 前和 LLM 前同时注入,而不是散落在 Coordinator 的临时代码里。
- 触发场景(哪个未来功能暴露的):1 表达习惯学习;2 行为规则;3 长期记忆;4 prompt 切换;5 词本 hot-words + prompt 注入;6 易错字优先修正。
- 现有架构文档位置:§3.1「正常输入流程」;§4.3 `VolcengineStreamingASR.openSession()`;§4.4 `PolishContext`;§10「per-app 风格规则」「个人词典提升识别准确率」。
- 修订建议:新增 `DictationContext` 与 `DictationPolicyResolver`。`DictationContext` 至少包含 `appBundleId`、`appName`、`languageHint`、`modeID`、`privacyClass`、`sessionIntent`。`DictationPolicy` 至少包含 `ASRSessionOptions(recognitionHints:)`、`PromptAssembly(modeID, promptRevisionID, fragments:)`、`BehaviorRules`、`MemoryDigest`。Coordinator 在 hotkey down 后、`ASR.openSession(options:)` 前解析一次 policy;`DoubaoPolishClient` 只消费组装后的 `PolishRequest`。
### 2.3 Persistence 只有历史表,不是可学习、可回放、可同步的长期数据层
- 问题:`DictationSession` 和 `dictation_session` 只保存 `raw_transcript`、`final_text`、`mode`、app、插入状态、错误码;没有 ASR run、polish run、prompt revision、词本 revision、易错字规则、用户反馈、派生记忆、删除血缘。未来一旦做长期学习或同步,无法解释某条输出由哪些规则产生,也无法安全回滚、重跑、清除或 A/B 对照。
- 触发场景(哪个未来功能暴露的):1 个性化学习;3 长期持久化/跨设备同步;4 A/B prompt;5 词本效果追踪;6 易错字标注闭环。
- 现有架构文档位置:§4.1 `DictationSession`;§4.6 `HistoryStore`;§5.1 `dictation_session` schema;§10「同步 / 团队词典」。
- 修订建议:把 `HistoryStore` 降级为 UI 查询门面,新增 `SessionEventStore`、`PromptStore`、`LexiconStore`、`CorrectionStore`、`MemoryStore`。新增表 `recognition_run`、`polish_run`、`session_event`、`user_memory`、`memory_event`、`correction_annotation`、`lexicon_revision`、`sync_metadata`、`tombstone`。`dictation_session` 增加 `started_at`、`ended_at`、`duration_ms`、`language_hint`、`mode_id`、`prompt_revision_id`、`lexicon_revision_id`、`correction_revision_id`、`privacy_class`。
### 2.4 个人词本与易错字被压成一个弱 `dict_entry`,且 hot-words 归属放错层
- 问题:`dict_entry` 只有 `phrase`、`aliases_json`、`category`,无法表达权重、语言、作用域、启停、来源、最近使用、修正优先级、同步版本;§10 说“`Recorder` 启动 ASR 时把词典作为 hot words 传入”,但 `Recorder` 的职责是 PCM 采集,不应该知道 ASR hot-words,也没有 `ASRSessionOptions`。
- 触发场景(哪个未来功能暴露的):5 个人词本;6 易错字标注;1 识别偏好学习;2 自定义词汇/行为规则。
- 现有架构文档位置:§2.3 `OpenLessRecorder` 职责;§4.2 `AudioConsumer`;§5.1 `dict_entry`;§10「个人词典提升识别准确率」。
- 修订建议:新增 `LexiconProvider` 与 `CorrectionProvider`,统一输出 `RecognitionHint(phrase, aliases, weight, language, scope, source)` 和 `PolishHint(kind: .lexicon/.correction, text:)`。ASR 层增加 `VolcengineASRRequestConfig.hotWords` 或通用 `ASRSessionOptions.recognitionHints`;LLM 层在 `PromptAssembly.fragments.lexicon`、`fragments.corrections` 注入。表结构改为 `lexicon_entry`、`lexicon_alias`、`lexicon_scope`、`correction_annotation`、`recognition_hint_usage`。
## 3. HIGH 问题
- §4.1 `RawTranscript` 只有 `text/language/durationMs`,没有 segment、confidence、alternatives、provider result id。影响 5/6/1:词本和易错字需要知道错误来自 ASR 候选、LLM 改写还是用户最终修改。
- §4.1 `FinalText` 只有 `text/mode`,没有 `promptRevisionID`、`appliedRuleIDs`、`memoryIDs`、`lexiconRevisionID`。影响 1/4/5/6:无法重跑、对照或解释个性化输出。
- §4.3 `VolcengineStreamingASR.openSession()` 无参数,§10 才说将来加 `hot_words`。影响 5/6:未来必须改 ASR public API 和 Coordinator 调用点。
- §4.6 `HistoryStore.clear()` 是全量清空,§5.1 schema 没有派生数据外键。影响 3/1/6:清历史时无法级联清除长期记忆、易错字规则和 prompt 实验记录。
- §6.2 设置页只有概览、凭据、快捷键、历史、隐私,删掉了 baseline 中的 Dictionary/Modes。影响 2/4/5/6:未来 UI 会反向逼迫 Persistence 和 Prompt 层重构。
- §9 “WS 中途断开”后原始转写丢弃,只写 `error_code`。影响 1/3/6:失败样本无法进入误识别学习,也无法让用户标注“这次哪里错了”。
- §10 `RemoteSyncAdapter` 只作为“同步/团队词典”的一句话,没有身份、版本、冲突、删除墓碑、加密边界。影响 3/5/6:跨设备同步一旦加入会牵动所有长期数据表。
- §10「per-app 风格规则」只说加 `AppStyleRule` 表和 prompt 前缀。影响 1/2:行为规则不一定只是 prompt,也可能改变 mode、词本作用域、插入策略和隐私策略。
- §14 测试策略没有 prompt revision、词本注入、易错字标注、长期记忆删除的 fixture。影响 3/4/5/6:这些能力晚加时很难证明没有污染主链路。
## 4. MEDIUM 问题
- §4.4 要求 system prompt ≤80 字。影响 1/2/4/5/6:对 v1 延迟有利,但未来应改为 `PromptBudget`,限制总 token 和各 fragment 上限,而不是硬限制 system prompt 字数。
- §5.1 `snippet` 表与 §10 语音命令/行为规则没有关系。影响 2:片段、命令、行为规则最好共用 `BehaviorRule` 或 `CommandAction` 模型。
- §5.2 `UserDefaults` 有 `last_used_app_bundle_id`,但没有 per-app settings。影响 1/2:至少预留 `app_profile` 表,不要把 app 适配塞进 UserDefaults。
- §4.5 `FallbackReason` 只有 `focusLost/accessibilityBlocked/unknown`。影响 1/2:未来按 app 学习插入偏好时,需要 `targetAppCapability`、`insertionMethod`、`failureDiagnostics`。
- §2.3 `OpenLessPersistence` 声称负责“历史、词典、片段”,但 §4.6 public API 只暴露 `HistoryStore` 和 `CredentialsVault`。影响 5/6:v1 至少应暴露空实现的 `LexiconStore`、`CorrectionStore`。
- §13 允许 DEBUG 打印 request/response body。影响 1/3/5/6:长期记忆、词本和易错字通常包含敏感名词,即便 DEBUG 也应默认脱敏。
## 4.5 UI/backend 解耦约束(未来约束 7)
当前 §2 的 7 个 Swift Package(`OpenLessCore`、`OpenLessRecorder`、`OpenLessASR`、`OpenLessPolish`、`OpenLessInsertion`、`OpenLessPersistence`、`OpenLessUI`)只做到了“代码目录和编译模块拆分”,还没有真正做到 UI/backend 解耦。关键风险在 §2.2 App target:`AppDelegate`、`MenuBarController`、`Settings/` 和 `DictationCoordinator` 同处 App 层,而 §2.1 又规定 App target 是唯一装配所有模块的地方。结果是 `DictationCoordinator` 很容易变成同时懂 UI 状态、快捷键事件、ASR、润色、插入、历史的上帝对象。
对未来约束 7,这个结构的主要缺口是:`OpenLessUI` 只是视图组件库,不是 UI 状态边界;架构没有声明 `ViewModel` 层,也没有声明 Views 只能通过 `@Observable ViewModel` 订阅状态。只要 SwiftUI/AppKit Views 直接调用 `DictationCoordinator.start()/stop()` 或读写 service,未来拆出设置 app、菜单栏 app、历史窗口、prompt 编辑器时,业务状态会被多个 UI 入口直接操纵。
路径 A:进程内 MVVM 解耦。形态是 `SwiftUI/AppKit Views → @Observable ViewModel → UseCase/Service protocol → Recorder/ASR/Polish/Insertion/Persistence`。服务层只暴露 `AsyncStream<DictationEvent>` 或 Combine publisher,不 import `SwiftUI`/`AppKit` UI 类型;ViewModel 负责把 `SessionState`、`CapsuleState`、设置表单状态和错误文案映射给 View。
路径 B:进程级 XPC 解耦。形态是录音/识别/润色/历史作为 LaunchAgent 或 XPC service,菜单栏 UI 和设置 UI 通过 XPC 调用同一个 backend。好处是 UI crash 不影响 backend,会自然支持“菜单栏 app + 设置 app 共用后端”,也更接近长期后台服务模型。
建议:v1 选 A,不选 B。依据是 §1.3 明确 KISS,§17 决策记录也偏向单 provider 直连和控制依赖;§15 落地路线强调先用 12 步跑通主链路。XPC 会引入 service lifecycle、权限归属、Keychain access group、音频权限归属、XPC 编码、崩溃恢复、升级兼容和调试成本,对 v1 的收益不足。
如果未来选择 B,合理触发条件应是:需要 UI 退出后继续录音/识别、需要多个独立前端同时连接 backend、或需要把后台学习/同步长期驻留为系统服务。当前 §1.1 v1 主链路和 §8 权限引导都没有提出这些硬需求。
但“不做 XPC”不等于接受当前 App target 直连所有业务。v1 至少要把 `DictationCoordinator` 从 UI 事件处理器改成无 UI 感知的 use case,并让 UI 只认识 ViewModel。否则 §2 的 7 package 拆分会被 App target 重新耦合起来,后续约束 7 会和 1/2/4/5/6 一起放大重构成本。
推荐的最小改动:
- 新增 `OpenLessWorkflow` 或 `OpenLessApplication` 模块,放置 `DictationUseCase` / `DictationCoordinator`,App target 只负责装配和创建 ViewModel。
- 新增 `OpenLessViewModels` 模块,至少包含 `CapsuleViewModel`、`SettingsViewModel`、`HistoryViewModel`、`ModePickerViewModel`;`OpenLessUI` 只依赖这些 ViewModel 或纯 display state。
- 在 `OpenLessCore` 增加协议:`DictationServiceProtocol`、`SettingsServiceProtocol`、`HistoryServiceProtocol`,以及事件模型 `DictationEvent`、`DictationCommand`。
- `DictationServiceProtocol` 暴露 `var events: AsyncStream<DictationEvent> { get }`、`start(context:)`、`stop()`、`cancel()`、`rerun(sessionID:modeID:)`;不要暴露具体 `VolcengineStreamingASR`、`DoubaoPolishClient`。
- `CapsuleState` 应由 `CapsuleViewModel` 从 `SessionState`/`DictationEvent` 映射,不让 `OpenLessRecorder`、`OpenLessASR`、`OpenLessPolish` 直接知道胶囊 UI。
- `Settings/` 不直接访问 `CredentialsVault`、`HistoryStore`、`UserDefaults`,而是通过 `SettingsViewModel` 调 `SettingsServiceProtocol`,避免设置页成为第二个业务编排入口。
- `DictationCoordinator` 输出结构化事件,如 `.recordingStarted`、`.partialTranscriptIgnored`、`.finalTranscriptReceived`、`.polishStarted`、`.inserted`、`.copiedFallback(reason:)`、`.failed(errorCode:)`,ViewModel 只消费事件。
- 为未来 XPC 保留可迁移边界:service protocol 的参数和返回值使用 `Codable`/`Sendable` DTO,不把 `NSView`、`NSWindow`、`AXUIElement`、`AVAudioPCMBuffer` 这类进程内对象泄露到 UI-facing protocol。
- App target 的 `AppContainer` 只做依赖装配,不持有业务状态;业务状态放在 service/use case,展示状态放在 ViewModel。
- 测试策略应补一类 ViewModel 单元测试:用 mock `DictationServiceProtocol` 驱动事件流,验证胶囊、历史、设置页不会直接依赖真实 ASR/LLM/AX。
结论:§2 的模块结构是必要但不充分。v1 不值得做 XPC,但必须做进程内 MVVM + UseCase/service protocol,否则 `DictationCoordinator` 会吞掉 UI/backend 边界,未来要拆独立设置 app、prompt 编辑 UI、长期后台学习服务时会变成架构级重构。
## 5. 隐私 / 长期化数据生命周期风险
- §13 “历史只存文本”对 v1 是安全简化,但对 1/3 会把 `raw_transcript` 和 `final_text` 变成默认学习语料。需要 `privacy_class`、`learnable`、`retention_policy_id`、`do_not_learn` 字段,且默认只进入历史,不进入长期记忆。
- §5.1 `dictation_session` 没有派生数据血缘。对 3/6,用户删除一条历史时,系统必须知道哪些 `user_memory`、`correction_annotation`、`recognition_hint_usage` 来自该 session,并可级联删除或重算。
- §5.2 只有全局 `history_retention_days` 和 `save_audio`。对 3/5/6,历史、词本、易错字、prompt 实验、同步日志应有不同 TTL 和导出/删除策略。
- §13 DEBUG body logging 应改为默认禁止正文日志,只允许结构化 metadata:provider、latency、status、error_code、token/audio duration。对 1/3/5/6,正文、词本、修正规则、prompt 片段都应走 redaction。
- §5.3 Keychain 保存 API Key 是正确的,但 `ark.endpoint`、`ark.model_id` 与 `volcengine.resource_id` 会影响数据出境和模型路径。对 3,需要在 session provenance 中记录实际 provider endpoint/model/resource,而不是只放 Keychain。
- §10 同步提案没有加密和冲突策略。对 3/5/6,长期记忆、个人词本和易错字标注默认应 local-only;同步必须显式 opt-in,并要求端到端加密或至少应用层加密字段。
## 6. 你的反提案
### 6.1 我会这样重写 §10 扩展点矩阵
| 未来功能 | v1 必须预留的接口 | 新模块/层 | 新字段/表 | 主链路接入点 |
|---|---|---|---|---|
| 1 个性化 | `MemoryProvider`、`StyleProfileResolver` | `OpenLessPersonalization` | `user_memory`、`memory_event`、`style_profile`;session `memory_revision_id` | ASR 前取识别偏好;Polish 前注入 style/memory fragment |
| 2 自定义 | `BehaviorRuleEngine`、`ModeRegistry` | `OpenLessPrompting`、`OpenLessRules` | `mode_definition`、`behavior_rule`、`rule_assignment` | PolicyResolver 输出 mode/rules/prompt fragments |
| 3 长期持久化 | `SessionEventStore`、`SyncAdapter` | `OpenLessPersistence` 内的 long-term 子层 | `session_event`、`sync_metadata`、`tombstone`、`retention_policy` | session 完成后写 event;清除时按血缘删除 |
| 4 Prompt 切换 | `PromptRegistry`、`PromptExperimentEngine` | `OpenLessPrompting` | `prompt_template`、`prompt_revision`、`prompt_set`、`prompt_experiment_assignment` | PolishRequest 固定记录 `prompt_revision_id` |
| 5 个人词本 | `LexiconProvider`、`RecognitionHintProvider` | `OpenLessLexicon` | `lexicon_entry`、`lexicon_alias`、`lexicon_revision`、`recognition_hint_usage` | ASR `openSession(options:)` 注入 hot-words;Polish 注入术语约束 |
| 6 易错字标注 | `CorrectionStore`、`CorrectionProvider` | `OpenLessFeedback` | `correction_annotation`、`correction_rule`、`correction_observation` | ASR 前提高候选权重;Polish 前注入纠错规则 |
### 6.2 建议新增的模块或抽象层
- `OpenLessPolicy`:`DictationPolicyResolver`,把 app、mode、隐私、词本、记忆、prompt 实验解析成一次会话的 policy。
- `OpenLessPrompting`:`ModeRegistry`、`PromptRegistry`、`PromptAssembler`,负责 mode/prompt 的版本化和片段拼装。
- `OpenLessLexicon`:`LexiconStore`、`RecognitionHintProvider`,把个人词本转换为 ASR hot-words 和 LLM 术语约束。
- `OpenLessFeedback`:`CorrectionStore`、`FeedbackEventSink`,承接用户标注、重跑、手改后的学习事件。
- `OpenLessPersonalization`:`MemoryStore`、`StyleProfileResolver`,只产出可审计的 memory/style fragments,不直接改 prompt 常量。
- `OpenLessSync`:`SyncAdapter`、`SyncMetadataStore`,v1 可空实现,但数据表从第一天带 version/tombstone。
建议把 provider 层接口从“多 provider 抽象”降级为“请求对象稳定化”:`ASRSessionOptions`、`ASRResult`、`PolishRequest`、`PolishResult`。这样不违背单火山 v1,但未来加词本、prompt 和易错字不必改主 Coordinator 签名。
### 6.3 v1 “不做”清单里应保留接口桩的项目
- §1.2「per-app 风格」:不做 UI 和规则引擎,但保留 `app_profile` 表和 `StyleProfileResolver.empty`。
- §1.2「多 ASR / 多 LLM provider 抽象层」:不做 provider router,但保留 `ASRSessionOptions`、`PolishRequest` 这种请求对象,避免未来 hot-words/prompt revision 改签名。
- §1.2「BYOK 路由」:UI 不暴露 provider 切换可以,但 `dictation_session` 要记录 `provider_route`、`asr_provider`、`llm_provider`、`model_id`、`endpoint_host`。
- §1.2「语音命令」:不做命令执行,但保留 `BehaviorRule`/`CommandAction` 表,避免片段、规则和命令各自长出模型。
- §5.1 `dict_entry` / §10「个人词典提升识别准确率」:不做词典 UI 可以,但 `LexiconStore`、`RecognitionHintProvider.empty` 应在 v1 存在。
- §4.1 `PolishMode` / §6.3 菜单栏模式切换:不做复杂模式市场,但内置 4 模式也应来自 `mode_definition` seeded records。
- §1.2/§10「同步」:不做 CloudKit/后端,但长期表必须有 `updated_at`、`version`、`deleted_at/tombstone`。
## 7. 不同意原文的地方
- 我不同意“为了未来功能现在就必须做多 provider 抽象”。§1.2 和 §17 选择单火山/Ark 直连是对的;6 个未来功能主要需要 policy、prompt、lexicon、memory、feedback 抽象,不需要 v1 上来做 Deepgram/Claude/GPT router。
- §4.4 “LLM `stream: true` 但等完整 finalText 一次性 AX 写入”是正确取舍。对 4 prompt A/B、1 个性化和 6 易错字回放来说,完整 polish run 更可审计;stream-replace 会让插入状态和历史 provenance 变复杂。
- §13 “API Key 进 Keychain、音频默认不落盘”是正确底线。未来 3 长期持久化也不应该从保存音频开始,而应从用户可控的文本、词本、标注和记忆对象开始。
- §4.4 `PolishContext` 先放 `appBundleId/appName` 是正确起点。不要删掉它;应扩展成 `DictationContext`,让 1 个性化和 2 per-app 规则有稳定入口。
- §11 把 macOS 26 glass/端侧能力封装在 UI 或未来模型层,不污染业务模块,是正确边界。未来 1/2/4/5/6 不应依赖 macOS 26 专属 API 才能工作。
-533
View File
@@ -1,533 +0,0 @@
# OpenLess 竞品评论、产品基调与 UI 方向调研
生成日期:2026-04-26
## 1. 本轮结论
当前项目根目录为 `openless`,因此本文把我们的产品名暂定为 **OpenLess**。OpenLess 的直接竞品不是单一产品,而是一组 AI 语音输入工具:Typeless、Wispr Flow、Aqua Voice、Superwhisper、Willow Voice、TalkTastic、MacWhisper,以及本机正在使用的 LazyTyper。
这类产品的核心竞争已经从“语音转文字准确率”升级为“全局输入体验”:
- 用户想要的是说完就能用,而不是拿到一段原始 transcript。
- 用户最在意的是速度、稳定、插入成功率、润色质量、隐私和价格。
- 用户最不满的是移动端键盘体验差、录音中断、结果丢失、插入失败、订阅太贵、隐私解释不透明。
- 对 OpenLess 来说,第一轮最值得打的差异点是:Mac 原生底部微型状态胶囊、本地优先/可选云增强、稳定兜底、轻量 UI、不过度 AI 化的润色,以及中英混输和开发者 prompt 体验。
## 2. 竞品到底在做什么
| 产品 | 正在做什么 | 用户方向 | 解决痛点 |
|---|---|---|---|
| Typeless | 全平台 AI voice keyboard,把自然语音变成 polished text | 普通知识工作者、移动端用户、AI 工具重度用户 | 手机/电脑打字慢、语音转写太生硬、长 prompt 难打 |
| Wispr Flow | 跨设备语音输入层,强调 auto edits、styles、dictionary、command mode | 高强度办公、销售、写作者、开发者、团队 | 在所有 app 里快速输入,语气按 app 自动调整 |
| Aqua Voice | 开发者和技术写作导向的高速 dictation,强调 context 和 Avalon 模型 | 开发者、vibe coding 用户、技术写作者 | 技术词、文件名、代码场景识别差,普通 dictation 不懂上下文 |
| Superwhisper | 可配置、本地/云混合、模式系统强的 power-user 工具 | 隐私敏感用户、Mac/iOS power users、需要自定义 prompt 的人 | 需要本地模型、BYOK、自定义模式、历史重处理 |
| Willow Voice | iOS/Mac 语音键盘和 AI rewrite,强调编辑能力和格式化 | 移动端办公用户、邮件/聊天/文档用户 | iOS 原生听写不够准,跨 app 输入难编辑 |
| TalkTastic | macOS context-aware voice keyboard,强调 screen context 和 rewrite | Mac 办公用户、非英语母语、ADHD/无障碍用户 | 用户说的是乱的,但需要输出符合当前屏幕上下文 |
| MacWhisper | 以本地转写为核心,附带 dictation 和 AI prompt | 会议/音频转写用户、隐私敏感 Mac 用户 | 音频文件转写、离线、一次性付费 |
| LazyTyper | 免费/轻量/多模型语音输入,支持本地和云端模型 | 中英混输用户、开发者、免费工具用户 | 不想订阅 Typeless/Wispr,想要多模型和本地模型 |
## 3. 评论内容与不足总结
### 3.1 Typeless
参考来源:[Typeless 官网](https://www.typeless.com/)、[Typeless App Store 评论](https://apps.apple.com/us/app/typeless-ai-voice-keyboard/id6749257650)、[Reddit 讨论](https://www.reddit.com/r/macapps/comments/1qwn64o/looking_at_dictation_apps_typeless_clear_winner/)
用户喜欢:
- 输出不是机械转写,而是能把粗糙想法变成更清楚的文本。
- punctuation 和 inference 被多次提到,尤其是能根据说话方式补标点和引号。
- 对 AI 对话、长 prompt、长文档很有帮助,用户觉得“思考速度不再被键盘卡住”。
- 学习成本低,有用户说大概一分钟就能上手。
- 移动端和桌面都有,跨设备覆盖强。
用户抱怨:
- iOS 键盘交互还不够稳定,尤其是进入语音键盘、保持语音界面激活、滑动切换等细节。
- 有用户反馈长语音会提前结束录音,导致长内容体验不可靠。
- 有评论提出移动端语音键盘高度太大,想要横向低矮的 mini mode,以减少屏幕占用。
- Reddit 上有隐私担忧,尤其是用户不清楚上下文和历史到底如何被使用。
- 对价格和长期订阅也有抵触,尤其是想要 lifetime license 的用户。
对 OpenLess 的启发:
- 不要只做 ASR,要把“粗糙想法变成可用文本”作为主卖点。
- 必须提供长语音防丢机制:录音中断也能找回片段。
- macOS 端第一轮可以避开 iOS 第三方键盘限制,先把桌面体验做到极稳。
- UI 要比移动键盘更轻,底部胶囊要小、可隐藏、不挡内容。
- 隐私说明要前置,不要让用户猜产品是否读取屏幕或保存音频。
### 3.2 Wispr Flow
参考来源:[Wispr Flow 官网](https://wisprflow.ai/)、[Wispr Flow Features](https://wisprflow.ai/features)、[Wispr Flow Auto Cleanup](https://docs.wisprflow.ai/articles/4136931124-how-to-use-auto-cleanup-beta)、[Wispr Flow Styles](https://docs.wisprflow.ai/articles/2368263928-how-to-setup-flow-styles)、[Wispr Flow App Store 评论](https://apps.apple.com/us/app/wispr-flow-ai-voice-dictation/id6497229487?see-all=reviews)、[Reddit 价格讨论](https://www.reddit.com/r/SaaSy/comments/1srmzu9/wispr_flow_pricing_is_it_actually_worth_paying/)
用户喜欢:
- 准确率、速度、低打扰度是核心好评。
- 用户觉得它在邮件、聊天、文档里比原生 dictation 更像“写出来的文字”。
- Flow Styles 和 Auto Cleanup 是很强的差异点:用户可以按 Formal、Casual、Very Casual、Excited 等风格调整输出。
- Command Mode 能处理选中文本,用户可以用语音让它改短、翻译、搜索。
- 个人词典、snippets、跨设备同步对高频用户有价值。
用户抱怨:
- 价格被大量讨论,典型抱怨是“不确定每月订阅是否值得”,或者免费额度/试用边界不够清楚。
- Reddit 上有用户认为营销强调免费,但“完整语音 dictation 是 limited time”这类表述容易让人失望。
- 隐私争议集中在屏幕上下文、截图/上下文采集、云端处理、权限范围是否足够透明。
- 有用户反馈质量波动,某段时间准确率下降后导致自己又回去打字。
- iOS 长文本体验不稳定,有人抱怨几分钟长 dictation 崩溃或退出后内容丢失。
- 官方文档中也承认 Android Flow Bubble / desktop Flow Bar 可能出现不显示、消失、闪烁、文本落到错误输入框、waveform 不响应等问题。
对 OpenLess 的启发:
- 自动润色要提供可控强度,而不是只有“开/关”。
- 免费/付费边界必须简单透明,避免“看起来免费,实际核心被限”的落差。
- 上下文读取必须有明显开关和清楚说明,最好默认只读必要上下文。
- 录音失败、插入失败、字段失焦时必须自动保存在历史和剪贴板。
- Flow 的最大机会点是“强但复杂”,OpenLess 可以主打“更安静、更可控、更透明”。
### 3.3 Aqua Voice
参考来源:[Aqua 官网](https://aquavoice.com/)、[Aqua Product Hunt 评论](https://www.producthunt.com/products/aqua/reviews)、[Aqua App Store 评论](https://apps.apple.com/us/app/6759074969?platform=iphone&see-all=reviews)、[Aqua Guide](https://aquavoice.com/guide/)、[Aqua History](https://aquavoice.com/guide/history)、[Aqua Replacements](https://aquavoice.com/guide/replacements)、[Reddit 讨论](https://www.reddit.com/r/macapps/comments/1qg73sx/wispr_flow_vs_aqua_voice/)
用户喜欢:
- 高频好评是快、准、技术词识别强。
- 开发者尤其喜欢它能利用屏幕上下文,在 Cursor、Windsurf、代码编辑器里识别术语、文件名、变量。
- Product Hunt 上用户反复提到它比其他工具更适合 coding、Slack、Notion、email。
- Replacements 和 Dictionary 对重复 prompt、邮箱、链接、术语很实用。
- History 支持本地保存音频、重跑转写、复制到剪贴板,这是强兜底。
用户抱怨:
- Product Hunt 总结里提到偶尔会错过 transcript 或 paste,需要重试。
- 需要网络访问仍然是隐私和可用性顾虑。
- 用户要求 iOS、移动端、Linux、offline 支持。
- Reddit 上有人说 Aqua 输出有时像一整段,没有足够分行或格式化。
- 有用户想要更好的 whisper/quiet dictation,因为公共环境讲话仍然尴尬。
对 OpenLess 的启发:
- 开发者模式值得做,但第一轮不必直接挑战 Aqua 的深度代码上下文。
- OpenLess 可以把“结构化分段”和“不过度一段流”做成默认优势。
- History 要像 Aqua 一样成为安全网,但默认更隐私:原始音频是否保存应由用户选择。
- Replacements 适合第一轮做,因为它实现成本低、用户感知强。
### 3.4 Superwhisper
参考来源:[Superwhisper 官网文档](https://superwhisper.com/docs)、[Superwhisper Recording Window](https://superwhisper.com/docs/get-started/interface-rec-window)、[Superwhisper History](https://superwhisper.com/docs/get-started/interface-history)、[Superwhisper Modes](https://superwhisper.com/docs/modes)、[Superwhisper App Store 评论](https://apps.apple.com/us/app/superwhisper/id6471464415?platform=ipad&see-all=reviews)、[Reddit iOS 反馈](https://www.reddit.com/r/superwhisper/comments/1s6ul36/ios_app_is_barely_useable/)
用户喜欢:
- 本地模型和隐私是它的核心好评。
- 模式系统强:Voice to Text、Message、Email、Note、Super、Meeting、Custom。
- 支持本地/云模型、BYOK、自定义 prompt、上下文感知,适合 power users。
- Recording Window 有实时 waveform、状态点、当前模式、context capture 指示、stop/cancel。
- History 很完整:可搜索、重处理、查看 Voice 原始转写和 AI 处理结果、复制、查看 prompt 和 metadata。
用户抱怨:
- iOS 键盘体验被多次批评:切回原 app 失败、不自动粘贴、崩溃、录音丢失。
- 有用户说桌面不错,但移动端差。
- 有用户反馈“no voice recording found”导致内容丢失。
- 某些用户认为支持响应慢,问题长期未解决。
- 自定义模式强但也复杂,如果 prompt 没写好,产品可能把用户语音当成命令去执行,而不是转写。
- lifetime 价格较高,部分用户对价格敏感。
对 OpenLess 的启发:
- History 面板非常值得借鉴,但 OpenLess 第一轮应该更简单,只保留原始转写、润色结果、复制、重跑模式。
- 不要把 mode 系统做得太复杂,第一轮固定 4 个模式即可。
- 要避免“AI 执行命令而非转写”的问题:默认润色 prompt 必须明确只整理文本,不回答、不执行。
- 底部状态胶囊可以借鉴 Superwhisper 的 waveform 和状态点,但要比它更小、更轻,只做提醒,不做大控制台。
### 3.5 Willow Voice
参考来源:[Willow 官网](https://willowvoice.com/)、[Willow App Store 评论](https://apps.apple.com/us/app/willow-ai-voice-dictation/id6753057525)、[Willow 自动格式化指南](https://help.willowvoice.com/en/articles/13183983-voice-commands-and-automatic-formatting-guide)、[TechCrunch 报道](https://techcrunch.com/2025/11/12/willows-voice-keyboard-lets-you-type-across-all-your-ios-apps-and-actually-edit-what-you-said/)、[Willow 故障排查](https://help.willowvoice.com/en/articles/12279120-dictation-quality-or-transcription-issues)
用户喜欢:
- iOS 端提供完整键盘,比只有数字键盘的竞品更便于临时修改。
- 自动格式化能力强:标点、段落、列表、引号、邮件结构。
- 支持 rewrite suggestions,适合发消息前调整 tone、grammar、length。
- 支持 100+ languages、个人词汇、不同 app category 的 writing styles。
用户抱怨:
- App Store 有用户明确抱怨键盘高度太高:下方增加全局快捷键行,上方又有较高的 dictation UI,导致空间被挤占。
- 后台持续录音让用户有隐私和电量担忧。
- 如果关闭后台录音,每次使用又要跳转到 dictation app,打断体验。
- 官方故障排查提到会遇到 dictation quality drop 或 transcription failure,常见原因包括蓝牙麦克风、网络、背景噪音、输入音量、说话太轻等。
对 OpenLess 的启发:
- macOS 底部胶囊一定要控制高度,不要占据太多阅读空间。
- 录音状态要非常明确,不能让用户觉得“它是不是一直在听”。
- 不要默认后台持续录音;OpenLess 应该是明确 push-to-talk 或 toggle-to-talk。
- 自动格式化值得借鉴,但 voice commands 要作为高级能力,不应成为第一轮使用门槛。
### 3.6 TalkTastic
参考来源:[TalkTastic Start Here](https://help.talktastic.com/en/articles/9554689-new-to-talktastic-start-here)、[TalkTastic Components](https://help.talktastic.com/en/articles/9654601-talktastic-components)、[TalkTastic Product Hunt 评论](https://www.producthunt.com/products/talktastic/reviews)
用户喜欢:
- 它不只是转写,而是会根据屏幕上下文进行 rewrite。
- Product Hunt 评论总体把它看作 polished voice-writing tool,适合 thinking out loud、draft emails、posts、notes。
- 用户喜欢 quick access、OS integration、summaries、rewrites,以及用 iPhone 当麦克风。
- TalkTastic 的 transcript windows 同时展示 cleaned transcript 和 AI rewrite,增强信任感。
用户抱怨或潜在风险:
- 上下文依赖 snapshot/screenshot,会触发隐私敏感。
- TalkTastic 文档提到 snapshot 可保存、删除或关闭,这说明默认上下文能力虽然强,但用户必须理解它。
- UI 上有 Active Microphone Bubble、transcript windows、menu bar、多种快捷方式,能力强但可能略重。
- macOS-only,跨平台不足。
对 OpenLess 的启发:
- “原始转写 + 润色结果”双结果非常值得做,能降低用户对 AI 改写的不信任。
- 上下文功能第一轮只做轻量:active app 类型、输入框文本、选中文本,不默认截图。
- 上下文提示不应常驻挤在胶囊里,可以在胶囊展开态或设置页显示。
### 3.7 MacWhisper
参考来源:[MacWhisper Dictation](https://macwhisper.helpscoutdocs.com/article/14-how-to-use-the-dictation-feature)、[MacWhisper 版本差异](https://macwhisper.helpscoutdocs.com/article/40-macwhisper-whisper-transcription-difference)、[Product Hunt 评论](https://www.producthunt.com/products/macwhisper/reviews)、[Reddit 讨论](https://www.reddit.com/r/MacWhisper/comments/1kw3qcn)、[Reddit 近期反馈](https://www.reddit.com/r/MacWhisper/comments/1stvuxm/macwhisper_is_the_best_tool_i_cant_fully_rely_on/)
用户喜欢:
- 本地转写、准确、便宜/一次性付费是主要优势。
- 对音频文件、会议、YouTube、字幕、批量转写这类任务更强。
- 直接下载版支持在任意文本框 dictation。
- 支持 AI prompt 处理 dictation,比如清理错误、翻译、扩写成客服邮件。
用户抱怨:
- 作为 dictation 工具,实时性不如专门的 voice input 工具。
- Reddit 有用户反馈 34 秒才完成转写,对日常输入太慢。
- 有用户遇到 AI prompt 把口述当成给 ChatGPT 的指令,而不是返回原文。
- 有近期用户说它很好但不能完全依赖:转写挂住、summary 忽略语言和 prompt、auto-export 静默失败。
- 历史和 dictation 的查找/恢复体验对部分用户不够直观。
对 OpenLess 的启发:
- OpenLess 不应该第一轮做会议/文件转写,避免和 MacWhisper 主战场重叠。
- 核心要做“实时输入”和“插入稳定”,而不是复杂音频工作台。
- AI prompt 要强约束:只整理用户语音,不回答、不扩写、不执行。
### 3.8 LazyTyper
参考来源:[LazyTyper 官网](https://lazytyper.com/)、[LazyTyper Reddit 发布帖](https://www.reddit.com/r/macapps/comments/1mt8z2x/couldnt_find_a_good_free_voicecoding_tool_so_i/)、本机 `/Applications/LazyTyper.app` 包信息与本地配置观察。
公开资料显示:
- LazyTyper 主打免费、轻量、多模型、Windows/macOS/Linux。
- 支持 12 个 speech models,其中包含 5 个本地/offline 选项。
- 官网强调中英混输、技术词、3x typing speed、90% accuracy、无广告。
- Reddit 发布帖强调 global hotkey、push-to-talk、写入任意 editor 或 terminal。
本机观察到:
- 安装版本为 1.8.7,bundle id 为 `com.lazytyper.desktop`。
- 这是一个 `LSUIElement` 菜单栏型应用,不默认显示 Dock 主窗口。
- 权限说明包括麦克风和 Accessibility,用于语音输入和在光标处输入文字。
- 默认快捷键配置显示:反引号用于按住录音,`Control+Command+V` 用于粘贴上一条结果,鼠标中键可作为录音并回车动作。
- 本地功能痕迹包括 AI polishing、history、audio_history、local-models、styles、text replacement、floating bubble、local preview、audio level to bubble、transcript to bubble。
- 本地模型目录显示它已下载多种模型,例如 Paraformer、Parakeet、SenseVoice、Whisper.cpp、Qwen ASR 等。
用户/产品不足推断:
- 它的优势是免费和模型多,但 UI 可能偏工具型,不是 Typeless/Wispr 那种极简消费级体验。
- 多模型选择对 power users 有吸引力,但普通用户容易困惑。
- 本地 `audio_history` 存在大量音频文件,说明“历史是否保存音频”必须在 OpenLess 里做成明确设置。
- 日志显示它会进行 post-edit learning / focus 检测 / text injection,这类能力强,但也会带来隐私解释压力。
对 OpenLess 的启发:
- 可以借鉴 LazyTyper 的快捷键哲学:录音、粘贴上一条、录音后回车,三件事都很高频。
- 不要在第一屏暴露太多模型名。OpenLess 默认只给用户“快 / 准 / 私密”这类选择。
- 底部状态胶囊要比 LazyTyper 更像产品化输入层,而不是开发者工具面板。
## 4. 我们相比竞品能做得更好的地方
### 4.1 更透明的隐私模型
竞品普遍需要麦克风、Accessibility、上下文读取,甚至截图或屏幕上下文。用户真实担忧不是“完全不能读”,而是“不知道你读了什么、存了什么、什么时候读”。
OpenLess 应做到:
- 麦克风只在用户明确按键时开启。
- 底部胶囊显示明确录音状态。
- 默认不保存原始音频,或首次使用时明确询问。
- 如果保存历史,默认只保存文本,音频保存另设开关。
- 上下文分级:无上下文、仅 active app、输入框文本、选中文本、截图,逐级授权。
- 每次使用上下文时,可以在胶囊展开态或菜单栏里显示小标签,例如 `Context: App`、`Context: Selection`;默认胶囊不常驻标签。
### 4.2 更小、更安静的 macOS 状态胶囊
用户对 Willow/Typeless iOS 键盘高度的抱怨说明:语音输入 UI 不能抢屏幕。Mac 上的机会是做一个底部居中的微型 glass capsule。
OpenLess 应做到:
- 默认高度控制在 32-38px。
- 不遮挡主内容,贴近屏幕底部,类似 macOS 系统提示和录音控制条的混合体。
- 只在录音、处理、结果短暂停留时出现。
- 空闲时完全消失,或收成一个 8-12px 的极淡小点。
### 4.3 更强的失败兜底
竞品评论反复出现:录音中断、插入失败、字段失焦、app 崩溃、长语音丢失。
OpenLess 第一轮必须把“失败时不丢内容”作为基本体验:
- 录音结束后先进入最近记录,再尝试插入。
- 插入失败时自动复制到剪贴板并提示。
- 最近记录同时保存原始转写和润色结果。
- 支持“粘贴上一条结果”快捷键。
- 处理超过一定时长时,底部胶囊展示可取消但不丢数据的状态。
### 4.4 不过度 AI 化的润色
竞品的 polish 很强,但用户会反感模板腔、过度正式、擅自扩写。
OpenLess 的基调应是:
- 像用户认真打出来的文字,不像 AI 替用户写的文章。
- 默认清晰,但克制。
- 提供轻度润色,服务聊天、微信、内部 IM。
- 正式模式只在用户选择或邮件场景中增强。
- 保留中英混输和用户常用短句。
### 4.5 更适合中文和中英混输
很多竞品的 style 功能主要面向英文。LazyTyper 官网强调中文输入比拼音更快,说明中文用户有很大需求。
OpenLess 应把中文和中英混输作为第一轮核心,而不是“也支持”:
- 中文口癖清理:嗯、啊、那个、就是、然后、对吧。
- 中英技术词保留:feature、merge、schema migration、PR、Cursor、Supabase。
- 中文标点习惯:中文句号、顿号、冒号、列表。
- 英文单词大小写和品牌名修正。
### 4.6 更清楚的价格和定位
Wispr/Typeless 最大付费阻力是订阅。OpenLess 可以用更亲民策略建立初期口碑:
- 免费版给足基础使用,不做 misleading “limited time”。
- Pro 提供本地模型、高级润色、历史、词典、更多字数。
- 可考虑买断或 BYOK,吸引反订阅用户。
## 5. 竞品功能实现方式与最终效果
### 5.1 通用实现方式
这类产品的共同链路大致是:
1. 全局快捷键或悬浮按钮启动录音。
2. 音频进入 ASR,可能是云端模型、本地 Whisper/Parakeet/Paraformer/SenseVoice,或自研模型。
3. 转写结果进入 LLM/规则层,做去口癖、标点、结构化、语气、词典、片段替换。
4. 读取上下文:active app、输入框文本、选中文本、剪贴板、屏幕截图或文件名。
5. 将结果插入当前光标位置,失败则复制到剪贴板。
6. 保存历史,用于找回、重跑、复制、反馈。
### 5.2 最终效果基准
OpenLess 第一轮的效果基准应是:
- 用户按住快捷键,说一句自然口语。
- 底部胶囊出现,显示正在录音和实时音量反馈。
- 松开后进入处理中状态。
- 1-3 秒内把润色结果插入当前输入框。
- 如果不能插入,复制到剪贴板,并在底部胶囊提示。
- 用户可以打开最近记录,看到原始转写和润色文本。
### 5.3 动画和状态效果参考
竞品可参考的 UI/动画效果:
- Wispr Flow:Flow Bar / Flow Bubble;Android 上文本框出现时显示浮动 bubble;录音后可 checkmark 插入;失败/阻塞用通知提示;新版本强调更平滑、不打扰的 notification UI。
- Superwhisper:主录音窗口有 waveform、状态点、模式显示、context capture 指示、stop/cancel;mini window hover 后展开控制;录音时 waveform 动态响应音量。
- TalkTastic:Active Microphone Bubble 表示录音;transcript windows 展示 cleaned transcript 和 AI rewrite;menu bar 控制 snapshot/context/auto-paste。
- Aqua:强调 streaming mode 和 history;历史中可重跑转写、复制、反馈 thumbs up/down。
- LazyTyper:本机包和配置显示它有 floating bubble、audio level to bubble、transcript to bubble、local preview、paste last result 等能力。
OpenLess 的动画基调应是:
- 轻,不要炫。
- 录音时有音量波动,让用户知道正在听。
- 处理时有明确但短暂的进度反馈。
- 成功插入时给一个小 check,不要大弹窗。
- 失败时出现可操作提示:已复制、查看最近记录、重试插入。
- 底部胶囊出现和消失使用 macOS 风格 spring motion,不要 web 弹窗感。
## 6. UI 与设计参考
### 6.1 当前产品与竞品名称
当前产品暂定名:**OpenLess**。
OpenLess 的含义可以解释为:
- Less typing。
- Less friction。
- Less keyboard。
- Open your thoughts, less effort。
当前对标竞品:
- 主对标:Typeless、Wispr Flow。
- 技术/开发者对标:Aqua Voice。
- 隐私/本地对标:Superwhisper、MacWhisper、LazyTyper。
- 上下文 UI 对标:TalkTastic、Wispr Flow、Superwhisper。
### 6.2 LazyTyper 浮层观察
公开网页没有展示足够清楚的底部浮层细节,但本机安装的 LazyTyper 可以确认其产品形态:
- 菜单栏常驻,不占 Dock。
- 通过全局快捷键启动录音。
- 使用 Accessibility 在当前光标处插入文本。
- 有浮动气泡/预览相关能力。
- 支持上一条结果粘贴。
- 支持本地模型和云端 provider。
- 支持 AI polishing、styles、text replacement、history。
可借鉴点:
- 快捷键设计要直接,不需要每次打开窗口。
- 上一条结果要有快捷恢复。
- 本地模型和云端模型可以共存。
- 浮动气泡要显示状态,但不应变成复杂控制台。
需要避免:
- 模型/provider 暴露太多,普通用户会困惑。
- 历史中默认保存大量音频,容易造成隐私疑虑。
- 工具感过强,缺少 Typeless/Wispr 那种顺滑的产品完成度。
### 6.3 OpenLess macOS 底部状态胶囊方案
底部状态胶囊是 OpenLess 第一轮的核心视觉锚点。它应该像一个“系统级语音输入提示”,而不是一个独立聊天框,也不是一整条输入栏。
默认结构:
| 区域 | 内容 | 说明 |
|---|---|---|
| 左侧 | 叉号 | 取消本次录音或处理 |
| 中间 | 3-5 根白色动态条 / 极短状态文案 | 提醒用户正在听、正在整理 |
| 右侧 | 勾号 | 确认完成或显示成功状态 |
| 展开态 | 原始转写 / 润色结果 / 复制 / 重试 | 默认不出现,只在失败或用户点击时展开 |
视觉要求:
- 底部居中,Listening 状态约 128-180px 宽、32-38px 高。
- 空闲时隐藏,或只保留极淡的小点。
- 只有录音、处理中、成功、失败时才从屏幕底部轻微弹出。
- 使用 macOS glass / material 质感,背景半透明但可读。
- 圆角接近胶囊,阴影轻,边框 1px。
- 不使用大面积紫蓝渐变,不做强营销色。
- 字体使用系统字体,状态文案只保留 2-4 个字。
- 状态色克制:录音用白色动态条或蓝绿色微光,错误用小红点。
- 不默认展示完整转写内容。
状态规划:
| 状态 | 显示 | 动画 |
|---|---|---|
| Idle | 隐藏,或极淡小点 | 无动画 |
| Listening | 叉号 + 动态白色条 + 勾号 | 胶囊从底部弹出,动态条轻微跳动 |
| Processing | 叉号 + “整理中” + 勾号/小 spinner | 胶囊略微变宽 |
| Inserted | 勾号高亮 | 0.8 秒后自动收起 |
| Cancelled | 叉号高亮 | 快速淡出 |
| Clipboard fallback | “已复制” | 保持 2-3 秒 |
| Error | 红点或“失败” | 点击后再展开详情 |
| Expanded | 显示原始转写和润色结果 | 只由用户主动点击或失败状态触发 |
### 6.4 OpenLess 页面基调
OpenLess 不应做成营销型 landing page。第一屏应该是产品可用体验:
- 主窗口:设置、历史、词典、片段、隐私。
- 常驻体验:底部微型状态胶囊。
- 菜单栏:开始录音、粘贴上一条、打开历史、设置、退出。
主窗口应低调、密集、工具化:
- 左侧导航:Overview、History、Dictionary、Snippets、Modes、Privacy、Settings。
- Overview 只展示快捷键、当前模式、麦克风、隐私状态。
- History 支持原文/润色切换。
- Dictionary 和 Snippets 直接表格编辑。
- Privacy 页明确显示本地/云端/历史/音频保存状态。
## 7. 第一轮产品基调
OpenLess 的产品基调应是:
- 安静:不打扰用户工作流。
- 可靠:失败也不丢内容。
- 透明:知道何时录音、何时处理、是否保存、是否读上下文。
- 克制:润色自然,不把用户变成 AI 腔。
- Mac 原生:底部状态胶囊像系统提示组件,而不是网页浮层。
- 对中文友好:中文口癖、中英混输、技术词是核心能力。
一句话定位:
> OpenLess 是一个本地优先、低打扰、可控润色的 macOS AI 语音输入层,让你在任何地方说话,得到像自己认真打出来的文字。
## 8. 信息汇总:市场痛点
用户真实痛点可以归纳为 8 类:
1. 打字慢,尤其是长 prompt、长邮件、长想法。
2. 原生 dictation 不准,标点差,专有名词差。
3. 普通 ASR 太机械,保留口癖和重复,后期编辑成本高。
4. AI dictation 价格越来越贵,订阅疲劳明显。
5. 移动端键盘限制多,高度、切换、粘贴、后台录音都容易差。
6. 隐私不透明,用户害怕录音、文本、截图、上下文被保存或训练。
7. 稳定性不足,录音崩溃或插入失败会立刻破坏信任。
8. 开发者场景特殊,代码名、变量、文件、英文术语很难被通用工具处理好。
OpenLess 第一轮不需要把所有都做满,但必须把第 1、2、3、6、7 类痛点解决到“日常可用”。
## 9. 建议加入第一轮需求的补充项
建议补充到第一轮 PRD:
- 底部微型状态胶囊作为核心 UI。
- 默认不保存音频,只保存文本历史;音频保存必须显式开启。
- 最近记录必须支持“粘贴上一条结果”的快捷键。
- 润色处理必须保存原始转写和最终结果。
- 上下文读取必须分级显示和开关控制。
- 录音中断时保留已录音片段或至少提示没有保存。
- 每次插入失败时自动复制到剪贴板。
- 模式从模型选择中抽象,不让用户第一屏面对 provider/model 名。
- 第一轮主打中文、中英混输、AI prompt 和工作 IM。
## 10. 调研来源
- [Typeless 官网](https://www.typeless.com/)
- [Typeless App Store](https://apps.apple.com/us/app/typeless-ai-voice-keyboard/id6749257650)
- [Typeless Reddit 讨论](https://www.reddit.com/r/macapps/comments/1qwn64o/looking_at_dictation_apps_typeless_clear_winner/)
- [Wispr Flow 官网](https://wisprflow.ai/)
- [Wispr Flow Features](https://wisprflow.ai/features)
- [Wispr Flow Auto Cleanup](https://docs.wisprflow.ai/articles/4136931124-how-to-use-auto-cleanup-beta)
- [Wispr Flow Styles](https://docs.wisprflow.ai/articles/2368263928-how-to-setup-flow-styles)
- [Wispr Flow Command Mode](https://docs.wisprflow.ai/articles/4816967992-how-to-use-command-mode)
- [Wispr Flow App Store](https://apps.apple.com/us/app/wispr-flow-ai-voice-dictation/id6497229487?see-all=reviews)
- [Aqua Voice 官网](https://aquavoice.com/)
- [Aqua Product Hunt 评论](https://www.producthunt.com/products/aqua/reviews)
- [Aqua User Guide](https://aquavoice.com/guide/)
- [Aqua History](https://aquavoice.com/guide/history)
- [Superwhisper 文档](https://superwhisper.com/docs)
- [Superwhisper Recording Window](https://superwhisper.com/docs/get-started/interface-rec-window)
- [Superwhisper History](https://superwhisper.com/docs/get-started/interface-history)
- [Superwhisper App Store](https://apps.apple.com/us/app/superwhisper/id6471464415?platform=ipad&see-all=reviews)
- [Willow Voice 官网](https://willowvoice.com/)
- [Willow App Store](https://apps.apple.com/us/app/willow-ai-voice-dictation/id6753057525)
- [Willow 自动格式化指南](https://help.willowvoice.com/en/articles/13183983-voice-commands-and-automatic-formatting-guide)
- [TalkTastic Start Here](https://help.talktastic.com/en/articles/9554689-new-to-talktastic-start-here)
- [TalkTastic Components](https://help.talktastic.com/en/articles/9654601-talktastic-components)
- [TalkTastic Product Hunt 评论](https://www.producthunt.com/products/talktastic/reviews)
- [MacWhisper Dictation](https://macwhisper.helpscoutdocs.com/article/14-how-to-use-the-dictation-feature)
- [MacWhisper Product Hunt 评论](https://www.producthunt.com/products/macwhisper/reviews)
- [LazyTyper 官网](https://lazytyper.com/)
- [LazyTyper Reddit 发布帖](https://www.reddit.com/r/macapps/comments/1mt8z2x/couldnt_find_a_good_free_voicecoding_tool_so_i/)
-679
View File
@@ -1,679 +0,0 @@
# OpenLess 架构设计文档(v1 / Demo 版)
更新时间:2026-04-26
适用范围:第一轮 demo 单云端 provider 实现
> 本文与已有产品文档的关系:
> - 产品需求以 `openless-requirements.md` 为准
> - 总体逻辑以 `openless-overall-logic.md` 为准
> - 工程模块拆分原型以 `openless-development.md` 为准
> - 本文是它们的**收紧版**:把 v1 的范围、模块边界、依赖图、扩展点固化下来,作为后续代码骨架的唯一来源
---
## 1. 设计目标与非目标
### 1.1 v1 必须做到
1. 用户在任意 macOS app 的输入框按住快捷键说话,松开后文字出现在光标处。
2. 录音、识别、润色、插入、兜底(剪贴板)、历史,端到端打通。
3. 底部状态胶囊(图片同款视觉)覆盖 listening / processing / inserted / cancelled / copied / error 六种状态。
4. 设置页可填火山引擎 ASR 凭据和 Ark API Key,Keychain 持久化。
5. 4 个润色模式(原文 / 轻度 / 清晰结构 / 正式)通过菜单栏快速切换。
6. 历史可查看原始转写和最终文本,可复制、可重跑。
### 1.2 v1 明确不做
- 多 ASR / 多 LLM provider 抽象层(只对接火山引擎一家)
- 本地 ASR、FoundationModels 端侧润色
- BYOK 路由(实质上设置页填的就是用户自己的 key,但 UI 不暴露 provider 切换)
- per-app 风格、语音命令、批量文件转写、截屏上下文、团队共享
- macOS App Store 分发(开源 + Direct distribution)
### 1.3 设计原则
| 原则 | 在本架构中的体现 |
|---|---|
| KISS | 单 provider 直连,不写 ASRBackend / PolishProvider 协议族 |
| 留扩展位置而非抽象层 | 火山引擎相关代码放在 `Networking/Volcengine/` 子目录,未来加 provider 是新增同级目录而不是重构 |
| 纵向切片 | 每个 Swift Package 模块对应一个职责,不为了"层"而拆 |
| 失败不丢内容 | 任何环节失败,原始转写 + 最终文本都进历史 |
| 隐私默认安全 | 默认不存音频;Key 只进 Keychain,不进 UserDefaults / 配置文件 |
---
## 2. 总体架构
### 2.1 分层与依赖方向
```
┌──────────────────────────────────────────────────────────────┐
│ App Target (OpenLess.app) │
│ - AppDelegate / MenuBarController │
│ - DictationCoordinator (主用例编排) │
│ - 依赖注入装配(Container) │
└──────┬───────────┬────────────┬────────────┬────────────┬───┘
│ │ │ │ │
▼ ▼ ▼ ▼ ▼
┌────────┐ ┌──────────┐ ┌─────────┐ ┌──────────┐ ┌──────────┐
│ Core │ │ Recorder │ │ ASR │ │ Polish │ │Insertion │
│(types) │ │(AVAudio) │ │ (WS) │ │(HTTP) │ │(AX/CG) │
└────┬───┘ └────┬─────┘ └────┬────┘ └────┬─────┘ └────┬─────┘
│ │ │ │ │
│ └────────────┴────────────┴────────────┘
│ │
▼ ▼
┌──────────────┐ ┌──────────────┐
│ Persistence │ │ AppKit/ │
│ (GRDB+Key- │ │ SwiftUI │
│ chain) │ │ 组件库 (UI) │
└──────────────┘ └──────────────┘
```
依赖规则:
- 上层可以依赖下层;同层互不依赖;下层永远不依赖上层。
- `Core` 是叶子模块,所有 Swift Package 都可以依赖它。
- App target 是唯一允许把所有模块组装起来的地方。
### 2.2 Swift Package 模块划分
```
OpenLess/
├── OpenLess.xcodeproj # App target
├── App/ # App 源码(.app 入口)
│ ├── OpenLessApp.swift # @main, AppDelegate
│ ├── AppContainer.swift # 依赖装配
│ ├── MenuBarController.swift # 菜单栏图标 + 菜单
│ ├── DictationCoordinator.swift # 主流程编排
│ └── Settings/ # 设置窗口(SwiftUI)
└── Packages/ # 本地 Swift Package
├── OpenLessCore/ # 类型、状态机、错误码
├── OpenLessRecorder/ # AVFoundation 录音
├── OpenLessASR/ # 火山引擎流式 ASR 客户端
├── OpenLessPolish/ # Ark Doubao 润色客户端
├── OpenLessInsertion/ # 插入 + 剪贴板兜底
├── OpenLessPersistence/ # SQLite 历史/词典 + Keychain
└── OpenLessUI/ # 胶囊视图 + 设置页通用组件
```
每个 Package 一个 `Package.swift`,`OpenLess` 主工程通过 local path 依赖它们。
### 2.3 模块职责一句话
| Package | 职责 | 不负责什么 |
|---|---|---|
| `OpenLessCore` | 共享类型(`PolishMode`、`DictationSession`、错误枚举)、`SessionStateMachine` | 任何 IO |
| `OpenLessRecorder` | 麦克风采集、音量采样、PCM chunk 推送、按需停止 | 转写、UI |
| `OpenLessASR` | 与火山 WebSocket 协议通信(鉴权头、二进制帧、gzip、partial/final 结果) | 录音采集、润色 |
| `OpenLessPolish` | Ark Chat Completions 调用、4 模式 prompt 模板、错误降级到原文 | 转写、UI |
| `OpenLessInsertion` | AX API 拼接到当前焦点、CGEvent 模拟粘贴、剪贴板兜底、`focus_lost` 检测 | 历史保存 |
| `OpenLessPersistence` | GRDB SQLite(历史、词典、片段)、Keychain(API key、access token) | UI |
| `OpenLessUI` | 胶囊视图(macOS 26 用 `.glassEffect()`,macOS 15 用 `NSVisualEffectView`)、设置页组件 | 业务流程 |
---
## 3. 核心数据流
### 3.1 正常输入流程(端到端)
```
用户 App Recorder ASR Polish Inserter
│ 按下快捷键 │ │ │ │ │
│ ───────────────▶ │ │ │ │ │
│ │ Coordinator.start() │ │ │ │
│ │ ─────────────────────────▶ │ │ │ │
│ │ │ AVAudio开 │ │ │
│ │ Capsule.show(.listening) │ │ │ │
│ │ ASR.openSession() │ │ │ │
│ │ ──────────────────────────────────────────▶│ WS 握手 + 鉴权 │ │
│ │ │ 100-200ms │ │ │
│ │ │ PCM chunk──▶│ 二进制帧+gzip │ │
│ │ │ │ │ │
│ 说话… │ AudioLevel更新胶囊白条 │ │ │ │
│ │ partial result(definite=F) ◀────────────│ │ │
│ │ (丢弃,不显示给用户) │ │ │ │
│ 松开快捷键 │ │ │ │ │
│ ───────────────▶ │ │ │ │ │
│ │ Recorder.stop() │ │ │ │
│ │ ─────────────────────────▶ │ │ │ │
│ │ ASR.sendLastFrame() │ │ │ │
│ │ Capsule.set(.processing) │ │ │ │
│ │ final result(definite=T) ◀────────────│ │ │
│ │ │ │ │ │
│ │ Polish.polish(raw, mode) │ │ │ │
│ │ ───────────────────────────────────────────────────────────▶ │ Ark POST │
│ │ finalText ◀───────────────────────────────│ │
│ │ │ │ │ │
│ │ Inserter.insert(finalText) │ │ │ │
│ │ ──────────────────────────────────────────────────────────────────────────▶│
│ │ insert OK ◀──────────────────────────────────────────────│
│ │ │ │ │ │
│ │ Persistence.save(session) │ │ │ │
│ │ Capsule.set(.inserted)→hide │ │ │ │
│ │ │ │ │ │
```
### 3.2 状态机(顶层 Session)
```
┌────────────┐
│ .idle │
└─────┬──────┘
│ hotkey down
▼
┌────────────┐ esc / cancel ┌────────────┐
│ .listening │ ────────────────▶│.cancelled │ → .idle
└─────┬──────┘ └────────────┘
│ hotkey up
▼
┌─────────────┐ asr error ┌────────────┐
│.transcribing│ ───────────────▶│ .failed │ → .idle
└─────┬───────┘ └────────────┘
│ final transcript
▼
┌────────────┐ polish error ┌────────────┐
│ .polishing │ ───────────────▶│ .insertRaw │
└─────┬──────┘ (兜底原文) └────┬───────┘
│ final text │
▼ │
┌────────────┐ │
│ .inserting │ ◀─────────────────── │
└─────┬──────┘
┌─────────┴──────────┐
insert ok│ │ insert fail
▼ ▼
┌──────────┐ ┌──────────────┐
│.inserted │ │.copiedFallback│
└────┬─────┘ └──────┬────────┘
│ │
└──────────┬───────────┘
▼
.idle
```
实现:`OpenLessCore.SessionStateMachine` 是 `@Observable` 类,所有状态改变只有它能做;其他模块只能调它的 `transition(to:)`,并被卫语句拦截非法迁移。
---
## 4. 关键模块接口
> 这里只列**模块对外暴露的最小 API**,不列内部实现细节。
### 4.1 OpenLessCore
```swift
public enum PolishMode: String, CaseIterable, Codable {
case raw, light, structured, formal
}
public struct RawTranscript: Sendable {
public let text: String
public let language: String? // "zh", "en", "mixed"
public let durationMs: Int
}
public struct FinalText: Sendable {
public let text: String
public let mode: PolishMode
}
public struct DictationSession: Identifiable, Codable, Sendable {
public let id: UUID
public let createdAt: Date
public let raw: String
public let final: String
public let mode: PolishMode
public let appBundleId: String?
public let appName: String?
public let insertStatus: InsertStatus
public let errorCode: String?
}
public enum InsertStatus: String, Codable, Sendable {
case inserted, copiedFallback, failed
}
public enum DictationError: Error, Sendable {
case micPermissionMissing
case accessibilityMissing
case asrFailed(String)
case polishFailed(String)
case networkUnavailable
case credentialsMissing
}
@MainActor
public final class SessionStateMachine: ObservableObject {
@Published public private(set) var state: SessionState = .idle
public func transition(to next: SessionState) { /* 卫语句 */ }
}
```
### 4.2 OpenLessRecorder
```swift
public protocol AudioConsumer: AnyObject, Sendable {
/// 16kHz / 16-bit PCM,单声道;100-200ms 一包
func consume(pcmChunk: Data)
}
public final class Recorder {
public init() throws
public func start(consumer: AudioConsumer) async throws
public func stop() async // 触发最后一个 chunk
public func cancel() async // 不发送最后一个 chunk
public var levelStream: AsyncStream<Float> { get } // 0...1,胶囊白条用
}
```
### 4.3 OpenLessASR(仅火山实现)
```swift
public protocol VolcengineCredentials: Sendable {
var appKey: String { get }
var accessKey: String { get }
var resourceId: String { get } // 通常 "volc.bigasr.sauc.duration"
}
public final class VolcengineStreamingASR: AudioConsumer {
public init(credentials: VolcengineCredentials)
public func openSession() async throws
public func consume(pcmChunk: Data) // AudioConsumer 实现
public func sendLastFrame() async throws
public func awaitFinalResult() async throws -> RawTranscript
public func cancelSession() async
}
```
火山协议要点(实现内部,对外不暴露):
- WSS:`wss://openspeech.bytedance.com/api/v3/sauc/bigmodel_async`
- 鉴权 header:`X-Api-App-Key` / `X-Api-Access-Key` / `X-Api-Resource-Id` / `X-Api-Connect-Id`(UUID)
- 二进制帧:4 字节 header + 4 字节大端 payload size + payload
- Header 字段:Protocol Version `0b0001` / Header Size `0b0001` / Message Type(请求 `0b0001` / 音频 `0b0010` / 响应 `0b1001`)/ Serialization `0b0001`(JSON)
- Payload 用 gzip 压缩
- 第一帧:full client request(音频参数 + `model_name: "bigmodel"` + `enable_itn` + `enable_punc`)
- 后续帧:每 100-200ms 一包 PCM
- 最后一包:flags `0x02`
- 响应 utterances 中 `definite: true` 才是最终结果
### 4.4 OpenLessPolish(仅 Ark Doubao 实现)
```swift
public struct ArkCredentials: Sendable {
public let apiKey: String
public let modelId: String // 默认 "deepseek-v3-2",可改 doubao-seed-1-6 等
public let endpoint: URL // 默认 https://ark.cn-beijing.volces.com/api/v3/chat/completions
}
public struct PolishContext: Sendable {
public let appBundleId: String?
public let appName: String?
}
public final class DoubaoPolishClient {
public init(credentials: ArkCredentials)
public func polish(
raw: RawTranscript,
mode: PolishMode,
context: PolishContext
) async throws -> FinalText
}
```
Prompt 模板(mode → system prompt)放在模块内的 `PolishPromptTemplates.swift`,与 `openless-development.md §7.3` 保持一致。
性能要点(实现时严格遵守):
- HTTP 请求 `stream: true`,用 SSE 解析;TTFT 推进胶囊状态,但**插入仍等完整 finalText 一次性 AX 写入**(避免 stream-replace 在中文 IME / 富文本 app 的副作用)
- 默认选**非推理模型**(DeepSeek V3.2 / doubao-seed-1-6 / doubao-1-5-lite),润色任务无需 reasoning,省 1–3 秒
- system prompt 极简(≤80 字,4 模式只换其中一句指令)
### 4.5 OpenLessInsertion
```swift
public final class TextInserter {
public init()
public func insert(_ text: String) async throws -> InsertResult
}
public enum InsertResult: Sendable {
case inserted
case copiedFallback(reason: FallbackReason)
}
public enum FallbackReason: String, Sendable {
case focusLost, accessibilityBlocked, unknown
}
```
实现策略(按顺序尝试,最简两步):
1. AX API 找到 focused element,通过 `kAXValueAttribute` 替换 / 追加文本
2. 失败 → `NSPasteboard.general` 写入 + 通过 `CGEvent` 模拟 `Cmd+V`(需要辅助功能权限)
3. 仍失败 → 仅复制,返回 `copiedFallback`
### 4.6 OpenLessPersistence
```swift
public final class HistoryStore {
public init() throws // 自动创建 SQLite 表
public func save(_ session: DictationSession) async throws
public func recent(limit: Int) async throws -> [DictationSession]
public func clear() async throws
}
public final class CredentialsVault {
public init()
public func saveVolcengine(_ creds: VolcengineCredentials) throws
public func loadVolcengine() throws -> VolcengineCredentials?
public func saveArk(_ creds: ArkCredentials) throws
public func loadArk() throws -> ArkCredentials?
}
```
Keychain service 名:`com.openless.app`,account 区分用 `volcengine.app_key`、`volcengine.access_key`、`ark.api_key`。
### 4.7 OpenLessUI
```swift
public struct CapsuleOverlay: View {
public init(state: CapsuleState, audioLevel: Float)
}
public enum CapsuleState: Equatable, Sendable {
case hidden
case listening // 叉号 + 动态白条 + 勾号
case processing // 整理中
case inserted // 勾号高亮
case cancelled // 叉号高亮
case copied // "已复制"
case error(String) // 红点
}
```
视觉:
- macOS 26:`.glassEffect(.regular.interactive(), in: .capsule)`
- macOS 15:`NSVisualEffectView` material `.hudWindow` + manual blur background
- 通过 `if #available(macOS 26.0, *)` 二选一,封装在 `OpenLessUI` 内的 `GlassBackground` 私有视图
底部胶囊浮窗(NSWindow):
- `level = .statusBar`、`isMovable = false`、`backgroundColor = .clear`、`hasShadow = false`
- `collectionBehavior = [.canJoinAllSpaces, .stationary, .ignoresCycle]`
- 不抢焦点(`canBecomeKey = false`)
- 屏幕底部居中,距底边 24px
- 多屏:跟随 `NSScreen.main`(活跃 app 所在屏),监听 `NSWindow.didChangeScreenNotification`
---
## 5. 数据持久化
### 5.1 SQLite Schema(GRDB)
```sql
CREATE TABLE dictation_session (
id TEXT PRIMARY KEY,
created_at INTEGER NOT NULL,
raw_transcript TEXT NOT NULL,
final_text TEXT NOT NULL,
mode TEXT NOT NULL,
app_bundle_id TEXT,
app_name TEXT,
insert_status TEXT NOT NULL,
error_code TEXT
);
CREATE INDEX idx_session_created_at ON dictation_session(created_at DESC);
-- 词典 / 片段 v1 暂不实现,但表结构预留位置
CREATE TABLE dict_entry (
id TEXT PRIMARY KEY,
phrase TEXT NOT NULL,
category TEXT NOT NULL DEFAULT 'custom',
notes TEXT NOT NULL DEFAULT '',
enabled INTEGER NOT NULL DEFAULT 1,
source TEXT NOT NULL DEFAULT 'manual',
created_at INTEGER NOT NULL
);
CREATE TABLE snippet (
id TEXT PRIMARY KEY,
trigger_phrase TEXT NOT NULL UNIQUE,
content TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1
);
```
存储路径:`~/Library/Application Support/OpenLess/openless.sqlite`
### 5.2 用户偏好(UserDefaults)
非敏感字段:
- `hotkey_record`(默认 `fn`,可改)
- `default_mode`(默认 `light`)
- `history_retention_days`(默认 `30`)
- `save_audio`(默认 `false`,且 v1 不实现)
- `last_used_app_bundle_id`
### 5.3 凭据(Keychain)
| account | 内容 |
|---|---|
| `volcengine.app_key` | X-Api-App-Key |
| `volcengine.access_key` | X-Api-Access-Key |
| `volcengine.resource_id` | 默认 `volc.bigasr.sauc.duration`,允许覆盖 |
| `ark.api_key` | Ark Bearer token |
| `ark.model_id` | 默认 `doubao-seed-1-6`,允许覆盖 |
| `ark.endpoint` | 默认 `https://ark.cn-beijing.volces.com/api/v3/chat/completions` |
---
## 6. UI 规格落地
### 6.1 胶囊状态对应视觉
| 状态 | 中部内容 | 宽度 | 动效 |
|---|---|---|---|
| `.listening` | 5 根白色音量条,跟随 `Recorder.levelStream` | 160px | spring 出现 |
| `.processing` | 3 根条静止 + 缓慢明灭,或 spinner | 180px | width spring 变宽 |
| `.inserted` | 中部隐藏,右侧勾号 0.4s 高亮 | 140px | 0.8s 后淡出 |
| `.cancelled` | 中部隐藏,左侧叉号 0.4s 高亮 | 140px | 快速淡出 |
| `.copied` | 文字"已复制" | 180px | 保留 2s |
| `.error(msg)` | 红点 + 短文案,点击展开详情 | 200px | 不自动消失 |
参考用户提供的截图:深色 graphite 胶囊,中央青绿色微光跳动条,左叉右勾。
### 6.2 设置窗口(SwiftUI `Form`)
5 个分页(左侧 sidebar):
1. **概览**:当前快捷键、麦克风状态、API 凭据是否齐、最近一次成功输入时间
2. **凭据**:填火山 ASR 三件套 + Ark API Key / Model ID
3. **快捷键**:录音键、取消键、粘贴上一条键(用 `KeyboardShortcuts` 库)
4. **历史**:表格,列:时间 / app / 最终文本 / 操作(复制 / 重跑 / 删除)
5. **隐私**:清空历史、关闭历史保存的开关
### 6.3 菜单栏
图标:SF Symbol `mic.circle`(普通)/ `mic.fill`(录音中)
菜单项:
- 当前模式:· 原文 · 轻度润色 · 清晰结构 · 正式表达(单选)
- ────
- 打开设置…
- 查看历史…
- ────
- 退出 OpenLess
---
## 7. 全局快捷键
依赖 [`KeyboardShortcuts`](https://github.com/sindresorhus/KeyboardShortcuts) 库(开源、维护活跃、API 简洁)。
默认绑定:
- 录音:`fn`(按住)
- 取消:`Esc`(仅在 listening 状态注册)
- 粘贴上一条:`⌥⌘V`
录音键采用 `onKeyDown` + `onKeyUp` 实现 push-to-talk;toggle 模式延后实现。
---
## 8. 权限与首次启动引导
启动时按顺序检查(`PermissionController`):
| 权限 | 检查 API | 缺失时引导 |
|---|---|---|
| 麦克风 | `AVCaptureDevice.authorizationStatus(for: .audio)` | 弹出系统授权 → 引导到「系统设置 → 隐私与安全 → 麦克风」 |
| 辅助功能 | `AXIsProcessTrustedWithOptions` | 弹引导卡,按钮直接打开「隐私与安全 → 辅助功能」 |
| 输入监控 | `IOHIDCheckAccess(.eventTap)` | 同上,「输入监控」面板 |
| API 凭据 | `CredentialsVault.loadVolcengine() && loadArk()` | 跳转设置页凭据分页 |
任一未通过 → 菜单栏图标显示警告徽章 + 主流程拒绝执行(胶囊弹"需补全权限")。
---
## 9. 错误降级策略
| 失败点 | 降级路径 |
|---|---|
| 麦克风权限缺 | 不进 listening,胶囊弹引导文案 |
| WS 握手失败(鉴权错) | 胶囊红点"识别凭据无效,请检查设置" |
| WS 中途断开 | 重试一次;仍失败 → 胶囊红点"识别失败",原始转写丢弃 |
| ASR 成功 + 润色失败 | 直接插入原始转写 + 胶囊"已插入原文" |
| 插入失败(focus_lost / AX block) | 复制到剪贴板 + 胶囊"已复制" |
| 网络完全不可用 | 胶囊"网络不可用",结束流程 |
录音 PCM 在 ASR 失败前**保留在内存**直到流程结束(用户可"重试上一次录音",v2 实现)。v1 失败即丢,但历史里写一条 `error_code` 记录。
---
## 10. 扩展点(为"加竞品功能"预留)
> 这部分**不在 v1 实现范围**,但目录、命名、协议要为它们留位置,避免未来重构。
| 未来要加的功能 | 改哪里 | 是否需要重构现有代码 |
|---|---|---|
| 加新 ASR provider(Deepgram / 讯飞 / Apple SpeechAnalyzer) | 在 `OpenLessASR/` 下加 `Deepgram/` 子目录,新增 client 类;提一个最小协议 `ASRClient`,让 `Coordinator` 持有协议而非具体类 | **半重构**:Coordinator 改 1 处依赖类型 |
| 加新 LLM provider(Claude / GPT / Gemini) | 同上,`OpenLessPolish/` 下加子目录;提 `PolishClient` 协议 | 半重构 |
| 用户自定义润色模式 | `PolishMode` 加 `.custom(id, name, prompt)`;模板引擎从内置常量改为 dict lookup;设置页加自定义 mode 编辑器 | 不动其它模块 |
| per-app 风格规则 | `PolishContext` 已带 `appBundleId`,加 `AppStyleRule` 表 + Polish 调用前 hook 注入 prompt 前缀 | 不动 ASR / 录音 |
| 语音命令("new line"、"delete that") | 在 `OpenLessASR` 输出与 `OpenLessPolish` 之间插一层 `VoiceCommandInterpreter` | 不动其它模块 |
| 个人词典提升识别准确率 | `Recorder` 启动 ASR 时把词典作为 hot words 传入;ASR full client request 加 `hot_words` 字段 | ASR client 加字段 |
| 截屏上下文 | `PolishContext` 加可选 `screenshot: Data?`;调用方决定是否传 | 走多模态模型 |
| 同步 / 团队词典 | `Persistence` 加 `RemoteSyncAdapter` 协议;CloudKit 或自建后端 | 不动业务逻辑 |
| 批量文件转写 | 新增 `OpenLessBatch` 模块复用 `OpenLessASR`;新窗口 + 队列 | 不动主流程 |
| iOS 键盘 | 不在 macOS 工程里做;新建 iOS app + Custom Keyboard Extension,复用 `OpenLessCore` / `OpenLessASR` / `OpenLessPolish` | 0 |
**核心保证**:上面 10 条里有 7 条是「新增模块或新增字段」,3 条是「半重构」,没有一条需要改动 Recorder / Inserter / Persistence / UI 主结构。这就是"良好扩展性"的具体定义。
---
## 11. macOS 版本兼容(双轨)
最低支持 macOS 15.0;macOS 26.0 启用增强体验。
| 能力 | macOS 15 | macOS 26 |
|---|---|---|
| 胶囊背景 | `NSVisualEffectView` material `.hudWindow` | `.glassEffect(.regular.interactive(), in: .capsule)` |
| 设置页材质 | 默认 SwiftUI form | `.glassEffect()` 包装 |
| 胶囊状态切换动画 | `withAnimation(.spring())` | `withAnimation(.spring())` + `glassEffectID` 实现 morph |
| 端侧润色(v2) | 不可用 | `FoundationModels.SystemLanguageModel` |
封装:所有版本分支只在 `OpenLessUI` 内部,不污染业务模块。
---
## 12. 第三方依赖
> 严格控制外部依赖。每加一个都要列理由。
| 依赖 | 用途 | 替代方案? | 决策 |
|---|---|---|---|
| `KeyboardShortcuts` (sindresorhus) | 全局快捷键录入 + 持久化 | 自己写 Carbon RegisterEventHotKey | 用,省 200 行 |
| `GRDB.swift` | SQLite ORM | SwiftData (要 macOS 14+,其实 OK;但 GRDB 更稳) | 用 GRDB |
| `swift-log` | 结构化日志 | `os.Logger` | 用 `os.Logger`(系统自带,不引入) |
**不用**:Alamofire、SnapKit、RxSwift、Sparkle(v1 暂不做自动更新)。
---
## 13. 安全与隐私
- **API Key 全部进 Keychain**,绝不写 `UserDefaults` / `~/Library/Preferences/*.plist` / 工程内常量
- **音频默认不落盘**:Recorder 在 `Data` 缓冲区里持有 PCM,stop 后释放
- **历史只存文本**:`raw_transcript` + `final_text`,不存音频文件路径
- **网络请求最小化日志**:URL / 状态码 / 错误码 OK,但 request body / response body 仅 DEBUG 构建打印
- **崩溃日志**:仅本地 `~/Library/Logs/OpenLess/`,不上传
---
## 14. 测试策略
v1 测试范围:
| 类型 | 覆盖 | 工具 |
|---|---|---|
| 单元测试 | `OpenLessCore` 状态机迁移、`OpenLessASR` 二进制帧编解码、`OpenLessPolish` prompt 拼装 | Swift Testing |
| 集成测试 | `VolcengineStreamingASR.openSession()` 用 mock WebSocket(先验证协议帧正确,无需真凭据);`DoubaoPolishClient` 用录制的固定 fixture | Swift Testing |
| 手工 UAT | 5 个常用 app 跑通端到端:TextEdit / Notes / 微信 / Cursor / ChatGPT 网页 | 人工 |
**不**做 UI snapshot test、E2E 自动化(v1 投产成本不划算)。
---
## 15. 落地路线(开发顺序)
按可独立验证的最小步骤拆:
| # | 步骤 | 验证方式 |
|---|---|---|
| 1 | Xcode 工程 + 7 个 Package 骨架 + App target 能跑出空菜单栏图标 | `Cmd+R` 看到菜单栏图标 |
| 2 | 设置窗口 + 凭据分页 + Keychain 存读 | 填一组假 key,重启 app 能读出来 |
| 3 | 全局快捷键(仅 log,不录音) | 按 `fn` 控制台打印「pressed / released」 |
| 4 | 麦克风录音 + AVAudio 16kHz/16bit/mono PCM | `dump WAV` 调试方法导出能播放 |
| 5 | 火山 ASR WS 协议(先打通鉴权 + 一次小录音的 finalText) | 终端能看到 final transcript |
| 6 | Doubao 润色(一个 mode) | console 看到 4 模式的 finalText |
| 7 | 胶囊浮窗 + 状态机串起 1-6 步 | 屏幕底部能看到胶囊变化 |
| 8 | 文本插入到 TextEdit + 剪贴板兜底 | TextEdit 收到字 |
| 9 | 历史持久化 + 设置页历史分页 | 设置页能看到 3 条记录 |
| 10 | 4 模式菜单栏切换 + per-mode prompt | 同一段语音切换模式输出不同 |
| 11 | 错误降级 + 胶囊错误状态 | 拔网络/改错 key 看胶囊红点 |
| 12 | 5 app UAT | 中文/英文/中英混输各 3 句 |
每一步必须能跑(pass)才进下一步。如果某步阻塞超过预算,回退到「v1 不做」清单或单独提出。
---
## 16. 不在本文范围
- 完整 prompt 内容(参考 `openless-development.md §7`)
- 4 个模式具体的 prompt 文本(实现时与产品文档逐句对齐)
- 历史 UI 的复杂筛选 / 搜索(v2)
- 自动更新机制(v2 接 Sparkle)
- 多语言 UI(v1 仅中文)
---
## 17. 决策记录
| 决策 | 备选 | 选择理由 |
|---|---|---|
| 单火山 provider 直连,不抽象 | 多 provider 协议族 | KISS,加新 provider 时再提抽象不晚 |
| 默认 LLM `deepseek-v3-2`(火山 Ark) | doubao-seed-1-6 / 推理模型 | 轻便、非推理、Ark 兼容;用户可在设置页改 |
| LLM `stream: true` 但等完整文本插入 | streaming + 边收边插 | 保留低延迟胶囊反馈,规避 stream-replace 副作用 |
| WSS 流式 ASR | HTTP 一次性 | 用户指定;松开快捷键到出文字延迟更短 |
| API Key 进 Keychain | .env / config | 开源仓库零泄露风险;UI 即引导,符合产品定位 |
| 最低 macOS 15 + 26 增强 | 仅 macOS 26 | 26 装机量小,开源软件需要更宽用户群 |
| Direct distribution | MAS | 沙盒不允许必要的 AX / Input Monitoring 完整能力 |
| GRDB | SwiftData | macOS 15 SwiftData 可以用,但 GRDB API 稳定、查询表达力强 |
| `KeyboardShortcuts` 库 | 自实现 Carbon | 省时间、维护活跃 |
---
## 18. 与已有文档的去重
| 已有文档 | 与本文关系 |
|---|---|
| `openless-requirements.md` | 产品需求源头,本文不重复,仅引用 |
| `openless-overall-logic.md` | 业务逻辑源头,本文不重复 |
| `openless-development.md` | 工程模块原型;本文是其**v1 落地版**,遇到分歧以本文为准 |
| `voice-input-mvp-requirements.md` | 第一轮总需求;本文是其工程切片 |
| `competitor-reviews-and-ui-direction.md` | UI / 竞品调研;本文 §10 引用其作为扩展点设计依据 |
| `openless-product-concept-diagnosis.md` | 概念诊断;本文不引用 |
-444
View File
@@ -1,444 +0,0 @@
# OpenLess 开发文档
更新时间:2026-04-26
本文是 OpenLess 第一轮开发的工程落地文档。产品需求以 `openless-requirements.md` 为准,本文负责说明推荐技术形态、模块拆分、状态机、数据字段、开发顺序和验收方式。
---
## 1. 技术目标
第一轮要做的是一个 macOS 常驻语音输入工具:
> 用户在任意 app 的输入框中按住快捷键说话,OpenLess 录音、转写、轻度整理,然后把结果插入当前光标位置;失败时自动复制并保存历史。
工程目标:
- 能稳定常驻菜单栏。
- 能可靠获取麦克风录音。
- 能响应全局快捷键。
- 能显示底部微型状态胶囊。
- 能完成 ASR 转写和 LLM 润色。
- 能向当前输入位置插入文本。
- 能在失败时复制到剪贴板并保存历史。
- 能清楚处理权限、错误、隐私和本地/云端路径。
---
## 2. 推荐技术路线
### 2.1 首选:原生 macOS
建议第一轮使用:
- Swift / SwiftUI:设置页、历史页、主窗口。
- AppKit:菜单栏、全局窗口、底部胶囊浮层、辅助功能相关能力。
- AVFoundation:麦克风录音、音量采样。
- Carbon / Keyboard Shortcuts 方案:全局快捷键。
- Accessibility API:定位当前输入焦点、模拟输入或粘贴。
- NSPasteboard:剪贴板兜底。
- SQLite 或本地 JSON:历史、词典、设置。
- Keychain:保存 API Key。
理由:
- OpenLess 是系统级输入层,原生 macOS 更适合处理菜单栏、悬浮窗口、权限和全局快捷键。
- Electron/Tauri 可以做设置页,但系统输入、悬浮层和权限体验更容易变复杂。
### 2.2 可选混合路线
如果后续想快速做漂亮设置页,可以采用:
- 原生 Swift 后台核心 + WebView 设置页。
第一轮不建议直接用纯 Web/Electron 做核心录音和插入体验。
---
## 3. 模块拆分
| 模块 | 职责 |
|---|---|
| `AppShell` | 应用生命周期、菜单栏、启动项、窗口管理 |
| `PermissionManager` | 麦克风、辅助功能、通知等权限检查和引导 |
| `HotkeyManager` | 注册录音、取消、粘贴上一条等全局快捷键 |
| `Recorder` | 录音、音频缓存、音量采样、取消 |
| `AudioLevelMonitor` | 输出胶囊动态条所需的实时音量值 |
| `CapsuleOverlay` | 底部微型状态胶囊窗口 |
| `ASRRouter` | 本地、云端、BYOK 转写路由 |
| `PolishEngine` | 原文、轻度润色、清晰结构、正式表达 |
| `TextInserter` | 当前输入框插入、模拟粘贴、失败检测 |
| `ClipboardFallback` | 插入失败时复制最终文本 |
| `HistoryStore` | 保存原始转写、最终文本、状态、错误 |
| `DictionaryStore` | 个人词典 |
| `SnippetStore` | 常用片段 |
| `SettingsStore` | 快捷键、模式、隐私、模型路径 |
| `ErrorReporter` | 本地错误码、日志、用户可读提示 |
---
## 4. 核心流程
### 4.1 正常输入流程
```text
Hotkey down
-> PermissionManager.check()
-> Recorder.start()
-> CapsuleOverlay.show(listening)
-> AudioLevelMonitor.update()
Hotkey up
-> Recorder.stop()
-> CapsuleOverlay.set(processing)
-> ASRRouter.transcribe(audio)
-> PolishEngine.polish(rawTranscript, mode, context)
-> TextInserter.insert(finalText)
-> HistoryStore.save(session)
-> CapsuleOverlay.set(inserted)
-> CapsuleOverlay.hide()
```
### 4.2 插入失败流程
```text
TextInserter.insert(finalText) fails
-> ClipboardFallback.copy(finalText)
-> HistoryStore.save(session with copied_fallback)
-> CapsuleOverlay.set(copied_fallback)
```
用户不需要重新录音。
### 4.3 取消流程
```text
Esc or capsule x clicked
-> Recorder.cancel()
-> discard unsaved audio by default
-> CapsuleOverlay.set(cancelled)
-> CapsuleOverlay.hide()
```
### 4.4 重跑润色流程
```text
History item selected
-> choose another mode
-> PolishEngine.polish(rawTranscript, newMode, context)
-> update finalText
-> copy or insert
```
---
## 5. 状态机
### 5.1 应用状态
| 状态 | 含义 | 用户表现 |
|---|---|---|
| `app_ready` | 应用可用 | 菜单栏正常,胶囊隐藏 |
| `permission_required` | 缺少权限 | 设置页提示补权限 |
| `model_unavailable` | 模型或 API 不可用 | 提示切换路径或检查 key |
| `offline` | 网络不可用 | 云端不可用,本地仍可用 |
### 5.2 录音状态
| 状态 | 含义 | 胶囊表现 |
|---|---|---|
| `idle` | 未录音 | 隐藏或极淡小点 |
| `listening` | 正在录音 | 叉号 + 动态条 + 勾号 |
| `stopping` | 正在收尾 | 动态条停止,进入处理 |
| `cancelled` | 用户取消 | 叉号高亮后淡出 |
### 5.3 处理状态
| 状态 | 含义 | 胶囊表现 |
|---|---|---|
| `transcribing` | 正在转写 | “识别中”或 spinner |
| `polishing` | 正在润色 | “整理中”或 spinner |
| `ready_to_insert` | 结果生成 | 准备插入 |
| `failed` | 处理失败 | 红点或“失败”,点击展开详情 |
### 5.4 插入状态
| 状态 | 含义 | 胶囊表现 |
|---|---|---|
| `inserted` | 成功插入 | 勾号高亮后淡出 |
| `copied_fallback` | 插入失败但已复制 | “已复制” |
| `focus_lost` | 输入框失焦 | “已复制” |
| `permission_blocked` | 权限不足 | 引导打开辅助功能权限 |
---
## 6. 数据模型
### 6.1 `DictationSession`
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | string | 会话 ID |
| `created_at` | datetime | 创建时间 |
| `started_at` | datetime | 录音开始时间 |
| `ended_at` | datetime | 录音结束时间 |
| `duration_ms` | number | 录音时长 |
| `source_app_name` | string | 当前 app 名 |
| `source_bundle_id` | string | 当前 app bundle id |
| `target_context_type` | enum | `chat` / `email` / `doc` / `code` / `unknown` |
| `language_hint` | enum | `auto` / `zh` / `en` / `mixed` |
| `mode` | enum | `raw` / `light` / `structured` / `formal` |
| `model_route` | enum | `local` / `cloud` / `byok` |
| `raw_transcript` | text | 原始转写 |
| `final_text` | text | 最终输出 |
| `insert_status` | enum | `inserted` / `copied_fallback` / `failed` |
| `fallback_reason` | string/null | 插入失败原因 |
| `audio_saved` | boolean | 是否保存音频 |
| `audio_path` | string/null | 音频路径,默认 null |
| `error_code` | string/null | 错误码 |
### 6.2 `UserSettings`
| 字段 | 类型 | 说明 |
|---|---|---|
| `hotkey_record` | string | 录音快捷键 |
| `hotkey_paste_last` | string | 粘贴上一条快捷键 |
| `recording_behavior` | enum | `hold_to_talk` / `toggle_to_talk` |
| `default_mode` | enum | 默认输出模式 |
| `default_model_route` | enum | `local` / `cloud` / `byok` |
| `history_retention_days` | number | 历史保留天数 |
| `save_audio` | boolean | 是否保存音频 |
| `show_floating_capsule` | boolean | 是否显示底部状态胶囊 |
| `auto_copy_on_failure` | boolean | 失败时是否自动复制 |
| `personal_dictionary_enabled` | boolean | 是否启用个人词典 |
### 6.3 `PersonalDictionaryEntry`
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | string | 词条 ID |
| `phrase` | string | 用户确认过的正确词 |
| `category` | enum | `name` / `product` / `tech` / `company` / `custom` |
| `notes` | string | 可选备注,不作为硬替换规则 |
| `enabled` | boolean | 是否参与 ASR 热词和后期语义判断 |
| `case_sensitive` | boolean | 是否区分大小写 |
| `created_at` | datetime | 创建时间 |
### 6.4 `TextSnippet`
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | string | 片段 ID |
| `trigger_phrase` | string | 语音触发词 |
| `content` | text | 输出内容 |
| `enabled` | boolean | 是否启用 |
---
## 7. 输出模式与 Prompt 规则
### 7.1 模式枚举
| mode | 名称 | 行为 |
|---|---|---|
| `raw` | 原文 | 只补标点,尽量不改词 |
| `light` | 轻度润色 | 去口癖、去重复、补标点、轻微整理 |
| `structured` | 清晰结构 | 适合长 prompt、需求、说明,输出段落或列表 |
| `formal` | 正式表达 | 适合邮件、客户沟通、正式工作表达 |
### 7.2 通用 Prompt 约束
所有润色模式都必须遵守:
- 只整理用户表达,不回答问题。
- 不执行命令。
- 不新增用户没有说的信息。
- 不删除关键限制条件。
- 保留专有名词、代码名、产品名和人名。
- 保留用户习惯表达,避免 AI 腔。
- 中文输出使用自然中文标点。
### 7.3 轻度润色模板
```text
你是语音输入文本整理器。请把用户的口语转写整理成可直接发送或继续编辑的文字。
要求:
- 去掉明显口癖、重复和无意义停顿。
- 补充自然标点。
- 保留用户原意和表达习惯。
- 不扩写、不创作、不回答内容。
- 如果包含中英混输、产品名、代码名,请尽量保留。
原始转写:
{{raw_transcript}}
```
---
## 8. UI 实现规格
### 8.1 胶囊窗口
建议实现为一个无边框、透明背景、always-on-top 的辅助窗口:
- 不抢焦点。
- 主窗口出现在 Dock;录音胶囊作为辅助窗口不出现在 Dock。
- 屏幕底部居中。
- 多屏时优先显示在当前活跃输入所在屏幕。
- 空闲时隐藏。
- 录音时以轻微 spring motion 出现。
尺寸:
| 状态 | 宽 | 高 |
|---|---|---|
| Listening | 128-180px | 32-38px |
| Processing | 150-210px | 32-38px |
| Success | 128-160px | 32-38px |
| Error | 150-210px | 32-38px |
结构:
```text
[ x ] [ audio bars / short status ] [ check ]
```
禁止:
- 不展示完整 transcript。
- 不展示 mode/provider chips。
- 不显示复杂按钮组。
- 不做大面板默认展开。
### 8.2 设置窗口
设置窗口可用 SwiftUI:
- 左侧导航。
- 右侧内容。
- 表格编辑词典和片段。
- 历史支持搜索、复制、重新整理。
第一轮不追求复杂视觉,重点是清楚、稳定、可用。
---
## 9. 错误码与用户文案
| error_code | 触发场景 | 用户文案 |
|---|---|---|
| `mic_permission_missing` | 缺少麦克风权限 | 需要麦克风权限才能录音 |
| `accessibility_missing` | 缺少辅助功能权限 | 需要辅助功能权限,OpenLess 才能输入到当前 app |
| `focus_lost` | 输入框失焦 | 找不到输入位置,结果已复制 |
| `asr_failed` | 转写失败 | 这次没有识别成功,请重试 |
| `polish_failed` | 润色失败 | 整理失败,原始转写已保存 |
| `network_unavailable` | 云端不可用 | 网络不可用,可切换本地模式 |
| `model_unavailable` | 模型不可用 | 当前模型不可用,请检查设置 |
文案原则:
- 短。
- 直接。
- 说明用户下一步能做什么。
- 不用“释放生产力”“智能增强”等营销词。
---
## 10. 开发顺序
建议按以下顺序开发:
1. 创建 macOS 菜单栏应用骨架。
2. 实现设置窗口基础导航。
3. 实现权限检查和引导。
4. 实现全局快捷键。
5. 实现录音与音量采样。
6. 实现底部微型状态胶囊。
7. 接入最小 ASR 路径。
8. 接入轻度润色。
9. 实现文本插入。
10. 实现剪贴板兜底。
11. 实现历史记录。
12. 实现个人词典。
13. 实现模式切换。
14. 完成错误处理和隐私设置。
15. 做真实 app 场景测试。
第一轮开发原则:
- 先跑通主链路,再优化模型。
- 先保证不丢内容,再追求插入成功率。
- 先做好小胶囊,再做主窗口美化。
- 先做中文和中英混输验收,再扩展更多语言。
---
## 11. 测试清单
### 11.1 功能测试
- 首次启动能引导麦克风权限。
- 首次启动能引导辅助功能权限。
- 快捷键能开始录音。
- 松开快捷键能结束录音。
- 胶囊在录音时出现。
- 胶囊动态条随声音变化。
- `Esc` 能取消。
- 中文能识别。
- 英文能识别。
- 中英混输能识别。
- 润色结果能插入当前输入框。
- 插入失败时能自动复制。
- 历史能看到原文和最终文本。
- 个人词典能影响后续输出。
### 11.2 场景测试
至少测试这些 app:
- TextEdit。
- Notes。
- Safari/Chrome 网页输入框。
- ChatGPT 或 Claude 输入框。
- Cursor 输入框。
- 微信/飞书/Slack 类 IM。
- 邮件客户端。
### 11.3 质量测试
准备样例库,覆盖:
- 去口癖和重复。
- 中途改口。
- 中文标点。
- 中英混输。
- 技术词。
- 邮件表达。
- Prompt 结构化。
- 插入失败兜底。
---
## 12. 第一轮完成定义
满足以下条件才算第一轮完成:
- 主链路可以连续稳定使用。
- 常用 app 中可以完成输入。
- 失败不会丢内容。
- 胶囊 UI 足够小,不干扰工作流。
- 默认润色自然,不过度 AI 化。
- 历史和剪贴板可以恢复最近输入。
- 隐私路径清楚,本地/云端/BYOK 不混淆。
- 用户可以在 5 分钟内完成第一次成功输入。
---
## 13. 关联文档
- [OpenLess 产品需求文档](</Users/lvbaiqing/TRUE 开发/openless/docs/openless-requirements.md>)
- [语音输入产品第一轮需求文档](</Users/lvbaiqing/TRUE 开发/openless/docs/voice-input-mvp-requirements.md>)
- [OpenLess 竞品评论、产品基调与 UI 方向调研](</Users/lvbaiqing/TRUE 开发/openless/docs/competitor-reviews-and-ui-direction.md>)
- [OpenLess 概念版诊断与落地规格](</Users/lvbaiqing/TRUE 开发/openless/docs/openless-product-concept-diagnosis.md>)
- [OpenLess 整体逻辑梳理](</Users/lvbaiqing/TRUE 开发/openless/docs/openless-overall-logic.md>)
-303
View File
@@ -1,303 +0,0 @@
# OpenLess 整体逻辑梳理
更新时间:2026-04-26
## 1. 核心判断
OpenLess 要解决的不是“语音转文字”这个单点功能,而是:
> 用户在真实工作流里想表达一段内容时,不想被键盘速度、口语整理、格式修正、跨应用插入、隐私担忧打断。
所以产品的核心逻辑不是“录音 -> 转写”,而是:
> 说话 -> 理解说话意图 -> 整理成可用文字 -> 输入到原来的地方 -> 失败也不丢。
这决定了第一轮产品必须围绕“输入层”做,而不是做成聊天工具、会议工具或写作工具。
---
## 2. 产品定位
OpenLess 第一轮定位为:
> 本地优先、低打扰、可控润色的 macOS AI 语音输入层。
关键词:
- macOS 原生。
- 任意输入框。
- 快捷键触发。
- 底部微型状态胶囊。
- 中文和中英混输友好。
- 轻度润色,不代替用户写作。
- 插入失败自动复制。
- 本地优先,可选云增强。
- 支持买断,不强迫订阅。
---
## 3. 用户逻辑
### 3.1 目标用户
第一轮优先服务:
- AI 工具高频用户:ChatGPT、Claude、Cursor、Perplexity。
- 中文和中英混输用户。
- 工作 IM 高频用户:微信、飞书、Slack。
- 需要写邮件、需求、评论、prompt 的知识工作者。
- 对隐私和本地处理敏感的 Mac 用户。
### 3.2 用户真实痛点
| 痛点 | 用户状态 | 产品回应 |
|---|---|---|
| 想得比打得快 | 长 prompt、长消息打字慢 | 快捷键说话,自动整理 |
| 口语不能直接发 | 有口癖、重复、停顿 | 去口癖、补标点、轻度结构化 |
| AI 味太重 | 润色后不像自己 | 保留表达习惯,不强行升华 |
| 跨应用不稳定 | 失焦、插入失败 | 自动复制 + 历史找回 |
| 价格反感 | 不想为普通转写月付 | 本地买断 + 云能力可选 |
| 隐私焦虑 | 不知道是否上传 | 清楚标识本地/云端/BYOK |
| 中文不够好 | 标点、术语、中英混输错 | 中文优先验收,个人词典 |
---
## 4. 产品主链路
OpenLess 的主链路如下:
1. 用户把光标放在任意输入框。
2. 按住或按下全局快捷键。
3. 屏幕底部出现微型状态胶囊。
4. 麦克风开始录音,波形随声音变化。
5. 用户说完后松开或再次按下快捷键。
6. 系统完成语音识别。
7. 系统按当前模式进行轻度整理。
8. 结果插入到原输入框。
9. 如果插入失败,自动复制到剪贴板。
10. 本次输入进入本地历史,方便找回。
一句话:
> 用户只需要负责说,OpenLess 负责把它变成可以直接用的文字,并尽量放回原来的地方。
---
## 5. 功能逻辑
### 5.1 录音逻辑
第一轮推荐默认:
- `hold_to_talk`:按住说话,松开结束。
- 可选 `toggle_to_talk`:按一下开始,再按一下结束。
- `Esc` 取消。
- 录音状态必须非常明显,避免用户担心后台监听。
### 5.2 转写逻辑
转写路径分三类:
| 路径 | 适合用户 | 产品表现 |
|---|---|---|
| 本地 | 隐私敏感、买断用户 | 仅在本机处理,速度取决于设备 |
| 云端 | 追求准确率和速度 | 上传音频识别,需明确提示 |
| BYOK | 开发者、高级用户 | 用户自带 API Key,成本自理 |
UI 不要直接把模型名堆给用户,默认只表达:
- 私密。
- 快。
- 准。
### 5.3 润色逻辑
第一轮只做 4 个模式:
| 模式 | 目标 |
|---|---|
| 原文 | 尽量保留原句,只补标点 |
| 轻度润色 | 默认模式,去口癖、补标点、轻微整理 |
| 清晰结构 | 适合 prompt、需求、说明、步骤 |
| 正式表达 | 适合邮件、客户沟通、工作汇报 |
润色原则:
- 不强行扩写。
- 不替用户补不存在的信息。
- 不把所有内容改成 AI 腔。
- 保留用户常用词和表达习惯。
- 中文标点和分段要自然。
### 5.4 插入逻辑
插入优先级:
1. 直接输入到当前光标位置。
2. 如果失败,复制到剪贴板。
3. 如果剪贴板也失败,在历史中保留结果。
产品底线:
> 用户说过的话不能丢。
---
## 6. UI 逻辑
### 6.1 视觉锚点
OpenLess 的核心 UI 是:
> macOS 屏幕底部的微型状态胶囊。
它不是聊天框,也不是大窗口,也不应该像一整条输入栏。它只负责提醒用户“现在正在听 / 正在整理 / 已完成 / 已取消”,平时应该尽量小,只有状态变化时才轻微弹出。
### 6.2 胶囊结构
| 区域 | 内容 |
|---|---|
| 左侧 | 叉号,用于取消 |
| 中间 | 极短状态提示或 3-5 根动态白色音量条 |
| 右侧 | 勾号,用于确认/完成 |
### 6.3 状态变化
| 状态 | 表现 |
|---|---|
| Idle | 完全隐藏,或只保留极淡的小点 |
| Listening | 从底部弹出一个很小的胶囊,中间白色条形随声音轻微跳动 |
| Processing | 胶囊略微变宽,显示“整理中”或小 spinner |
| Inserted | 右侧勾号短暂高亮,随后淡出 |
| Cancelled | 左侧叉号短暂高亮,随后淡出 |
| Copied fallback | 胶囊短暂显示“已复制”,不展开大面板 |
| Error | 胶囊显示红色状态点,点击后才展开错误详情 |
动画基调:
- 轻。
- 稳。
- 原生。
- 不做夸张动效。
- 不像营销页。
- 不让用户怀疑它一直在听。
- 不默认展示转写全文。
- 不常驻占用屏幕空间。
- 只有用户操作或状态变化时才出现。
### 6.4 胶囊尺寸建议
第一轮默认尺寸应非常克制:
| 状态 | 宽度 | 高度 | 说明 |
|---|---|---|---|
| Idle | 0 或 8-12px 点状提示 | 0 或 8-12px | 默认推荐隐藏 |
| Listening | 128-180px | 32-38px | 只显示叉号、动态条、勾号 |
| Processing | 150-210px | 32-38px | 可显示 2-3 个字的状态 |
| Success | 128-160px | 32-38px | 勾号高亮后淡出 |
| Error | 150-210px | 32-38px | 不展开详情,点击再看 |
位置:
- 屏幕底部居中。
- 距离底边 18-28px。
- 视觉上像 macOS 系统提示,而不是产品主界面。
---
## 7. 数据逻辑
第一轮至少需要记录:
- 原始转写。
- 最终文本。
- 使用模式。
- 当前 app。
- 插入状态。
- 失败原因。
- 是否保存音频。
- 本地/云端/BYOK 路径。
历史只解决找回问题,不做内容管理系统。
音频默认不保存。用户主动开启后,才保存音频。
---
## 8. 商业逻辑
OpenLess 不应一开始做纯订阅。
推荐商业结构:
| 层级 | 内容 |
|---|---|
| 免费版 | 基础体验、有限额度、完整主流程 |
| 买断版 | 本地无限使用、基础润色、历史、词典 |
| Pro 订阅 | 云端高精度、高级润色、同步、团队能力 |
| BYOK | 用户自带 API Key,适合高级用户 |
商业判断:
- 用户愿意为“稳定输入层”付费。
- 用户不愿意为“普通 Whisper 套壳”长期订阅。
- 云端能力有持续成本,可以订阅。
- 本地能力更适合买断。
---
## 9. 技术逻辑
第一轮技术模块:
| 模块 | 职责 |
|---|---|
| Menu Bar App | 常驻、设置、状态 |
| Hotkey Manager | 全局快捷键 |
| Recorder | 麦克风录音、音量采样 |
| ASR Router | 本地/云端/BYOK 转写路由 |
| Polish Engine | 模式化润色 |
| Inserter | 当前输入框插入 |
| Clipboard Fallback | 插入失败兜底 |
| History Store | 本地历史 |
| Dictionary | 个人词典 |
| Settings | 权限、模式、隐私、模型 |
第一轮可行性高,但要控制复杂度:
- 不先做会议。
- 不先做多端同步。
- 不先做复杂 voice command。
- 不默认截图上下文。
- 不默认保存音频。
---
## 10. 第一轮落地顺序
建议按这个顺序做:
1. macOS 菜单栏应用骨架。
2. 权限引导:麦克风 + Accessibility。
3. 全局快捷键录音。
4. 底部微型状态胶囊 UI。
5. ASR 转写。
6. 轻度润色。
7. 当前输入框插入。
8. 插入失败复制兜底。
9. 历史记录。
10. 个人词典。
11. 设置页。
12. 模式切换。
优先级原则:
> 先保证用户每天能用,再增加高级能力。
---
## 11. 最终概念版
OpenLess 的概念版可以定义为:
> 一个 macOS 上的语音输入层。用户在任何 app 里按住快捷键说话,OpenLess 在底部显示录音状态,把语音整理成自然、清楚、不过度 AI 化的文字,并自动输入到当前光标位置。它本地优先、失败不丢、支持中文和中英混输,商业上以本地买断为基础,云端增强作为可选订阅。
-554
View File
@@ -1,554 +0,0 @@
# OpenLess 概念版诊断与落地规格
更新时间:2026-04-26
本文用于回答一个核心问题:**OpenLess 是否在解决真实痛点,而不是堆一组看起来很 AI 的功能?**
结论先放前面:
- 用户痛点不是“不会打字”,而是“从想法到可用文字之间摩擦太大”。
- 竞品已经证明语音输入有付费意愿,但用户对高价订阅、隐私不透明、失焦丢内容、AI 味过重非常敏感。
- OpenLess 的概念版应定位为:**本地优先、低打扰、可控润色的 macOS AI 语音输入层**,默认 UI 是底部微型状态胶囊。
- 第一轮必须先把“任意输入框里稳定说话、整理、插入、找回”做扎实,不要先做会议、复杂 agent、团队管理或大而全写作工具。
---
## 1. 三视角诊断
### 1.1 用户视角:痛点在哪里,如何解决?
目标用户不是所有人,而是这些高频输入人群:
| 用户类型 | 高频场景 | 核心痛点 | OpenLess 应提供的帮助 |
|---|---|---|---|
| AI 工具重度用户 | ChatGPT、Claude、Cursor、Perplexity | Prompt 想得快,打字慢,长 prompt 输入成本高 | 直接说出需求,自动整理成清楚的 prompt |
| 创作者/知识工作者 | 文档、备忘录、邮件、社媒草稿 | 想法稍纵即逝,打字会打断思路 | 把口语整理成自然段落,保留用户语气 |
| 远程办公用户 | Slack、微信、飞书、邮件 | 短消息频繁,手动输入碎片化 | 快速生成可发送的自然文本 |
| 开发者/产品经理 | Cursor、Issue、PR 评论、需求说明 | 中英混输、术语、人名、函数名容易错 | 个人词典 + 中英混输 + 轻度结构化 |
| 对隐私敏感的 Mac 用户 | 工作内容、客户信息、内部文档 | 不希望音频或文字默认上传 | 本地优先,明确显示本地/云端状态 |
真实痛点可以归纳为 7 类:
| 痛点 | 用户真实表达 | 竞品不足 | OpenLess 第一轮解法 | 是否真需求的判断方式 |
|---|---|---|---|---|
| 打字打断思考 | “我脑子里有一大段,但打出来就断了” | 很多工具只做逐字转写,仍需大量改写 | 录音后自动轻度润色、标点化、分段 | 用户是否愿意在 AI prompt、IM、邮件中一天使用 10 次以上 |
| 原始语音不可直接用 | “转出来像口水话,还得重写” | 只追求识别准确,不解决表达质量 | 默认输出“像自己认真打出来的文字” | 用户是否明显减少二次编辑 |
| AI 润色过度 | “它把我改得不像我了” | 部分工具太像 AI 写作助手,语气漂移 | 轻度润色作为默认,保留习惯词和表达节奏 | 用户是否愿意把结果直接发给熟人/同事 |
| 跨应用不稳定 | “有时没插进去,有时窗口失焦” | 系统权限、输入框兼容、剪贴板回退处理不足 | 插入失败时自动复制并保存历史,不丢内容 | 失败场景下用户是否还能立刻拿回结果 |
| 价格心理阻力 | “只是语音输入,为什么每月十几美元” | Typeless/Wispr/Superwhisper 都有订阅或高价 Pro 争议 | 本地基础能力可买断,云能力按需/订阅 | 用户是否接受为稳定、润色、隐私付费,而不是为“转写”付费 |
| 隐私不确定 | “它是不是一直在听?音频去哪了?” | 部分产品强调 AI,但录音状态和数据路径不够直观 | 明确 push-to-talk、状态灯、本地/云端标签、音频保存开关 | 用户是否能一句话说清“它什么时候听、数据去哪” |
| 中文和中英混输不够好 | “中文标点、英文术语、产品名容易错” | 很多竞品更偏英文语境 | 中文标点、本地词典、专有名词、中英混输作为核心验收 | 中文用户是否觉得它不是英文产品顺便支持中文 |
#### 需要排除的伪需求
以下内容看起来高级,但第一轮不应作为核心:
- “用语音控制整个电脑”:这是语音助手路线,不是输入工具路线。
- “自动帮我写完整文章”:这会把产品带向 AI 写作工具,偏离“输入层”。
- “会议录音转写总结”:这是 MacWhisper/Otter/Fireflies 的主战场。
- “复杂的工作流 agent”:用户现在要的是少打字,不是配置自动化系统。
- “几十种风格模板”:第一轮会增加认知负担,且不一定提高留存。
- “默认后台常驻监听”:会制造隐私焦虑,反而破坏信任。
判断一个需求是不是自嗨,要问四个问题:
1. 用户现在是否已经用替代方案绕着解决?
2. 不做这个功能,用户是否仍然能完成主要工作?
3. 做了这个功能,用户每天是否会重复使用?
4. 用户是否愿意为这个结果付费,而不是只觉得“挺酷”?
第一轮真正必须验证的不是“功能是否多”,而是:**用户是否会在真实工作流里自然按下快捷键,然后少打很多字。**
---
### 1.2 商业视角:买断还是订阅?项目能不能持续做?
#### 市场信号
从竞品和评论看,市场存在明确付费意愿:
- Typeless 在 App Store 强调 AI 语音输入、自动去口癖、去重复、自动格式化、不同 app 不同语气、个人词典和 100+ 语言,并提供 Pro 订阅。
- Wispr Flow 官方定价页显示有 14 天 Pro 试用、免费 Basic、Pro、团队、企业方案。
- Superwhisper 官方文档显示 Pro 有月付、年付和 lifetime 方案,Pro 解锁本地模型、custom modes、custom vocabulary 等能力。
- Reddit 上关于 dictation app 订阅价格的讨论很多,典型观点是:如果只是本地转写,用户不愿意长期订阅;如果包含稳定工作流、云能力、跨设备同步、持续模型优化,订阅才更容易被接受。
这说明用户不是不愿意付费,而是不愿意为“看起来已经被开源模型解决的普通转写”持续付费。
#### 建议的商业模式
OpenLess 不建议第一轮直接做纯订阅。更合适的是:
| 方案 | 内容 | 适合阶段 | 风险 |
|---|---|---|---|
| 免费基础版 | 本地基础转写、有限历史、基础模式 | 获客、建立口碑 | 如果免费额度太足,转化弱 |
| 一次性买断 | 本地模型、无限本地转写、基础润色、本地历史 | Indie/Mac 工具用户 | 需要控制云成本,不能承诺无限云能力 |
| 可选 Pro 订阅 | 云端高精度转写、跨设备同步、高级润色、团队词典 | 有稳定用户后 | 必须让用户感到订阅对应真实持续价值 |
| BYOK/自带 API Key | 用户自己填 OpenAI/Groq/其他 provider key | 早期高级用户、开发者 | 配置门槛高,不适合默认路径 |
| 团队版 | 共享词典、统一配置、合规、账单 | 后期 B2B | 第一轮不应投入过多 |
第一轮推荐定价方向:
- **免费版**:可体验完整主流程,但限制高级能力或每日额度。
- **个人买断版**:主打本地优先、无订阅压力,价格应明显低于竞品 lifetime。
- **可选订阅**:只绑定真实持续成本,例如云转写、云润色、同步和团队能力。
#### 这个项目能不能持续做下去?
可以,但必须守住单位经济模型:
| 成本项 | 是否持续发生 | 控制方式 |
|---|---|---|
| 本地 ASR 推理 | 否,主要消耗用户本机算力 | 本地模型买断可持续 |
| 云端 ASR | 是,按音频时长计费 | 免费额度限制、Pro 订阅、BYOK、用量包 |
| LLM 润色 | 是,按 token 计费 | 默认轻量模型、短上下文、失败回退、本地模板化 |
| macOS 签名/分发/更新 | 是,但可控 | 买断收入覆盖基础维护 |
| 客服与兼容适配 | 是,会随用户规模增长 | 优先做好少数核心场景,减少复杂平台 |
| 模型和词典优化 | 是 | 高价值用户订阅或版本升级覆盖 |
可持续的关键不是“卖语音转写”,而是卖这几个结果:
- 可靠地进入任何输入框。
- 把口语变成可直接用的文字。
- 不丢内容。
- 保留用户自己的表达习惯。
- 给隐私敏感用户一个可信路径。
如果 OpenLess 只是 Whisper 套壳,商业上很难成立;如果它是一个稳定、低打扰、会整理表达的 macOS 输入层,就有长期空间。
---
### 1.3 技术视角:产品形态、可行性和成本
#### 产品形态
OpenLess 第一轮应是一个 macOS 常驻输入工具:
- 菜单栏应用:负责状态、设置、权限、历史入口。
- 全局快捷键:负责开始/停止录音。
- 底部微型状态胶囊:负责录音状态、动态条、处理状态、取消和完成反馈。
- 设置页:负责快捷键、模式、模型路径、隐私、历史、词典。
- 历史页:负责找回原文、润色结果、复制、重新处理。
它不是聊天窗口,也不是文档编辑器。它应该像“系统级输入法附件”一样,平时不打扰,用时立刻出现。
#### 技术上能不能做出来?
可以。第一轮可分为 6 个技术能力:
| 能力 | 可行性 | 风险 |
|---|---|---|
| 全局快捷键和菜单栏常驻 | 高 | 快捷键冲突、权限提示 |
| 麦克风录音和音量波形 | 高 | 设备切换、权限失败 |
| 本地或云端 ASR | 高 | 中文准确率、长音频切片、延迟 |
| LLM 轻度润色 | 高 | 过度改写、上下文不足、成本 |
| 插入当前输入框 | 中高 | Accessibility 权限、不同 app 兼容、失焦 |
| 历史和失败恢复 | 高 | 隐私默认值、音频是否保存 |
第一轮建议采用“稳定优先”的技术路线:
- 默认 push-to-talk,不做一直监听。
- 录音结束后再出最终文本,不追求复杂实时协同编辑。
- 插入失败时复制到剪贴板,并在底部胶囊里明确提示。
- 音频默认不长期保存,除非用户打开调试或历史音频开关。
- 本地模型和云模型可二选一,但 UI 不暴露复杂模型名,只暴露“私密 / 快 / 准”。
#### 成本是否可接受?
成本可接受,但前提是不要把“无限云端能力”卖成低价买断。
根据 2026-04-26 查询到的公开信息,OpenAI 官方定价页显示 `gpt-4o-transcribe` 有按分钟计费的语音转写价格;Groq 也提供 Whisper 相关的语音转写服务价格。实际接入时必须重新复核,因为 API 价格会变化。
建议成本策略:
| 场景 | 默认路径 | 成本控制 |
|---|---|---|
| 免费用户 | 本地模型或小额云额度 | 限制每日分钟数/字数 |
| 买断用户 | 本地模型 + 用户可选 BYOK | 不承诺无限云 |
| Pro 用户 | 云端高精度 + 高级润色 | 订阅或用量包覆盖 |
| 开发者用户 | BYOK + 本地模型 | 降低平台成本 |
技术判断:**第一轮能做出来,成本也能控制,但必须从产品上把本地、云端、BYOK 三种路径说清楚。**
---
## 2. 概念版定义
### 2.1 一句话定义
**OpenLess 是一个本地优先、低打扰、可控润色的 macOS AI 语音输入层,让用户在任何输入框里说话,并得到像自己认真打出来的文字。**
### 2.2 不是做什么
OpenLess 第一轮不做:
- 会议纪要工具。
- 录音文件批量转写工具。
- AI 聊天助手。
- 全局语音控制系统。
- 团队知识库。
- 重型 prompt agent。
- 多平台大而全客户端。
### 2.3 第一轮必须成立的价值
第一轮只要证明 4 件事:
1. 用户能在任意常用 app 里快速开始说话。
2. 输出结果比原始口语更可用,但不像 AI 代写。
3. 插入失败、网络失败、模型失败时不丢内容。
4. 用户知道数据走本地还是云端,并能控制。
如果这 4 件事做不到,其他高级功能都没有意义。
---
## 3. 对齐方向
### 3.1 产品定位
OpenLess 的第一轮定位:
> macOS 上替代 Typeless/Wispr/Superwhisper 的轻量 AI 语音输入层,优先服务中文、中英混输、AI prompt、IM、邮件和开发者输入场景。
关键词:
- macOS 原生。
- 本地优先。
- 任意输入框。
- 轻度润色。
- 不丢内容。
- 可买断。
- 中文友好。
### 3.2 目标用户优先级
| 优先级 | 用户 | 是否第一轮服务 |
|---|---|---|
| P0 | Mac 上高频使用 AI 工具和 IM 的用户 | 是 |
| P0 | 中文/中英混输用户 | 是 |
| P1 | 开发者、产品经理、创作者 | 是 |
| P1 | 隐私敏感的本地模型用户 | 是,但功能要简化 |
| P2 | 团队管理员、企业采购 | 暂不主攻 |
| P2 | 会议纪要用户 | 暂不主攻 |
| P3 | 全平台用户 | 第一轮不主攻 |
### 3.3 北极星指标
第一轮不要只看安装量,要看这些指标:
- 首次成功输入率:新用户 5 分钟内能否完成第一次录音并插入。
- 日均触发次数:真实用户一天是否使用 10 次以上。
- 结果直接可用率:输出后用户是否少量编辑或不编辑。
- 插入失败恢复率:失败后是否仍能通过剪贴板/历史拿回结果。
- 次日留存:用户第二天是否继续用它替代键盘。
---
## 4. 落地并补充细节
### 4.1 交互细节
#### 首次启动
首次启动必须完成 4 件事:
1. 麦克风权限。
2. Accessibility 权限。
3. 快捷键设置。
4. 隐私路径选择:本地优先 / 云端增强 / 自带 API Key。
文案原则:不要讲太多技术术语,只解释用户为什么需要授权。
#### 主流程
标准输入流程:
1. 用户把光标放到任意输入框。
2. 按住或按下快捷键。
3. 底部微型状态胶囊从屏幕底部浮现。
4. 中间 3-5 根白色动态条随音量轻微变化。
5. 左侧显示叉号,右侧显示勾号,只保留极短状态提示。
6. 用户松开或再次按下快捷键。
7. 状态变为“整理中”。
8. 输出结果插入当前输入框。
9. 成功后右侧勾号短暂高亮,胶囊随后淡出。
#### 插入失败流程
如果当前输入框失焦或插入失败:
1. 结果自动复制到剪贴板。
2. 历史中保存原始转写和最终结果。
3. 底部胶囊短暂显示“已复制”。
4. 用户可以点击“重试输入”或按快捷键粘贴上一条。
不能让用户重新录一遍。
#### 取消流程
录音中按 `Esc`:
- 停止录音。
- 不发送转写。
- 不保存音频。
- 左侧叉号短暂高亮,底部胶囊随后淡出。
处理中按 `Esc`:
- 取消当前处理。
- 保留原始音频临时缓存到会话结束。
- 提供“复制原始转写”的入口。
#### 模式切换
第一轮只保留 4 个输出模式:
| 模式 | 作用 | 默认场景 |
|---|---|---|
| 原文 | 尽量保留说话内容,只补标点 | 代码、术语、引用 |
| 轻度润色 | 去口癖、补标点、轻微整理 | 默认模式 |
| 清晰结构 | 分段、列表、步骤化 | Prompt、需求、说明 |
| 正式表达 | 更适合邮件、工作沟通 | 邮件、客户沟通 |
模式切换不应做成复杂模板市场。第一轮不建议把模式常驻放在胶囊里,模式可以放在菜单栏或设置页;胶囊只显示当前动作状态。
#### 历史入口
历史不是内容管理系统,只解决“找回刚才那段”:
- 最近 50 条。
- 每条显示时间、app、模式、插入状态。
- 可复制最终结果。
- 可查看原始转写。
- 可用其他模式重新整理。
- 音频默认不保存。
---
### 4.2 状态层定义
OpenLess 至少需要 5 层状态,不要把所有状态混在一个 loading 里。
#### 应用状态
| 状态 | 含义 | 用户可见表现 |
|---|---|---|
| `app_ready` | 应用可用 | 菜单栏正常,底部胶囊隐藏 |
| `permission_required` | 缺少权限 | 设置页提示补权限 |
| `model_unavailable` | 模型/API 不可用 | 提示切换模型或检查 key |
| `offline` | 网络不可用 | 云模式不可用,本地模式仍可用 |
#### 录音状态
| 状态 | 含义 | 底部胶囊表现 |
|---|---|---|
| `idle` | 未录音 | 小胶囊或不显示 |
| `listening` | 正在录音 | 胶囊弹出,动态条轻微跳动 |
| `paused` | 暂停或短暂停顿 | 波形降低,仍显示正在听 |
| `stopping` | 正在收尾 | 麦克风变暗,准备处理 |
| `cancelled` | 用户取消 | 显示已取消后淡出 |
#### 处理状态
| 状态 | 含义 | 用户可见表现 |
|---|---|---|
| `transcribing` | 正在转写 | “正在识别” |
| `polishing` | 正在润色 | “正在整理” |
| `ready_to_insert` | 结果已生成 | 准备插入 |
| `failed` | 处理失败 | 提示失败原因和恢复入口 |
#### 插入状态
| 状态 | 含义 | 用户可见表现 |
|---|---|---|
| `inserted` | 成功插入 | “已输入” |
| `copied_fallback` | 插入失败但已复制 | “已复制,可直接粘贴” |
| `focus_lost` | 输入框失焦 | “找不到输入位置,已复制” |
| `permission_blocked` | 权限不足 | “需要辅助功能权限” |
#### 隐私状态
| 状态 | 含义 | 用户可见表现 |
|---|---|---|
| `local_only` | 仅本地处理 | 本地标签 |
| `cloud_enabled` | 使用云端增强 | 云端标签 |
| `byok` | 用户自带 API Key | BYOK 标签 |
| `audio_not_saved` | 不保存音频 | 历史中仅保存文本 |
| `audio_saved` | 用户选择保存音频 | 历史中明确标识 |
---
### 4.3 字段规范
字段规范是为了后续 Cursor 实现时不乱。以下字段不是要求一次全部建复杂数据库,而是第一轮的数据边界。
#### DictationSession
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | string | 会话 ID |
| `created_at` | datetime | 创建时间 |
| `started_at` | datetime | 录音开始时间 |
| `ended_at` | datetime | 录音结束时间 |
| `duration_ms` | number | 录音时长 |
| `source_app_name` | string | 当前 app 名 |
| `source_bundle_id` | string | 当前 app bundle id |
| `target_context_type` | enum | `chat` / `email` / `doc` / `code` / `unknown` |
| `language_hint` | enum | `auto` / `zh` / `en` / `mixed` |
| `mode` | enum | `raw` / `light` / `structured` / `formal` |
| `model_route` | enum | `local` / `cloud` / `byok` |
| `raw_transcript` | text | 原始转写 |
| `final_text` | text | 最终输出 |
| `insert_status` | enum | `inserted` / `copied_fallback` / `failed` |
| `fallback_reason` | string | 插入失败原因 |
| `audio_saved` | boolean | 是否保存音频 |
| `audio_path` | string/null | 音频路径,默认 null |
| `error_code` | string/null | 错误码 |
| `user_edited_after_insert` | boolean/unknown | 后续可用于质量判断 |
#### UserSettings
| 字段 | 类型 | 说明 |
|---|---|---|
| `hotkey_record` | string | 录音快捷键 |
| `hotkey_paste_last` | string | 粘贴上一条快捷键 |
| `recording_behavior` | enum | `hold_to_talk` / `toggle_to_talk` |
| `default_mode` | enum | 默认输出模式 |
| `default_model_route` | enum | 默认本地/云端/BYOK |
| `history_retention_days` | number | 历史保留天数 |
| `save_audio` | boolean | 是否保存音频 |
| `show_floating_capsule` | boolean | 是否显示底部状态胶囊 |
| `auto_copy_on_failure` | boolean | 失败时是否自动复制 |
| `personal_dictionary_enabled` | boolean | 是否启用个人词典 |
#### PersonalDictionaryEntry
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | string | 词条 ID |
| `phrase` | string | 用户确认过的正确词 |
| `category` | enum | `name` / `product` / `tech` / `company` / `custom` |
| `notes` | string | 可选备注,不作为硬替换规则 |
| `enabled` | boolean | 是否参与 ASR 热词和后期语义判断 |
| `case_sensitive` | boolean | 是否区分大小写 |
| `created_at` | datetime | 创建时间 |
#### TextSnippet
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | string | 片段 ID |
| `trigger_phrase` | string | 语音触发词 |
| `content` | text | 输出内容 |
| `enabled` | boolean | 是否启用 |
---
### 4.4 文案规范
OpenLess 的文案要短、明确、可信。不要像营销页,也不要像 AI 助手自我介绍。
#### 状态文案
| 场景 | 推荐文案 | 避免文案 |
|---|---|---|
| 空闲 | “OpenLess 就绪” | “准备好释放你的生产力” |
| 正在录音 | “正在听” | “AI 正在聆听您的灵感” |
| 正在识别 | “正在识别” | “正在通过先进模型解析语义” |
| 正在润色 | “正在整理” | “正在为您创作完美文本” |
| 成功插入 | “已输入” | “大功告成!” |
| 插入失败 | “已复制,可直接粘贴” | “发生未知错误” |
| 权限缺失 | “需要麦克风权限才能录音” | “权限异常” |
| 本地模式 | “仅在本机处理” | “绝对安全” |
| 云端模式 | “将发送到云端识别” | “智能云增强” |
#### 设置页文案
推荐:
- “按住快捷键说话,松开后自动输入。”
- “轻度润色会去掉口癖、补上标点,但尽量保留你的说法。”
- “插入失败时,OpenLess 会自动复制结果,避免丢失。”
- “音频默认不保存。你可以在历史设置中开启保存。”
避免:
- “用 AI 革命你的输入方式。”
- “彻底告别键盘。”
- “让灵感自由流动。”
- “行业领先的智能语义增强。”
#### 错误文案
错误文案要说明发生了什么、用户能做什么:
| 错误 | 文案 |
|---|---|
| 麦克风权限缺失 | “需要麦克风权限才能录音。打开系统设置后允许 OpenLess 使用麦克风。” |
| Accessibility 缺失 | “需要辅助功能权限,OpenLess 才能把文字输入到当前 app。” |
| 输入框失焦 | “找不到刚才的输入位置,结果已复制到剪贴板。” |
| 网络失败 | “网络不可用,已保留录音。你可以切换本地模式或稍后重试。” |
| 模型失败 | “这次没有整理成功,原始转写已保存。” |
---
## 5. 最小可验证版本
### 5.1 第一轮必须做
- macOS 菜单栏常驻。
- 全局录音快捷键。
- 底部微型状态胶囊。
- 麦克风权限和 Accessibility 权限引导。
- 中文、英文、中英混输转写。
- 默认轻度润色。
- 4 个输出模式。
- 当前输入框插入。
- 插入失败自动复制。
- 最近历史。
- 个人词典。
- 隐私设置:本地/云端/BYOK 路径说明。
### 5.2 第一轮不做
- iOS 键盘。
- Windows/Linux。
- 团队管理后台。
- 会议总结。
- 批量文件转写。
- 复杂 voice command。
- 截屏上下文理解。
- 自动长期保存音频。
- 多人协作词典。
### 5.3 第一轮验收问题
完成后只问这些问题:
1. 新用户是否能在 5 分钟内完成第一次成功输入?
2. 用户是否愿意在真实工作中连续用 1 天?
3. 输出是否比原始转写更可用?
4. 输出是否不像 AI 代写?
5. 插入失败时是否完全不丢内容?
6. 用户是否知道数据在哪里处理?
7. 免费、本地买断、云端 Pro 的边界是否清楚?
如果这些问题回答为“是”,概念版就成立。
---
## 6. 调研依据
本文基于当前项目已有两份文档,并结合 2026-04-26 复核的公开资料:
- [OpenLess 竞品评论、产品基调与 UI 方向调研](</Users/lvbaiqing/TRUE 开发/openless/docs/competitor-reviews-and-ui-direction.md>)
- [语音输入产品第一轮需求文档](</Users/lvbaiqing/TRUE 开发/openless/docs/voice-input-mvp-requirements.md>)
- [Typeless App Store 页面](https://apps.apple.com/us/app/typeless-ai-voice-keyboard/id6749257650)
- [Typeless Pricing](https://www.typeless.com/pricing)
- [Wispr Flow Pricing](https://wisprflow.ai/pricing)
- [Superwhisper Pro 文档](https://superwhisper.com/docs/get-started/sw-pro)
- [Reddit: paying a monthly subscription for Mac dictation in 2026 is absurd](https://www.reddit.com/r/MacOS/comments/1sor0nn/unpopular_opinion_paying_a_monthly_subscription/)
- [Reddit: Struggling with the new Superwhisper pricing](https://www.reddit.com/r/superwhisper/comments/1s7k6f3/struggling_with_the_new_superwhisper_pricing/)
- [Reddit: Wispr Flow full voice dictation access for a limited time](https://www.reddit.com/r/ProductivityApps/comments/1rn70q4/gentle_reminder_wispr_flow_full_voice_dictation/)
- [OpenAI API Pricing](https://platform.openai.com/docs/pricing/)
- [Groq Pricing](https://groq.com/pricing)
-338
View File
@@ -1,338 +0,0 @@
# OpenLess 产品需求文档
更新时间:2026-04-26
本文是 OpenLess 第一轮开发的主需求文档,由现有调研文档、竞品评论总结、概念诊断和整体逻辑文档融合而成。后续开发优先以本文和 `openless-development.md` 为准。
---
## 1. 产品结论
OpenLess 要解决的不是“语音转文字”这个单点功能,而是:
> 用户在真实工作流里想表达一段内容时,不想被键盘速度、口语整理、格式修正、跨应用插入和隐私担忧打断。
因此,OpenLess 的第一轮产品定位是:
> 本地优先、低打扰、可控润色的 macOS AI 语音输入层。
第一轮默认 UI 不是大输入框,而是 Typeless 类产品那种底部微型状态胶囊:只有录音、处理、完成、失败时出现,左边叉号,中间动态条,右边勾号,只做状态提醒。
---
## 2. 目标用户
第一轮优先服务以下用户:
| 优先级 | 用户 | 高频场景 | 主要痛点 |
|---|---|---|---|
| P0 | AI 工具重度用户 | ChatGPT、Claude、Cursor、Perplexity | Prompt 想得快,打字慢 |
| P0 | 中文和中英混输用户 | 微信、飞书、文档、AI prompt | 中文标点、英文术语、产品名容易错 |
| P1 | 开发者/产品经理 | Cursor、Issue、PR 评论、需求说明 | 长文本、技术词、结构化表达成本高 |
| P1 | 知识工作者/创作者 | 邮件、备忘录、文档、社媒草稿 | 想法容易被打字打断 |
| P1 | 隐私敏感 Mac 用户 | 工作资料、客户信息、内部内容 | 不清楚音频和文本是否上传 |
第一轮不主攻:
- 会议纪要用户。
- 批量录音文件转写用户。
- 企业管理员。
- 全平台用户。
- 语音控制电脑的用户。
---
## 3. 用户痛点与产品回应
| 痛点 | 用户真实问题 | OpenLess 的回应 |
|---|---|---|
| 想得比打得快 | 想说一大段,但打字慢,思路被打断 | 全局快捷键说话,结束后自动输入 |
| 原始转写不可直接用 | 语音里有口癖、重复、停顿、临时改口 | 默认轻度润色,去口癖、补标点、自然分段 |
| AI 味太重 | 润色后不像自己说的话 | 保留用户习惯,不强行扩写、不替用户创作 |
| 跨应用不稳定 | 插入失败、输入框失焦、结果丢失 | 插入失败自动复制,并保存到本地历史 |
| 订阅价格敏感 | 用户不愿为普通转写长期月付 | 本地买断为基础,云端增强可选订阅 |
| 隐私焦虑 | 不知道是否一直监听,音频去哪了 | 明确 push-to-talk,本地/云端/BYOK 状态可见 |
| 中文体验不足 | 中文标点、中英混输、技术词容易错 | 中文和中英混输作为第一轮核心验收 |
第一轮必须排除的伪需求:
- 不做完整 AI 写作工具。
- 不做会议总结。
- 不做语音控制整个电脑。
- 不做复杂 agent 工作流。
- 不做几十种风格模板。
- 不默认后台持续监听。
- 不默认保存音频。
---
## 4. 第一轮范围
### 4.1 必须完成
第一轮必须完成这些能力:
| 模块 | 需求 |
|---|---|
| macOS 常驻应用 | 菜单栏常驻,能打开设置、历史、词典 |
| 全局快捷键 | 支持按住说话,后续可支持切换式录音 |
| 权限引导 | 麦克风权限、辅助功能权限必须有清楚引导 |
| 录音 | 能采集麦克风音频,并给用户明确状态反馈 |
| 底部状态胶囊 | 录音和处理时出现,空闲时隐藏 |
| 语音转文字 | 支持中文、英文、中英混输 |
| 轻度润色 | 默认把口语整理成可直接使用的文字 |
| 输出模式 | 原文、轻度润色、清晰结构、正式表达 |
| 当前输入框插入 | 优先插入到用户当前光标所在位置 |
| 失败兜底 | 插入失败自动复制到剪贴板,并写入历史 |
| 最近历史 | 保存最近输入,支持复制、查看原文、重新整理 |
| 个人词典 | 支持产品名、人名、技术词、缩写 |
| 设置页 | 快捷键、默认模式、隐私、历史、模型路径 |
| 隐私控制 | 明确本地/云端/BYOK,音频默认不保存 |
### 4.2 暂不完成
第一轮明确不做:
- iOS 键盘。
- Windows/Linux 客户端。
- 团队后台。
- 会议录音总结。
- 批量文件转写。
- 复杂 voice command。
- 截屏上下文理解。
- 跨设备同步。
- 自动长期保存音频。
- 多人协作词典。
---
## 5. 主使用流程
标准流程:
1. 用户把光标放在任意输入框。
2. 用户按住全局快捷键。
3. 屏幕底部弹出微型状态胶囊。
4. 胶囊中间的白色动态条随声音轻微跳动。
5. 用户说完后松开快捷键。
6. OpenLess 完成语音识别。
7. OpenLess 按当前模式进行整理。
8. 结果插入到当前输入框。
9. 勾号短暂高亮,胶囊淡出。
插入失败流程:
1. 识别和润色仍正常完成。
2. 如果当前输入框失焦或无法插入,结果自动复制到剪贴板。
3. 胶囊显示“已复制”。
4. 本次原始转写和最终文本写入历史。
5. 用户可以手动粘贴,或用“粘贴上一条”快捷键恢复。
取消流程:
1. 录音中按 `Esc` 或点击左侧叉号。
2. 当前录音取消。
3. 叉号短暂高亮。
4. 胶囊淡出。
5. 默认不保存音频。
---
## 6. UI 需求
### 6.1 核心 UI:底部微型状态胶囊
OpenLess 的核心可见 UI 是底部微型状态胶囊,而不是大输入栏。
视觉要求:
| 项目 | 要求 |
|---|---|
| 位置 | 屏幕底部居中,距底边 18-28px |
| 默认状态 | 隐藏,或仅保留极淡小点 |
| Listening 尺寸 | 约 128-180px 宽,32-38px 高 |
| Processing 尺寸 | 约 150-210px 宽,32-38px 高 |
| 背景 | macOS glass/material 质感,半透明深色或浅色 |
| 圆角 | 接近胶囊 |
| 阴影 | 轻,不要网页弹窗感 |
| 左侧 | 叉号,取消 |
| 中间 | 3-5 根动态白色音量条,或 2-4 字状态文案 |
| 右侧 | 勾号,完成/确认 |
| 文本 | 不默认展示完整转写 |
| 颜色 | graphite、white、teal/blue-green 微光,错误用小红点 |
状态要求:
| 状态 | 展示 | 动画 |
|---|---|---|
| Idle | 隐藏或极淡小点 | 无 |
| Listening | 叉号 + 动态白条 + 勾号 | 从底部轻微弹出,白条随音量跳动 |
| Processing | 叉号 + “整理中”或 spinner + 勾号 | 胶囊略微变宽 |
| Inserted | 勾号高亮 | 约 0.8 秒后淡出 |
| Cancelled | 叉号高亮 | 快速淡出 |
| Copied fallback | “已复制” | 保留 2-3 秒 |
| Error | 小红点或“失败” | 点击后才展开详情 |
不要做:
- 不做大输入栏。
- 不在胶囊中显示整段转写。
- 不常驻占屏。
- 不堆模式 chip、语言 chip、provider 名称。
- 不做营销式大动效。
### 6.2 主窗口
主窗口是设置和管理中心,不是日常输入界面。
建议导航:
- Overview:快捷键、当前模式、麦克风、隐私状态。
- History:最近记录。
- Dictionary:个人词典。
- Snippets:常用片段。
- Modes:输出模式设置。
- Privacy:本地/云端/BYOK、历史、音频保存。
- Settings:启动项、快捷键、模型路径、更新等。
---
## 7. 文本输出需求
### 7.1 输出模式
第一轮只保留 4 个模式:
| 模式 | 目标 | 适合场景 |
|---|---|---|
| 原文 | 尽量保留原句,只补标点 | 代码、引用、术语密集内容 |
| 轻度润色 | 默认模式,去口癖、补标点、轻微整理 | IM、AI prompt、日常输入 |
| 清晰结构 | 分段、列表、步骤化 | 需求、说明、长 prompt |
| 正式表达 | 更适合工作邮件和正式沟通 | 邮件、客户沟通、汇报 |
默认模式:轻度润色。
### 7.2 润色质量标准
合格输出必须满足:
- 保留用户原始意思。
- 不新增用户没说的信息。
- 不把短句扩写成长篇。
- 不强行改成 AI 腔。
- 口癖、重复、明显停顿词要去掉。
- 中文标点自然。
- 需要时自动分段。
- 中英混输术语尽量保留。
不合格输出包括:
- 擅自替用户下判断。
- 把口语全部改成营销文案。
- 把原始语气改得过于正式。
- 删除关键限制条件。
- 把代码、变量、产品名改错。
- 过度结构化简单短句。
### 7.3 典型效果
口语输入:
> 帮我把这个登录页面改一下,接口不要动,然后错误提示统一放到下面,提交的时候别让用户重复点。
轻度润色输出:
> 帮我调整一下登录页面,保持现有接口不变。错误提示统一放到表单下方,提交时按钮需要禁用,避免用户重复点击。
清晰结构输出:
> 帮我调整登录页面,要求如下:
>
> - 保持现有接口不变。
> - 错误提示统一展示在表单下方。
> - 提交时禁用按钮,避免重复点击。
---
## 8. 隐私与数据
第一轮隐私原则:
- 默认 push-to-talk,不后台持续监听。
- 录音状态必须清晰可见。
- 音频默认不保存。
- 历史默认只保存文本。
- 使用云端识别或云端润色时必须让用户知道。
- 支持本地优先。
- 支持 BYOK,用户可自带 API Key。
数据保留:
| 数据 | 默认行为 |
|---|---|
| 原始音频 | 不保存 |
| 原始转写 | 保存到最近历史 |
| 最终文本 | 保存到最近历史 |
| 当前 app 名称 | 可保存,用于历史和场景适配 |
| 输入框内容 | 第一轮谨慎使用,默认不长期保存 |
| 个人词典 | 本地保存 |
| API Key | 本地安全存储 |
---
## 9. 商业模式
第一轮不建议纯订阅。推荐结构:
| 层级 | 内容 |
|---|---|
| 免费版 | 基础体验、有限额度、完整主流程 |
| 买断版 | 本地无限使用、基础润色、历史、词典 |
| Pro 订阅 | 云端高精度、高级润色、同步、团队能力 |
| BYOK | 用户自带 API Key,适合开发者和高级用户 |
商业判断:
- 用户愿意为稳定、好用、低打扰的输入层付费。
- 用户不愿意为普通 Whisper 套壳长期订阅。
- 云端能力有持续成本,可以订阅。
- 本地能力更适合买断。
---
## 10. 第一轮验收标准
第一轮完成后,必须满足:
1. 新用户能在 5 分钟内完成第一次成功输入。
2. 用户能在常用 app 中用快捷键录音并输入文字。
3. 中文、英文、中英混输均能正常输出。
4. 默认轻度润色结果可以直接发送或少量编辑。
5. 输出不像 AI 代写。
6. 插入失败时自动复制,不丢内容。
7. 历史中能找回原始转写和最终结果。
8. 用户能明确知道当前是本地、云端还是 BYOK。
9. 胶囊 UI 足够小,不遮挡主内容。
10. 权限缺失时有清楚的修复引导。
---
## 11. 参考文档
融合来源:
- [语音输入产品第一轮需求文档](</Users/lvbaiqing/TRUE 开发/openless/docs/voice-input-mvp-requirements.md>)
- [OpenLess 竞品评论、产品基调与 UI 方向调研](</Users/lvbaiqing/TRUE 开发/openless/docs/competitor-reviews-and-ui-direction.md>)
- [OpenLess 概念版诊断与落地规格](</Users/lvbaiqing/TRUE 开发/openless/docs/openless-product-concept-diagnosis.md>)
- [OpenLess 整体逻辑梳理](</Users/lvbaiqing/TRUE 开发/openless/docs/openless-overall-logic.md>)
外部参考:
- [Typeless Quickstart](https://www.typeless.com/help/quickstart/first-dictation)
- [Typeless Pricing](https://www.typeless.com/pricing)
- [Wispr Flow Pricing](https://wisprflow.ai/pricing)
- [Wispr Flow Dictation Bubble](https://docs.wisprflow.ai/articles/3400534884-snooze-the-dictation-bubble)
- [Superwhisper Docs](https://superwhisper.com/docs)
- [Aqua Voice Guide](https://aquavoice.com/guide/)
- [LazyTyper 官网](https://lazytyper.com/)
-53
View File
@@ -1,53 +0,0 @@
# Platform adapter architecture
## Goal
把 `Coordinator` 需要的热键边沿事件(`pressed` / `released` / `cancelled`)与各平台的 OS hook 细节隔离开,避免把 UI 文案、权限判断、按键映射和 session state machine 混在一起。
## Backend boundary
Rust 层统一暴露三类对象:
- `HotkeyAdapter` trait:平台监听器只负责安装、更新 binding、发送边沿事件。
- `HotkeyCapability`:描述当前平台能提供什么(可选 trigger、是否需要辅助功能权限、是否支持 modifier-only trigger、是否有 fallback)。
- `HotkeyStatus` / `HotkeyInstallError`:描述当前 hook 是否已安装、失败原因、当前实际 adapter。
`Coordinator` 不再关心 CGEventTap / Windows hook / `rdev` 的实现差异,只消费统一事件和状态。
## Platform adapters
### macOS
- Adapter: `MacHotkeyAdapter`
- Hook: `CGEventTap`
- 目的:保留现有已验证实现,不回退到 `rdev`
- 限制:依赖辅助功能权限;授权后通常需要完全退出再重开
### Windows
- Adapter: `WindowsHotkeyAdapter`
- Hook: `SetWindowsHookExW(WH_KEYBOARD_LL)`
- 目的:支持右 Control / 右 Alt 这类 modifier-only trigger,并且保留左右侧语义
- 备注:默认推荐 `右 Control + 按住说话`
### Linux / other
- Adapter: `RdevHotkeyAdapter`
- Hook: `rdev::listen`
- 目的:best-effort 兜底,不承诺与 macOS / Windows 同等行为
## UI contract
前端通过 IPC 读取:
- `get_hotkey_capability`
- `get_hotkey_status`
- `get_settings`
设置页、权限页和快捷键提示必须基于 capability / status / actual binding 渲染,而不是再写 `if (os === 'win') ... else ...` 的平台硬编码文案。
## Explicit non-goals
- 不静默把 modifier-only trigger 替换成普通 registered shortcut
- 不把平台差异泄漏到 `Coordinator`
- 不在这层引入新的全局快捷键依赖
-115
View File
@@ -1,115 +0,0 @@
# OpenLess 润色参考样例库
更新时间:2026-04-27
## 目标
这套样例库用于沉淀“语音原文 → 可输入文本”的改写规律。它不是聊天历史,也不是知识库问答材料。
核心用途:
- 对比竞品长期文本的改写方式。
- 提炼口语清理、分段、列表化、正式化的规律。
- 后续接入向量数据库,根据当前原始转写检索 1-3 条相似样例。
- 把检索结果作为 few-shot 参考传给润色模型,做轻量后处理和风格约束。
- 建立回归样例,防止模型把用户问题当成需要回答的问题。
## 数据边界
每条样例只描述一次文本转换:
```text
raw 用户原始口语或竞品改写前文本
polished 目标整理结果
tags 可检索标签
notes 从样例中提炼出的规律
```
不要把样例当成应用上下文。即使样例来自竞品,也只学习整理方式,不复制竞品事实、内容、品牌文案或专有表达。
## JSONL 格式
每行一个 JSON 对象:
```json
{"id":"structured-001","mode":"structured","raw":"帮我把登录页面改一下接口不要动错误提示放下面提交的时候别让用户重复点","polished":"帮我调整登录页面,要求如下:\n\n- 保持现有接口不变。\n- 错误提示统一展示在表单下方。\n- 提交时禁用按钮,避免重复点击。","tags":["需求整理","列表化","防重复提交"],"notes":"把连贯口语拆成约束清单,保留否定条件和交互细节。"}
```
字段说明:
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | string | 稳定 ID,建议按模式和序号命名 |
| `mode` | string | `raw` / `light` / `structured` / `formal` |
| `raw` | string | 原始口语文本 |
| `polished` | string | 目标输出 |
| `tags` | string[] | 改写类型、场景、风险点 |
| `notes` | string/null | 人工提炼的改写规律 |
## 推荐标签
- `去口癖`
- `去重复`
- `中途改口`
- `中文标点`
- `中英混输`
- `技术词保留`
- `列表化`
- `段落化`
- `正式表达`
- `IM 短消息`
- `AI prompt`
- `邮件`
- `需求整理`
- `防重复提交`
- `不回答问题`
## 向量库接入方式
后续向量数据库建议只索引这些内容:
```text
mode + tags + raw + notes
```
不建议只索引 `polished`,否则检索会偏向“结果长得像”,而不是“输入问题长得像”。
推荐检索流程:
1. 用户完成一次语音转写。
2. 根据当前 `mode` 过滤样例。
3. 用 `raw + tags + notes` 做向量检索。
4. 取 top 1-3 条样例。
5. 传给 `PolishPrompts.userPrompt(for:referenceExamples:)`。
6. 模型只参考改写规律,不继承样例事实。
## 回归样例
必须保留一类“不要回答问题”的样例:
```json
{"id":"guardrail-001","mode":"light","raw":"我们这个应用还有哪些功能没有完成","polished":"我们这个应用还有哪些功能没有完成?","tags":["不回答问题","问题整理"],"notes":"用户是在输入一句问题,模型只能补标点,不能替用户列功能清单。"}
```
这类样例用于防止模型在 OpenLess 场景里误把语音输入当作给模型的任务。
## 质量检查
导入样例前至少检查:
- `raw` 和 `polished` 都不能为空。
- `polished` 不得新增用户没说过的事实。
- `polished` 不得包含“根据您给的内容”“我整理如下”等引导语。
- 竞品样例不得包含不可公开的用户隐私、客户信息、账号、密钥或内部项目名。
- 如果样例来自竞品公开材料,只记录转换规律;不要复制大段受版权保护内容进仓库。
## 与微调的关系
短期优先使用“向量检索 + few-shot 后处理”,原因是:
- 可解释,方便人工审查。
- 能按模式和场景动态选择样例。
- 出问题时可以删除或降权单条样例。
- 不需要频繁重新训练模型。
真正微调适合在样例稳定到数百到数千条后再做,且需要单独的数据清洗、版权确认、训练/验证/回归集拆分。
-30
View File
@@ -1,30 +0,0 @@
# OpenLess 1.0.02
第一个支持**自动更新**的版本。装一次之后,所有未来版本会通过 Sparkle 静默推送,不再需要手动下载或 `xattr -dr`。
## 新功能
- **按住说话(hold-to-talk)模式**。设置 → 「录音方式」可以在「切换式」和「按住说话」之间切换。按住快捷键说话、松手立即结束,适合短句和连续口播。Closes #1.
- **检查更新…**。OpenLess 菜单第一项,手动触发;同时每小时后台自检一次,发现新版自动下载替换。
- **API 密钥眼睛切换**。设置里的 Token / Key 字段右侧加了 👁️ 按钮,可临时显示明文便于核对。
## 修复
- **设置窗口拖动吞掉文字选择**。之前任何位置拖都拖窗,现在只有原生顶栏(traffic lights 那条)才拖窗,TextField 里能正常选词复制了。
- **润色失败/缺凭据时假装成功**。Ark Key 没填或调用失败时,胶囊不再显示绿色「已插入」假装润色完成;改为橙色「已插入原文 · 未润色」或「润色失败 · 已用原文」,历史记录也按真实情况存为「原文」mode。
## 提示词改进
- **「清晰结构」模式**强制三层层级输出:`1.` 大板块 → `1)` 子要点 → `a.` 细分项。prompt 内带具体示例,弱模型也能稳定遵守。
## 安装
**老用户(已经装过 1.0.0)**:什么都不用做。下次启动 OpenLess 会自动检查并提示升级。
**新用户**:到 [Releases](https://github.com/appergb/openless/releases) 下载 zip,解压拖到 `/Applications`,跑一次:
```bash
xattr -dr com.apple.quarantine /Applications/OpenLess.app
```
之后双击启动。详见 [USAGE.md](https://github.com/appergb/openless/blob/main/USAGE.md)。
-42
View File
@@ -1,42 +0,0 @@
# OpenLess 1.0.03
整轮 UI 重构 + 词汇表升级 + 自动学词关闭。老用户走自动更新即可。
## 侧边栏
旧 4 项变成 6 项,把臃肿的「设置」拆开:**首页 / 历史记录 / 词汇表 / 风格 / 帮助中心 / 设置**。
- **风格**(原「润色模式」改名):右上角启用开关 + 4 个模式卡片(忠实转写 / 去口癖 / 结构化 / 正式书面),每张卡有样例文本预览,选中卡顶部一条绿色 stroke。关闭时识别后直接插入原文,不调 Ark。
- **帮助中心**(新增):4 步快速上手、快捷键速查、3 条常见问题、GitHub / Issues 外链。
- **设置**:火山 ASR 凭据、Ark / DeepSeek 润色凭据、录音快捷键、授权状态、隐私说明、版本 + 检查更新——全部归到这一处。
## 词汇表
「词典」改名「词汇表」。整页换成 chip 流式布局:
- 每个词条显示 `phrase + 命中次数`,命中数高的排前面
- 鼠标悬停时右上角浮 ✕,点击删除
- 顶部支持一行一个批量添加;右上角 **重置统计** / **清除全部**
- 命中次数:每次润色后的最终输出里出现该词,自增 1
- **AI 自动学词已关闭**——之前会自动从转写里抓 `MCP`、`CLI` 之类的英文词塞进词汇表,行为太乱。现在词汇表只增长用户手动输入的词
## 修复
- **胶囊外圈黑边**:`NSPanel.hasShadow` 关闭 + macOS 26 路径去掉冗余 SwiftUI 阴影;过大背景下不再裁出深色矩形带
- **侧边栏灰边 / 黑色卡顿**:`glassPanel` 的 `strokeBorder` opacity 从 0.08 → 0.04、阴影 radius 从 16 → 6;侧边栏顶 padding 收紧后不再被父容器裁出暗矩形
## 首页
底部新增两个区块:**今日概览**(7 段柱状图)+ **风格**(当前模式 + 模式说明,关闭时显示「已关闭 / 直接插入原文」)。
## 安装
**老用户**:什么都不用做。下次启动 OpenLess 会自动检查更新并提示。
**新用户**:到 [Releases](https://github.com/appergb/openless/releases) 下载 zip,解压拖到 `/Applications`,跑一次:
```bash
xattr -dr com.apple.quarantine /Applications/OpenLess.app
```
之后双击启动。
-39
View File
@@ -1,39 +0,0 @@
# OpenLess 1.0.04
build B1003 — A 系收尾、切到 B 系前缀。本次主体是项目结构整理 + 两处 Codex 体检发现的细节修复。
## 修复
- **编辑词条时命中次数被清零**。词汇表 chip 上的命中数代表"该词在润色后的最终输出里出现过的次数";此前点击 chip 进入编辑、保存后 `hitCount` 被默认值 0 覆盖,统计静默归零。已在 `DictionaryEditorSheet` 保存时把原值带回去。
- **帮助中心文案过期**。1.0.03 把"火山 ASR" / "润色模式" 两个独立 Tab 合并到了"设置",但帮助页里"快速上手"和"常见问题"还在指原来的 Tab 名。已统一改成「设置」/「风格」。
## 内部结构整理
旧的 `Sources/OpenLessApp/Settings/SettingsView.swift` 长到 1697 行,超出 800 行红线一倍多。本版本拆成单一职责文件,零行为变化:
```
Settings/SettingsView.swift 87 根:枚举 + 导航模型 + 主分发
Settings/SettingsComponents.swift 218 共享 UI 组件 + 通知名 + 工具函数
Settings/Sidebar.swift 270 侧边栏 + 状态卡 + stats
Settings/Tabs/HomeTab.swift 161
Settings/Tabs/HistoryTab.swift 50
Settings/Tabs/DictionaryTab.swift 350 含 Chip + ChipFlow + EditorSheet
Settings/Tabs/StyleTab.swift 123
Settings/Tabs/HelpTab.swift 100
Settings/Tabs/SettingsHubTab.swift 184
Settings/Tabs/LegacyTabs.swift 172 旧版孤儿集中放(待清)
```
整个项目最长文件现在是 566 行的 `DictationCoordinator.swift`,全部在 800 红线以下。
## 安装
**老用户**:什么都不用做。下次启动 OpenLess 会自动检查更新。
**新用户**:到 [Releases](https://github.com/appergb/openless/releases) 下载 zip,解压拖到 `/Applications`,跑一次:
```bash
xattr -dr com.apple.quarantine /Applications/OpenLess.app
```
之后双击启动。
-21
View File
@@ -1,21 +0,0 @@
# OpenLess 1.0.05
build B1004 — 帮助中心整理。
## 修复
- **帮助中心 GitHub 链接 404**。「更多」分组里的"GitHub 仓库"和"提交问题或建议"原指向 `github.com/baiqing/openless`,仓库其实在 `github.com/appergb/openless`,老链接点进去打不开。已改回正确地址。
- **帮助中心链接行布局乱**。之前把整段 URL 当作链接 label 渲染,"标题"和"被中间截断的长 URL"挤在一行。现在改成右侧一个简洁的「打开 ↗」按钮,hover 时 tooltip 显示完整 URL。
- **常见问题 Q/A 太挤**。粗体问题和灰色答案之间只有 4pt 间距,密度过高。间距加到 8pt + lineSpacing 2,每条整体 padding 9 → 11,可读性提升。
## 安装
**老用户**:什么都不用做。下次启动 OpenLess 会自动检查更新。
**新用户**:到 [Releases](https://github.com/appergb/openless/releases) 下载 zip,解压拖到 `/Applications`,跑一次:
```bash
xattr -dr com.apple.quarantine /Applications/OpenLess.app
```
之后双击启动。
-22
View File
@@ -1,22 +0,0 @@
# OpenLess 1.0.06
build B1005 — 紧急修复设置页更新入口。
## 修复
- **设置里的「检查更新…」按钮无响应**。设置页通过 responder chain 发送 `checkForUpdates:`,但实际的 `UpdaterController` 不是 responder chain 的一员,导致 action 找不到 target。现在由 `AppDelegate` 接住同名 action 并转发给 Sparkle updater。
- **帮助中心 GitHub 链接 404**。链接已从旧的 `github.com/baiqing/openless` 改为正确的 `github.com/appergb/openless`。
- **帮助中心链接行布局乱**。右侧改为简洁的「打开」按钮,完整 URL 放到 tooltip。
- **常见问题 Q/A 太挤**。增加问题和答案之间的间距,提升可读性。
## 安装
**老用户**:什么都不用做。下次启动 OpenLess 会自动检查更新。
**新用户**:到 [Releases](https://github.com/appergb/openless/releases) 下载 zip,解压拖到 `/Applications`,跑一次:
```bash
xattr -dr com.apple.quarantine /Applications/OpenLess.app
```
之后双击启动。
-22
View File
@@ -1,22 +0,0 @@
# OpenLess 1.0.07
build B1006 — 修复更新后权限不会重新进入授权流程的问题。
## 修复
- **新版本安装后自动清理旧权限**。OpenLess 从 `.app` bundle 启动时会比较当前版本和上次已处理版本;发现新版本后,先自动清理旧的 Accessibility、Microphone、AppleEvents、ListenEvent TCC 授权记录。
- **清理后自动进入权限请求流程**。旧权限清掉后,启动流程继续按实际授权状态判断;缺少辅助功能或麦克风权限时,会直接显示现有授权引导窗口,让用户为新版本重新授权。
- **同一版本不重复清理**。授权完成后再次打开同一版本不会反复 reset;下一个版本安装后会再次执行同样逻辑。
- **开发运行更安全**。只有从 `.app` bundle 启动时才触发版本化权限清理,避免 `swift run` 等开发调试误清理已安装应用的权限。
## 安装
**老用户**:启动 OpenLess 后自动检查更新。安装新版本并重新打开后,会按新版本权限流程重新授权。
**新用户**:到 [Releases](https://github.com/appergb/openless/releases) 下载 zip,解压拖到 `/Applications`,跑一次:
```bash
xattr -dr com.apple.quarantine /Applications/OpenLess.app
```
之后双击启动。
-33
View File
@@ -1,33 +0,0 @@
# OpenLess 1.0.08
build B1007 — macOS 版本整理、provider 配置合并、移除 Windows spike 方向。
## 新增
- **ASR provider 可切换**。配置页现在支持火山引擎、Apple Speech、阿里 Paraformer,以及自定义 OpenAI Whisper 兼容接口。
- **LLM provider 可切换**。Ark、OpenAI、阿里通义、DeepSeek、Moonshot 和自定义 OpenAI 兼容接口统一放在「配置」页。
- **配置统一保存**。LLM 与 ASR 的 provider、凭据和 active 选择先进入草稿,点击「保存」后一次性写入,避免切换 provider 时把半成品配置落盘。
## 修复
- **阿里 Paraformer / 自定义 Whisper 凭据不再写入 UserDefaults**。现在通过统一 vault schema 保存,避免敏感字段散落。
- **多 provider 凭据迁移**。旧版火山/Ark/DeepSeek 字段会迁移到 versioned schema,保留兼容兜底。
- **润色输出清理更稳**。OpenAI 兼容 provider 的输出会经过统一 cleaner,减少包装文案进入最终插入文本。
## 整理
- **删除 Windows spike 本地代码与构建缓存**。移除 `apps/windows-tauri` 和 `build/windows-spike` 这类本地遗留产物,当前公开发布只保留 macOS 主线。
- **配置页拆分**。`ProvidersConfigTab.swift` 从 1086 行拆成页面、chip 组件、草稿模型和添加 provider sheet,逻辑边界更清楚。
- **忽略本地 apps 工作目录**。避免后续本地 spike 目录误进入 macOS 发布分支。
## 安装
**老用户**:启动 OpenLess 后自动检查更新。
**新用户**:到 [Releases](https://github.com/appergb/openless/releases) 下载 zip,解压拖到 `/Applications`,跑一次:
```bash
xattr -dr com.apple.quarantine /Applications/OpenLess.app
```
之后双击启动。
-137
View File
@@ -1,137 +0,0 @@
# 键盘监测 Code Review 报告
更新时间:2026-04-26
切片范围:`OpenLessHotkey` Swift Package + `HotkeyDemo` executable
---
## 1. 编译验证
| 命令 | 结果 |
|---|---|
| `swift build` | ✅ Build complete! (29.19s),无 warning |
| `swift test` | ⚠️ Skipped — CLT 5.9 缺 XCTest 模块;需要 full Xcode 环境跑 |
| `swift run HotkeyDemo` | ✅ Link 成功;runtime 测试需要 Input Monitoring 权限手动授予 |
测试代码已写好(`Tests/OpenLessHotkeyTests/`),等用户在装了 Xcode 的机器上跑,或在 CI 用 `xcodebuild test`。
---
## 2. 代码质量复核(按规划 §6 验收标准)
| # | 验收项 | 状态 | 证据 |
|---|---|---|---|
| 1 | `swift build` 干净通过 | ✅ | 上表 |
| 2 | `swift test` 全过 | ⏸ | 待 Xcode 环境验证 |
| 3 | `HotkeyDemo` 跑起来按 right_option 输出 pressed/released | ⏸ | 待用户授权 Input Monitoring 后实测 |
| 4 | 切换 binding 到 fn 后按 fn 触发 | ⏸ | 同上 |
| 5 | 模块依赖纯净 | ✅ | 仅 import `AppKit` / `CoreGraphics` / `Foundation`,零业务依赖 |
---
## 3. 与规划文档的一致性
| 规划文档要点 | 实际实现 | 是否一致 |
|---|---|---|
| 弃用 KeyboardShortcuts library | Package.swift 无外部依赖 | ✅ |
| 默认 right_option | `HotkeyBinding.default.pushToTalk == .rightOption` | ✅ |
| 6 个 Trigger case | `Trigger.allCases.count == 6` | ✅ |
| 协议 + 实现分离 | `HotkeyServiceProtocol` + `HotkeyMonitor` | ✅ |
| 单向 AsyncStream | `events: AsyncStream<HotkeyEvent>` 只读 | ✅ |
| 启动时同步 modifier 状态 | `start()` 末尾查 `NSEvent.modifierFlags` | ✅ |
| Esc 仅在 isPressed 时触发 cancelled | `handleKeyDown` 守卫 `isPressed` | ✅ |
| stop() 时收尾 released | `stop()` 内 `if isPressed { yield(.released) }` | ✅ |
---
## 4. 自审发现的潜在问题
### 4.1 [LOW] `MainActor.assumeIsolated` 在非主线程会 trap
`NSEvent.addGlobalMonitorForEvents` callback 文档说在主线程,但**没有运行时保证**。Apple 内部偶尔在非主线程派发的可能性存在(极少)。当前代码用 `MainActor.assumeIsolated` 强转,若出现非主线程会立即 crash。
**取舍**:crash 比 race condition 更易诊断;v1 接受此风险。如果实测确认有问题,改成 `Task { @MainActor in self?.handle(event) }`,代价是事件延迟一拍。
### 4.2 [LOW] 没有 `deinit` 清理
`HotkeyMonitor` 不写 `deinit`。如果调用方忘记 `stop()` 就让对象析构,monitor 会泄漏到 NSEvent 系统层(NSEvent 的 monitor 本身持 closure 引用 self,会形成循环引用导致对象不析构 → 实际上不会泄漏,但 monitor 永远活着)。
**Swift 6 严格 concurrency 下** `@MainActor` class 的 `deinit` 是 nonisolated,访问 isolated state 会编译报错。本代码暂以 Swift 5.9 模式编译,规避该问题。
**缓解**:Demo 和未来 Coordinator 必须显式 `stop()`(生命周期管理责任在调用方)。
### 4.3 [LOW] modifier raw flags 0x040 等 magic numbers 未抽常量
`isTriggerActive` 里散落了 `0x0001 / 0x0010 / 0x0020 / 0x0040 / 0x2000`,对应 `NX_DEVICE*` 系列 mask。文档参考来自 IOKit `<IOKit/hidsystem/IOLLEvent.h>`。
**修复(v1.1)**:抽出 `private enum DeviceModifierMask`,并加注释链接到 IOKit header。**v1 不改**——5 个常量直接局部使用,简洁优于过度抽象。
### 4.4 [MEDIUM] Demo 未授权时立即 exit,没机会让 user 手动重试
`HotkeyDemoMain` 在 `isGranted() == false` 时打印提示后 `exit(1)`。授权后必须用户手动重启 demo。
**评价**:这是 unsigned binary 在 macOS 输入监控权限模型下的**本质限制**——授权与否绑定二进制 hash。对一个 demo 切片,提示 + 退出是合理简化。完整 .app bundle 在架构 v2 重写时再优化。
### 4.5 [PASS] 无线程数据竞争
所有 mutable state(`isPressed`、`binding`、`globalMonitor`、`localMonitor`、`isRunning`)只在 `@MainActor` 上读写。NSEvent callback 通过 `MainActor.assumeIsolated` 强制隔离。✅
### 4.6 [PASS] AsyncStream 未关闭风险
`events` stream 在 `HotkeyMonitor` 实例存活期间持续。调用方 `for await event in monitor.events` 在 monitor 不被销毁时不会自动结束。这是预期行为(demo 和 Coordinator 都需要长时订阅)。如要终止订阅,调用方在 `Task` 上 `cancel()` 即可。
---
## 5. Karpathy 原则自检
| 原则 | 是否遵守 |
|---|---|
| Think Before Coding | ✅ 规划文档 §7 自我审查覆盖了边界情况、错误路径、跨线程 |
| Simplicity First | ✅ 单文件 < 130 行;零外部依赖;无 speculative 抽象(如未抽 `DeviceModifierMask` 常量) |
| Surgical Changes | ✅ 仅创建本切片所需文件;未触碰 docs 主架构文档 |
| Goal-Driven Execution | ✅ 6 条验收标准,build 已 pass;3 条运行时验收等用户实测 |
---
## 6. 待用户实测的验收清单
请在 macOS 15+ 设备上:
```bash
cd "/Users/lvbaiqing/TRUE 开发/openless"
swift run HotkeyDemo
```
**首次运行**:会提示 Input Monitoring 权限未授予 → 系统设置 → 隐私与安全 → 输入监控 → 添加 `.build/debug/HotkeyDemo` → 重启 demo
**预期输出**:
```
[hotkey-demo] 已启动,绑定: right_option
[hotkey-demo] 按住 right_option 测试 pressed/released
...
[hotkey] pressed ← 按下右 option
[hotkey] released ← 松开
[hotkey] pressed
[hotkey] cancelled ← 在按下期间按 Esc
```
`⌃C` 退出。
---
## 7. 后续要回头改的文档
完成本切片后必须并入架构文档 v2 重写时:
- §7「全局快捷键」:把 `KeyboardShortcuts` 库换成 `OpenLessHotkey` 模块
- §12「第三方依赖」:移除 `KeyboardShortcuts` 行
- §15「落地路线」第 3 步「全局快捷键」可标记完成
- §17「决策记录」补一条「弃用 KeyboardShortcuts library 因不支持 modifier-only push-to-talk」
---
## 8. Building 前置条件
满足以下条件即可进入 `Building macOS App` 阶段:
- ✅ `swift build` 通过
- ✅ Code Review 无 CRITICAL/HIGH 问题
- ⏸ 用户实测 demo(按上面 §6 步骤)确认主功能 OK
-823
View File
@@ -1,823 +0,0 @@
# 语音输入产品第一轮需求文档
生成日期:2026-04-26
## 1. 产品目标
本产品目标是做一个可以替代 Typeless、Wispr Flow 等付费语音输入工具的桌面语音输入软件。
第一轮不追求覆盖所有平台和所有高级能力,而是先完成一个可日常使用的核心版本:用户在电脑任意可输入文本的地方,通过快捷键说话,软件自动把口语内容整理成清楚、自然、可直接发送或继续编辑的文字,并插入到当前光标位置。
第一轮的核心判断标准是:
- 是否能明显减少打字时间。
- 是否能在常用输入场景中稳定工作。
- 是否能把随口说的话变成像认真打出来的文字。
- 是否能让用户觉得“这已经可以替代 Typeless 的基础付费价值”。
## 2. 第一轮产品定位
第一轮定位为“桌面端 AI 语音输入工具”,优先服务以下场景:
- 在 AI 对话工具中快速输入长 prompt。
- 在聊天工具中快速回复消息。
- 在邮件、文档、笔记中快速写内容。
- 在代码编辑器中输入自然语言需求、注释、提交信息或给 AI 编程助手的指令。
第一轮不做完整办公套件,也不做会议纪要工具。产品重点是“语音替代键盘输入”,而不是“录音文件转写”或“会议总结”。
## 3. 第一轮需求详情
### 3.1 桌面端常驻语音输入
第一轮需要完成一个桌面端应用,用户打开后可以常驻后台。
用户在任意应用中把光标放到输入框、编辑器、聊天框、文档或网页输入区后,可以通过一个快捷键启动语音输入。
第一轮至少需要支持:
- 快捷键开始录音。
- 快捷键结束录音。
- 录音过程中有明确状态提示。
- 结束后自动生成文字。
- 生成结果自动插入当前光标位置。
默认交互可以采用“按下快捷键开始,松开或再次按下结束”的方式。具体快捷键可以先有默认值,但需要给用户一个可理解、可修改的设置入口。
### 3.2 任意应用输入
第一轮需要实现“在用户原本打字的地方输入文字”的核心体验。
至少需要覆盖以下常见类型:
- 浏览器网页输入框。
- AI 对话框,例如 ChatGPT、Claude、Perplexity、Cursor Chat。
- 聊天软件输入框。
- 邮件编辑框。
- 笔记、文档、Markdown 编辑器。
- 代码编辑器中的文本输入区域。
如果某些应用无法直接插入,产品需要有可感知的兜底效果,例如把结果放入剪贴板,并明确提示用户可以粘贴。
### 3.3 语音转文字
第一轮需要把用户的自然语音转换成文字。
基础要求:
- 支持中文普通话输入。
- 支持英文输入。
- 支持中文和英文混合输入。
- 能识别常见英文产品名、技术名词、应用名和缩写。
- 自动添加基础标点。
- 自动处理自然停顿,让长段语音不要变成难读的一整段。
第一轮不需要追求所有语言覆盖,但中文、英文和中英混输必须是核心能力。
### 3.4 自动清理口语内容
用户说话时通常会有停顿、重复、口癖和临时改口。第一轮需要把这些内容整理成更适合阅读的文字。
需要处理:
- 去掉“嗯”“啊”“那个”“就是”“you know”等口癖。
- 去掉明显重复的词句。
- 用户中途改口时,尽量保留最终想表达的版本。
- 把过于口语化但无意义的连接词减少。
- 保持原意,不要过度改写成完全不同的内容。
示例效果:
用户说:
> 嗯你帮我跟他说一下,就是我们今天可能来不及发版本了,那个先把测试环境的问题修掉,然后明天上午再同步。
输出应接近:
> 你帮我跟他说一下,我们今天可能来不及发版本了,先把测试环境的问题修掉,明天上午再同步。
### 3.5 自动润色为可发送文本
第一轮需要提供一个默认的智能润色效果,让输出文字比原始口语更清楚、更完整。
需要达到:
- 语句通顺。
- 逻辑顺序清楚。
- 标点和段落自然。
- 语气不过度 AI 化。
- 保留用户本人的表达习惯。
- 聊天消息不要被润色得太正式。
- 邮件和文档可以更完整、更有条理。
第一轮至少提供 4 种输出模式:
- 原文模式:尽量忠实转写,只做基础标点和必要分句。
- 轻度润色:去掉明显口癖和重复,尽量保留原句式、原语气和用户习惯。
- 清晰模式:去口癖、去重复、整理句子和段落,作为默认模式。
- 正式模式:适合邮件、工作沟通、文档内容,语气更完整专业。
润色效果必须遵守以下原则:
- 结构化:当用户口述列表、步骤、计划、总结时,输出应自动变成清晰的段落、编号列表或项目符号。
- 标点化:不要让长语音变成一整段无标点文本,也不要机械地每隔几个词就加句号。
- 不过度 AI 化:避免过多套话、夸张修辞、生硬连接词和明显 AI 写作腔。
- 保留用户习惯:保留用户常用词、口吻、称呼、语气强弱和中英混合习惯。
- 不擅自扩写事实:可以整理表达,但不能增加用户没有表达过的新事实、新承诺或新结论。
### 3.6 当前应用语气适配
第一轮需要有基础的“当前场景适配”效果。
用户在不同应用中使用语音输入时,输出风格应尽量符合当前场景:
- 在聊天软件中,输出应简短、自然、像日常回复。
- 在邮件中,输出应更完整、有礼貌。
- 在文档或笔记中,输出应更结构化。
- 在代码编辑器或 AI 编程工具中,输出应更像清楚的任务说明或开发指令。
第一轮可以先做基础适配,不需要复杂自定义规则,但用户应该能明显感觉到同一段语音在不同场景下不会总是产出同一种生硬文本。
### 3.7 个人词典
第一轮需要支持个人词典,用来提升专有名词、产品名、人名、技术词和缩写的准确性。
需要支持:
- 添加词条。
- 编辑词条。
- 删除词条。
- 查看词条列表。
- 词条在后续语音输入中生效。
词典适合存放:
- 人名,例如团队成员、客户、投资人。
- 产品名,例如 Typeless、Cursor、OpenLess。
- 技术名词,例如 Supabase、PostHog、Whisper、Kubernetes。
- 公司内部缩写。
- 用户经常被识别错误的词。
第一轮个人词典不需要做团队共享,但必须为后续团队词典预留产品空间。
### 3.8 常用片段
第一轮需要支持简单的常用片段能力,用语音触发固定文本。
示例:
- 用户说“我的邮箱”,输出真实邮箱地址。
- 用户说“会议链接”,输出固定会议链接。
- 用户说“签名”,输出固定邮件签名。
- 用户说“我的地址”,输出固定地址。
第一轮需要支持:
- 创建触发词。
- 创建展开内容。
- 编辑和删除常用片段。
- 在语音输入结果中自动替换。
常用片段的目标是解决重复输入,而不是做复杂自动化。
### 3.9 最近记录
第一轮需要有本地最近记录,方便用户找回刚才的输入内容。
需要支持:
- 查看最近的语音输入结果。
- 复制某条历史结果。
- 删除某条历史结果。
- 清空全部历史。
- 用户可以关闭历史保存。
历史记录第一轮只需要保存在本机,不需要跨设备同步。
### 3.10 设置页
第一轮需要有基础设置页,让用户能够控制关键行为。
至少包括:
- 快捷键设置。
- 麦克风选择。
- 默认输出模式。
- 是否保存历史。
- 个人词典管理入口。
- 常用片段管理入口。
- 隐私说明入口。
设置页不需要复杂,但要让用户知道产品正在做什么,以及如何调整最影响体验的几个选项。
### 3.11 权限和新手引导
第一轮需要有简洁的新手引导,帮助用户完成首次设置。
需要说明:
- 为什么需要麦克风权限。
- 为什么需要在其他应用中插入文字的权限。
- 如何测试第一次语音输入。
- 如何修改快捷键。
新用户第一次打开产品后,应该能在 1-2 分钟内完成设置,并成功在一个输入框里用语音打出第一段文字。
### 3.12 隐私和数据控制
第一轮需要把隐私作为明确卖点。
用户需要能看到清楚的隐私说明:
- 语音和文本是否会被保存。
- 历史记录是否只保存在本机。
- 用户是否可以关闭历史记录。
- 用户是否可以删除历史记录。
- 用户数据不会被用于训练模型。
第一轮不一定要完成所有企业级合规能力,但要让个人用户放心日常使用。
### 3.13 润色结果检查与恢复
第一轮需要让用户对“自动润色”有基本掌控感,避免用户担心产品擅自改写。
需要支持:
- 最近一条输入可以查看原始转写和最终润色结果。
- 用户可以复制原始转写或复制润色结果。
- 用户可以对最近一条结果切换输出模式,例如从清晰模式改为原文模式或正式模式。
- 如果自动插入失败,最终润色结果必须保留在最近记录中。
- 当润色改动较明显时,界面应有轻量提示,让用户知道结果已经被整理过。
第一轮不需要做复杂 diff 对比,但需要让用户能找回原始内容,并能理解产品不是黑盒改写。
## 4. 第一轮实现效果
### 4.1 基础使用效果
用户安装并完成设置后,可以在任意常用输入场景中按快捷键说话。说完后,文字会自动出现在光标所在的位置。
用户不需要先打开一个单独转写窗口,不需要手动复制粘贴,也不需要把内容从另一个应用搬回来。
理想感受是:
> 我原来要打字的地方,现在可以直接说出来。
### 4.2 文本质量效果
输出结果应该不是机械转写,而是“可读、可用、接近可发送”的文字。
用户随口说一段话后,产品应自动完成:
- 删除无意义口癖。
- 修正明显语病。
- 加上标点。
- 整理段落。
- 保留用户真正想表达的内容。
第一轮达到的目标不是写作神器,而是让用户不用反复修正语音转写结果。
### 4.3 工作沟通效果
在聊天和邮件场景中,用户应该能快速生成自然回复。
示例:
用户说:
> 帮我回他说我们已经看到了这个问题,今天下午会先排查一下,如果是配置问题的话应该今天能修,如果是代码问题我们明天上午给他一个明确时间。
清晰模式输出应接近:
> 我们已经看到这个问题了,今天下午会先排查。如果是配置问题,应该今天可以修好;如果是代码问题,我们明天上午给你一个明确的修复时间。
正式模式输出应接近:
> 我们已经收到并关注到这个问题。今天下午会先进行排查。如果确认是配置问题,预计今天可以修复;如果涉及代码调整,我们会在明天上午同步明确的修复时间。
### 4.4 AI 编程输入效果
在 Cursor、Claude、ChatGPT 等工具中,用户应该可以用语音快速输入较长的开发需求。
示例:
用户说:
> 帮我把这个登录页面重构一下,保持现在的接口不要动,然后把错误提示统一放到表单下面,顺便加一下 loading 状态,提交的时候按钮不能重复点击。
输出应接近:
> 帮我重构这个登录页面,保持现有接口不变。请将错误提示统一展示在表单下方,并补充 loading 状态。提交过程中按钮需要禁用,避免重复点击。
### 4.5 中英混输效果
用户说中英混合内容时,输出不应该强行翻译或错误拆分。
示例:
用户说:
> 这个 feature 先不要 merge 到 main,等 Supabase 那边的 schema migration 跑完之后再开 PR。
输出应接近:
> 这个 feature 先不要 merge 到 main,等 Supabase 那边的 schema migration 跑完之后再开 PR。
### 4.6 个人词典效果
用户添加个人词典后,后续输入中相关词汇应更稳定。
例如用户添加:
- OpenLess
- lvbaiqing
- Supabase
- Wispr Flow
- Typeless
后续语音输入时,这些词应尽量按照用户设置的写法出现。
### 4.7 常用片段效果
用户设置常用片段后,可以用短语触发长文本。
示例:
用户设置:
- 触发词:我的签名
- 展开内容:Best regards,\n[用户名]
用户说:
> 麻烦你看一下这个版本,有问题随时告诉我,我的签名
输出应接近:
> 麻烦你看一下这个版本,有问题随时告诉我。
>
> Best regards,
> [用户名]
### 4.8 可恢复效果
如果文本没有成功插入,用户不应该丢失内容。
需要达到:
- 用户能在最近记录中找回刚才生成的文本。
- 用户能一键复制结果。
- 用户知道当前结果是否已经插入成功。
### 4.9 整体体验效果
第一轮完成后,产品应该能让用户在一天内真实使用多次,而不是只完成 demo。
体验目标:
- 启动快。
- 录音反馈明确。
- 等待时间可接受。
- 输出质量稳定。
- 插入失败有兜底。
- 常用设置容易找到。
- 用户愿意把它放在后台常驻。
## 5. 润色效果参考案例
以下案例来自 Typeless、Wispr Flow、Willow Voice、TalkTastic、Aqua、Superwhisper、MacWhisper 等公开产品页面或帮助文档。文档中的“我们的目标样例”不是直接照搬竞品文案,而是根据它们公开展示的能力,转化成第一轮可验收的产品样例。
### 案例 1:去口癖、去重复、保留最终意图
参考来源:[Typeless 官网](https://www.typeless.com/) 公开强调去口癖、去重复、用户中途改口时保留最终意图,并能自动格式化 spoken lists、steps、key points。
用户输入:
> 嗯这个周报我想写三点,第一点是我们这周把登录问题修完了,第二点是,第二点先别写,应该是支付这块还在测,然后第三点就是下周要把灰度发出去。
我们的目标输出:
> 这周周报可以写三点:
>
> 1. 登录问题已经修复完成。
> 2. 支付模块还在测试中。
> 3. 下周计划发布灰度版本。
验收重点:
- 删除“嗯”“这个”等无意义口癖。
- 用户说“第二点先别写”时,输出保留用户最终想表达的第二点。
- 自动把口述内容整理成编号列表。
- 不额外添加用户没有说过的进度或承诺。
### 案例 2:按润色强度输出不同版本
参考来源:[Wispr Flow Auto Cleanup](https://docs.wisprflow.ai/articles/4136931124-how-to-use-auto-cleanup-beta) 提供从原始转写到高度润色的多级清理,并允许用户控制改写程度。
用户输入:
> 小王那个明天的 demo 还是两点吧,不对三点,因为客户上午还有会,然后你帮我顺便提醒他把测试账号提前发一下。
原文模式目标输出:
> 小王,明天的 demo 还是两点吧,不对,三点,因为客户上午还有会。你帮我顺便提醒他把测试账号提前发一下。
轻度润色目标输出:
> 小王,明天的 demo 改到三点,因为客户上午还有会。你顺便提醒他提前发一下测试账号。
清晰模式目标输出:
> 小王,明天的 demo 改到三点,客户上午还有会。你顺便提醒他提前发一下测试账号。
正式模式目标输出:
> 小王,明天的 demo 时间调整为三点,因为客户上午还有会议。请你顺便提醒他提前发送测试账号。
验收重点:
- 同一段语音在不同模式下输出明显不同。
- 轻度润色最接近用户原本口吻。
- 清晰模式删除口癖并压缩表达。
- 正式模式更适合工作消息,但不增加客套废话。
### 案例 3:按应用场景调整语气
参考来源:[Wispr Flow Styles](https://docs.wisprflow.ai/articles/2368263928-how-to-setup-flow-styles) 支持按个人消息、工作消息、邮件和其他类别配置语气;[Typeless 官网](https://www.typeless.com/) 也强调不同应用使用不同 tone。
用户输入:
> 你跟他说一下我今天晚上可能没法看完,但是明天上午一定给他反馈。
聊天软件目标输出:
> 我今晚可能看不完,明天上午一定给你反馈。
工作 IM 目标输出:
> 我今天晚上可能没法全部看完,明天上午一定给你反馈。
邮件目标输出:
> 我今天晚上可能无法全部看完,但会在明天上午给你明确反馈。
验收重点:
- 聊天场景简短自然。
- 工作 IM 保留口语感,但表达更清楚。
- 邮件场景更完整,但不写成模板腔。
- 用户不需要每次手动切换模式,默认能根据当前应用做基础适配。
### 案例 4:自动标点和自然分段
参考来源:[Willow Voice 自动格式化指南](https://help.willowvoice.com/en/articles/13183983-voice-commands-and-automatic-formatting-guide) 说明产品会自动处理标点、换行、列表、段落和引号,也允许用户用语音命令精确控制。
用户输入:
> 今天先这样我们先把 onboarding 跑通明天再补设置页对了权限提示那里要简单一点不要写太长。
我们的目标输出:
> 今天先这样。我们先把 onboarding 跑通,明天再补设置页。
>
> 对了,权限提示那里要简单一点,不要写太长。
验收重点:
- 根据语义自动切句。
- 对“对了”这类转折做自然分段。
- 不把每个短停顿都变成句号。
- 保持用户随口说话的自然感。
### 案例 5:口述列表变成结构化列表
参考来源:[Willow Voice 自动格式化指南](https://help.willowvoice.com/en/articles/13183983-voice-commands-and-automatic-formatting-guide) 展示了口述编号和项目符号时,结果会变成真正的列表。
用户输入:
> 这个版本先做四件事,第一全局快捷键录音,第二说完自动插入当前输入框,第三加个人词典,第四加最近记录。
我们的目标输出:
> 这个版本先做四件事:
>
> 1. 全局快捷键录音。
> 2. 说完后自动插入当前输入框。
> 3. 添加个人词典。
> 4. 添加最近记录。
验收重点:
- 识别“第一、第二、第三、第四”为列表结构。
- 自动补齐简洁动作描述。
- 每个列表项长度相近,方便阅读。
- 不把列表写成一整段。
### 案例 6:邮件结构化
参考来源:[Willow Voice 自动格式化指南](https://help.willowvoice.com/en/articles/13183983-voice-commands-and-automatic-formatting-guide) 展示了邮件中问候语、正文和落款会被自动拆分成邮件格式;[Superwhisper Email Mode](https://superwhisper.com/docs/modes/email) 也强调把自然语音变成格式正确的邮件,同时保留自然语气。
用户输入:
> Hi Alex 谢谢你今天的时间我觉得我们可以先从 Mac 版本开始下周给你一个可以试用的 demo best Leo。
我们的目标输出:
> Hi Alex,
>
> 谢谢你今天的时间。我觉得我们可以先从 Mac 版本开始,下周给你一个可以试用的 demo。
>
> Best,
> Leo
验收重点:
- 自动识别问候语和落款。
- 中英混合不被强行翻译。
- 邮件正文清楚,但不加入额外寒暄。
- 保留用户使用英文 greeting 和 sign-off 的习惯。
### 案例 7:中途改口和回退
参考来源:[Willow Voice 自动格式化指南](https://help.willowvoice.com/en/articles/13183983-voice-commands-and-automatic-formatting-guide) 说明产品能理解说话中途的修正;[Wispr Flow Features](https://wisprflow.ai/features) 也展示了用户先说一个时间又马上改口时,结果应保留修正后的时间。
用户输入:
> 明天下午四点开会,不对,五点,四点我还有另一个 call,你帮我发给项目群。
我们的目标输出:
> 明天下午五点开会,四点我还有另一个 call。你帮我发到项目群。
验收重点:
- 保留最终时间“五点”。
- 不把“明天下午四点开会”作为有效安排。
- 保留解释原因。
- 输出应适合直接发送。
### 案例 8:原始转写和润色结果并存
参考来源:[TalkTastic Components](https://help.talktastic.com/en/articles/9654601-talktastic-components) 说明产品会展示 cleaned transcript 和 AI rewrite 两类结果,并可根据上下文选择插入版本。
用户输入:
> 帮我跟进一下那个 supabase 登录失败的问题如果今天能修就今天发不行的话明天上午先同步原因。
原始转写目标:
> 帮我跟进一下那个 Supabase 登录失败的问题,如果今天能修就今天发,不行的话明天上午先同步原因。
润色结果目标:
> 帮我跟进一下 Supabase 登录失败的问题。如果今天能修复,就今天发版;如果不行,明天上午先同步原因。
验收重点:
- 默认插入润色结果。
- 最近记录中可以看到原始转写和润色结果。
- 用户可以复制原始转写。
- 用户不满意润色时,可以切回更接近原文的版本。
### 案例 9:开发者场景结构化 prompt
参考来源:[Aqua 官网](https://aquavoice.com/) 展示了面向开发者和 AI 编程场景的 voice prompt,并能把设计 brief 整理成编号目标;[Aqua File Tagging](https://app.aquavoice.com/guide/file-tagging) 也强调开发者可用语音引用文件名。
用户输入:
> 帮我改一下 pricing page,先突出 free pro enterprise 三个套餐,然后把速度和准确率放到最前面,最后加一个对比表。
我们的目标输出:
> 帮我调整 pricing page,重点完成三件事:
>
> 1. 突出 Free、Pro、Enterprise 三个套餐。
> 2. 将速度和准确率作为页面最前面的核心卖点。
> 3. 增加一个套餐对比表,方便用户快速比较。
验收重点:
- 开发需求自动转成清楚的任务说明。
- 产品名、页面名、英文套餐名保留英文。
- 适合直接粘贴到 Cursor、Claude 或 ChatGPT。
- 不擅自设计具体 UI 方案。
### 案例 10:用样例训练个人表达习惯
参考来源:[Superwhisper Custom Mode](https://superwhisper.com/docs/modes/custom) 说明自定义模式可以添加 input/output examples 来教会 AI 预期输出;[Wispr Flow Styles](https://docs.wisprflow.ai/articles/2368263928-how-to-setup-flow-styles) 也支持添加写作样本来学习用户风格。
用户表达习惯:
- 喜欢短句。
- 不喜欢过度客套。
- 经常保留英文技术词。
- 工作消息希望直接、明确,但不要命令感太强。
用户输入:
> 你帮我跟他说这个我看了方向没问题但是实现有点绕能不能先拆小一点我们先做核心链路。
我们的目标输出:
> 我看了,方向没问题,但现在实现有点绕。能不能先拆小一点?我们先把核心链路做出来。
验收重点:
- 输出直接、短句、清楚。
- 不写成“尊敬的某某”或过度正式语气。
- 保留用户的判断强度。
- 不把语气改得像 AI 助手生成的模板消息。
### 案例 11:按应用使用不同 prompt 或风格
参考来源:[MacWhisper Dictation](https://macwhisper.helpscoutdocs.com/article/14-how-to-use-the-dictation-feature) 说明可以用 AI prompt 清理语音文本、翻译、扩展成客服邮件,并支持按应用配置不同 prompt。
用户输入:
> 这个 bug 应该是缓存没清导致的你让他先刷新一下如果还有问题再把 console 截图发我。
在聊天软件目标输出:
> 这个 bug 可能是缓存没清导致的。你让他先刷新一下,如果还有问题,再把 console 截图发我。
在客服邮件目标输出:
> 这个问题可能与缓存有关。请先尝试刷新页面。如果问题仍然存在,请将 console 截图发送给我们,方便进一步排查。
验收重点:
- 同一段语音根据目标场景生成不同输出。
- 客服邮件更完整,但不凭空承诺解决时间。
- 聊天消息保留更直接的工作沟通口吻。
## 6. 第一轮润色规划
### 6.1 润色功能目标
第一轮润色能力要解决的不是“把话写得更华丽”,而是“把自然说话变成可直接使用的文字”。
第一轮必须达到:
- 结构清楚:列表像列表,步骤像步骤,邮件像邮件,prompt 像 prompt。
- 标点自然:自动补标点和分段,但不机械切碎。
- 改写克制:只整理表达,不替用户扩写事实。
- 习惯保留:保留用户常用词、中英混输、语气强弱和表达风格。
- 可回退:用户能找回原始转写,避免润色不满意时丢失内容。
### 6.2 输出模式规划
第一轮输出模式如下:
| 模式 | 适合场景 | 输出效果 |
|---|---|---|
| 原文模式 | 记录原话、保留口语、需要精确转写时 | 只做必要标点和断句,不主动改写 |
| 轻度润色 | 日常聊天、个人消息、需要保留自然口吻时 | 删除明显口癖和重复,尽量保留原句式 |
| 清晰模式 | 默认使用场景、工作 IM、AI prompt、笔记 | 整理语序、补标点、适度压缩、结构更清楚 |
| 正式模式 | 邮件、客户沟通、文档、对外说明 | 更完整专业,但不增加空泛客套 |
默认模式为清晰模式。用户可以在设置中修改默认模式,也可以对最近一条结果切换模式。
### 6.3 场景风格规划
第一轮需要内置 4 类基础场景:
| 场景 | 典型应用 | 默认效果 |
|---|---|---|
| 个人聊天 | iMessage、微信、WhatsApp、Telegram | 简短自然,避免正式腔 |
| 工作沟通 | Slack、飞书、Teams、邮件客户端 | 清楚、礼貌、直接 |
| 文档笔记 | Notion、Obsidian、Google Docs、Apple Notes | 更结构化,适合段落和列表 |
| AI 编程 | Cursor、VS Code、Claude、ChatGPT | 输出为明确任务、约束、验收点 |
第一轮只需要基础适配,不要求用户给每个应用配置复杂规则。产品需要尽量让用户感受到:同样一句话,在聊天框、邮件和 Cursor 里不会得到完全一样的生硬文本。
### 6.4 润色 UI 规划
第一轮润色相关界面需要包含:
- 录音状态:显示正在听、正在处理、已插入或已复制到剪贴板。
- 模式选择:用户能选择原文、轻度润色、清晰模式、正式模式。
- 最近结果:展示最近一次的原始转写和润色结果。
- 结果操作:复制、重新按其他模式生成、删除记录。
- 轻量提示:当产品做了明显润色时,提示用户结果已整理。
界面目标是让润色能力“可感知但不打扰”。默认情况下用户说完就能得到结果,不需要每次确认;需要追溯时,用户可以进入最近记录查看原文和润色版本。
### 6.5 第一轮样例库规划
为了保证润色效果稳定,第一轮需要把上面的案例整理成内置验收样例库。样例库至少覆盖:
- 去口癖。
- 去重复。
- 中途改口。
- 自动标点。
- 自然分段。
- 编号列表。
- 项目符号。
- 邮件格式。
- 聊天消息。
- 工作 IM。
- AI 编程 prompt。
- 中英混输。
- 个人词典词汇。
- 常用片段展开。
- 原文和润色结果回退。
每个样例需要包含:
- 用户原始口述。
- 目标场景。
- 目标模式。
- 期望输出。
- 不能出现的问题。
### 6.6 不过度 AI 化的质量标准
第一轮需要明确哪些输出算不合格:
- 加入用户没有说过的事实、承诺、时间、原因。
- 把短消息改成过度正式的邮件体。
- 加入“希望您一切顺利”“感谢您的理解与支持”等无来源套话。
- 过度使用排比、总结、标题、小节。
- 把用户的个人口吻全部抹掉。
- 把中英混合强行翻译成全中文或全英文。
- 每句话都写得像公告或客服模板。
- 乱加感叹号、破折号、emoji 或营销语气。
合格输出应该像“用户认真打出来的文字”,而不是“AI 替用户重新写了一篇”。
### 6.7 第一轮开发任务拆分
第一轮可以拆成 6 个产品任务:
1. 完成基础输出模式:原文、轻度润色、清晰模式、正式模式。
2. 完成基础场景适配:个人聊天、工作沟通、文档笔记、AI 编程。
3. 完成结构化输出:标点、分段、编号列表、项目符号、邮件结构。
4. 完成用户习惯保留:个人词典、中英混输、常用表达不被强行改写。
5. 完成结果可控:最近记录里保留原始转写和润色结果,并支持复制或切换模式。
6. 完成润色验收样例库:用本文档案例作为第一批人工验收样例。
### 6.8 第一轮验收方式
第一轮润色能力可以按以下方式验收:
- 随机选 20 条真实口语输入,清晰模式下至少 16 条可直接发送或只需少量修改。
- 对 10 条中英混输输入,不能强行翻译技术词、产品名和文件名。
- 对 10 条含口癖或重复的输入,应删除明显口癖和重复,不损失核心意思。
- 对 5 条含中途改口的输入,应保留最终意图。
- 对 5 条列表型输入,应自动输出列表结构。
- 对 5 条邮件型输入,应自动形成问候、正文和落款。
- 用户能在最近记录中找回原始转写和润色结果。
- 用户能把同一条输入切换成不同模式,并看到明显差异。
## 7. 第一轮暂不做的内容
为保证第一轮可落地,以下能力暂不作为必须完成项:
- iOS 和 Android 语音键盘。
- Windows 完整适配。
- 团队管理和团队词典。
- 企业 SSO、SOC2、HIPAA 等合规能力。
- 会议录音、多人说话分离、会议纪要。
- 音频/视频文件批量转写。
- 复杂语音命令系统。
- 网页搜索、Ask anything、自动打开网页。
- 与第三方工具深度集成。
- 插件系统。
- 多设备同步。
- 复杂数据看板。
这些内容可以作为第二轮或商业化阶段能力。
## 8. 第二轮可考虑方向
第一轮跑通后,可以根据用户反馈扩展以下方向:
- Windows 版本。
- 移动端键盘。
- 选中文本后语音改写、翻译、总结。
- 每个应用单独配置输出风格。
- 更强的开发者模式,例如文件名识别、代码术语优化、AI 编程 prompt 模式。
- 本地优先隐私模式。
- 自带云端增强模式。
- BYOK,即用户使用自己的模型 API key。
- 团队词典和共享常用片段。
- 价格上提供低订阅或买断版本,区别于 Typeless/Wispr Flow。
## 9. 第一轮验收标准
第一轮可以按以下标准判断是否完成:
- 用户可以完成首次安装、授权、设置和测试输入。
- 用户可以在至少 5 类常见输入场景中成功语音输入。
- 中文、英文、中英混输都有可用结果。
- 默认清晰模式能自动去除明显口癖和重复。
- 用户可以在原文、轻度润色、清晰模式和正式模式之间切换。
- 正式模式能生成适合工作沟通的文本。
- 润色结果具备结构化、标点化、不过度 AI 化、保留用户习惯四个特征。
- 个人词典可以影响后续输出。
- 常用片段可以被语音触发并展开。
- 最近记录可以查看、复制、删除,并能查看原始转写和润色结果。
- 插入失败时,用户不会丢失生成内容。
- 用户能在设置中修改快捷键、麦克风、默认模式和隐私选项。
如果以上全部满足,即可认为第一轮已经具备 Typeless 类产品的核心替代基础。
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "openless-app",
"private": true,
"version": "1.1.4",
"version": "1.2.0",
"type": "module",
"scripts": {
"dev": "vite",
+414
View File
@@ -139,6 +139,137 @@ dependencies = [
"x11rb",
]
[[package]]
name = "async-broadcast"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
dependencies = [
"event-listener",
"event-listener-strategy",
"futures-core",
"pin-project-lite",
]
[[package]]
name = "async-channel"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2"
dependencies = [
"concurrent-queue",
"event-listener-strategy",
"futures-core",
"pin-project-lite",
]
[[package]]
name = "async-executor"
version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a"
dependencies = [
"async-task",
"concurrent-queue",
"fastrand",
"futures-lite",
"pin-project-lite",
"slab",
]
[[package]]
name = "async-io"
version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc"
dependencies = [
"autocfg",
"cfg-if",
"concurrent-queue",
"futures-io",
"futures-lite",
"parking",
"polling",
"rustix",
"slab",
"windows-sys 0.61.2",
]
[[package]]
name = "async-lock"
version = "3.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
dependencies = [
"event-listener",
"event-listener-strategy",
"pin-project-lite",
]
[[package]]
name = "async-process"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75"
dependencies = [
"async-channel",
"async-io",
"async-lock",
"async-signal",
"async-task",
"blocking",
"cfg-if",
"event-listener",
"futures-lite",
"rustix",
]
[[package]]
name = "async-recursion"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "async-signal"
version = "0.2.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485"
dependencies = [
"async-io",
"async-lock",
"atomic-waker",
"cfg-if",
"futures-core",
"futures-io",
"rustix",
"signal-hook-registry",
"slab",
"windows-sys 0.61.2",
]
[[package]]
name = "async-task"
version = "4.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
[[package]]
name = "async-trait"
version = "0.1.89"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "atk"
version = "0.18.2"
@@ -286,6 +417,19 @@ dependencies = [
"objc2 0.6.4",
]
[[package]]
name = "blocking"
version = "1.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21"
dependencies = [
"async-channel",
"async-task",
"futures-io",
"futures-lite",
"piper",
]
[[package]]
name = "brotli"
version = "8.0.2"
@@ -532,6 +676,15 @@ dependencies = [
"memchr",
]
[[package]]
name = "concurrent-queue"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "convert_case"
version = "0.4.0"
@@ -1070,6 +1223,12 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "endi"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
[[package]]
name = "enigo"
version = "0.2.1"
@@ -1087,6 +1246,27 @@ dependencies = [
"xkeysym",
]
[[package]]
name = "enumflags2"
version = "0.7.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef"
dependencies = [
"enumflags2_derive",
"serde",
]
[[package]]
name = "enumflags2_derive"
version = "0.7.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "env_filter"
version = "1.0.1"
@@ -1143,6 +1323,27 @@ version = "3.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59"
[[package]]
name = "event-listener"
version = "5.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab"
dependencies = [
"concurrent-queue",
"parking",
"pin-project-lite",
]
[[package]]
name = "event-listener-strategy"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
dependencies = [
"event-listener",
"pin-project-lite",
]
[[package]]
name = "fastrand"
version = "2.4.1"
@@ -1301,6 +1502,19 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
[[package]]
name = "futures-lite"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
dependencies = [
"fastrand",
"futures-core",
"futures-io",
"parking",
"pin-project-lite",
]
[[package]]
name = "futures-macro"
version = "0.3.32"
@@ -1727,6 +1941,12 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hermit-abi"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
[[package]]
name = "hex"
version = "0.4.3"
@@ -2975,6 +3195,7 @@ dependencies = [
"tauri",
"tauri-build",
"tauri-plugin-shell",
"tauri-plugin-single-instance",
"thiserror 1.0.69",
"tokio",
"tokio-tungstenite",
@@ -2996,6 +3217,16 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
name = "ordered-stream"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50"
dependencies = [
"futures-core",
"pin-project-lite",
]
[[package]]
name = "os_pipe"
version = "1.2.3"
@@ -3031,6 +3262,12 @@ dependencies = [
"system-deps",
]
[[package]]
name = "parking"
version = "2.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
[[package]]
name = "parking_lot"
version = "0.12.5"
@@ -3259,6 +3496,17 @@ version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "piper"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1"
dependencies = [
"atomic-waker",
"fastrand",
"futures-io",
]
[[package]]
name = "pkg-config"
version = "0.3.33"
@@ -3304,6 +3552,20 @@ dependencies = [
"miniz_oxide",
]
[[package]]
name = "polling"
version = "3.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218"
dependencies = [
"cfg-if",
"concurrent-queue",
"hermit-abi",
"pin-project-lite",
"rustix",
"windows-sys 0.61.2",
]
[[package]]
name = "portable-atomic"
version = "1.13.1"
@@ -4733,6 +4995,21 @@ dependencies = [
"tokio",
]
[[package]]
name = "tauri-plugin-single-instance"
version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a33a5b7d78f0dec4406b003ea87c40bf928d801b6fd9323a556172c91d8712c1"
dependencies = [
"serde",
"serde_json",
"tauri",
"thiserror 2.0.18",
"tracing",
"windows-sys 0.60.2",
"zbus",
]
[[package]]
name = "tauri-runtime"
version = "2.10.1"
@@ -4833,6 +5110,19 @@ dependencies = [
"toml 1.1.2+spec-1.1.0",
]
[[package]]
name = "tempfile"
version = "3.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.4.2",
"once_cell",
"rustix",
"windows-sys 0.61.2",
]
[[package]]
name = "tendril"
version = "0.4.3"
@@ -5214,9 +5504,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"pin-project-lite",
"tracing-attributes",
"tracing-core",
]
[[package]]
name = "tracing-attributes"
version = "0.1.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "tracing-core"
version = "0.1.36"
@@ -5286,6 +5588,17 @@ version = "1.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
[[package]]
name = "uds_windows"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [
"memoffset",
"tempfile",
"windows-sys 0.61.2",
]
[[package]]
name = "unic-char-property"
version = "0.9.0"
@@ -6564,6 +6877,67 @@ dependencies = [
"synstructure",
]
[[package]]
name = "zbus"
version = "5.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3bcbf15c8708d7fc1be0c993622e0a5cbd5e8b52bfa40afa4c3e0cd8d724ac1"
dependencies = [
"async-broadcast",
"async-executor",
"async-io",
"async-lock",
"async-process",
"async-recursion",
"async-task",
"async-trait",
"blocking",
"enumflags2",
"event-listener",
"futures-core",
"futures-lite",
"hex",
"libc",
"ordered-stream",
"rustix",
"serde",
"serde_repr",
"tracing",
"uds_windows",
"uuid",
"windows-sys 0.61.2",
"winnow 1.0.2",
"zbus_macros",
"zbus_names",
"zvariant",
]
[[package]]
name = "zbus_macros"
version = "5.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51fa5406ad9175a8c825a931f8cf347116b531b3634fcb0b627c290f1f2516ff"
dependencies = [
"proc-macro-crate 3.5.0",
"proc-macro2",
"quote",
"syn 2.0.117",
"zbus_names",
"zvariant",
"zvariant_utils",
]
[[package]]
name = "zbus_names"
version = "4.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d"
dependencies = [
"serde",
"winnow 1.0.2",
"zvariant",
]
[[package]]
name = "zerocopy"
version = "0.8.48"
@@ -6664,3 +7038,43 @@ checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
dependencies = [
"zune-core",
]
[[package]]
name = "zvariant"
version = "5.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4db0ecb8987cf5e92653c57c098f7f0e39a03112edb796f4fe089fb7eaa14ff"
dependencies = [
"endi",
"enumflags2",
"serde",
"winnow 1.0.2",
"zvariant_derive",
"zvariant_utils",
]
[[package]]
name = "zvariant_derive"
version = "5.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b949b639ab1b4bed763aa7481ba0e368af68d8b55532f8ed4bec86a59f2ca98"
dependencies = [
"proc-macro-crate 3.5.0",
"proc-macro2",
"quote",
"syn 2.0.117",
"zvariant_utils",
]
[[package]]
name = "zvariant_utils"
version = "3.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d464f5733ffa07a3164d656f18533caace9d0638596721355d73256a410d691"
dependencies = [
"proc-macro2",
"quote",
"serde",
"syn 2.0.117",
"winnow 1.0.2",
]
+1
View File
@@ -16,6 +16,7 @@ tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = ["macos-private-api", "tray-icon"] }
tauri-plugin-shell = "2"
tauri-plugin-single-instance = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] }
@@ -4,6 +4,7 @@
//! quirks are preserved verbatim — see comments tagged with `[asr]` for the
//! original learnings (especially the "definite=true is NOT stream end" bug).
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::Instant;
@@ -12,7 +13,7 @@ use parking_lot::Mutex as ParkingMutex;
use serde_json::{json, Value};
use tokio::net::TcpStream;
use tokio::runtime::Handle;
use tokio::sync::{oneshot, Mutex as AsyncMutex};
use tokio::sync::{oneshot, Mutex as AsyncMutex, Notify};
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
use tokio_tungstenite::tungstenite::http::header::HeaderValue;
use tokio_tungstenite::tungstenite::Message;
@@ -72,6 +73,10 @@ struct SyncState {
final_tx: Option<oneshot::Sender<Result<RawTranscript, VolcengineASRError>>>,
runtime: Option<Handle>,
start: Option<Instant>,
/// 最近一次 partial(非 final)的累积 transcript。服务端在 final 帧到达前
/// 关闭连接 / 网络中断时,作为 fallback 回给上层,避免「用户的话已经识别出来
/// 但没拿到 final」就丢光。
last_partial_text: String,
}
pub struct VolcengineStreamingASR {
@@ -83,6 +88,11 @@ pub struct VolcengineStreamingASR {
/// of the lifetime of any particular `&self` borrow.
writer: SharedWriter,
final_rx: ParkingMutex<Option<oneshot::Receiver<Result<RawTranscript, VolcengineASRError>>>>,
/// 在飞的 audio 帧 spawn 数。consume_pcm_chunk +1,spawn 内 send 完成 -1。
/// send_last_frame 必须等它降到 0 才能安全发末帧,否则末帧可能被服务端先收到
/// 而把后续 chunk 当成「stream 已结束」之后的多余数据丢弃 → 尾句丢失。
pending_sends: Arc<AtomicUsize>,
send_done: Arc<Notify>,
}
impl VolcengineStreamingASR {
@@ -93,6 +103,8 @@ impl VolcengineStreamingASR {
state: ParkingMutex::new(SyncState::default()),
writer: Arc::new(AsyncMutex::new(None)),
final_rx: ParkingMutex::new(None),
pending_sends: Arc::new(AtomicUsize::new(0)),
send_done: Arc::new(Notify::new()),
}
}
@@ -152,7 +164,9 @@ impl VolcengineStreamingASR {
st.final_tx = Some(tx);
st.runtime = Some(Handle::current());
st.start = Some(Instant::now());
st.last_partial_text.clear();
}
self.pending_sends.store(0, Ordering::SeqCst);
*self.final_rx.lock() = Some(rx);
*self.writer.lock().await = Some(write);
@@ -186,14 +200,17 @@ impl VolcengineStreamingASR {
}
}
Ok(Message::Close(_)) => {
// Server closed without a final frame — treat as no result.
this.signal_error(VolcengineASRError::NoFinalResult);
// 服务端没发 final 就关连接 → 用最近一次 partial 兜底,不丢已识别的文字。
this.fallback_to_partial_or_error(VolcengineASRError::NoFinalResult);
break;
}
Ok(_) => { /* ignore text/ping/pong */ }
Err(e) => {
log::error!("[asr] receive loop error: {}", e);
this.signal_error(VolcengineASRError::ConnectionFailed(e.to_string()));
// 网络中断同样回退到 partial,让用户至少拿到已经识别的部分。
this.fallback_to_partial_or_error(VolcengineASRError::ConnectionFailed(
e.to_string(),
));
break;
}
}
@@ -207,6 +224,23 @@ impl VolcengineStreamingASR {
}
pub async fn send_last_frame(&self) -> Result<(), VolcengineASRError> {
// 等所有 fire-and-forget 发送完成。否则末帧(NegativeSequence)可能比尾部
// chunk 先到服务端,被识别为「流已结束」之后再到的 chunk 全部丢弃 = 尾句吞掉。
// 给一个 800ms 上限避免极端网络下永远等。
let drain_deadline = Instant::now() + std::time::Duration::from_millis(800);
while self.pending_sends.load(Ordering::SeqCst) > 0 {
let remaining = drain_deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
log::warn!(
"[asr] send_last_frame: pending {} 帧未发送完,超时强制继续",
self.pending_sends.load(Ordering::SeqCst)
);
break;
}
// notified() 返回 future,被 timeout 包住 → 等待发送完成或超时
let _ = tokio::time::timeout(remaining, self.send_done.notified()).await;
}
// Drain leftover audio (if any) into one final positive-sequence frame.
let leftover = {
let mut st = self.state.lock();
@@ -394,6 +428,12 @@ impl VolcengineStreamingASR {
}
}
// 缓存最新的 partial transcript:服务端在 final 帧前断连时 fallback 用。
// 仅在非空且不是 final 时更新(final 走另一条路径)。
if !has_final && !full_text.is_empty() {
self.state.lock().last_partial_text = full_text.clone();
}
if has_final {
let duration_ms = self
.state
@@ -425,6 +465,34 @@ impl VolcengineStreamingASR {
let _ = tx.send(Err(err));
}
}
/// 服务端 close / 网络中断时调用:如果有缓存的 partial 文本,作为 transcript
/// 兜底返回;否则才报错。配合 `last_partial_text` 实现「至少不丢用户已识别出的话」。
fn fallback_to_partial_or_error(&self, err: VolcengineASRError) {
let (partial, duration_ms) = {
let st = self.state.lock();
(
st.last_partial_text.clone(),
st.start
.map(|s| s.elapsed().as_millis() as u64)
.unwrap_or(0),
)
};
if !partial.is_empty() {
log::warn!(
"[asr] {}; 使用 partial 兜底({} 字)",
err,
partial.chars().count()
);
self.signal_success(RawTranscript {
text: partial,
duration_ms,
});
} else {
self.signal_error(err);
}
self.state.lock().is_connected = false;
}
}
impl AudioConsumer for VolcengineStreamingASR {
@@ -464,6 +532,10 @@ impl AudioConsumer for VolcengineStreamingASR {
};
let writer = Arc::clone(&self.writer);
// pending_sends + Notify 让 send_last_frame 知道何时所有 chunk 都已发出。
self.pending_sends.fetch_add(1, Ordering::SeqCst);
let pending = Arc::clone(&self.pending_sends);
let notify = Arc::clone(&self.send_done);
runtime.spawn(async move {
let frame = frame::build(
MessageType::AudioOnlyRequest,
@@ -476,6 +548,9 @@ impl AudioConsumer for VolcengineStreamingASR {
// 把丢帧错误顶到日志里,定位"为什么服务端只收到 100ms"
log::error!("[asr] audio frame seq={} send 失败: {}", seq, e);
}
if pending.fetch_sub(1, Ordering::SeqCst) == 1 {
notify.notify_waiters();
}
});
}
}
+17 -2
View File
@@ -25,8 +25,13 @@ impl WhisperBatchASR {
/// Stop collecting audio, encode the buffer as WAV, and POST to the
/// Whisper transcriptions endpoint.
///
/// 失败时**保留** PCM buffer,让上层有机会重试或在历史中至少留一个失败记录;
/// 之前的实现一进函数就 `mem::take` 把 buffer 清空,凭证错或网络中断都会
/// 让用户的录音直接消失。
pub async fn transcribe(&self) -> Result<RawTranscript> {
let pcm = std::mem::take(&mut *self.buffer.lock());
// clone 而不是 take:~30s 16 kHz 16-bit 音频 ≈ 960 KB,会话末调用一次,可接受。
let pcm = self.buffer.lock().clone();
if pcm.is_empty() {
return Ok(RawTranscript {
text: String::new(),
@@ -34,6 +39,16 @@ impl WhisperBatchASR {
});
}
let result = self.transcribe_inner(&pcm).await;
// 仅在成功路径上才清 buffer。失败时 PCM 还在,coordinator 拿到 Err 但
// 用户重新触发 stop 时仍能再发一次,或日后增加重试入口时复用。
if result.is_ok() {
self.buffer.lock().clear();
}
result
}
async fn transcribe_inner(&self, pcm: &[u8]) -> Result<RawTranscript> {
// 16 kHz mono 16-bit: 2 bytes per sample.
let duration_ms = (pcm.len() as u64 / 2) * 1000 / 16_000;
@@ -41,7 +56,7 @@ impl WhisperBatchASR {
anyhow::bail!("Whisper API key missing");
}
let wav = encode_wav_16k_mono(&pcm);
let wav = encode_wav_16k_mono(pcm);
let base_url = self.base_url.trim_end_matches('/');
let url = format!("{}/audio/transcriptions", base_url);
+215 -29
View File
@@ -37,6 +37,11 @@ enum SessionPhase {
Starting,
Listening,
Processing,
/// 已经过了最后一次 cancel 检查、即将 / 正在调用 inserter.insert 的窗口。
/// cancel_session 在此阶段拒绝介入:Cmd+V 模拟点击已开始或已发出,
/// 无法撤销,硬把 cancelled=true 也救不回来,只会让 UI 出现 cancelled
/// 但实际还是插入了的诡异状态。详见 PR 修 Codex audit HIGH #2。
Inserting,
}
enum ActiveAsr {
@@ -47,6 +52,12 @@ enum ActiveAsr {
struct SessionState {
phase: SessionPhase,
started_at: Instant,
/// Starting 阶段(ASR 握手中)按下 stop 边沿(toggle 第二次按 / hold 松开)→
/// 等握手完成 phase=Listening 后立刻 end_session,不丢边沿。issue #51。
pending_stop: bool,
/// 用户在 Processing 阶段按 Esc 取消:end_session 在 polish/insert 检查点跳过插入 +
/// 跳过 history.append。issue #52。
cancelled: bool,
}
impl Default for SessionState {
@@ -54,6 +65,8 @@ impl Default for SessionState {
Self {
phase: SessionPhase::Idle,
started_at: Instant::now(),
pending_stop: false,
cancelled: false,
}
}
}
@@ -261,6 +274,12 @@ async fn handle_pressed(inner: &Arc<Inner>) {
(HotkeyMode::Hold, SessionPhase::Idle) => {
let _ = begin_session(inner).await;
}
// Toggle 模式 Starting 阶段第二次按 → 用户想停。
// 不能直接 end_session(ASR session 还没建好),存边沿,握手完成后立即触发。
(HotkeyMode::Toggle, SessionPhase::Starting) => {
inner.state.lock().pending_stop = true;
log::info!("[coord] toggle stop edge during Starting — queued");
}
_ => {}
}
}
@@ -270,8 +289,16 @@ async fn handle_released(inner: &Arc<Inner>) {
let phase = inner.state.lock().phase;
log::info!("[coord] hotkey released (mode={mode:?}, phase={phase:?})");
if mode == HotkeyMode::Hold {
if phase == SessionPhase::Listening {
let _ = end_session(inner).await;
match phase {
SessionPhase::Listening => {
let _ = end_session(inner).await;
}
// Hold 模式 Starting 阶段松开 → 用户想停。同上:握手完成后再 end。
SessionPhase::Starting => {
inner.state.lock().pending_stop = true;
log::info!("[coord] hold release edge during Starting — queued");
}
_ => {}
}
}
}
@@ -286,6 +313,9 @@ async fn begin_session(inner: &Arc<Inner>) -> Result<(), String> {
}
state.phase = SessionPhase::Starting;
state.started_at = Instant::now();
// 新会话清掉旧 pending_stop / cancelled,避免上一会话遗留触发奇怪行为
state.pending_stop = false;
state.cancelled = false;
}
#[cfg(any(debug_assertions, test))]
@@ -307,6 +337,7 @@ async fn begin_session(inner: &Arc<Inner>) -> Result<(), String> {
None,
);
inner.state.lock().phase = SessionPhase::Idle;
schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS);
return Err(message);
}
@@ -335,8 +366,18 @@ async fn begin_session(inner: &Arc<Inner>) -> Result<(), String> {
None,
);
inner.state.lock().phase = SessionPhase::Idle;
schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS);
return Err(e.to_string());
}
// open_session.await 期间用户可能按了 Esc / 改变心意。如果 cancel_session
// 已触发(cancelled=true 或 phase 被改回 Idle),别再装 ASR,直接善后。
// audit HIGH #1。
if cancel_raced_during_starting(inner) {
log::info!("[coord] cancel raced during ASR open_session — aborting begin");
asr.cancel();
inner.state.lock().phase = SessionPhase::Idle;
return Ok(());
}
let c: Arc<dyn crate::recorder::AudioConsumer> = Arc::new(AsrBridge {
asr: Arc::clone(&asr),
});
@@ -345,31 +386,82 @@ async fn begin_session(inner: &Arc<Inner>) -> Result<(), String> {
};
let inner_for_level = Arc::clone(inner);
// 节流:电平回调本身约 185 Hz(cpal 默认音频块),全部转发到前端会让 CSS
// transition 互相覆盖、视觉上"被平均"成静止。限制为 ~30 Hz(33ms 最少间隔),
// 配合 CSS 短 transition 让每次 emit 完整可见。
let last_emit_at = Arc::new(Mutex::new(None::<Instant>));
const LEVEL_EMIT_MIN_INTERVAL_MS: u64 = 33;
let level_handler: Arc<dyn Fn(f32) + Send + Sync> = Arc::new(move |level| {
let phase = inner_for_level.state.lock().phase;
if phase == SessionPhase::Listening || phase == SessionPhase::Starting {
let elapsed = inner_for_level
.state
.lock()
.started_at
.elapsed()
.as_millis() as u64;
emit_capsule(
&inner_for_level,
CapsuleState::Recording,
level,
elapsed,
None,
None,
);
if phase != SessionPhase::Listening && phase != SessionPhase::Starting {
return;
}
let now = Instant::now();
{
let mut last = last_emit_at.lock();
if let Some(prev) = *last {
if now.duration_since(prev).as_millis() < LEVEL_EMIT_MIN_INTERVAL_MS as u128 {
return;
}
}
*last = Some(now);
}
let elapsed = inner_for_level
.state
.lock()
.started_at
.elapsed()
.as_millis() as u64;
emit_capsule(
&inner_for_level,
CapsuleState::Recording,
level,
elapsed,
None,
None,
);
});
match Recorder::start(consumer, level_handler) {
Ok(rec) => {
*inner.recorder.lock() = Some(rec);
inner.state.lock().phase = SessionPhase::Listening;
log::info!("[coord] session started (asr={})", active_asr);
// audit HIGH #1:转 Listening 之前在同一 lock 内检查 cancel race。
// 之前是无条件 phase=Listening,会把 cancel_session 在 await 期间设的 Idle
// 反向覆盖回 Listening → 用户的 cancel 边沿被吞掉。
let outcome = {
let mut state = inner.state.lock();
if state.cancelled || state.phase != SessionPhase::Starting {
BeginOutcome::CancelRaced
} else {
state.phase = SessionPhase::Listening;
let pending = std::mem::replace(&mut state.pending_stop, false);
if pending {
BeginOutcome::PendingStop
} else {
BeginOutcome::Started
}
}
};
match outcome {
BeginOutcome::CancelRaced => {
log::info!("[coord] cancel raced during recorder start — aborting begin");
rec.stop();
if let Some(asr) = inner.asr.lock().take() {
match asr {
ActiveAsr::Volcengine(v) => v.cancel(),
ActiveAsr::Whisper(w) => w.cancel(),
}
}
inner.state.lock().phase = SessionPhase::Idle;
}
BeginOutcome::Started | BeginOutcome::PendingStop => {
*inner.recorder.lock() = Some(rec);
log::info!("[coord] session started (asr={})", active_asr);
if matches!(outcome, BeginOutcome::PendingStop) {
log::info!("[coord] applying pending_stop edge → end_session immediately");
let _ = end_session(inner).await;
}
}
}
}
Err(e) => {
log::error!("[coord] recorder start failed: {e}");
@@ -388,6 +480,7 @@ async fn begin_session(inner: &Arc<Inner>) -> Result<(), String> {
None,
);
inner.state.lock().phase = SessionPhase::Idle;
schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS);
return Err(e.to_string());
}
}
@@ -438,6 +531,7 @@ async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
None,
);
inner.state.lock().phase = SessionPhase::Idle;
schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS);
return Err(e.to_string());
}
}
@@ -455,11 +549,19 @@ async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
None,
);
inner.state.lock().phase = SessionPhase::Idle;
schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS);
return Err(e.to_string());
}
},
};
// ASR 完成后 cancel 检查:用户在 transcribe 进行中按 Esc 时,这里就会命中。
if inner.state.lock().cancelled {
log::info!("[coord] cancel detected after ASR — discarding transcript");
inner.state.lock().phase = SessionPhase::Idle;
return Ok(());
}
emit_capsule(inner, CapsuleState::Polishing, 0.0, elapsed, None, None);
let prefs = inner.prefs.get();
@@ -467,9 +569,45 @@ async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
let hotword_strs = enabled_phrases(inner);
let polished = polish_or_passthrough(&raw, mode, &hotword_strs).await;
// 原子化最后一次 cancel 检查 + 转 Inserting:
// 在同一 lock 内决定「丢弃」还是「进入 Inserting」。一旦设到 Inserting,
// cancel_session 就拒绝介入(Cmd+V 已发出,撤销不掉)。这是 audit HIGH #2 的修复,
// 之前 check 与 inserter.insert 之间有窗口期。
let proceed_to_insert = {
let mut state = inner.state.lock();
if state.cancelled {
state.phase = SessionPhase::Idle;
false
} else {
state.phase = SessionPhase::Inserting;
true
}
};
if !proceed_to_insert {
log::info!("[coord] cancel detected before insert — discarding output (chars={})", polished.chars().count());
return Ok(());
}
let status = inner.inserter.insert(&polished);
let inserted_chars = polished.chars().count() as u32;
// 累计每条 enabled 词条在最终文本中的命中次数。
// 用 polished(最终插入的文本)扫描,与用户实际看到的输出一致。
let total_hits: u64 = match inner.vocab.record_hits(&polished) {
Ok(n) => n,
Err(e) => {
log::error!("[coord] record_hits failed: {e}");
0
}
};
// 词汇本页面在打开时通常需要立即看到 hits 增长,否则用户得手动切走再切回来才刷新。
// 命中数 > 0 时通知前端:Vocab 页面订阅 vocab:updated 即时 listVocab() 重新加载。
if total_hits > 0 {
if let Some(app) = inner.app.lock().clone() {
let _ = app.emit("vocab:updated", total_hits);
}
}
let session = DictationSession {
id: Uuid::new_v4().to_string(),
created_at: Utc::now().to_rfc3339(),
@@ -481,7 +619,9 @@ async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
insert_status: status,
error_code: None,
duration_ms: Some(raw.duration_ms),
dictionary_entry_count: Some(hotword_strs.len() as u32),
// 历史详情页的"X 个热词"显示:用本次实际命中次数(每个匹配实例算一次),
// 比"启用词条总数"更能反映本段口述命中了多少。u64 → u32 截断对单段听写足够。
dictionary_entry_count: Some(total_hits.min(u32::MAX as u64) as u32),
};
if let Err(e) = inner.history.append(session) {
log::error!("[coord] history append failed: {e}");
@@ -507,12 +647,7 @@ async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
);
inner.state.lock().phase = SessionPhase::Idle;
let inner_clone = Arc::clone(inner);
async_runtime::spawn(async move {
tokio::time::sleep(std::time::Duration::from_millis(700)).await;
emit_capsule(&inner_clone, CapsuleState::Idle, 0.0, 0, None, None);
});
schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS);
Ok(())
}
@@ -522,6 +657,17 @@ fn cancel_session(inner: &Arc<Inner>) {
if phase == SessionPhase::Idle {
return;
}
// Inserting 阶段已经过了最后一次 cancel 检查 + 锁内转换,inserter.insert 即将
// 或正在执行 → Cmd+V 已发出无法撤销。这里硬设 cancelled=true 只会让 UI 显示
// "已取消" 但文本仍被插入,与用户预期相反。直接拒绝,让本次 session 走完。
if phase == SessionPhase::Inserting {
log::info!("[coord] cancel ignored — already in Inserting phase, can't undo paste");
return;
}
// Processing 阶段 cancel 不能直接干掉 in-flight polish task(已经 await 了),
// 但可以打 cancelled 标记,让 end_session 在插入前检查并丢弃结果。
inner.state.lock().cancelled = true;
if let Some(rec) = inner.recorder.lock().take() {
rec.stop();
}
@@ -531,9 +677,14 @@ fn cancel_session(inner: &Arc<Inner>) {
ActiveAsr::Whisper(w) => w.cancel(),
}
}
inner.state.lock().phase = SessionPhase::Idle;
// Processing 阶段保持 phase=Processing 让 end_session 自己走完检查 + 收尾;
// 其他阶段直接转 Idle。
if phase != SessionPhase::Processing {
inner.state.lock().phase = SessionPhase::Idle;
}
emit_capsule(inner, CapsuleState::Cancelled, 0.0, 0, None, None);
log::info!("[coord] session cancelled");
log::info!("[coord] session cancelled (was {phase:?})");
schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS);
}
// ─────────────────────────── helpers ───────────────────────────
@@ -689,6 +840,41 @@ fn enabled_phrases(inner: &Arc<Inner>) -> Vec<String> {
.collect()
}
/// 终止态(Done / Cancelled / Error)后延迟 N ms 把胶囊改回 Idle,让浮窗自动消失。
/// 用户点 ✕ / ✓ / 中途出错 / 按 Esc 都走这里,统一 2 秒。
const CAPSULE_AUTO_HIDE_DELAY_MS: u64 = 2000;
/// begin_session 中各 await 之间的 cancel race 检查结果。
enum BeginOutcome {
/// 正常进入 Listening。
Started,
/// Starting 阶段积累了 pending_stop 边沿,应立即 end_session(hold 快速松开 / toggle 快速双击)。
PendingStop,
/// 期间 cancel_session 触发(cancelled=true 或 phase 被外部改回 Idle)。
/// 必须回滚 recorder + ASR 资源,不进 Listening。
CancelRaced,
}
/// 检查 begin_session 的 await 间隙是否被 cancel_session 打断。
/// 必须在持有 state lock 的瞬间读,结果一拿就过期,所以用 helper 名字提醒只在
/// 「准备做下一步副作用前」用。
fn cancel_raced_during_starting(inner: &Arc<Inner>) -> bool {
let state = inner.state.lock();
state.cancelled || state.phase != SessionPhase::Starting
}
fn schedule_capsule_idle(inner: &Arc<Inner>, delay_ms: u64) {
let inner_clone = Arc::clone(inner);
async_runtime::spawn(async move {
tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
// 仅在仍然 Idle 时(即用户没在这 2s 内重新触发)才 hide。
// 否则可能把新启动的 Recording 状态意外覆盖回 Idle。
if inner_clone.state.lock().phase == SessionPhase::Idle {
emit_capsule(&inner_clone, CapsuleState::Idle, 0.0, 0, None, None);
}
});
}
fn emit_capsule(
inner: &Arc<Inner>,
state: CapsuleState,
+7
View File
@@ -38,6 +38,13 @@ pub fn run() {
let coordinator = Arc::new(coordinator::Coordinator::new());
tauri::Builder::default()
// 单实例锁:第二个进程启动时立即退出,激活信号转给已运行实例的主窗口。
// 否则两份 OpenLess(如 /Applications/ + dev build)会各自抓全局热键,
// 导致按一次键、两个进程同时跑流水线、文本被插入两遍。见 issue #50。
.plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| {
log::info!("[single-instance] another instance launched, focusing existing main window");
show_main_window(app);
}))
.plugin(tauri_plugin_shell::init())
.manage(coordinator.clone())
.setup(move |app| {
@@ -522,6 +522,43 @@ impl DictionaryStore {
self.write_locked(&entries)
}
/// 扫描一段最终文本,对每个 enabled 词条按出现次数累加 `hits`。
///
/// 匹配是大小写不敏感的子串扫描:「Hello hello HELLO」算 3 次。
/// 返回本次累加的总命中数,方便调用方记录到 history.dictionary_entry_count。
pub fn record_hits(&self, text: &str) -> Result<u64> {
if text.is_empty() {
return Ok(0);
}
let _guard = self.lock.lock();
let mut entries = self.read_locked()?;
if entries.is_empty() {
return Ok(0);
}
let haystack = text.to_lowercase();
let mut total: u64 = 0;
let mut changed = false;
for entry in entries.iter_mut() {
if !entry.enabled {
continue;
}
let needle = entry.phrase.trim().to_lowercase();
if needle.is_empty() {
continue;
}
let count = count_occurrences(&haystack, &needle);
if count > 0 {
entry.hits = entry.hits.saturating_add(count);
total = total.saturating_add(count);
changed = true;
}
}
if changed {
self.write_locked(&entries)?;
}
Ok(total)
}
fn read_locked(&self) -> Result<Vec<DictionaryEntry>> {
read_or_default::<Vec<DictionaryEntry>>(&self.path)
}
@@ -532,6 +569,23 @@ impl DictionaryStore {
}
}
/// 统计 `needle` 在 `haystack` 中的非重叠出现次数。两侧调用前都应已转小写。
fn count_occurrences(haystack: &str, needle: &str) -> u64 {
if needle.is_empty() || haystack.len() < needle.len() {
return 0;
}
let mut count: u64 = 0;
let mut start = 0usize;
while let Some(pos) = haystack[start..].find(needle) {
count = count.saturating_add(1);
start = start + pos + needle.len();
if start >= haystack.len() {
break;
}
}
count
}
// ───────────────────────── CredentialsVault ─────────────────────────
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
+9 -14
View File
@@ -449,28 +449,23 @@ pub mod prompts {
PolishMode::Structured => "# 任务(清晰结构)\n\
把口述整理为脉络清晰、可直接用作 AI prompt 或工作文档的结构化文本。\n\
\n\
内容涉及 \u{2265}2 个主题、步骤或要求时,强制使用以下三层层级:\n\
- 第一层(大板块):行首用 \"1.\" \"2.\" \"3.\" \u{2026},每个大板块一行短标题;\n\
- 第二层(要点):在大板块下缩进 3 个空格,行首用 \"1)\" \"2)\" \"3)\" \u{2026},每条一句;\n\
- 第三层(细分项):必要时再缩进 3 个空格,行首用 \"a.\" \"b.\" \"c.\" \u{2026}。\n\
内容涉及 \u{2265}2 个主题、步骤或要求时,使用两层层级:\n\
- 第一层(主题):行首用 \"1.\" \"2.\" \"3.\" \u{2026},每个主题一行短标题;\n\
- 第二层(要点):另起一行,行首用 \"a.\" \"b.\" \"c.\" \u{2026},每条一句。\n\
\u{4E0D}使用带括号的中间层(如 \"1)\" \"2)\")。\n\
\n\
即使原文没有显式说\u{201C}第一/第二\u{201D},只要可以归并到 \u{2265}2 个主题,也要自动归类。\n\
单一简短主题 \u{2192} 直接输出连贯段落,\u{4E0D}硬塞层级。\n\
\n\
# 示例\n\
原:发布前要做几件事,第一是回归测试,要测登录页和支付页,登录页里测正常登录、密码错和图形验证码,支付页测信用卡和微信,第二是文档要更新,要改 README 和 changelog\n\
原:发布前要做几件事,第一是回归测试,要测登录页和支付页,第二是文档要更新,要改 README 和 changelog\n\
出:\n\
1. 回归测试\n\
1) 登录页\n\
a. 正常登录。\n\
b. 密码错误提示。\n\
c. 图形验证码刷新。\n\
2) 支付页\n\
a. 信用卡支付。\n\
b. 微信支付。\n\
a. 登录页。\n\
b. 支付页。\n\
2. 文档更新\n\
1) 更新 README。\n\
2) 更新 changelog。",
a. 更新 README。\n\
b. 更新 changelog。",
PolishMode::Formal => "# 任务(正式表达)\n\
输出适合工作沟通和邮件的正式表达。\n\
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "OpenLess",
"version": "1.1.4",
"version": "1.2.0",
"identifier": "com.openless.app",
"build": {
"beforeDevCommand": "npm run dev",
+15 -3
View File
@@ -56,8 +56,9 @@ function AudioBars({ level }: AudioBarsProps) {
borderRadius: 999,
background: 'var(--ol-blue)',
opacity: 0.82,
// Swift spring(response: 0.18, damping: 0.7) 的 cubic-bezier 近似
transition: 'height 0.18s cubic-bezier(.5, 1.7, .5, 1)',
// 后端节流到 ~30 Hz(33ms),CSS 用 0.06s linear 让每次 emit 在下一次到达前
// 已经完整跳到目标高度 → 视觉上是 30 帧/s 的"跳动"动画,不会被叠加平均成静止。
transition: 'height 0.06s linear',
}}
/>
))}
@@ -142,6 +143,7 @@ function CircleButton({ variant, enabled, onClick }: CircleButtonProps) {
flexShrink: 0,
padding: 0,
boxShadow: '0 1px 2px rgba(0, 0, 0, 0.06)',
transition: 'opacity 0.15s ease-out, background 0.12s ease-out, transform 0.08s ease-out',
}}
>
{isCancel ? (
@@ -202,6 +204,12 @@ function Pill({ state, level, insertedChars, message, onCancel, onConfirm }: Pil
center = <AudioBars level={0} />;
}
// 玻璃整体随电平做微缩放 + 投影增强,让"说话时整个胶囊在呼吸"。
// 仅在录音态联动;其他态保持静止。系数控制在 ≤2%,不破坏 176×42 的视觉规格。
const ambient = state === 'recording' ? Math.min(1, Math.max(0, level)) : 0;
const scale = 1 + ambient * 0.018;
const shadowAlpha = 0.20 + ambient * 0.10;
return (
<div
style={{
@@ -219,11 +227,15 @@ function Pill({ state, level, insertedChars, message, onCancel, onConfirm }: Pil
WebkitBackdropFilter: 'blur(28px) saturate(180%)',
border: '1px solid rgba(255, 255, 255, 0.55)',
boxShadow:
'0 18px 50px -10px rgba(0, 0, 0, 0.20),' +
`0 18px 50px -10px rgba(0, 0, 0, ${shadowAlpha.toFixed(3)}),` +
' 0 0 0 0.5px rgba(0, 0, 0, 0.08),' +
' inset 0 0.5px 0 rgba(255, 255, 255, 0.55)',
color: 'var(--ol-ink)',
fontFamily: 'var(--ol-font-sans)',
transform: `scale(${scale.toFixed(4)})`,
transformOrigin: 'center',
transition: 'transform 0.06s linear, box-shadow 0.06s linear',
willChange: 'transform, box-shadow',
}}
>
<CircleButton variant="cancel" enabled={enabled} onClick={onCancel} />
@@ -150,7 +150,8 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
color: active ? 'var(--ol-ink)' : 'var(--ol-ink-3)',
fontFamily: 'inherit', fontSize: 13, fontWeight: active ? 600 : 500,
boxShadow: active ? '0 1px 2px rgba(0,0,0,.05), 0 0 0 0.5px rgba(0,0,0,.06)' : 'none',
cursor: 'default', transition: 'background .12s, color .12s',
cursor: 'default',
transition: 'background 0.12s ease-out, color 0.12s ease-out, box-shadow 0.12s ease-out',
textAlign: 'left',
}}>
@@ -220,11 +221,19 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
}}
>
<div style={{ padding: '24px 28px 32px', flex: 1, minHeight: 0, overflow: 'auto' }}>
{currentTab === 'overview' ? (
<Overview onOpenHistory={() => setCurrentTab('history')} />
) : (
<Page />
)}
{/* key={currentTab} 让每次切换重挂这棵子树 → ol-page-fade keyframe 重新触发 */}
<div
key={currentTab}
style={{
animation: 'ol-page-fade 0.18s ease-out',
}}
>
{currentTab === 'overview' ? (
<Overview onOpenHistory={() => setCurrentTab('history')} />
) : (
<Page />
)}
</div>
</div>
</main>
</div>
@@ -265,6 +274,7 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
fontSize: 11,
cursor: 'default',
padding: 0,
transition: 'opacity 0.12s ease-out',
}}
>
{t('shell.footer.checkUpdates')}
@@ -287,6 +297,19 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
onOpenSettings={openProviderSettings}
/>
)}
{/* tab 切换 + provider prompt 公用的入场关键帧 */}
<style>{`
@keyframes ol-page-fade {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes ol-prompt-fade { from { opacity: 0 } to { opacity: 1 } }
@keyframes ol-prompt-pop {
from { opacity: 0; transform: translateY(6px) scale(.97); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
`}</style>
</div>
);
}
@@ -306,6 +329,7 @@ function ProviderSetupPrompt({ onLater, onOpenSettings }: { onLater: () => void;
background: 'rgba(15,17,22,0.28)',
backdropFilter: 'blur(2px)',
WebkitBackdropFilter: 'blur(2px)',
animation: 'ol-prompt-fade 0.18s ease-out',
}}
>
<div
@@ -316,6 +340,7 @@ function ProviderSetupPrompt({ onLater, onOpenSettings }: { onLater: () => void;
border: '0.5px solid rgba(0,0,0,.08)',
boxShadow: '0 24px 70px -24px rgba(15,17,22,.38), 0 0 0 0.5px rgba(0,0,0,.06)',
padding: 20,
animation: 'ol-prompt-pop 0.22s cubic-bezier(.2,.9,.3,1.1)',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 12 }}>
@@ -353,6 +378,7 @@ function ProviderSetupPrompt({ onLater, onOpenSettings }: { onLater: () => void;
fontSize: 12.5,
fontWeight: 500,
cursor: 'default',
transition: 'background 0.12s ease-out, border-color 0.12s ease-out',
}}
>
{t('shell.providerPrompt.later')}
@@ -370,6 +396,7 @@ function ProviderSetupPrompt({ onLater, onOpenSettings }: { onLater: () => void;
fontSize: 12.5,
fontWeight: 500,
cursor: 'default',
transition: 'background 0.12s ease-out, transform 0.08s ease-out',
}}
>
{t('shell.providerPrompt.openSettings')}
@@ -398,6 +425,7 @@ function FooterIcon({ name, tip, active, onClick }: FooterIconProps) {
color: active ? 'var(--ol-ink)' : 'var(--ol-ink-4)',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
cursor: 'default',
transition: 'background 0.12s ease-out, color 0.12s ease-out',
}}>
<Icon name={name} size={15} />
</button>
@@ -244,6 +244,7 @@ function PermissionStep({ index, title, desc, status, actionLabel, onAction, dis
color: granted ? 'var(--ol-ink-3)' : '#fff',
cursor: disabled ? 'not-allowed' : 'default',
opacity: disabled && !granted ? 0.6 : 1,
transition: 'background 0.15s ease-out, color 0.15s ease-out, opacity 0.15s ease-out, transform 0.08s ease-out',
}}
>
{actionLabel}
@@ -1,6 +1,8 @@
// SettingsModal.tsx — centered sheet with sub-nav on the left.
// Ported verbatim from design_handoff_openless/variants.jsx::SettingsModal
// (plus its AccountSection / PersonalizeSection / AboutMini siblings).
//
// 设计原则:每个可见控件都必须可用。没有后端支撑的占位(账号 / 主题切换 / 启动项 /
// 开机自启)已从此弹窗移除,避免 "看似可点实际无效" 的负面体感。
// 待 backend 就位后再补回(参见 issue #69)。
import { useEffect, useState, type CSSProperties } from 'react';
import { useTranslation } from 'react-i18next';
@@ -8,8 +10,7 @@ import { Icon } from './Icon';
import { APP_VERSION_LABEL } from '../lib/appVersion';
import { Settings as SettingsContent, type SettingsSectionId } from '../pages/Settings';
import { Row } from './ui/Row';
import { SegSimple } from './ui/SegSimple';
import { SwitchLite } from './ui/SwitchLite';
import { openExternal } from '../lib/ipc';
import {
FOLLOW_SYSTEM,
getLocalePreference,
@@ -25,24 +26,39 @@ interface SettingsModalProps {
}
// 稳定 ID(与 i18n key 一致,方便 modal.sections.* 渲染)。
type ModalSectionId = 'account' | 'settings' | 'personalize' | 'about';
type ModalSectionId = 'settings' | 'personalize' | 'about';
interface ModalNavItem {
id: string;
icon: string;
external?: boolean;
href?: string;
}
interface ModalGroup {
items: ModalNavItem[];
}
const HELP_URL = 'https://github.com/appergb/openless#readme';
const RELEASE_NOTES_URL = 'https://github.com/appergb/openless/releases';
export function SettingsModal({ os: _os, onClose, initialSettingsSection }: SettingsModalProps) {
const { t } = useTranslation();
const [section, setSection] = useState<ModalSectionId>('settings');
const groups: ModalGroup[] = [
{ items: [{ id: 'account', icon: 'user' }, { id: 'settings', icon: 'settings' }, { id: 'personalize', icon: 'sparkle' }, { id: 'about', icon: 'info' }] },
{ items: [{ id: 'helpCenter', icon: 'help', external: true }, { id: 'releaseNotes', icon: 'doc', external: true }] },
{
items: [
{ id: 'settings', icon: 'settings' },
{ id: 'personalize', icon: 'sparkle' },
{ id: 'about', icon: 'info' },
],
},
{
items: [
{ id: 'helpCenter', icon: 'help', external: true, href: HELP_URL },
{ id: 'releaseNotes', icon: 'doc', external: true, href: RELEASE_NOTES_URL },
],
},
];
return (
@@ -89,7 +105,13 @@ export function SettingsModal({ os: _os, onClose, initialSettingsSection }: Sett
return (
<button
key={it.id}
onClick={() => !it.external && setSection(it.id as ModalSectionId)}
onClick={() => {
if (it.external && it.href) {
void openExternal(it.href);
} else {
setSection(it.id as ModalSectionId);
}
}}
style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '7px 10px',
@@ -99,6 +121,7 @@ export function SettingsModal({ os: _os, onClose, initialSettingsSection }: Sett
fontFamily: 'inherit', fontSize: 13, fontWeight: active ? 600 : 500,
boxShadow: active ? '0 1px 2px rgba(0,0,0,.05), 0 0 0 0.5px rgba(0,0,0,.06)' : 'none',
cursor: 'default', textAlign: 'left',
transition: 'background 0.12s ease-out, color 0.12s ease-out',
}}>
<Icon name={it.icon} size={14} />
@@ -121,7 +144,10 @@ export function SettingsModal({ os: _os, onClose, initialSettingsSection }: Sett
background: 'transparent', color: 'var(--ol-ink-3)',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
cursor: 'default',
transition: 'background 0.12s ease-out',
}}
onMouseEnter={e => (e.currentTarget.style.background = 'rgba(0,0,0,0.05)')}
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}
title={t('common.close')}>
<Icon name="close" size={14} />
@@ -130,7 +156,6 @@ export function SettingsModal({ os: _os, onClose, initialSettingsSection }: Sett
<h2 style={{ margin: '0 0 18px', fontSize: 22, fontWeight: 600, letterSpacing: '-0.02em' }}>{t(`modal.sections.${section}`)}</h2>
{section === 'settings' && <SettingsContent embedded initialSection={initialSettingsSection} />}
{section === 'account' && <AccountSection />}
{section === 'personalize' && <PersonalizeSection />}
{section === 'about' && <AboutMini />}
</div>
@@ -147,38 +172,6 @@ export function SettingsModal({ os: _os, onClose, initialSettingsSection }: Sett
);
}
function AccountSection() {
const { t } = useTranslation();
return (
<div>
<div style={{
padding: 16, borderRadius: 12,
border: '0.5px solid var(--ol-line)',
display: 'flex', alignItems: 'center', gap: 14,
}}>
<div style={{
width: 44, height: 44, borderRadius: 999,
background: 'linear-gradient(135deg, #0a0a0b, #2563eb)',
color: '#fff', fontSize: 16, fontWeight: 600,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>L</div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 14, fontWeight: 600 }}>{t('modal.account.localUser')}</div>
<div style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', marginTop: 2 }}>{t('modal.account.localUserDesc')}</div>
</div>
<button style={{
padding: '7px 14px', fontSize: 12.5, fontWeight: 500,
borderRadius: 8, border: 0, background: 'var(--ol-ink)', color: '#fff',
cursor: 'default', fontFamily: 'inherit',
}}>{t('modal.account.loginSync')}</button>
</div>
<p style={{ margin: '20px 0 0', fontSize: 12, color: 'var(--ol-ink-4)', lineHeight: 1.6 }}>
{t('modal.account.footer')}
</p>
</div>
);
}
function PersonalizeSection() {
const { t } = useTranslation();
// 玻璃强度持久化到 localStorage,并实时写入 CSS var --ol-glass-blur。
@@ -196,12 +189,6 @@ function PersonalizeSection() {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<Row label={t('modal.personalize.appearance')} desc={t('modal.personalize.appearanceDesc')}>
<SegSimple
options={[t('modal.personalize.appearanceSystem'), t('modal.personalize.appearanceLight'), t('modal.personalize.appearanceDark')]}
active={t('modal.personalize.appearanceSystem')}
/>
</Row>
<Row label={t('modal.personalize.language')}>
<LanguagePicker />
</Row>
@@ -220,15 +207,6 @@ function PersonalizeSection() {
</span>
</div>
</Row>
<Row label={t('modal.personalize.startupOpen')}>
<SegSimple
options={[t('modal.personalize.startupOverview'), t('modal.personalize.startupLast')]}
active={t('modal.personalize.startupLast')}
/>
</Row>
<Row label={t('modal.personalize.startupAtBoot')}>
<SwitchLite on />
</Row>
</div>
);
}
@@ -244,9 +222,30 @@ function AboutMini() {
<div style={{ fontSize: 12, color: 'var(--ol-ink-3)' }}>{t('modal.about.tagline')} · {APP_VERSION_LABEL}</div>
</div>
</div>
<Row label={t('modal.about.checkUpdate')}><button style={btnGhost}>{t('modal.about.checkUpdateBtn')}</button></Row>
<Row label={t('modal.about.docs')}><button style={btnGhost}>{t('modal.about.docsBtn')}</button></Row>
<Row label={t('modal.about.feedback')}><button style={btnGhost}>{t('modal.about.feedbackBtn')}</button></Row>
<Row label={t('modal.about.checkUpdate')}>
<button
style={btnGhost}
onClick={() => openExternal('https://github.com/appergb/openless/releases')}
>
{t('modal.about.checkUpdateBtn')}
</button>
</Row>
<Row label={t('modal.about.docs')}>
<button
style={btnGhost}
onClick={() => openExternal('https://github.com/appergb/openless#readme')}
>
{t('modal.about.docsBtn')}
</button>
</Row>
<Row label={t('modal.about.feedback')}>
<button
style={btnGhost}
onClick={() => openExternal('https://github.com/appergb/openless/issues')}
>
{t('modal.about.feedbackBtn')}
</button>
</Row>
<Row label={t('modal.about.privacy')} desc={t('modal.about.privacyDesc')}>
<span style={{ fontSize: 11, padding: '3px 8px', borderRadius: 999, background: 'var(--ol-blue-soft)', color: 'var(--ol-blue)', fontWeight: 500 }}>{t('modal.about.localFirst')}</span>
</Row>
@@ -259,6 +258,7 @@ const btnGhost: CSSProperties = {
border: '0.5px solid var(--ol-line-strong)',
background: '#fff', color: 'var(--ol-ink-2)',
cursor: 'default', fontFamily: 'inherit',
transition: 'background 0.12s ease-out, border-color 0.12s ease-out',
};
// 真正可用的语言切换器 —— 用原生 <select>,与 Settings → Language 分区共享同一份 localStorage 偏好。
@@ -142,6 +142,7 @@ const winBtnStyle: CSSProperties = {
justifyContent: 'center',
color: 'var(--ol-ink-3)',
cursor: 'default',
transition: 'background 0.12s ease-out, color 0.12s ease-out',
};
const winCloseBtnStyle: CSSProperties = {
+1 -1
View File
@@ -141,7 +141,7 @@ export const en: typeof zhCN = {
modes: {
raw: { name: 'Raw', desc: 'Only adds punctuation and natural breaks — no rewriting or expansion.', sample: "Keeps spoken cadence; fillers like 'um' or 'you know' get dropped, but sentences stay intact." },
light: { name: 'Light polish', desc: 'Drops fillers, adds punctuation, and produces sendable natural prose.', sample: "Makes the transcript flow well without sounding scripted — your tone and habits remain." },
structured: { name: 'Structured', desc: 'Auto-organizes into a numbered outline when you cover several topics or steps.', sample: '1. Topic one\n 1) Point a\n 2) Point b\n2. Topic two\n 1) Point c' },
structured: { name: 'Structured', desc: 'Auto-organizes into a numbered outline when you cover several topics or steps.', sample: '1. Topic one\na. Point\nb. Point\n2. Topic two\na. Point\nb. Point' },
formal: { name: 'Formal', desc: 'Email and workplace tone — more complete, more professional.', sample: 'Detects greetings/sign-offs in email contexts; avoids empty pleasantries.' },
},
},
+1 -1
View File
@@ -139,7 +139,7 @@ export const zhCN = {
modes: {
raw: { name: '原文', desc: '只补标点和必要分句,不改写不扩写。', sample: '保留原始口语;嗯、那个等口癖会被去除,但不会重组语句。' },
light: { name: '轻度润色', desc: '去口癖、补标点,整理为可发送的自然文字。', sample: '让转写听起来不像念稿——保留语气和表达习惯,但行文流畅。' },
structured: { name: '清晰结构', desc: '多个主题或步骤时,自动组织为分点列表。', sample: '1. 主题一\n 1) 要点 a\n 2) 要点 b\n2. 主题二\n 1) 要点 c' },
structured: { name: '清晰结构', desc: '多个主题或步骤时,自动组织为分点列表。', sample: '1. 主题一\na. 要点\nb. 要点\n2. 主题二\na. 要点\nb. 要点' },
formal: { name: '正式表达', desc: '工作沟通和邮件场景,更专业更完整。', sample: '邮件场景自动识别问候 / 落款;不引入空泛客套。' },
},
},
+2
View File
@@ -119,6 +119,7 @@ export function History() {
background: filter === f.id ? 'var(--ol-ink)' : 'transparent',
color: filter === f.id ? '#fff' : 'var(--ol-ink-3)',
cursor: 'default', fontFamily: 'inherit', fontWeight: 500,
transition: 'background 0.12s ease-out, color 0.12s ease-out, border-color 0.12s ease-out',
}}
>{f.label}</button>
))}
@@ -142,6 +143,7 @@ export function History() {
background: selectedId === s.id ? 'rgba(37,99,235,0.06)' : 'transparent',
boxShadow: selectedId === s.id ? 'inset 2px 0 0 var(--ol-blue)' : 'none',
cursor: 'default', fontFamily: 'inherit', marginBottom: 1,
transition: 'background 0.12s ease-out, box-shadow 0.12s ease-out',
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
+1
View File
@@ -230,6 +230,7 @@ function WeekChart({ data }: { data: number[] }) {
borderRadius: 4,
background: isToday ? 'var(--ol-blue)' : 'var(--ol-ink)',
opacity: v === 0 ? 0.15 : isToday ? 1 : 0.85,
transition: 'height 0.18s ease-out, opacity 0.18s ease-out',
}}
/>
</div>
+7 -9
View File
@@ -74,6 +74,7 @@ export function Settings({ embedded = false, initialSection = 'recording' }: Set
background: section === s ? 'rgba(0,0,0,0.04)' : 'transparent',
border: 0, borderRadius: 8, fontFamily: 'inherit', fontWeight: section === s ? 600 : 500,
cursor: 'default',
transition: 'background 0.12s ease-out, color 0.12s ease-out',
}}
>
{t(`settings.sections.${s}`)}
@@ -170,6 +171,7 @@ function RecordingSection() {
color: prefs.hotkey.mode === v ? 'var(--ol-ink)' : 'var(--ol-ink-3)',
boxShadow: prefs.hotkey.mode === v ? '0 1px 2px rgba(0,0,0,.08)' : 'none',
cursor: 'default',
transition: 'background 0.12s ease-out, color 0.12s ease-out, box-shadow 0.12s ease-out',
}}
>
{l}
@@ -197,6 +199,7 @@ function Toggle({ on, onToggle }: { on: boolean; onToggle?: (next: boolean) => v
position: 'relative', width: 32, height: 18, borderRadius: 999, border: 0,
background: on ? 'var(--ol-blue)' : 'rgba(0,0,0,0.15)',
cursor: 'default',
transition: 'background 0.15s ease-out',
}}
>
<span
@@ -222,9 +225,10 @@ type LlmPresetId = typeof LLM_PRESETS[number]['id'];
const ASR_DEFAULT_RESOURCE_ID = 'volc.bigasr.sauc.duration';
// SiliconFlow ASR 暂未在后端实现(coordinator.rs 只路由 whisper / volcengine)。
// 在后端接入前不暴露给用户,避免选了之后必然失败。重新启用见 issue #58 的 follow-up。
const ASR_PRESETS = [
{ id: 'volcengine', nameKey: 'asrVolcengine' },
{ id: 'siliconflow', nameKey: 'asrSiliconflow' },
{ id: 'whisper', nameKey: 'asrWhisper' },
] as const;
@@ -318,14 +322,6 @@ function ProvidersSection() {
<CredentialField key={`${asrProvider}:resource_id`} label="Resource ID" account="volcengine.resource_id" mono
placeholder={ASR_DEFAULT_RESOURCE_ID} defaultValue={ASR_DEFAULT_RESOURCE_ID} />
</>
) : asrProvider === 'siliconflow' ? (
<>
<CredentialField key={`${asrProvider}:api_key`} label="API Key" account="asr.api_key" mono mask />
<CredentialField key={`${asrProvider}:endpoint`} label="Base URL" account="asr.endpoint"
placeholder="https://api.siliconflow.cn/v1" defaultValue="https://api.siliconflow.cn/v1" />
<CredentialField key={`${asrProvider}:model`} label="Model" account="asr.model"
placeholder="FunAudioLLM/SenseVoiceSmall" defaultValue="FunAudioLLM/SenseVoiceSmall" />
</>
) : (
<>
<CredentialField key={`${asrProvider}:api_key`} label="API Key" account="asr.api_key" mono mask />
@@ -443,6 +439,7 @@ const inputStyle: CSSProperties = {
fontFamily: 'inherit', outline: 'none',
background: 'var(--ol-surface-2)',
width: '100%', maxWidth: 360,
transition: 'background 0.12s ease-out, border-color 0.12s ease-out',
};
const iconBtnStyle: CSSProperties = {
width: 32, height: 32,
@@ -450,6 +447,7 @@ const iconBtnStyle: CSSProperties = {
borderRadius: 8, background: 'var(--ol-surface)',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
color: 'var(--ol-ink-3)', cursor: 'default', flexShrink: 0,
transition: 'background 0.12s ease-out, border-color 0.12s ease-out, color 0.12s ease-out',
};
function ShortcutsSection() {
+3 -1
View File
@@ -86,7 +86,7 @@ export function Style() {
style={{
position: 'relative', width: 36, height: 20, borderRadius: 999, border: 0,
background: masterEnabled ? 'var(--ol-blue)' : 'rgba(0,0,0,0.15)',
cursor: 'default', transition: 'background .15s',
cursor: 'default', transition: 'background 0.15s ease-out',
}}
>
<span
@@ -115,6 +115,7 @@ export function Style() {
boxShadow: isDefault ? '0 0 0 3px var(--ol-blue-ring), var(--ol-shadow-sm)' : 'var(--ol-shadow-sm)',
opacity: isEnabled ? 1 : 0.55,
position: 'relative',
transition: 'border-color 0.15s ease-out, box-shadow 0.15s ease-out, opacity 0.15s ease-out',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
@@ -152,6 +153,7 @@ export function Style() {
position: 'relative', width: 30, height: 18, borderRadius: 999, border: 0,
background: isEnabled ? 'var(--ol-blue)' : 'rgba(0,0,0,0.15)',
cursor: 'default',
transition: 'background 0.15s ease-out',
}}
>
<span style={{
+27 -1
View File
@@ -3,7 +3,7 @@
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { addVocab, listVocab, removeVocab, setVocabEnabled } from '../lib/ipc';
import { addVocab, isTauri, listVocab, removeVocab, setVocabEnabled } from '../lib/ipc';
import type { DictionaryEntry } from '../lib/types';
import { Btn, Card, PageHeader } from './_atoms';
@@ -30,6 +30,23 @@ export function Vocab() {
useEffect(() => {
refresh();
// 订阅后端 vocab:updated:每段口述结束、record_hits 触发后由 coordinator 推送。
// Vocab 页面打开期间能即时看到命中数累加,无需切到其他 tab 再切回。
if (!isTauri) return;
let unlisten: (() => void) | undefined;
let cancelled = false;
(async () => {
const { listen } = await import('@tauri-apps/api/event');
const handle = await listen('vocab:updated', () => {
void refresh();
});
if (cancelled) handle();
else unlisten = handle;
})();
return () => {
cancelled = true;
if (unlisten) unlisten();
};
}, []);
const onAdd = async () => {
@@ -83,6 +100,7 @@ export function Vocab() {
borderRadius: 8, fontSize: 13,
fontFamily: 'inherit', outline: 'none',
background: 'var(--ol-surface-2)',
transition: 'border-color 0.12s ease-out, box-shadow 0.12s ease-out, background 0.12s ease-out',
}}
/>
<Btn variant="primary" icon="plus" onClick={onAdd}>{t('common.add')}</Btn>
@@ -108,6 +126,12 @@ export function Vocab() {
))}
</div>
</Card>
<style>{`
@keyframes ol-chip-in {
from { opacity: 0; transform: scale(.92); }
to { opacity: 1; transform: scale(1); }
}
`}</style>
</>
);
}
@@ -132,6 +156,8 @@ function VocabChip({ entry, onRemove, onToggle }: VocabChipProps) {
opacity: enabled ? 1 : 0.55,
fontSize: 12, color: 'var(--ol-ink)',
fontFamily: 'var(--ol-font-mono)',
transition: 'background 0.15s ease-out, opacity 0.15s ease-out, border-color 0.15s ease-out',
animation: 'ol-chip-in 0.18s ease-out',
}}
>
<button
+1
View File
@@ -128,6 +128,7 @@ export function Btn({ children, variant = 'ghost', size = 'md', icon, style, onC
boxShadow: v.sh,
fontFamily: 'inherit', fontWeight: 500,
cursor: 'default',
transition: 'background 0.12s ease-out, color 0.12s ease-out, border-color 0.12s ease-out, box-shadow 0.12s ease-out, transform 0.08s ease-out',
...sizes[size],
...style,
}}