diff --git a/docs/02-features.md b/docs/02-features.md index 3ff77fc..b1bc443 100644 --- a/docs/02-features.md +++ b/docs/02-features.md @@ -28,7 +28,7 @@ ### 描述 -用户在**任意应用**中,按下听写快捷键说话,松开后语音被转成文字、经**轻量文本整理模型去噪**(去语气词、修标点、保留改口后的最终意图),再注入到当前光标位置。这是 Typex 的主干路径,其他功能都是这条流水线的变体。 +用户在**任意应用**中,按下听写快捷键说话,按所选触发方式结束录音后,语音被转成文字、经**轻量文本整理模型去噪**(去语气词、修标点、保留改口后的最终意图),再注入到当前光标位置。这是 Typex 的主干路径,其他功能都是这条流水线的变体。 > 为什么必须有「整理」这一级:用户口语天然杂乱(语气词、重复、中途改口),原样上屏并不可用。「STT 原始转写 → 轻量模型整理」两级流水线是本品类的核心价值,规格详见 F-9。 @@ -36,7 +36,8 @@ ``` 按键按下 ──▶ 开始录音(HUD 出现,波形反馈) -按键松开 ──▶ 停止录音 ──▶ [所选 VAD 裁剪静音] ──▶ STT Provider 转写 +松开按键(按住说话)/ 再次按下(按下切换)──▶ 停止录音 + ──▶ [所选 VAD 裁剪静音] ──▶ STT Provider 转写 ──▶ 文本整理(F-9,默认开启,可一键切「原样模式」) ──▶ 文本注入(光标处)──▶ HUD 成功反馈后隐藏 ``` @@ -44,8 +45,9 @@ ### 规格 - **触发方式**(详见 F-5): - - **长按说话(push-to-talk)**:按住 ≥ 350 ms 进入长按模式,松开即结束。这是主推交互。 - - **短按切换(toggle)**:按一下开始,再按一下结束。两种模式同一个键自动区分,无需配置。 + - **按住说话(hold)**:按下立即开始,松开立即结束。 + - **按下切换(toggle)**:第一次按下立即开始,第二次按下立即结束;两次 keyup 都不参与状态切换,适配只发送瞬时按键事件的翻页器与第三方键盘。 + - 两种方式在设置中显式二选一,并在录音开始时快照;不再按按住时长自动推断意图。 - `Esc` 或点击 HUD 取消按钮 = 放弃当前尚未提交的流程,不产生任何输出;覆盖录音、录音收尾、转写、处理、注入提交前与失败态。全局 `Esc` 仅在成功认领当前会话取消权时吞掉该次物理按键的 down、自动重复与配对 up;空闲、设置关闭或注入已经提交时完整透传。设置中可关闭全局 `Esc` 取消,但不影响回答弹窗自身的关闭行为。 - **录音**:16-bit PCM,按设备原生采样率采集后重采样为 16 kHz 单声道(见 [06 代码架构 §7.4](06-code-architecture.md))。录音期间 HUD 显示实时波形(麦克风是否在工作必须一眼可见)。 - **松键反馈**:录音结束后先发布 `Transcribing` 状态,再在阻塞工作线程中关闭音频流、重采样、执行 VAD 与 WAV 编码;快捷键结束到 HUD 切换为「正在转写」目标 ≤ 100 ms,音频收尾期间仍可响应取消,且取消后不得启动 STT。 @@ -141,7 +143,7 @@ **规格**: -- **呼出**:仅助手快捷键(按住 = push-to-talk;短按 = 切换式录音,再按结束——与听写键语义一致)。弹窗本身没有输入能力,不能主动打开空弹窗。 +- **呼出**:仅助手快捷键,触发方式与听写键使用同一项全局设置(按住说话 / 按下切换)。弹窗本身没有输入能力,不能主动打开空弹窗。 - **弹窗内容**:顶部回显本次语音指令(有选区时附选区字数摘要),下方流式 Markdown 渲染回答,右上角 ✕ 关闭。无打字输入条、无麦克风键、无动作按钮(回答文本可直接选中复制)。 - **位置**:有选区 → 选区下方(放不下时上方);无选区 → 屏幕上 1/3 居中。 - **关闭**:✕ / `Esc` / 焦点切换到其他应用时自动关闭。回答仍在生成时,三种关闭方式都取消当前助手会话,迟到的流事件不得重新显示窗口或写入历史;回答已完成后关闭只隐藏窗口并保留成功历史。该行为不受全局「Esc 取消当前流程」设置影响。 @@ -198,7 +200,7 @@ - 三个全局快捷键:**听写**、**助手**、**翻译**(默认为全修饰键三角方案:右⌘/右Ctrl、右⌥/右Alt、两键同按;详见 [05 UX 规格 §7](05-ux-spec.md))。 - 三项配置中的每个按键数组分别表示一个可编辑的**完整 chord**,不是多个候选键:只有数组内全部物理键同时按住才触发。翻译 chord 可独立于听写与助手直接触发;默认值仍是听写键与助手键的有序组合。 - 三组 chord 必须非空。听写与助手不能相同或互为子集;翻译只不能与听写或助手完全相同,允许包含前两项的按键,也允许成为其中一项的严格子集。若当前 chord 是随后补全 chord 的严格子集,较长 chord 接管当前模式并保留音频;没有包含关系的其他已完成 chord 不改变本次手势最先启动的模式。设置界面必须阻止保存非法组合,后端也必须拒绝非法更新;读取到非法配置时仅把热键恢复为当前平台默认值,其他设置保持不变。 -- 每个键支持「长按 push-to-talk / 短按 toggle」双模(同键自动区分,阈值 350 ms,可调)。 +- 设置提供全局触发方式二选一:`hold`(按下开始、松开结束)或 `toggle`(第一次按下开始、第二次按下结束)。两者都在完整 chord 的 keydown 立即开始;`toggle` 的停止也发生在第二次 keydown,不依赖持续按下、keyup 或时长阈值。 - 支持**单个修饰键**作为触发键(如仅「右 ⌘」「右 Alt」)——这是品类标准体验,需要底层键盘监听而非普通热键 API(实现见 [06 代码架构 §7.3](06-code-architecture.md));**组合键让路规则**保证触发键的日常组合用法(⌘C 等)零干扰。 - Windows 在单修饰键 75 ms 语义确认窗口开始时立即启动仅驻内存的候选录音;确认 Typex 手势后以同一 token 提升为正式录音,不重开设备。普通键、AltGr、暂停、配置更新、hook 失败或退出会静默取消匹配候选,不显示 HUD、不播放提示音、不写历史或磁盘,也不向 Provider/电平事件发送任何候选数据。 - 自定义普通键按物理位置记录,与当前键盘布局及输入法输出字符无关;历史版本保存的后端别名在读取时迁移到稳定 `KeyId`,界面标签仍按 macOS/Windows/Linux 惯例显示。 @@ -208,7 +210,7 @@ ## F-6 托盘与常驻行为 - Typex 无主窗口,启动后驻留系统托盘/菜单栏。托盘图标反映状态:空闲 / 录音中 / 处理中 / 错误(图标规格见 [04 设计系统 §3.4](04-design-system.md))。 -- 托盘菜单:`复制上次结果`、`文本整理开关`、`翻译目标`、**`模型 ▸`(按 听写/文本整理/翻译/问答 分组列出兼容服务配置,点选即切,F-4 配置池的快速切换入口)**、`暂停 Typex`(临时禁用全局监听;进入暂停时静默取消当前录音,含短按 toggle,不中断已经进入转写/处理的会话)、`设置…`、`主页…`、`检查更新`、`退出`。 +- 托盘菜单:`复制上次结果`、`文本整理开关`、`翻译目标`、**`模型 ▸`(按 听写/文本整理/翻译/问答 分组列出兼容服务配置,点选即切,F-4 配置池的快速切换入口)**、`暂停 Typex`(临时禁用全局监听;进入暂停时静默取消当前录音,含 toggle 会话,不中断已经进入转写/处理的会话)、`设置…`、`主页…`、`检查更新`、`退出`。 - 单实例:二次启动唤起设置窗口而非新进程。 - 开机自启:默认询问(onboarding 最后一步),可随时在设置中改;应用启动与设置变更只在系统注册状态和配置不一致时写入,已关闭且不存在注册项是正常状态,不得产生警告。Windows 注册项必须使用带引号的当前 EXE 完整路径;启用时自动修复仍指向旧 debug/安装目录的条目,关闭时删除残留条目,完全一致时不重复写注册表([ADR-26](08-decisions.md))。 - 自动更新:内置 updater(GitHub Releases 渠道),默认开启检查、手动确认安装([ADR-11](08-decisions.md))。首次生成设置时,开发版(SemVer prerelease,当前为 `-dev`)默认使用 nightly 通道,纯 `MAJOR.MINOR.PATCH` 正式版默认使用 stable 通道;用户手动选择通道后按保存值检查,不由构建类型覆盖。 diff --git a/docs/03-model-providers.md b/docs/03-model-providers.md index f42b5db..3c1a3d0 100644 --- a/docs/03-model-providers.md +++ b/docs/03-model-providers.md @@ -92,7 +92,44 @@ Content-Type: application/json - F-10 词典经 `corpus.context` 传入(词条一行一个)。 - 流式识别(二进制 WS 帧协议,`wss://openspeech.bytedance.com/api/v3/sauc/bigmodel`)留待实时字幕需求出现时再实现。 -### 2.3 内置实现三:`local`(本地推理,[ADR-20](08-decisions.md)/[ADR-22](08-decisions.md)) +### 2.3 内置实现三:`mimo`(Xiaomi MiMo ASR) + +MiMo ASR 不实现 OpenAI Audio Transcriptions 协议;它使用 Chat Completions 路径承载 JSON + Base64 WAV: + +```text +POST {base_url}/chat/completions +Authorization: Bearer {api_key} +Content-Type: application/json + +{ + "model": "mimo-v2.5-asr", + "messages": [{ + "role": "user", + "content": [{ + "type": "input_audio", + "input_audio": { + "data": "data:audio/wav;base64,<完整 WAV 字节的 Base64>" + } + }] + }], + "stream": false, + "asr_options": { "language": "auto" } +} +``` + +响应文本位于 `choices[0].message.content`。`SttOptions.language` 未设置、空字符串或为 `auto` 时发送 `auto`,其他值原样发送;MiMo adapter 不发送 `prompt` 或 `temperature`。默认 Base URL 为 `https://api.xiaomimimo.com/v1`,默认模型为 `mimo-v2.5-asr`。 + +用户配置示例: + +```text +Provider: Xiaomi MiMo +Base URL: https://api.xiaomimimo.com/v1 +Model: mimo-v2.5-asr +API Key: 用户自己的 MiMo API Key +Language: auto 或 zh +``` + +### 2.4 内置实现四:`local`(本地推理,[ADR-20](08-decisions.md)/[ADR-22](08-decisions.md)) 不走 HTTP,进程内推理,实现同一个 `SttProvider` trait。按硬件档位提供两条引擎路线: @@ -105,7 +142,7 @@ Content-Type: application/json - `capabilities()` 报告:不限音频时长(本地无 25 MB 上限);错误分类只剩 `InvalidRequest`/模型未下载。 - 模型文件由**模型下载管理器**负责(见 §8):不随安装包分发,按需下载。 -### 2.4 扩展位 +### 2.5 扩展位 - `deepgram` / `elevenlabs`:各约百行的薄 adapter(改鉴权头、上传方式)。 - 流式:各家协议互不兼容(OpenAI Realtime 事件 JSON / 火山二进制帧 / Deepgram 裸推);唯一准标准是 OpenAI Realtime(阿里 Qwen3-ASR 已模仿)。故流式做成可选 capability;当前默认路径使用「快 Provider + 一次性转写」。 @@ -358,7 +395,7 @@ F-3 不引入新的 Provider 类型: ``` 功能槽位 服务池能力 实现走向 ──────────────────────────────────────────────────────────── -语音转文字 ──▶ stt profile ──▶ SttProvider(openai_compat | volcengine | local) +语音转文字 ──▶ stt profile ──▶ SttProvider(openai_compat | mimo | volcengine | local) 文本整理 ──▶ llm profile ──▶ LlmProvider + 整理 system/XML(推荐轻量快模型;可用 local) 翻译模型 ──▶ llm profile ──▶ LlmProvider + 翻译 system/XML(可用 local) 问答模型 ──▶ llm profile ──▶ LlmProvider + 处理/问答 system/XML(推荐强模型;可手动选择 local) @@ -374,7 +411,7 @@ F-3 不引入新的 Provider 类型: ```jsonc { - "schema_version": 10, + "schema_version": 11, "dictionary": { "terms": ["Typex", "OpenAI", "Qwen3-ASR"] }, @@ -402,7 +439,7 @@ F-3 不引入新的 Provider 类型: "dictation": ["ControlRight"], // 一个完整 chord,稳定物理 KeyId "assistant": ["AltRight"], "translation": ["ControlRight", "AltRight"], // 独立完整 chord;此处为默认三角键位 - "hold_threshold_ms": 350 + "trigger_mode": "hold" // hold | toggle;显式选择,不按时长推断 }, "slots": { "stt": { "active_profile": "groq-fast" }, @@ -431,6 +468,14 @@ F-3 不引入新的 Provider 类型: }, "options": { "resource_id": "volc.bigasr.auc_turbo", "enable_punc": true, "enable_itn": true } }, + { + "id": "mimo-asr", "capability": "stt", "kind": "mimo", + "label": "Xiaomi MiMo", + "base_url": "https://api.xiaomimimo.com/v1", + "model": "mimo-v2.5-asr", "timeout_ms": 60000, + "credentials": { "api_key": "用户自己的 MiMo API Key" }, + "options": { "language": "auto" } + }, { "id": "deepseek", "capability": "llm", "kind": "chat_completions", "label": "DeepSeek V3", @@ -467,9 +512,10 @@ F-3 不引入新的 Provider 类型: - schema v8 将 `hotkeys.translation` 从派生值改为独立完整 chord。v7 及更旧配置升级时仍按旧规则把听写与助手 chord 有序去重合并为翻译 chord,保持当前行为;v8 起三组 chord 分别归一化和持久化,修改任一项不再重算另外两项。 - schema v9 以四个 `*_system_prompt` 字段替换旧的 `polish_prompt` / `translate_prompt` / `process_prompt` / `ask_prompt` 模板字段。应用尚未发布,不兼容旧自定义模板:v8 及更旧配置升级时删除旧字段并把新字段置空,直接使用当前内置 system prompt。固定 XML user message 不进入配置 schema。 - schema v10 将 profile 调用超时默认值从 30 秒提高到 60 秒;迁移时把旧版 UI 自动写入的 `timeout_ms=30000` 更新为 `60000`,其他显式值保持不变。 +- schema v11 以 `hotkeys.trigger_mode`(`hold` / `toggle`)替换 `hold_threshold_ms`。旧配置迁移为 `hold` 以保持原先主推的按住说话行为;触发方式在会话开始时快照,后端不再按按住时长推断语义。 - profile 的 `timeout_ms` 是该模型服务的唯一全局调用时限,默认 `60000`。STT 覆盖从转写调用开始到完整文本返回,LLM 覆盖连接、请求发送、首 token 等待与完整流式响应接收;本地与远端实现使用相同语义。同一 profile 被多个功能或连接测试复用时统一生效,功能层不得另设总时限或 idle timeout。 - LLM `options.reasoning_effort` 控制思考等级,允许 `none` / `minimal` / `low` / `medium` / `high` / `xhigh`;设置 UI 默认保存 `none`,缺省仅表示旧配置或手写配置“不指定”。Responses 发送 `reasoning.effort`,普通 OpenAI 兼容 Chat Completions 发送顶层 `reasoning_effort`。Qwen 兼容端点与本地模型只支持开关语义,使用兼容字段 `options.enable_thinking` / `/think` / `/no_think`,其中 `none` 视为关闭,其他等级视为开启。 -- **预设模板**(前端内置数据,非后端逻辑):OpenAI / Groq / SiliconFlow / 火山·豆包 / DeepSeek / OpenRouter / Ollama —— 选中即预填 `kind/base_url/model` 与凭据字段表单,用户只贴密钥。 +- **预设模板**(前端内置数据,非后端逻辑):OpenAI / Groq / SiliconFlow / Xiaomi MiMo / 火山·豆包 / DeepSeek / OpenRouter / Ollama —— 选中即预填 `kind/base_url/model` 与凭据字段表单,用户只贴密钥。 - 「测试连接」:STT 槽发内置 2 秒样音(assets 内置,中文「你好,Typex」),LLM 槽发 `ping` 单词请求;展示延迟与分类后的错误。 ## 7. 各厂商兼容性速查(配置预设的依据) @@ -479,6 +525,7 @@ F-3 不引入新的 Provider 类型: | OpenAI | openai_compat | chat_completions / responses | 基准 | | Groq | openai_compat | chat_completions | STT 极快(whisper-turbo),语音输入首选预设 | | SiliconFlow | openai_compat(子集) | chat_completions | SenseVoice 中文;参数支持面窄 | +| Xiaomi MiMo | **mimo adapter**(`/chat/completions` JSON + Base64 WAV) | chat_completions | MiMo ASR 不是 OpenAI Audio Transcriptions 兼容接口 | | 火山引擎 · 豆包 | **volcengine adapter** | chat_completions(方舟端点) | STT 双凭据;LLM 是 OpenAI 兼容的 | | DeepSeek | — | chat_completions | 整理/翻译高性价比 | | OpenRouter | — | chat_completions / responses | 聚合网关 | diff --git a/docs/05-ux-spec.md b/docs/05-ux-spec.md index 98f0506..0676ec7 100644 --- a/docs/05-ux-spec.md +++ b/docs/05-ux-spec.md @@ -37,7 +37,7 @@ ``` - 左键点击托盘 = 打开主页;右键 = 菜单(macOS 均为菜单,符合平台惯例)。 -- 选择「暂停 Typex」立即禁用全局触发,并静默取消当前录音(含短按 toggle);已进入转写/处理的会话继续完成。恢复后只接受新的完整按键手势,不沿用暂停前的 held 状态。 +- 选择「暂停 Typex」立即禁用全局触发,并静默取消当前录音(含 toggle 会话);已进入转写/处理的会话继续完成。恢复后只接受新的完整按键手势,不沿用暂停前的 held 状态。 - Windows 低级键盘钩子在运行期失效时,立即静默取消当前录音并把托盘状态改为 `⚠ 快捷键不可用`;同一终态不得重复取消或反复提示。应用主动退出导致的正常 hook shutdown 不显示错误。 ## 3. HUD(核心界面) @@ -193,15 +193,17 @@ macOS 权限卡片:麦克风、辅助功能(注入+读选区)、输入监 | 功能 | macOS | Windows / Linux | 行为 | |---|---|---|---| -| 听写 | 按住 `右 ⌘` | 按住 `右 Ctrl` | 长按=说话,短按=切换录音 | -| 助手 | 按住 `右 ⌥` | 按住 `右 Alt` | 长按=说出指令/提问,短按=切换录音 | -| 翻译 | 按住 `右 ⌘ + 右 ⌥` | 按住 `右 Ctrl + 右 Alt` | 两键按住(不分先后)=翻译录音 | +| 听写 | `右 ⌘` | `右 Ctrl` | 按全局触发方式开始/结束听写 | +| 助手 | `右 ⌥` | `右 Alt` | 按全局触发方式开始/结束指令或提问 | +| 翻译 | `右 ⌘ + 右 ⌥` | `右 Ctrl + 右 Alt` | 两键不分先后,按全局触发方式开始/结束翻译 | 设计逻辑:**只用修饰键,不碰任何普通键**。修饰键「单独按住」在几乎所有软件里是空操作,右侧修饰键又极少参与日常组合键——这是冲突概率最低的一类键位;三键有记忆结构(A=输入,B=问答,A+B=输入的变体:翻译)。 **乐观启动与模式判定**:任一已确认触发手势开始可见会话;按住期间若补全一个严格包含当前 chord 的更长 chord,则无缝切换为后者对应模式(音频保留、HUD 徽标即时变化)。默认键位中这表现为在听写或助手手势上追加另一键后切换为翻译。Windows 默认右 Ctrl / 右 Alt 单键进入最多 75 ms 的静默确认窗口时,立即以唯一 token 启动内存候选录音;出现普通键/AltGr 则静默取消并释放,出现第二触发键并补全更长 chord 时立即确认并切换,单键超时确认则将同一流提升为正式录音。候选阶段无 HUD、提示音、电平、历史、磁盘或 Provider 副作用;单键确认后的 HUD 仍须满足按下到可见 ≤ 100 ms。 -**自定义 chord 语义**:三个 `HotkeyRecorder` 分别保存听写、助手与翻译的完整 chord;数组内全部键按住后才算手势开始,partial chord 不启动录音。三组 chord 必须非空;听写与助手不能相同或互为子集,翻译不能与任一前两项完全相同,界面就地显示校验提示并阻止保存。翻译可以包含前两项的按键,也可以是其中一项的严格子集;任一已启动 chord 被更长 chord 完整包含时,较长 chord 接管模式并保留音频,没有包含关系的额外完整 chord 则保持最先启动的模式。随后沿用既有 release 语义,等本次 tracked 触发键全部释放才结束。录制普通键时按物理位置保存稳定 `KeyId`,换 QWERTZ 等布局或切换输入法不会把同一位置变成另一绑定,展示标签仍按当前平台惯例本地化。 +**触发方式**:快捷键页提供全局二选一。`按住说话` 在完整 chord 的 keydown 开始、tracked 触发键全部释放时结束;`按下切换` 在第一次完整 chord 的 keydown 开始、第二次完整 chord 的 keydown 立即结束,期间的 keyup 只清理物理键状态。触发方式在录音开始时快照,录音中修改设置不改变当前会话;不得再用按住时长推断模式。 + +**自定义 chord 语义**:三个 `HotkeyRecorder` 分别保存听写、助手与翻译的完整 chord;数组内全部键按住后才算手势开始,partial chord 不启动录音。三组 chord 必须非空;听写与助手不能相同或互为子集,翻译不能与任一前两项完全相同,界面就地显示校验提示并阻止保存。翻译可以包含前两项的按键,也可以是其中一项的严格子集;任一已启动 chord 被更长 chord 完整包含时,较长 chord 接管模式并保留音频,没有包含关系的额外完整 chord 则保持最先启动的模式。录制普通键时按物理位置保存稳定 `KeyId`,换 QWERTZ 等布局或切换输入法不会把同一位置变成另一绑定,展示标签仍按当前平台惯例本地化。 `HotkeyRecorder` 空闲时按钮显示「更改」,录制时只把同一按钮文案切换为「按下键位…」,不得在设置行下方额外展开录制提示块。`Esc` 仍静默取消且不修改原绑定;录制完成后的快捷键冲突警告继续单独显示。Windows WebView2 若把同一物理修饰键的 keydown / keyup 报成不同侧,录制器必须按修饰键家族完成配对,并保留事件中可识别的右侧身份;若录制期间确实收到 `Unidentified` / `Process` keydown,而对应 keyup 可识别,则以 keyup 恢复该键并完成 chord,不得退化为只保存其他已按下键;没有此前未知 keydown 的孤立 keyup 必须忽略。任一 keyup 仍表明其他修饰键处于按下状态时不得提前提交,须继续监听到最后一个修饰键释放;进入该等待后 2 秒仍未收到最终 keyup,或窗口失焦时,静默取消本次录入并保留原绑定。 @@ -209,7 +211,7 @@ macOS 权限卡片:麦克风、辅助功能(注入+读选区)、输入监 1. **组合键让路**:按住触发键期间若按下任何**普通键**(用户实际在用 `⌘C`、`Ctrl+C`、`AltGr+E` 等系统/输入组合键),立即静默让路并完全放行按键。Windows 在 75 ms 确认窗口内只能存在不可见候选流,不得触发 HUD、提示音、电平或业务会话;普通组合键取消候选,确认窗口后已经启动的正式录音则立即静默取消。这条规则是单修饰键触发可行性的根基。 2. **Esc 选择性吞键**:Windows 在现有低级钩子中、macOS 在 grab event tap 中,仅吞成功认领会话的 Esc down/repeat/up;认领失败与所有非 Esc 键鼠事件原样返回。Linux 当前仍为 listen-only,待平台后端正式适配。 -3. **Windows Alt 单击吞键**:单击 Alt 会聚焦应用菜单栏,助手键短按(切换录音)时须经低级键盘钩子吞掉该次 keyup,防止菜单栏被误激活。 +3. **Windows Alt 单击吞键**:单击 Alt 会聚焦应用菜单栏,助手键作为已确认 Typex 手势时须经低级键盘钩子吞掉该次 keyup,防止菜单栏被误激活。 4. AltGr 布局(欧洲键盘):右 Alt 物理键即 AltGr——规则 1 已保证 `AltGr+字母` 打特殊字符不受影响;单独按住 AltGr 说话无副作用。 5. **漏 release 自恢复**:底层监听偶发漏掉触发键 release 时,下一次同触发键 down 不得永久静默;判定器应在短暂抖动窗口后重置旧状态并重新触发,最多丢弃上一段卡住的录音,不让快捷键进入“按了没反应”的持久状态。 6. Windows 低级钩子必须忽略 Typex 自己通过 SendInput 注入的事件;`Right Ctrl+C`、物理 `Right Alt+普通键` 与典型 AltGr 原始序列(伪 `Left Ctrl` + `Right Alt`)在确认窗口内产生普通键时,不得产生任何 HUD、提示音或录音副作用。 diff --git a/docs/06-code-architecture.md b/docs/06-code-architecture.md index b2e4e5b..d9dc4cb 100644 --- a/docs/06-code-architecture.md +++ b/docs/06-code-architecture.md @@ -112,7 +112,7 @@ typex/ │ │ ├── session.rs # SessionState 状态机 + 转移逻辑(纯逻辑,可单测) │ │ └── pipeline.rs # 听写/翻译/助手 三条流水线的步骤编排 │ ├── hotkey/ -│ │ ├── mod.rs # trait HotkeyBackend + HotkeyService(含长短按/组合判定) +│ │ ├── mod.rs # trait HotkeyBackend + HotkeyService(含完整 chord/组合判定) │ │ ├── rdev_backend.rs │ │ └── portal_backend.rs # Wayland (ashpd) │ ├── audio/ @@ -262,7 +262,7 @@ pub enum SessionPhase { | 能力 | macOS | Windows | Linux X11 | Linux Wayland | |---|---|---|---|---| -| 全局按住说话 | rdev grab(需辅助功能/输入监听权限) | 原生 `WH_KEYBOARD_LL` 消息循环 | rdev listen(XTEST/XRecord) | Portal GlobalShortcuts(ashpd);evdev 兜底 | +| 全局快捷键 down/up | rdev grab(需辅助功能/输入监听权限) | 原生 `WH_KEYBOARD_LL` 消息循环 | rdev listen(XTEST/XRecord) | Portal GlobalShortcuts(ashpd);evdev 兜底 | | 文本注入 | 剪贴板 + CGEvent Cmd+V | 剪贴板 + SendInput Ctrl+V | 剪贴板 + XTEST Ctrl+V | wtype(wlroots 系)/ ydotool·dotool(GNOME/KDE)/ 仅复制降级 | | 读选中文本 | AX API → 静音 Cmd+C 降级 | UIA TextPattern → Ctrl+C 降级 | primary selection | primary selection(部分可用)→ 仅手动粘贴降级 | | HUD 置顶浮窗 | NSPanel(不抢焦点) | 原生支持 | 原生支持 | gtk-layer-shell(GNOME 不支持 → 降级为托盘状态) | @@ -285,14 +285,14 @@ pub enum SessionPhase { 13. **Windows 安装路径不主动迁移**:NSIS 保持 `currentUser`;仅当没有历史卸载/产品登记、`$INSTDIR` 仍是 Tauri 原默认值时,installer hook 改用 `%LOCALAPPDATA%\Programs\Typex`。历史安装位置与显式 `/D=` 优先,GUI 与静默安装使用同一判断([ADR-26](08-decisions.md))。 14. **更新安装器按需提权**:各平台 updater 先以当前用户权限更新,仅当安装目标确实不可写时调用平台原生管理员认证。Windows updater 固定使用可交互的 `passive` 模式;NSIS hook 在最终 `$INSTDIR` 做临时文件创建/删除探测,可写则原权限继续,不可写才以 `runas` 重启同一个已验签安装器,并把原参数与最终 `/D=$INSTDIR` 传给提升后的进程。内部重入标记必须阻止无限提权循环;UAC 取消或提升后仍不可写时不得覆盖旧版本。NSIS 完成后使用 Tauri 的 `RunAsUser` 以普通用户重启 Typex,不得把应用进程永久提升。macOS 由 updater 在普通替换返回 `PermissionDenied` 后请求管理员认证;Linux 平台落地时沿用同一最小权限原则([ADR-28](08-decisions.md))。 -### 7.3 快捷键(push-to-talk 细节) +### 7.3 快捷键(触发语义与平台细节) - 不用 `tauri-plugin-global-shortcut` 作为主路径(无法监听单个修饰键;X11 release 有 bug)。macOS 用启用 `unstable_grab` 的 **rdev grab event tap**,X11 暂用 rdev listen-only 独立线程,Windows 用 `WH_KEYBOARD_LL` + 消息循环;三者都把归一化事件送入同一个纯判定器维护 down/up 状态——默认键位为全修饰键三角方案(见 [05 §7.1](05-ux-spec.md)),必须支持单修饰键触发。 - **稳定 `KeyId` 契约**:持久化名称以物理 `KeyboardEvent.code` 为主,至少包括 `Enter`、`Digit0..9`、`ArrowLeft/Right/Up/Down`、`AltLeft/AltRight`、`MetaLeft/MetaRight`、`Menu`、`F1..F19`、`KeyA..Z`、`Semicolon`/`Period`/`Backquote`/`BracketLeft`/`BracketRight`/`Backslash` 与 `Numpad*`。`Menu` 是对浏览器 `ContextMenu` 的稳定例外。前端 code、rdev `Key` 与 Win32 VK/scan 必须在各自 adapter 显式映射,禁止把 crate `Debug` 文本当持久化协议。`Return`→`Enter`、`Num1`→`Digit1`、`LeftArrow`→`ArrowLeft`、`AltGr`→`AltRight`、`Alt`→`AltLeft`、`ContextMenu`→`Menu`、`SemiColon`→`Semicolon`、`Dot`→`Period`、`Kp*`→`Numpad*` 等旧名仅作读取/迁移别名。 - 字母、数字行与标点是**物理位置语义**:浏览器读取 `KeyboardEvent.code`;左右修饰键额外用标准 `KeyboardEvent.location` 校正侧别,以兼容 Windows WebView2 将物理右 Shift 的 `code` 误报为 `ShiftLeft` 的情况。同一物理修饰键的 keydown / keyup 若被误报成不同侧,录制器按 `Shift` / `Control` / `Alt` / `Meta` 家族配对,并在任一事件能确认右侧时保存右侧 `KeyId`;只有录制期间已观察到 `Unidentified` / `Process` keydown 时,后续可识别但无法配对的 keyup 才用于恢复缺失键,其他孤立 keyup 忽略。keyup 的 `ctrlKey` / `shiftKey` / `altKey` / `metaKey` 仍为真时继续监听,防止先释放已识别键后过早提交;等待最终 keyup 超过 2 秒或窗口失焦则静默取消且不修改绑定。`location` 只修正这四类修饰键的左右侧,不参与普通键映射。Windows 普通键优先按低级 hook 的 set-1 scan code 解码(VK 只作 scan 缺失时的后备),rdev 使用其物理 `Key` variant。不得用 `KeyboardEvent.key`、当前布局产生的字符或输入法结果作为绑定 ID。 - 听写、助手与翻译的 `Vec` 各表示一个独立完整 chord;判定器把三组所有按键纳入 global physical-held 集合,只有某 chord 全部 held 才发 `TriggerDown`。独立翻译 chord 可直接启动翻译;若随后完成的 chord 严格包含当前 chord,判定器发 `ModeUpgraded(target_mode)` 让较长 chord 接管并保留音频。没有包含关系且长度不更长的额外完整 chord 不改变本次手势最先启动的模式。partial chord 从未启动时,全释放不得误发 `TriggerUp`;手势已启动后仍等本次 tracked 触发键全部释放才发一次 `TriggerUp`。 - 三组 chord 必须非空;听写与助手不得相同或互为子集,翻译不得与听写或助手完全相同,但允许双方存在严格子集关系。前端在 IPC 前阻止,`SettingsService::update` 再以 `InvalidRequest` 拒绝;启动读取到历史非法值时只恢复 `HotkeySettings::default()` 并写无键值 warning,其他设置不得丢失。 -- **组合键让路规则(核心)**:触发键按住期间收到任何**普通键** down 事件 → 判定用户在使用系统组合键(`⌘C`、`Ctrl+C`、`AltGr+E` 等),立即静默让路、不产生任何输出、按键完全放行。Windows adapter 对默认右 Ctrl / 右 Alt 的 `TriggerDown` 语义最多暂存 75 ms,同时立即发出带唯一 token 的内部 `CaptureCandidateStarted`;AudioService 异步开流并只在内存积累样本。窗口内普通键/AltGr 发匹配取消,双触发键或超时确认让 `TriggerDown` 携带同 token 并提升现有流。暂停、配置更新、hook 失败/终止与退出也必须取消未决 token;原始 down 时间戳必须保留用于 350 ms 长短按判定。 +- **组合键让路规则(核心)**:触发键按住期间收到任何**普通键** down 事件 → 判定用户在使用系统组合键(`⌘C`、`Ctrl+C`、`AltGr+E` 等),立即静默让路、不产生任何输出、按键完全放行。Windows adapter 对默认右 Ctrl / 右 Alt 的 `TriggerDown` 语义最多暂存 75 ms,同时立即发出带唯一 token 的内部 `CaptureCandidateStarted`;AudioService 异步开流并只在内存积累样本。窗口内普通键/AltGr 发匹配取消,双触发键或超时确认让 `TriggerDown` 携带同 token 并提升现有流。暂停、配置更新、hook 失败/终止与退出也必须取消未决 token。 - **漏 release 自恢复**:rdev/CGEventTap 偶发漏掉触发键 release 时,`HotkeyDetector` 会残留 held 状态。修饰键正常不会自动连发,因此同一触发键在 250 ms 抖动窗口后再次 down 视为上一轮 release 丢失:旧 held 状态重置,必要时向状态机发 `Yielded` 取消卡住的录音,再发新的 `TriggerDown`,保证下一次按键恢复响应。 - **配置热更新边界**:settings 桥接只广播归一化后确实变化的三组 chord 或 `esc_cancels`,判定器和 Windows adapter 仍须对相同配置幂等;只切换 `esc_cancels` 不得结束或重置当前 chord。任一 chord 改绑时若旧 chord 已触发,backend 必须先发送一次配对 `TriggerUp` 再替换配置;旧物理键随后到达的 release 只用于清理,不得再次结束会话。partial chord 从未触发则直接清空且不产生语义事件;Windows 75 ms 内尚未确认的候选必须发送匹配 token 的取消事件,禁止把未确认手势提交给会话状态机。已确认的 Windows 右 Alt 手势在改绑后保留一次性 release tombstone,只吞对应的下一次物理 RAlt keyup;未确认候选、AltGr、让路和未配置路径不得设置该 tombstone。 - **Esc 会话门闩**:orchestrator 用线程安全门闩发布当前可取消 `session_id`。Recording、Transcribing、Processing、Failed 直接可认领;Injecting 绑定该会话现有 `InjectionLatch`,Esc 取消与首个 OS 输入提交竞争同一原子状态。认领成功只生成一次 `EscPressed { session_id }`,执行器必须校验该 ID 仍是活动会话;Idle、设置关闭、过期 ID 或注入已提交均认领失败。平台 adapter 只对成功认领的物理 Esc 序列吞 down、重复与配对 up,后续新 Esc 和其他键鼠事件完整放行。 @@ -300,7 +300,7 @@ pub enum SessionPhase { - Windows 事件解码必须区分 `VK_RCONTROL`、`VK_RMENU`、扩展键与 `LLKHF_INJECTED`;Typex 自己的 SendInput 事件不得反向触发会话。右 Ctrl / Ctrl+C、物理右 Alt / 普通键和 AltGr 常见的伪 `Left Ctrl` + `Right Alt` 序列必须经过同一 75 ms 副作用确认边界;只有已确认的右 Alt 助手/翻译手势可以吞对应 keyup。 - Windows hook 的 health watch 是运行期安全信号而不只是诊断查询:从 Healthy 进入 `Failed` 或意外 `Stopped` 时,runner 必须只发送一次会话 `Cancel` 并刷新托盘为「快捷键不可用」,防止漏掉 TriggerUp 后持续占用麦克风。callback panic 或事件通道关闭进入 `Failed` 时,hook state 必须原子禁止后续 raw event、退出消息循环并卸钩,且该 `Failed` 不得在 `WM_QUIT` 收尾时被覆盖为 `Stopped`。启动失败走同一个可订阅 health 状态;应用主动退出使用独立的正常 `Shutdown` 终态,不取消、不报错。 - rdev backend 必须观察暂停 watch 的版本变化而不只读取最终布尔值;任一暂停/恢复 transition 都先清空 detector held 状态,暂停期间到达的 release 不得在恢复后留下 stale gesture。 -- 长按/短按判定:press 后 350 ms 内 release = toggle(三种模式一致,含助手);超过 = push-to-talk(release 即停止)。**乐观启动**:非 Windows 默认修饰键在触发键按下即开始录音;Windows 默认右侧修饰键在 75 ms 内确认,但麦克风候选流从原始 keydown 即开始,确认时原位提升且不重开设备。普通组合键路径取消候选并保持完全静默。 +- **显式触发语义**:`HotkeyTriggerMode` 在会话开始时快照。`hold` 的首个配对 `TriggerUp` 停止录音;`toggle` 忽略 release,并在下一次 `TriggerDown` 立即停止(三种功能模式一致,助手不再等待第二次 release)。状态机不读取 `held_ms` 推断用户意图。**乐观启动**:非 Windows 默认修饰键在触发键按下即开始录音;Windows 默认右侧修饰键在 75 ms 内确认,但麦克风候选流从原始 keydown 即开始,确认时原位提升且不重开设备。普通组合键路径取消候选并保持完全静默。 - Wayland:探测 `XDG_SESSION_TYPE`;优先 `ashpd` 走 `org.freedesktop.portal.GlobalShortcuts`(KDE/GNOME≥48/Hyprland 支持,Activated/Deactivated 信号天然支持按住;注意 Portal 快捷键由 compositor 分配,未必能绑到「单独的右⌥」,此时默认键退化为 compositor 允许的组合键);不可用时提示 evdev 方案(用户加入 `input` 组)或 compositor 绑定 `typex toggle` CLI 命令(经 single-instance 转发)。 ### 7.4 录音 diff --git a/docs/07-testing.md b/docs/07-testing.md index ee88eab..94d78e9 100644 --- a/docs/07-testing.md +++ b/docs/07-testing.md @@ -35,10 +35,10 @@ 对 `advance(state, event) -> (state, Vec)` 做穷举式表驱动测试。**必须逐条覆盖的场景清单**(与 [02 功能规格](02-features.md)、[05 §7](05-ux-spec.md) 对齐,新增行为先加进这张表): -- 长按/短按:349ms 释放 = toggle 开始;351ms 释放 = push-to-talk 结束;toggle 模式下听写/翻译在第二次 keydown 立即结束,助手模式等待完整 chord 释放后结束(确保读取选区时触发修饰键已松开)。 +- 显式触发方式:`hold` 在首次 keyup 结束;`toggle` 忽略首次 keyup,并在第二次 keydown 立即结束,听写/翻译/助手三种模式一致;录音中修改设置不改变当前会话已快照的触发方式。 - 组合键:默认键位录音中追加第二触发键 → 模式切为翻译且音频保留;自定义配置中,较短 chord 启动后补全严格包含它的较长 chord → 切为后者模式,顺序不影响最终模式。 - **组合键让路**:触发键按住期间出现普通键 down → 会话静默取消,Effect 仅含取消/释放录音(`CancelRecording` + `ReleaseAudio`),无 `EmitUi`、注入或提示音。 -- 暂停:Recording 的 push-to-talk 与短按 toggle 两种状态收到托盘暂停发来的 `Cancel` → Idle + 释放录音;Transcribing/Processing 不被暂停强杀。 +- 暂停:Recording 的 hold 与 toggle 两种状态收到托盘暂停发来的 `Cancel` → Idle + 释放录音;Transcribing/Processing 不被暂停强杀。 - 重按忽略:Transcribing/Processing/Injecting 中触发键按下 → 状态不变,Effect = `EmitUi(busy-hint)`;Failed 中按下 → 放弃旧会话开新录音。 - 录音结束两阶段:松键先进入 Transcribing,Effect 严格为 `EmitUi` → `StopRecording`;匹配的 `RecordingFinished` 才产生 `CallStt`,取消后迟到的完成事件无 Effect。 - Esc:门闩在开关启用时允许 Recording、Transcribing、Processing、注入尚未提交的 Injecting 与 Failed 按当前 `session_id` 认领并回到 Idle;Idle、开关关闭、过期 ID 与注入已经提交时认领失败。覆盖首次 down、自动重复、配对 up、取消后新 Esc,以及取消赢/提交赢两种注入竞争结果。 @@ -55,9 +55,9 @@ | 对象 | 重点用例 | |---|---| -| hotkey 判定器(独立于 OS backend 的纯逻辑层) | `KeyId` 别名归一化表(Enter/Return、Digit/Num、Arrow/LeftArrow、AltRight/AltGr、Meta/Win、Menu、标点、字母数字、F13–F19、Numpad/Kp);三组多键 chord 的 partial/完整/乱序、独立翻译直触发、较短 chord 补全后由严格包含它的较长 chord 接管,空值、听写/助手互含及翻译与另两项完全相同均拒绝,翻译为另两项严格子集合法;partial 全释放无事件、已激活手势等全部 tracked 键释放才 Up;active single/multi chord 配置热更新先 Up、旧 release 不重复,partial 更新无事件,相同配置与无关 settings 更新完全幂等;Esc 与普通键让路严格分离,`esc_cancels=false` 时活动/空闲/Windows 75 ms 候选阶段均不取消,只切换该设置不得重置 chord;Esc 序列只在首次 down 认领,成功时吞 repeat/up 且只发一次会话事件,失败时完整透传;rdev 暂停 transition 清空 held;修饰键 down/up 与 349/351ms 边界;Windows scan code 物理位置不随布局 VK 漂移且右 Shift 绑定可端到端触发;75 ms 确认窗内 Right Ctrl+C、物理 RAlt+普通键和 AltGr 伪 LCtrl+RAlt 均无语义事件/副作用,单键确认 ≤100 ms,快速释放保留原始 held_ms;`LLKHF_INJECTED` 被忽略;仅已确认助手手势吞 RAlt keyup,配置更新后对应旧 RAlt keyup 仍恰好吞一次;callback terminal Failed 后 raw event 零产出且 `WM_QUIT` 不覆盖失败;漏 release 后 stale duplicate down 重置恢复,普通键 auto-repeat 不得误判 stale release | +| hotkey 判定器(独立于 OS backend 的纯逻辑层) | `KeyId` 别名归一化表(Enter/Return、Digit/Num、Arrow/LeftArrow、AltRight/AltGr、Meta/Win、Menu、标点、字母数字、F13–F19、Numpad/Kp);三组多键 chord 的 partial/完整/乱序、独立翻译直触发、较短 chord 补全后由严格包含它的较长 chord 接管,空值、听写/助手互含及翻译与另两项完全相同均拒绝,翻译为另两项严格子集合法;partial 全释放无事件、已激活手势等全部 tracked 键释放才 Up;active single/multi chord 配置热更新先 Up、旧 release 不重复,partial 更新无事件,相同配置与无关 settings 更新完全幂等;Esc 与普通键让路严格分离,`esc_cancels=false` 时活动/空闲/Windows 75 ms 候选阶段均不取消,只切换该设置不得重置 chord;Esc 序列只在首次 down 认领,成功时吞 repeat/up 且只发一次会话事件,失败时完整透传;rdev 暂停 transition 清空 held;修饰键 down/up 与原始 held_ms 透传;Windows scan code 物理位置不随布局 VK 漂移且右 Shift 绑定可端到端触发;75 ms 确认窗内 Right Ctrl+C、物理 RAlt+普通键和 AltGr 伪 LCtrl+RAlt 均无语义事件/副作用,单键确认 ≤100 ms;`LLKHF_INJECTED` 被忽略;仅已确认助手手势吞 RAlt keyup,配置更新后对应旧 RAlt keyup 仍恰好吞一次;callback terminal Failed 后 raw event 零产出且 `WM_QUIT` 不覆盖失败;漏 release 后 stale duplicate down 重置恢复,普通键 auto-repeat 不得误判 stale release | | Windows 候选录音 adapter | 原始触发键立即发候选 token;75 ms 确认携带同 token 提升;Ctrl+C/物理 RAlt+普通键/AltGr 匹配取消且无可见副作用;`translation=[AltRight]` 在 AltGr 伪 LCtrl+RAlt 路径同样从原始 down 预启动并提升为翻译;双键翻译复用候选;快速释放保留原始 held_ms;暂停、配置更新、hook 失败/意外终止与退出清理未决 token | -| Windows hook health monitor | Healthy/Starting 与暂停态不误取消;运行期 Failed/意外 Stopped 对 push-to-talk/toggle 统一发一次 `Cancel`;重复终态不重复;启动失败使用同一可订阅状态;主动 Shutdown 静默 | +| Windows hook health monitor | Healthy/Starting 与暂停态不误取消;运行期 Failed/意外 Stopped 对 hold/toggle 统一发一次 `Cancel`;重复终态不重复;启动失败使用同一可订阅状态;主动 Shutdown 静默 | | Windows 音频转换与设备解析 | WASAPI 常见 `f32/i16/u16` → mono f32 的边界值、声道混合、重采样长度;有界缓冲溢出计数;endpoint ID 精确选择;旧 display name 唯一匹配迁移;同名歧义/固定设备缺失;设备拔出/stream error 脱敏分类与主动通知 | | Windows 坐标与完整性纯逻辑 | mixed-DPI、负坐标与 work area 转换;目标完整性高于 Typex 时判定 UIPI 降级,不触发自动提权 | | VAD 与切片(`audio/vad.rs` / `audio/pipeline.rs`) | schema v7 迁移与门限校验;能量/神经网络两条路径;Silero 初始化/推理失败降级;弱声连续 90 ms 保底与纯静音拒绝;首部 300 ms/尾部 150 ms 非对称 padding;长录音切片沿用录音快照;短音频不切、超长无静音音频强制切片 | @@ -75,10 +75,10 @@ ### 4.1 Provider × wiremock(每个 adapter 一个测试文件) -对 `openai_compat` / `volcengine` / `chat_completions` / `responses` 各建 wiremock 服务端,断言**请求构造**与**响应解析**两个方向: +对 `openai_compat` / `mimo` / `volcengine` / `chat_completions` / `responses` 各建 wiremock 服务端,断言**请求构造**与**响应解析**两个方向: -- 请求:URL 拼接(base_url 带/不带尾斜杠)、鉴权头(Bearer vs 火山四件套 header)、multipart 字段完整性、自定义 extra_headers/extra_form 透传。 -- 响应:正常 JSON;SSE 流式(含 delta 分片跨 chunk 边界、`[DONE]`、Responses 的 `response.output_text.delta`/`response.failed` 事件);火山 `X-Api-Status-Code` 非 20000000 的错误映射。 +- 请求:URL 拼接(base_url 带/不带尾斜杠)、鉴权头(Bearer vs 火山四件套 header)、OpenAI multipart 字段完整性、MiMo JSON 中完整 WAV data URL 与语言参数、自定义 extra_headers/extra_form 透传。 +- 响应:正常 JSON;MiMo `choices[0].message.content` 缺失/类型错误;SSE 流式(含 delta 分片跨 chunk 边界、`[DONE]`、Responses 的 `response.output_text.delta`/`response.failed` 事件);火山 `X-Api-Status-Code` 非 20000000 的错误映射。 - 错误与重试:401 → `auth_error` 且**不重试**;429/503 → 退避重试 2 次后放弃;请求体在重试间不被消耗(multipart body 可重放)。 - 慢响应:STT / LLM profile 的单一 `timeout_ms` 对连接测试及所有使用该档案的功能统一生效;本地/远端调用延迟 > timeout → `timeout` 分类,LLM 持续输出 delta 也不得重置总时限。该契约由 Provider 包装层测试,不由 HTTP adapter 的 reqwest 超时模拟代替。 - 本地 STT 阻塞隔离:Whisper / SenseVoice / Qwen3-ASR 的原生推理不占用 Tokio worker;超时后原生任务尚未返回时,重试只异步等待同一在途许可,不会启动第二个原生任务或阻塞运行时。 diff --git a/src-tauri/src/app/commands.rs b/src-tauri/src/app/commands.rs index 149c5cd..267ea59 100644 --- a/src-tauri/src/app/commands.rs +++ b/src-tauri/src/app/commands.rs @@ -109,7 +109,10 @@ fn profile_kind_matches_capability(profile: &ProviderProfile) -> bool { match profile.capability { ProviderCapability::Stt => matches!( profile.kind, - ProviderKind::OpenaiCompat | ProviderKind::Volcengine | ProviderKind::Local + ProviderKind::OpenaiCompat + | ProviderKind::Mimo + | ProviderKind::Volcengine + | ProviderKind::Local ), ProviderCapability::Llm => matches!( profile.kind, @@ -1076,6 +1079,10 @@ mod tests { ProviderCapability::Stt, ProviderKind::OpenaiCompat, ))); + assert!(profile_kind_matches_capability(&profile( + ProviderCapability::Stt, + ProviderKind::Mimo, + ))); assert!(profile_kind_matches_capability(&profile( ProviderCapability::Llm, ProviderKind::Responses, diff --git a/src-tauri/src/orchestrator/mod.rs b/src-tauri/src/orchestrator/mod.rs index c95d7d0..4d6c731 100644 --- a/src-tauri/src/orchestrator/mod.rs +++ b/src-tauri/src/orchestrator/mod.rs @@ -243,10 +243,10 @@ impl Orchestrator { event => event, }; - let threshold = self.settings.get().hotkeys.hold_threshold_ms; + let trigger_mode = self.settings.get().hotkeys.trigger_mode; let previous_state = exec.state.clone(); let (mut new_state, mut effects) = - advance(previous_state.clone(), event.clone(), threshold); + advance(previous_state.clone(), event.clone(), trigger_mode); for effect in &effects { if let Effect::Inject { session_id, .. } = effect { exec.injection_latches @@ -262,7 +262,7 @@ impl Orchestrator { &mut exec, ) { event = Event::Esc; - (new_state, effects) = advance(previous_state, event.clone(), threshold); + (new_state, effects) = advance(previous_state, event.clone(), trigger_mode); self.escape_latch.disarm(); } diff --git a/src-tauri/src/orchestrator/session.rs b/src-tauri/src/orchestrator/session.rs index 7cc7d8e..2046d4f 100644 --- a/src-tauri/src/orchestrator/session.rs +++ b/src-tauri/src/orchestrator/session.rs @@ -4,11 +4,9 @@ //! 执行器 dispatch 到各 service。全项目单测密度最高处(07 §3.1 场景清单)。 use crate::error::{ErrorCode, TypexError}; +use crate::settings::schema::HotkeyTriggerMode; use crate::types::{FailedStage, SessionMode, SessionPhase}; -/// 长按/短按判定阈值(可被设置覆盖)。 -pub const DEFAULT_HOLD_THRESHOLD_MS: u64 = 350; - /// 状态机内部状态(携带 payload;对前端的投影见 SessionSnapshot)。 #[derive(Debug, Clone, PartialEq)] pub enum State { @@ -16,8 +14,8 @@ pub enum State { Recording { session_id: u64, mode: SessionMode, - /// toggle 模式(短按开始,再按结束);押住模式为 false - toggled: bool, + /// 会话开始时快照,录音中修改设置不改变当前手势语义。 + trigger_mode: HotkeyTriggerMode, }, Transcribing { session_id: u64, @@ -178,13 +176,12 @@ pub enum Chime { Error, } -/// 判定短按(toggle)还是长按(push-to-talk)。 -fn is_toggle(held_ms: u64, threshold_ms: u64) -> bool { - held_ms < threshold_ms -} - -/// 状态转移函数。`threshold_ms`:长短按阈值(设置注入)。 -pub fn advance(state: State, event: Event, threshold_ms: u64) -> (State, Vec) { +/// 状态转移函数。`configured_trigger_mode` 仅在开始或重试录音时快照。 +pub fn advance( + state: State, + event: Event, + configured_trigger_mode: HotkeyTriggerMode, +) -> (State, Vec) { use Effect as E; match (state, event) { // ───────── Idle ───────── @@ -198,7 +195,7 @@ pub fn advance(state: State, event: Event, threshold_ms: u64) -> (State, Vec (State, Vec (State, Vec (State, Vec { - if !toggled && is_toggle(held_ms, threshold_ms) { - // 短按 = toggle 开始:继续录音,等待第二次按键(三模式一致,含助手,ADR-23) - ( - State::Recording { - session_id, - mode, - toggled: true, - }, - vec![E::EmitUi], - ) - } else { - // push-to-talk 结束,或 toggle 模式第二次触发 chord 已完整释放。 - ( - State::Transcribing { session_id, mode }, - vec![E::EmitUi, E::StopRecording { session_id }], - ) - } - } - ( - s @ State::Recording { - mode: SessionMode::Assistant, - toggled: true, - .. + trigger_mode, }, - Event::TriggerDown { .. }, - ) => { - // 助手要等 chord 完整释放,避免仍按住的触发修饰键污染 Ctrl+C 选区读取。 - (s, vec![]) - } + Event::TriggerUp { .. }, + ) => match trigger_mode { + HotkeyTriggerMode::Hold => ( + State::Transcribing { session_id, mode }, + vec![E::EmitUi, E::StopRecording { session_id }], + ), + HotkeyTriggerMode::Toggle => ( + State::Recording { + session_id, + mode, + trigger_mode, + }, + vec![], + ), + }, ( State::Recording { session_id, mode, - toggled: true, + trigger_mode: HotkeyTriggerMode::Toggle, }, Event::TriggerDown { .. }, ) => { - // 听写/翻译 toggle 模式下二次按下 = 结束录音。 + // toggle 模式下二次按下立即结束;三种业务模式语义一致。 ( State::Transcribing { session_id, mode }, vec![E::EmitUi, E::StopRecording { session_id }], ) } - (s @ State::Recording { toggled: false, .. }, Event::TriggerDown { .. }) => { + ( + s @ State::Recording { + trigger_mode: HotkeyTriggerMode::Hold, + .. + }, + Event::TriggerDown { .. }, + ) => { // 按住期间的重复 down(OS 重复已在 detector 滤掉;防御) (s, vec![]) } @@ -568,7 +555,7 @@ pub fn advance(state: State, event: Event, threshold_ms: u64) -> (State, Vec (State, Vec (State, Vec TypexError { TypexError::new(code, "test") @@ -679,7 +667,7 @@ mod tests { State::Recording { session_id: id, mode: SessionMode::Dictation, - toggled: false, + trigger_mode: T, } } @@ -690,43 +678,56 @@ mod tests { } } - // ── 长按/短按(07 §3.1 场景 1)── + // ── 显式触发方式(07 §3.1 场景 1)── + + #[test] + fn hold_release_stops_regardless_of_duration() { + for held_ms in [1, 10_000] { + let (state, effects) = advance(recording(1), Event::TriggerUp { held_ms }, TOGGLE); + assert_eq!(state.phase(), SessionPhase::Transcribing); + assert_eq!( + effects, + vec![Effect::EmitUi, Effect::StopRecording { session_id: 1 }] + ); + } + } #[test] - fn hold_349ms_release_enters_toggle_mode() { - let (s, fx) = advance(recording(1), Event::TriggerUp { held_ms: 349 }, T); + fn toggle_release_is_ignored_and_mode_is_snapshotted() { + let (recording, _) = advance(State::Idle, down(1), TOGGLE); + let (state, effects) = advance(recording, Event::TriggerUp { held_ms: 10_000 }, T); + assert_eq!( - s, + state, State::Recording { session_id: 1, mode: SessionMode::Dictation, - toggled: true + trigger_mode: TOGGLE, } ); - assert!(!fx.contains(&Effect::CallStt { session_id: 1 })); - } - - #[test] - fn hold_351ms_release_is_push_to_talk_end() { - let (s, fx) = advance(recording(1), Event::TriggerUp { held_ms: 351 }, T); - assert_eq!(s.phase(), SessionPhase::Transcribing); - assert_eq!( - fx, - vec![Effect::EmitUi, Effect::StopRecording { session_id: 1 }] - ); + assert!(effects.is_empty()); } #[test] - fn dictation_and_translation_toggle_second_press_finishes_audio_on_keydown() { - for mode in [SessionMode::Dictation, SessionMode::Translation] { - let initial = State::Recording { - session_id: 1, - mode, - toggled: false, - }; - let (toggled, _) = advance(initial, Event::TriggerUp { held_ms: 100 }, T); - let (transcribing, down_fx) = advance( - toggled, + fn toggle_second_press_finishes_all_modes_on_keydown() { + for mode in [ + SessionMode::Dictation, + SessionMode::Translation, + SessionMode::Assistant, + ] { + let (recording, _) = advance( + State::Idle, + Event::TriggerDown { + mode, + next_session_id: 1, + }, + TOGGLE, + ); + let (recording, release_effects) = + advance(recording, Event::TriggerUp { held_ms: 1 }, T); + assert!(release_effects.is_empty()); + let (transcribing, down_effects) = advance( + recording, Event::TriggerDown { mode, next_session_id: 99, @@ -737,62 +738,12 @@ mod tests { assert_eq!(transcribing.phase(), SessionPhase::Transcribing); assert_eq!(transcribing.session_id(), Some(1)); assert_eq!( - down_fx, + down_effects, vec![Effect::EmitUi, Effect::StopRecording { session_id: 1 }] ); } } - #[test] - fn assistant_toggle_second_press_waits_for_keyup_before_finishing_audio() { - let initial = State::Recording { - session_id: 1, - mode: SessionMode::Assistant, - toggled: false, - }; - let (toggled, _) = advance(initial, Event::TriggerUp { held_ms: 100 }, T); - let (still_recording, down_fx) = advance( - toggled, - Event::TriggerDown { - mode: SessionMode::Assistant, - next_session_id: 99, - }, - T, - ); - - assert_eq!(still_recording.phase(), SessionPhase::Recording); - assert_eq!(still_recording.session_id(), Some(1)); - assert!(!down_fx.contains(&Effect::CallStt { session_id: 1 })); - - let (transcribing, up_fx) = advance(still_recording, Event::TriggerUp { held_ms: 100 }, T); - assert_eq!(transcribing.phase(), SessionPhase::Transcribing); - assert_eq!(transcribing.session_id(), Some(1)); - assert_eq!( - up_fx, - vec![Effect::EmitUi, Effect::StopRecording { session_id: 1 }] - ); - } - - #[test] - fn assistant_short_press_enters_toggle_like_dictation() { - // ADR-23:助手键短按 = 切换式录音(「仅呼出面板」已废除) - let s0 = State::Recording { - session_id: 1, - mode: SessionMode::Assistant, - toggled: false, - }; - let (s, fx) = advance(s0, Event::TriggerUp { held_ms: 100 }, T); - assert_eq!( - s, - State::Recording { - session_id: 1, - mode: SessionMode::Assistant, - toggled: true - } - ); - assert!(!fx.contains(&Effect::CancelRecording)); - } - // ── 助手分流(07 §3.1 / ADR-23)── #[test] @@ -879,7 +830,7 @@ mod tests { let state = State::Recording { session_id: 1, mode: SessionMode::Translation, - toggled: false, + trigger_mode: T, }; let (state, effects) = advance( state, @@ -895,20 +846,29 @@ mod tests { } #[test] - fn combo_upgrade_during_toggle_still_stops_after_release() { - let toggled = State::Recording { + fn combo_upgrade_during_toggle_keeps_toggle_release_semantics() { + let toggle_recording = State::Recording { session_id: 1, mode: SessionMode::Assistant, - toggled: true, + trigger_mode: TOGGLE, }; let (upgraded, _) = advance( - toggled, + toggle_recording, Event::ModeUpgraded { mode: SessionMode::Translation, }, T, ); - let (transcribing, effects) = advance(upgraded, Event::TriggerUp { held_ms: 100 }, T); + let (upgraded, release_effects) = advance(upgraded, Event::TriggerUp { held_ms: 100 }, T); + assert!(release_effects.is_empty()); + let (transcribing, effects) = advance( + upgraded, + Event::TriggerDown { + mode: SessionMode::Translation, + next_session_id: 99, + }, + T, + ); assert_eq!(transcribing.phase(), SessionPhase::Transcribing); assert_eq!(transcribing.mode(), Some(SessionMode::Translation)); @@ -1076,12 +1036,12 @@ mod tests { #[test] fn pause_cancel_releases_toggle_recording() { - let toggled = State::Recording { + let toggle_recording = State::Recording { session_id: 8, mode: SessionMode::Assistant, - toggled: true, + trigger_mode: TOGGLE, }; - let (state, effects) = advance(toggled, Event::Esc, T); + let (state, effects) = advance(toggle_recording, Event::Esc, T); assert_eq!(state, State::Idle); assert_eq!( @@ -1142,7 +1102,7 @@ mod tests { State::Recording { session_id: 7, mode: SessionMode::Translation, - toggled: false, + trigger_mode: T, } )); assert!(effects.contains(&Effect::StartRecording)); diff --git a/src-tauri/src/providers/registry.rs b/src-tauri/src/providers/registry.rs index 134ce5e..0c22379 100644 --- a/src-tauri/src/providers/registry.rs +++ b/src-tauri/src/providers/registry.rs @@ -9,7 +9,8 @@ use crate::providers::llm::{ LlmProvider, TimedLlmProvider, chat_completions::ChatCompletionsLlm, responses::ResponsesLlm, }; use crate::providers::stt::{ - SttProvider, TimedSttProvider, openai_compat::OpenAiCompatStt, volcengine::VolcengineStt, + SttProvider, TimedSttProvider, mimo::MimoStt, openai_compat::OpenAiCompatStt, + volcengine::VolcengineStt, }; use crate::settings::schema::Settings; use crate::types::{ProviderCapability, ProviderKind, ProviderProfile, SlotKind}; @@ -298,6 +299,16 @@ impl ProviderRegistry { .with_extras(profile.extra_headers.clone(), profile.extra_form.clone()), ) } + ProviderKind::Mimo => { + let key = self.resolve_secret(profile, "api_key")?; + let client = self.http_client(); + Arc::new(MimoStt::new( + client, + profile.base_url.clone(), + key, + profile.model.clone(), + )) + } ProviderKind::Volcengine => { let app_key = self.resolve_secret(profile, "app_key")?; let access_key = self.resolve_secret(profile, "access_token")?; @@ -562,6 +573,13 @@ mod tests { assert!(Arc::ptr_eq(&a, &b)); // 缓存命中 } + #[test] + fn mimo_stt_builds_with_shared_registry_path() { + let reg = setup(); + let profile = profile("mimo", ProviderKind::Mimo); + assert!(reg.build_stt(&profile).is_ok()); + } + #[test] fn zero_stt_timeout_is_rejected_at_provider_boundary() { let reg = setup(); diff --git a/src-tauri/src/providers/stt/mimo.rs b/src-tauri/src/providers/stt/mimo.rs new file mode 100644 index 0000000..2ec0c47 --- /dev/null +++ b/src-tauri/src/providers/stt/mimo.rs @@ -0,0 +1,173 @@ +//! Xiaomi MiMo ASR(03 §2.3):JSON POST {base_url}/chat/completions。 + +use super::{ + AudioInput, SttCapabilities, SttOptions, SttProvider, Transcript, transcript_from_provider_text, +}; +use crate::providers::{ProviderError, http}; +use base64::Engine; + +const RESPONSE_BODY_LIMIT_CHARS: usize = 2_048; + +pub struct MimoStt { + client: reqwest::Client, + base_url: String, + api_key: String, + model: String, +} + +impl MimoStt { + pub fn new( + client: reqwest::Client, + base_url: impl Into, + api_key: impl Into, + model: impl Into, + ) -> Self { + Self { + client, + base_url: base_url.into().trim_end_matches('/').to_string(), + api_key: api_key.into(), + model: model.into(), + } + } + + fn build_body<'a>(&'a self, audio: &AudioInput, opts: &'a SttOptions) -> MimoRequest<'a> { + let language = match opts.language.as_deref().map(str::trim) { + Some(language) if !language.is_empty() && language != "auto" => language, + _ => "auto", + }; + let wav = base64::engine::general_purpose::STANDARD.encode(&audio.wav_16k_mono); + MimoRequest { + model: &self.model, + messages: [MimoUserMessage { + role: "user", + content: [MimoAudioContent { + kind: "input_audio", + input_audio: MimoInputAudio { + data: format!("data:audio/wav;base64,{wav}"), + }, + }], + }], + stream: false, + asr_options: MimoAsrOptions { language }, + } + } +} + +#[derive(serde::Serialize)] +struct MimoRequest<'a> { + model: &'a str, + messages: [MimoUserMessage; 1], + stream: bool, + asr_options: MimoAsrOptions<'a>, +} + +#[derive(serde::Serialize)] +struct MimoUserMessage { + role: &'static str, + content: [MimoAudioContent; 1], +} + +#[derive(serde::Serialize)] +struct MimoAudioContent { + #[serde(rename = "type")] + kind: &'static str, + input_audio: MimoInputAudio, +} + +#[derive(serde::Serialize)] +struct MimoInputAudio { + data: String, +} + +#[derive(serde::Serialize)] +struct MimoAsrOptions<'a> { + language: &'a str, +} + +#[derive(serde::Deserialize)] +struct MimoResponse { + choices: Option>, +} + +#[derive(serde::Deserialize)] +struct MimoChoice { + message: Option, +} + +#[derive(serde::Deserialize)] +struct MimoResponseMessage { + content: Option, +} + +fn response_error(reason: &str, body: &str) -> ProviderError { + ProviderError::InvalidRequest(format!("MiMo {reason}; body: {}", truncated_body(body))) +} + +fn truncated_body(body: &str) -> String { + let mut chars = body.chars(); + let truncated: String = chars.by_ref().take(RESPONSE_BODY_LIMIT_CHARS).collect(); + if chars.next().is_some() { + format!("{truncated}…") + } else { + truncated + } +} + +fn parse_response(body: &str) -> Result { + let parsed: MimoResponse = serde_json::from_str(body) + .map_err(|error| response_error(&format!("响应 JSON 解析失败: {error}"), body))?; + let choices = parsed + .choices + .ok_or_else(|| response_error("响应 choices 缺失", body))?; + let choice = choices + .first() + .ok_or_else(|| response_error("响应 choices 为空", body))?; + let message = choice + .message + .as_ref() + .ok_or_else(|| response_error("响应 message 缺失", body))?; + let content = message + .content + .as_ref() + .ok_or_else(|| response_error("响应 message.content 缺失", body))? + .as_str() + .ok_or_else(|| response_error("响应 message.content 不是字符串", body))?; + Ok(transcript_from_provider_text(content, None)) +} + +#[async_trait::async_trait] +impl SttProvider for MimoStt { + async fn transcribe( + &self, + audio: AudioInput, + opts: SttOptions, + ) -> Result { + let url = format!("{}/chat/completions", self.base_url); + let body = self.build_body(&audio, &opts); + http::with_retry(|| async { + let response = self + .client + .post(&url) + .bearer_auth(&self.api_key) + .json(&body) + .send() + .await + .map_err(ProviderError::from_reqwest)?; + let status = response.status().as_u16(); + let text = response.text().await.map_err(ProviderError::from_reqwest)?; + if status >= 400 { + return Err(ProviderError::from_status(status, text)); + } + parse_response(&text) + }) + .await + } + + fn capabilities(&self) -> SttCapabilities { + SttCapabilities { + max_bytes: None, + supports_prompt: false, + supports_language: true, + } + } +} diff --git a/src-tauri/src/providers/stt/mod.rs b/src-tauri/src/providers/stt/mod.rs index 05f1878..55fe71a 100644 --- a/src-tauri/src/providers/stt/mod.rs +++ b/src-tauri/src/providers/stt/mod.rs @@ -1,4 +1,5 @@ //! SttProvider trait(03 §2)。 +pub mod mimo; pub mod openai_compat; pub mod volcengine; diff --git a/src-tauri/src/settings/migrate.rs b/src-tauri/src/settings/migrate.rs index bd54529..3ffada9 100644 --- a/src-tauri/src/settings/migrate.rs +++ b/src-tauri/src/settings/migrate.rs @@ -30,6 +30,9 @@ pub fn migrate(mut value: Value) -> Value { if version < 10 { migrate_v9_to_v10(&mut value); } + if version < 11 { + migrate_v10_to_v11(&mut value); + } normalize_hotkey_ids(&mut value, version < 8); drop_legacy_keyring_credentials(&mut value); if let Some(obj) = value.as_object_mut() { @@ -41,6 +44,21 @@ pub fn migrate(mut value: Value) -> Value { value } +fn migrate_v10_to_v11(value: &mut Value) { + let Some(root) = value.as_object_mut() else { + return; + }; + let hotkeys = root + .entry("hotkeys") + .or_insert_with(|| Value::Object(Default::default())); + if let Some(hotkeys) = hotkeys.as_object_mut() { + hotkeys.remove("hold_threshold_ms"); + hotkeys + .entry("trigger_mode") + .or_insert_with(|| Value::String("hold".into())); + } +} + fn migrate_v9_to_v10(value: &mut Value) { let Some(profiles) = value.get_mut("profiles").and_then(Value::as_array_mut) else { return; @@ -223,7 +241,7 @@ fn migrate_v1_to_v2(value: &mut Value) { } }) .unwrap_or_else(|| match obj.get("kind").and_then(Value::as_str) { - Some("openai_compat" | "volcengine") => "stt", + Some("openai_compat" | "mimo" | "volcengine") => "stt", _ => "llm", }); obj.insert("capability".into(), Value::String(capability.into())); @@ -524,6 +542,23 @@ mod tests { assert!(migrated["profiles"][2].get("timeout_ms").is_none()); } + #[test] + fn v10_hotkey_threshold_becomes_explicit_hold_mode() { + let migrated = migrate(serde_json::json!({ + "schema_version": 10, + "hotkeys": { + "dictation": ["ControlRight"], + "assistant": ["AltRight"], + "translation": ["ControlRight", "AltRight"], + "hold_threshold_ms": 999 + } + })); + + assert_eq!(migrated["schema_version"], CURRENT_SCHEMA_VERSION); + assert_eq!(migrated["hotkeys"]["trigger_mode"], "hold"); + assert!(migrated["hotkeys"].get("hold_threshold_ms").is_none()); + } + #[test] fn current_schema_preserves_explicit_30_second_timeout() { let migrated = migrate(serde_json::json!({ diff --git a/src-tauri/src/settings/mod.rs b/src-tauri/src/settings/mod.rs index f333132..0163c3c 100644 --- a/src-tauri/src/settings/mod.rs +++ b/src-tauri/src/settings/mod.rs @@ -309,7 +309,10 @@ mod tests { assert_eq!(settings.hotkeys.dictation, defaults.dictation); assert_eq!(settings.hotkeys.assistant, defaults.assistant); assert_eq!(settings.hotkeys.translation, defaults.translation); - assert_eq!(settings.hotkeys.hold_threshold_ms, 999); + assert_eq!( + settings.hotkeys.trigger_mode, + schema::HotkeyTriggerMode::Hold + ); let _ = std::fs::remove_dir_all(&dir); } diff --git a/src-tauri/src/settings/schema.rs b/src-tauri/src/settings/schema.rs index c0117bc..7f2af15 100644 --- a/src-tauri/src/settings/schema.rs +++ b/src-tauri/src/settings/schema.rs @@ -8,7 +8,7 @@ use crate::types::{ use serde::{Deserialize, Serialize}; use std::collections::HashMap; -pub const CURRENT_SCHEMA_VERSION: u32 = 10; +pub const CURRENT_SCHEMA_VERSION: u32 = 11; pub const VAD_ENERGY_THRESHOLD_MIN: f32 = 0.001; pub const VAD_ENERGY_THRESHOLD_MAX: f32 = 0.050; @@ -277,14 +277,22 @@ pub struct AssistantSettings { /// 快捷键绑定:每组是一个完整 chord,按键使用稳定物理 KeyId。 /// 默认全修饰键三角方案(ADR-7)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, specta::Type)] +#[serde(rename_all = "snake_case")] +pub enum HotkeyTriggerMode { + #[default] + Hold, + Toggle, +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, specta::Type)] #[serde(default)] pub struct HotkeySettings { pub dictation: Vec, pub assistant: Vec, pub translation: Vec, - /// 长按/短按判定阈值 ms(02 F-5,可调) - pub hold_threshold_ms: u64, + /// 录音开始时快照;hold 在 keyup 停止,toggle 在第二次 keydown 停止。 + pub trigger_mode: HotkeyTriggerMode, } impl Default for HotkeySettings { @@ -301,7 +309,7 @@ impl Default for HotkeySettings { dictation: dict, assistant: assist, translation, - hold_threshold_ms: 350, + trigger_mode: HotkeyTriggerMode::Hold, } } } @@ -492,7 +500,7 @@ mod tests { #[test] fn default_hotkeys_are_triangle_scheme() { let h = HotkeySettings::default(); - assert_eq!(h.hold_threshold_ms, 350); + assert_eq!(h.trigger_mode, HotkeyTriggerMode::Hold); assert_eq!(h.translation.len(), 2); assert!(h.translation.contains(&h.dictation[0])); assert!(h.translation.contains(&h.assistant[0])); @@ -505,7 +513,7 @@ mod tests { dictation: vec!["ControlRight".into(), "Num1".into(), "Digit1".into()], assistant: vec!["AltGr".into(), "KeyA".into()], translation: vec!["F13".into(), "ContextMenu".into(), "Menu".into()], - hold_threshold_ms: 350, + trigger_mode: HotkeyTriggerMode::Toggle, }; h.normalize(); diff --git a/src-tauri/src/types/profile.rs b/src-tauri/src/types/profile.rs index a02e44a..f8dfecc 100644 --- a/src-tauri/src/types/profile.rs +++ b/src-tauri/src/types/profile.rs @@ -45,6 +45,8 @@ pub enum ProviderCapability { pub enum ProviderKind { /// STT:multipart /audio/transcriptions OpenaiCompat, + /// STT:Xiaomi MiMo /chat/completions JSON + Base64 WAV + Mimo, /// STT:火山/豆包极速版 flash(双凭据) Volcengine, /// LLM:OpenAI Chat Completions @@ -59,7 +61,10 @@ impl ProviderKind { pub fn is_stt(self) -> bool { matches!( self, - ProviderKind::OpenaiCompat | ProviderKind::Volcengine | ProviderKind::Local + ProviderKind::OpenaiCompat + | ProviderKind::Mimo + | ProviderKind::Volcengine + | ProviderKind::Local ) } } diff --git a/src-tauri/tests/contract_snapshots.rs b/src-tauri/tests/contract_snapshots.rs index 4dd5940..4508947 100644 --- a/src-tauri/tests/contract_snapshots.rs +++ b/src-tauri/tests/contract_snapshots.rs @@ -1,4 +1,4 @@ -//! 契约快照测试(07 §4.1):四个 adapter 构造的完整 HTTP 请求形状。 +//! 契约快照测试(07 §4.1):五个 adapter 构造的完整 HTTP 请求形状。 //! //! 厂商格式是外部契约——任何无意的请求变化(哪怕是「顺手重构」)都会在快照 diff 中显形。 //! 波动字段(multipart boundary、request-id、host)先归一化再快照。 @@ -9,7 +9,8 @@ use typex_lib::providers::llm::{ LlmProvider, LlmRequest, Msg, chat_completions::ChatCompletionsLlm, responses::ResponsesLlm, }; use typex_lib::providers::stt::{ - AudioInput, SttOptions, SttProvider, openai_compat::OpenAiCompatStt, volcengine::VolcengineStt, + AudioInput, SttOptions, SttProvider, mimo::MimoStt, openai_compat::OpenAiCompatStt, + volcengine::VolcengineStt, }; use wiremock::{Mock, MockServer, Request, ResponseTemplate, matchers::method}; @@ -132,6 +133,32 @@ async fn snapshot_openai_compat_stt_request() { insta::assert_json_snapshot!("openai_compat_stt_request", captured); } +#[tokio::test] +async fn snapshot_mimo_stt_request() { + let (server, slot) = + capture_server(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "choices": [{ "message": { "content": "ok" } }] + }))) + .await; + let stt = MimoStt::new( + client(), + format!("{}/v1", server.uri()), + "sk-test", + "mimo-v2.5-asr", + ); + stt.transcribe( + wav_stub(), + SttOptions { + language: Some("zh".into()), + ..Default::default() + }, + ) + .await + .unwrap(); + let captured = slot.lock().unwrap().clone().unwrap(); + insta::assert_json_snapshot!("mimo_stt_request", captured); +} + #[tokio::test] async fn snapshot_volcengine_stt_request() { let (server, slot) = capture_server( diff --git a/src-tauri/tests/providers_wiremock.rs b/src-tauri/tests/providers_wiremock.rs index 7c9b7bd..1301204 100644 --- a/src-tauri/tests/providers_wiremock.rs +++ b/src-tauri/tests/providers_wiremock.rs @@ -14,7 +14,7 @@ use typex_lib::providers::llm::{ LlmProvider, LlmRequest, chat_completions::ChatCompletionsLlm, responses::ResponsesLlm, }; use typex_lib::providers::stt::{ - AudioInput, SttOptions, SttProvider, openai_compat::OpenAiCompatStt, + AudioInput, SttOptions, SttProvider, mimo::MimoStt, openai_compat::OpenAiCompatStt, }; use typex_lib::settings::SettingsService; use typex_lib::settings::schema::{ProxyMode, Settings, SlotConfig}; @@ -212,6 +212,149 @@ async fn stt_extra_headers_and_form_passthrough() { .unwrap(); } +// ── Xiaomi MiMo STT(03 §2.3)── + +#[tokio::test] +async fn mimo_request_shape_audio_roundtrip_and_response_parse() { + use base64::Engine; + use wiremock::matchers::header_regex; + + let server = MockServer::start().await; + let expected_wav = wav_stub().wav_16k_mono; + Mock::given(method("POST")) + .and(path("/v1/chat/completions")) + .and(header("authorization", "Bearer sk-test")) + .and(header_regex("content-type", "^application/json")) + .respond_with(move |req: &Request| { + let body: serde_json::Value = serde_json::from_slice(&req.body).unwrap(); + assert_eq!(body["model"], "mimo-v2.5-asr"); + assert_eq!(body["stream"], false); + assert_eq!(body["messages"][0]["role"], "user"); + assert_eq!(body["messages"][0]["content"][0]["type"], "input_audio"); + assert_eq!(body["asr_options"]["language"], "zh"); + assert!(body.get("prompt").is_none()); + assert!(body.get("temperature").is_none()); + + let data_url = body["messages"][0]["content"][0]["input_audio"]["data"] + .as_str() + .unwrap(); + let encoded = data_url + .strip_prefix("data:audio/wav;base64,") + .expect("MiMo audio must be a WAV data URL"); + let decoded = base64::engine::general_purpose::STANDARD + .decode(encoded) + .unwrap(); + assert_eq!(decoded, expected_wav); + + ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "choices": [{ "message": { "content": "测试转写文本" } }] + })) + }) + .mount(&server) + .await; + + let stt = MimoStt::new( + client(), + format!("{}/v1/", server.uri()), + "sk-test", + "mimo-v2.5-asr", + ); + let transcript = stt + .transcribe( + wav_stub(), + SttOptions { + language: Some("zh".into()), + prompt: Some("must not be sent".into()), + temperature: Some(0.5), + }, + ) + .await + .unwrap(); + assert_eq!(transcript.text, "测试转写文本"); +} + +#[tokio::test] +async fn mimo_auto_language_is_used_for_none_empty_and_auto() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .respond_with(move |req: &Request| { + let body: serde_json::Value = serde_json::from_slice(&req.body).unwrap(); + assert_eq!(body["asr_options"]["language"], "auto"); + ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "choices": [{ "message": { "content": "ok" } }] + })) + }) + .expect(3) + .mount(&server) + .await; + + let stt = MimoStt::new(client(), server.uri(), "k", "m"); + for language in [None, Some(String::new()), Some("auto".into())] { + stt.transcribe( + wav_stub(), + SttOptions { + language, + ..Default::default() + }, + ) + .await + .unwrap(); + } +} + +#[tokio::test] +async fn mimo_malformed_or_incomplete_responses_are_readable_errors() { + for (body, expected) in [ + ("not-json", "响应 JSON 解析失败"), + (r#"{}"#, "choices 缺失"), + (r#"{"choices":[]}"#, "choices 为空"), + (r#"{"choices":[{}]}"#, "message 缺失"), + (r#"{"choices":[{"message":{}}]}"#, "message.content 缺失"), + ( + r#"{"choices":[{"message":{"content":[]}}]}"#, + "message.content 不是字符串", + ), + ] { + let server = MockServer::start().await; + Mock::given(method("POST")) + .respond_with(ResponseTemplate::new(200).set_body_string(body)) + .mount(&server) + .await; + let stt = MimoStt::new(client(), server.uri(), "k", "m"); + let err = stt + .transcribe(wav_stub(), SttOptions::default()) + .await + .unwrap_err(); + assert!( + matches!(err, ProviderError::InvalidRequest(ref message) if message.contains(expected) && message.contains(body)), + "body={body:?}, error={err:?}" + ); + } +} + +#[tokio::test] +async fn mimo_http_error_classification_and_retry_match_shared_policy() { + for (status, attempts) in [(401, 1), (429, 3), (500, 3)] { + let server = MockServer::start().await; + Mock::given(method("POST")) + .respond_with(ResponseTemplate::new(status).set_body_string(format!("status-{status}"))) + .expect(attempts) + .mount(&server) + .await; + let stt = MimoStt::new(client(), server.uri(), "k", "m"); + let err = stt + .transcribe(wav_stub(), SttOptions::default()) + .await + .unwrap_err(); + match status { + 401 => assert!(matches!(err, ProviderError::Auth(_))), + 429 => assert!(matches!(err, ProviderError::RateLimited(_))), + 500 => assert!(matches!(err, ProviderError::Server { status: 500, .. })), + _ => unreachable!(), + } + } +} + // ── chat_completions LLM ── fn sse_body(chunks: &[&str]) -> String { diff --git a/src-tauri/tests/snapshots/contract_snapshots__mimo_stt_request.snap b/src-tauri/tests/snapshots/contract_snapshots__mimo_stt_request.snap new file mode 100644 index 0000000..f953f97 --- /dev/null +++ b/src-tauri/tests/snapshots/contract_snapshots__mimo_stt_request.snap @@ -0,0 +1,35 @@ +--- +source: tests/contract_snapshots.rs +expression: captured +--- +{ + "body": { + "asr_options": { + "language": "zh" + }, + "messages": [ + { + "content": [ + { + "input_audio": { + "data": "data:audio/wav;base64,UklGRndhdnN0dWIh" + }, + "type": "input_audio" + } + ], + "role": "user" + } + ], + "model": "mimo-v2.5-asr", + "stream": false + }, + "headers": [ + "accept: */*", + "authorization: Bearer sk-test", + "content-length: ", + "content-type: application/json", + "host: " + ], + "method": "POST", + "path": "/v1/chat/completions" +} diff --git a/src/__tests__/presets.test.ts b/src/__tests__/presets.test.ts index 9dd5b78..5bda4a5 100644 --- a/src/__tests__/presets.test.ts +++ b/src/__tests__/presets.test.ts @@ -13,4 +13,16 @@ describe("模型预设", () => { expect(presets.map((p) => p.id)).toContain("local-stt"); expect(presets.map((p) => p.id)).not.toContain("local-llm"); }); + + it("Xiaomi MiMo 预设锁定原生 ASR adapter 与 base URL", () => { + const preset = presetsForSlot("stt").find((p) => p.id === "mimo-stt"); + expect(preset).toEqual({ + id: "mimo-stt", + label: "Xiaomi MiMo", + kind: "mimo", + base_url: "https://api.xiaomimimo.com/v1", + models: ["mimo-v2.5-asr"], + for: "stt", + }); + }); }); diff --git a/src/i18n/en.json b/src/i18n/en.json index 88b6aa9..200f9ac 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -263,6 +263,11 @@ "import_confirm": "Import" }, "hotkeys": { + "trigger_mode": "Trigger mode", + "trigger_hold": "Hold to talk", + "trigger_toggle": "Press to toggle", + "trigger_hold_hint": "Starts on keydown and stops on keyup.", + "trigger_toggle_hint": "The first keydown starts and the second stops; holding the key is unnecessary.", "unreachable_chords": "Choose three non-empty shortcuts. Dictation and assistant cannot contain each other, and translation cannot exactly match either one." }, "history": { diff --git a/src/i18n/zh-CN.json b/src/i18n/zh-CN.json index 754edb4..0b3495f 100644 --- a/src/i18n/zh-CN.json +++ b/src/i18n/zh-CN.json @@ -263,6 +263,11 @@ "import_confirm": "导入" }, "hotkeys": { + "trigger_mode": "触发方式", + "trigger_hold": "按住说话", + "trigger_toggle": "按下切换", + "trigger_hold_hint": "按下立即开始,松开立即结束。", + "trigger_toggle_hint": "第一次按下立即开始,第二次按下立即结束;无需保持按键。", "unreachable_chords": "请选择三个非空快捷键;听写与助手不能相同或互相包含,翻译不能与前两项完全相同。" }, "history": { diff --git a/src/shared/presets.ts b/src/shared/presets.ts index ae2ee7d..d161447 100644 --- a/src/shared/presets.ts +++ b/src/shared/presets.ts @@ -20,6 +20,7 @@ export const PRESETS: Preset[] = [ { id: "openai-stt", label: "OpenAI", kind: "openai_compat", base_url: "https://api.openai.com/v1", models: ["gpt-4o-mini-transcribe", "whisper-1"], for: "stt" }, { id: "groq-stt", label: "Groq", kind: "openai_compat", base_url: "https://api.groq.com/openai/v1", models: ["whisper-large-v3-turbo"], for: "stt" }, { id: "siliconflow-stt", label: "SiliconFlow", kind: "openai_compat", base_url: "https://api.siliconflow.cn/v1", models: ["FunAudioLLM/SenseVoiceSmall"], for: "stt" }, + { id: "mimo-stt", label: "Xiaomi MiMo", kind: "mimo", base_url: "https://api.xiaomimimo.com/v1", models: ["mimo-v2.5-asr"], for: "stt" }, { id: "volcano-stt", label: "火山引擎 · 豆包(极速版)", kind: "volcengine", base_url: "", models: ["bigmodel"], for: "stt" }, { id: "custom-stt", label: "自定义", labelKey: "settings.profile.preset_custom", kind: "openai_compat", base_url: "", models: [], for: "stt" }, // LLM diff --git a/src/windows/home/Home.test.ts b/src/windows/home/Home.test.ts index f138899..8c0b3df 100644 --- a/src/windows/home/Home.test.ts +++ b/src/windows/home/Home.test.ts @@ -31,7 +31,7 @@ vi.mock("@/ipc/bindings", () => ({ function makeSettings(): Settings { return { - schema_version: 9, + schema_version: 11, general: { theme: "system", language: "zh_cn", @@ -71,7 +71,7 @@ function makeSettings(): Settings { dictation: ["MetaRight"], assistant: ["AltRight"], translation: ["MetaRight", "AltRight"], - hold_threshold_ms: 350, + trigger_mode: "hold", }, dictionary: { terms: [] }, slots: {}, diff --git a/src/windows/onboarding/Onboarding.test.ts b/src/windows/onboarding/Onboarding.test.ts index 23e0eef..aedb4a9 100644 --- a/src/windows/onboarding/Onboarding.test.ts +++ b/src/windows/onboarding/Onboarding.test.ts @@ -42,7 +42,7 @@ function makeSettings(): Settings { dictation: ["ControlRight"], assistant: ["ShiftRight"], translation: ["ControlRight", "ShiftRight"], - hold_threshold_ms: 350, + trigger_mode: "hold", }, } as Settings; } diff --git a/src/windows/settings/pages/HotkeysPage.test.ts b/src/windows/settings/pages/HotkeysPage.test.ts index bc3ee6d..a0ed103 100644 --- a/src/windows/settings/pages/HotkeysPage.test.ts +++ b/src/windows/settings/pages/HotkeysPage.test.ts @@ -29,12 +29,12 @@ function makeSettings( translation: string[] = [...new Set([...dictation, ...assistant])], ): Settings { return { - schema_version: 9, + schema_version: 11, hotkeys: { dictation, assistant, translation, - hold_threshold_ms: 350, + trigger_mode: "hold", }, } as Settings; } @@ -114,6 +114,15 @@ describe("HotkeysPage chord validation", () => { expect(saved.hotkeys.translation).toEqual(["F13", "Menu"]); }); + it("persists press-to-toggle as an explicit trigger mode", async () => { + const wrapper = mountPage(); + await wrapper.find('[role="radio"][aria-checked="false"]').trigger("click"); + await flushPromises(); + + const saved = vi.mocked(commands.updateSettings).mock.calls[0][0] as Settings; + expect(saved.hotkeys.trigger_mode).toBe("toggle"); + }); + it("blocks a translation chord identical to dictation", async () => { const wrapper = mountPage(); const recorders = wrapper.findAllComponents(HotkeyRecorder); diff --git a/src/windows/settings/pages/HotkeysPage.vue b/src/windows/settings/pages/HotkeysPage.vue index 7ef122d..6e4b269 100644 --- a/src/windows/settings/pages/HotkeysPage.vue +++ b/src/windows/settings/pages/HotkeysPage.vue @@ -5,6 +5,7 @@ import { useI18n } from "vue-i18n"; import Callout from "@/components/Callout.vue"; import FormRow from "@/components/FormRow.vue"; import HotkeyRecorder from "@/components/HotkeyRecorder.vue"; +import SegmentedControl from "@/components/SegmentedControl.vue"; import { hotkeyChordsAreReachable, normalizeHotkeyChord, @@ -17,6 +18,22 @@ const validationError = ref(false); type FunctionalHotkey = "dictation" | "assistant" | "translation"; +const triggerModeOptions = computed(() => [ + { value: "hold", label: t("settings.hotkeys.trigger_hold") }, + { value: "toggle", label: t("settings.hotkeys.trigger_toggle") }, +]); +const triggerMode = computed({ + get: () => store.settings?.hotkeys.trigger_mode ?? "hold", + set: (value: string) => { + void store.mutate((draft) => { + draft.hotkeys.trigger_mode = value as typeof draft.hotkeys.trigger_mode; + }); + }, +}); +const triggerModeHint = computed(() => + t(`settings.hotkeys.trigger_${triggerMode.value}_hint`), +); + function saveHotkey(slot: FunctionalHotkey, value: string[]) { const settings = store.settings; if (!settings) return; @@ -56,6 +73,16 @@ const translation = computed({ {{ t("settings.hotkeys.unreachable_chords") }} + + +