前端实现规格(React 19 + TypeScript 5.9 + Tailwind 4 + Vite 7 + Vitest 3)
读者:实现
packages/ui、packages/shared、apps/desktop/src、apps/mobile/src的工程师/Agent。Rust 侧(crates/、apps/*/src-tauri)不在本规格范围内。
0. 视觉基线
- 令牌与组件的唯一真相是代码:
packages/ui/src/tokens.css(六套主题的色板与外壳材质、字体、圆角、阴影)与packages/ui/src/components/*。页面文案以packages/shared/src/labels.ts与各页面源码为准。 - 当前 UI 的截图:网站用的
docs/site/public/screens/(浏览器 MockBackend 模式,1440 × 900,light 是澄空、dark 是夜空,中 / 英,docs/site/README.md「Screenshots」);各功能验收时的截图在docs/acceptance/screens/desktop/(当时的界面,0.1.1 之前是明亮 / 暗黑)与docs/acceptance/screens/tauri/(Linux Xvfb 下的真实 Tauri 窗口)。
1. 令牌(CSS 变量)
六套主题 glass | glass_dark | light | dark | warm | graphite(澄空、夜空、明亮、暗黑、暖纸、石墨;新安装默认澄空,跟随系统时浅色用澄空、深色用夜空,resolveTheme),角色:canvas surface inset inset2 border borderStrong fg fgMuted fgSubtle primary primaryFg accent accentFg accentSoft accentText accentTextHover thumb ok okSoft okText danger dangerSoft warning warningSoft info infoSoft nav navActive ledOff ledOn ledPeak pillBg pillBorder pillFg pillMuted wave desktop desktopField keycapBg keycapBorder scrim diffAdd diffDel mark + 三组阴影 shadowPop shadowPill shadowWin。
- 实现为
packages/ui/src/tokens.css::root, :root[data-theme="glass"] { --canvas: #eef2fa; … }等六段(裸:root是澄空);Tailwind 4 用@theme inline { --color-canvas: var(--canvas); … }暴露为bg-canvas text-fg border-border等工具类。 - 外壳材质(0.1.1,Penpot「Voltip 服务商设置」的「澄空 Glass · Voltip」画板):角色色之外,每套主题还定义
--canvas-image(极光)、--panel/--panel-edge/--panel-shadow(卡片)、--raised*(菜单、弹层、对话框、抽屉、提示)、--field*(输入框、选择框、分段、轮廓按钮、胶囊)、--sidebar*、--glass-blur、--primary-glow、--field-shadow、--row-active-shadow与圆角--r-panel/--r-control/--r-row,Tailwind 名为bg-panelborder-panel-edgebg-raisedbg-fieldbg-sidebarrounded-panelrounded-controlrounded-rowshadow-panelshadow-primary等,另有工具类app-canvas(画布色 + 极光,background-attachment: fixed)与glass-blur(backdrop-filter)。澄空与夜空的面板半透明(白 62 % / 6 %)、圆角 24 / 14 / 14,背后是四色极光,比画板淡(35–40 % / 12–22 %):theme.test.ts检查极光最亮处的正文、次要文字和强调色文字在画布和面板上都 ≥ 4.5:1;经典四套这些变量取不透明的surface/border、没有极光与阴影、圆角 10 / 6 / 6,外观与 0.1.0 相同(阴影写成0 0 #0000,不写none,好让组合阴影照常生效)。悬浮胶囊与提示浮在其他窗口之上,澄空下用夜空(floatingTheme,胶囊窗口与提示容器上的data-theme)。 - 蓝色系与强调色(2026-09-29,用户选定):澄空、夜空、明亮、暗黑、石墨的默认强调色是 Codex 桌面应用的
#339cff,暖纸保留赭色;info一律是 Codex 的蓝。设置 › 外观的「强调色」是本机选项(LocalAppearance.accent,写进<html data-accent>,其他窗口经storage事件跟随):默认、蓝、绿、黄、粉、橙、紫、墨色,取 ChatGPT / Codex 的色板,浅色主题用浅档、深色主题用深档。每个主题 × 强调色的accent / accent-fg / accent-soft / accent-text / accent-text-hover / led-peak事先算好写进tokens.css:accent-fg取对比度更高的黑或白,accent-text与悬停色在表面、画布、嵌入色和accent-soft上都 ≥ 4.5:1,焦点框用accent-text(theme.test.ts逐一检查)。开关照 Codex:32 × 19,开为强调色、关为淡墨色,白色滑块(--thumb、shadow-thumb);文字链接用accent-text,无下划线,悬停换accent-text-hover;设置项标签 14 px 中等字重,说明 12 px。浅色主题里#339cff、黄、绿、粉等填充色与白底的对比度低于 3:1,这是 Codex / ChatGPT 的原样设计:开关的状态同时由滑块位置表示,文字和焦点框都用加深后的颜色。 - 字体:UI
Instrument Sans,等宽JetBrains Mono,中文Noto Sans SC(font-ui / font-mono;CJK 自动回退到 PingFang SC / Microsoft YaHei UI)。三族字体随包自带(@fontsource-variable/*可变字体,packages/ui/src/tokens.css顶部@import),不从 Google Fonts 拉取(离线 / 受限网络下远程字体会回退成系统字体);CSP 不放行任何远程 font / style 源(apps/desktop/src/offline-assets.test.ts)。圆角:4 / 6 / 10 / 14 / 20 / 24 / 999。经典主题的卡片无阴影、1 px hairline,阴影只用于弹出层与胶囊;澄空与夜空的卡片带柔和阴影,主按钮带主色光晕。 - 滚动条(2026-10-11):
tokens.css的 base 层给所有元素scrollbar-width: thin,颜色取--fg-subtle的 45 %、轨道透明,各主题一致。小窗口里才常见滚动条(大字号下的侧栏、页面、比可用空间高的菜单),平台默认的宽灰条带箭头按钮会挤占这些区域;不支持这两个属性的 WebView 保持原样。 - 绿色只做 8 px 状态点(
ok),强调色accent用于链接 / 波形 / 电平 / 进度 / 选中态。
2. 组件(packages/ui,每个都有 Vitest 测试)
Card Panel(eyebrow) Eyebrow Badge(tone: neutral|ok|accent|danger|warn|info|ink,ok = 中性底 + 绿点) Chip Button(primary|outline|ghost|danger, disabled) IconButton Toggle Segmented Tabs(页面内的视图切换:Segmented的外观,tab 语义,左右方向键与 Home / End 切换,只有选中的一个在 Tab 顺序里,只做桌面) Input(icon, keys, focus) Select Keycap/Keycaps LedMeter Waveform(2px 条 2px 间距圆头镜像,近端 accent 尾迹 subtle 55%) Sparkline Progress Table(列类型 text|mono|chip|badge|toggle|progress|lamp|actions|keys|link|radio|two) LampText Readout Pill(胶囊八态) Toast Dialog(叠加时各有名称,Esc 只关最上面的) Menu(菜单按钮:单选行 / 命令行 / 分组,单选行可带右侧次要文字detail与disabled(键盘跳过),onOpen 在每次打开时调用,键盘可用,每行不折行;打开时留在窗口内(fitPanel):下方放不下而上方更宽时向上打开,比可用空间高时在里面上下滚动,靠近窗口右边缘时左移) Popover(「依据」这类说明:非模态对话框,正文按 UI 字体左对齐折行,不随所在的单行副标题不换行;同样留在窗口内) Pagination(长列表的翻页按钮:第一页、上一页、页码与省略号、下一页、最后一页,超过 7 页时始终 7 个位置;当前页 aria-current="page",浅强调色底;到头的按钮 aria-disabled,仍在 Tab 顺序里;容器窄于 19rem 时收起第一页与最后一页按钮) EmptyState CommandPalette ThemeTile ThemeSwitch(侧栏底部的方形主题按钮 + 上方菜单) Sidebar(浮动面板、状态卡插槽、底部方形按钮 SidebarButton) TitleBar(页头:标题 + 副标题 + 命令栏 + 右侧插槽 + 窗口控制) Toolbar(读数:中文标签 + 可读值 + 状态点) StatusRow
3. IPC 契约(packages/shared)
Tauri command 名与参数(invoke(name, args)),返回值 JSON:
| command | args | returns |
|---|---|---|
core_state | — | UiState |
pairing_start | — | null |
pairing_join_code | { code: string } | null |
pairing_join_ticket | { uri: string } | null |
pairing_confirm / pairing_reject / pairing_cancel / pairing_reset | — | null |
device_forget | { publicKey: hex } | null |
device_rename | { name: string } | null |
send_text | { publicKey: hex, body: string } | null |
settings_set_relay | { url: string | null, enabled: boolean } | null |
relay_reconnect | — | null(docs/pairing.md「重连」:网络恢复或窗口回到前台时,中继连接立即检查一次;桌面主窗口在 online 与 visibilitychange 时发送) |
settings_set_pairing_always_on | { enabled: boolean } | null(docs/pairing.md「常开配对」:持久化 Settings.pairing_always_on 并回发 settings;打开即开一个会话,关掉即关闭还没人加入的会话;手机上为 error 事件) |
settings_set_theme | { theme: ThemeId, followSystem: boolean } | null |
devices_refresh | — | null |
dictation_start / dictation_stop / dictation_cancel | — | null(docs/dictation.md §5) |
settings_set_engines | { engines: EngineSettings }(全量) | null |
provider_key_set | { provider: ProviderId, kind: "asr" | "llm", value: string | null } | null(值永不回传;docs/dictation.md §3) |
provider_probe | { provider, kind, baseUrl?, key? } | null(结果为 provider_probe 事件:模型列表或不含主机名的失败原因) |
provider_models_refresh | { provider, kind } | null(用已保存的地址与密钥列出服务商的模型,结果在 engines 的 ServiceStatus.listed;没有密钥或地址、内置服务和本机时什么也不做;docs/dictation.md §3.3) |
engines_quota_reset | { kind: "asr" | "llm" } | null(「重新检查」:清除该服务额度用完的记录,回发 engines;docs/dictation.md §3.5) |
provider_console_open | { provider, page?: "key" | "quota" | "setup", site? } | null(在浏览器里打开该服务商的密钥页(site 的,§3.2);page: "quota" 时打开额度页,目前只有阿里云百炼,§3.5;page: "setup" 时打开开通服务、创建应用等的页面,ProviderStatus.setup,§3.16;docs/dictation.md) |
history_delete / history_star | { id } / { id, starred } | null |
history_clear | — | null |
settings_set_locale | { locale: "system" | "zh-cn" | "en" } | null(核心持久化并回发 settings,所有窗口与手机同步换语言,§7) |
settings_set_auto_update | { enabled: boolean } | null |
hotkey_edge | { pressed: boolean, atMs?: number, source?: "hotkey" | "cli" | "ui", purpose?: "dictation" | "edit", chorded?: boolean } | null(一个热键边沿进核心的激活状态机,docs/dictation.md §13;界面只传 pressed,壳层补 now / ui / dictation;purpose: "edit" 是语音编辑键,§19) |
settings_set_edit_hotkey | { hotkey: string | null } | null(§19:语音编辑热键;与听写键同样校验、不得与听写键相同,null 关闭;持久化 Settings.edit_hotkey 并回发 settings,拒绝为 error 事件) |
settings_set_solo_key | { key: SoloKey | null } | null(§13.1:单键触发,right_ctrl … mouse_forward,null 关闭;持久化 Settings.solo_key 并回发 settings,桌面壳随即安装或卸下输入钩子,结果在 hotkey 事件的 solo_registered / solo_error) |
phone_text_send | { publicKey: string, body: string, source: "typed" | "clipboard" } | null(§20.6:手机把文字发给在线的可信电脑插入光标处;sent_texts 事件跟随每个回音;桌面壳返回 PHONE_TEXT_UNAVAILABLE) |
sent_texts_clear | — | null(手机清空已发送列表) |
phone_clipboard_read | — | 查询:{ text: string | null },手机剪贴板里的文字(Android 插件;其他构建报错) |
phone_share_text | { text } | null(手机经系统分享面板交给其他应用,docs/dictation.md §20.7;桌面报错) |
settings_set_activation | { activation: "hold" | "toggle" | "hold_or_toggle", holdThresholdMs: number, extraRecordingMs: number } | null(三值一起下发;> 5000 ms 核心以 error 事件拒绝,设置不变;成功则回发 settings) |
update_check / update_install | — | null(进度以 update 事件回报,§7) |
update_status | — | UpdateStatus(查询,Backend.updateStatus()) |
dictionary_add | { entry: { term, heard_as, enabled }, historyId?: string | null } | null(docs/dictation.md §16.4:草稿本身不合法时 reject 核心原文;与其他词条冲突为 error 事件、列表不变;成功回发 dictionary) |
dictionary_update / dictionary_remove | { id, entry } / { id } | null |
dictionary_reorder / rules_reorder | { ids: string[] }(现有 id 的一个排列) | null |
rules_add / rules_update / rules_remove | { rule: { name, kind, pattern, replacement, case_sensitive, enabled } } / { id, rule } / { id } | null(正则在 bridge 里编译,失败即 reject;成功回发 rules) |
rules_import | { toml: string, mode: "replace" | "merge" } | null(整份解析与校验,失败 reject 并带行号 / 第 N 条) |
rules_export | — | string(查询,Backend.rulesExport():TOML 文本) |
vocabulary_preview | { text: string, draft: { id?: string | null, rule } | null } | { corrected, output, corrections: [{ id, count }], rules: [{ id, count }], error? }(查询,Backend.vocabularyPreview(text, draft?):核心同一份代码,不跑 LLM) |
scenes_add | { scene: SceneDraft }({ name, enabled, match: { apps, title_contains }, overrides },未设的覆盖项省略或为 null) | null(docs/dictation.md §18.6:草稿本身不合法时 reject 核心原文;重名 / 超过 50 个为 error 事件、列表不变;成功回发 scenes) |
scenes_update / scenes_remove | { id, scene } / { id } | null(未知 id 为 error 事件) |
scenes_reorder | { ids: string[] }(现有 id 的一个排列) | null |
settings_set_context_sharing | { appName: boolean, windowTitle: boolean } | null(持久化 Settings.context_sharing 并回发 settings) |
recent_apps | — | AppRef[](查询,Backend.recentApps():历史里最近出现的应用,最新在前,按 id 去重,≤ 20;读历史库) |
history_query | { sinceMs?, starred, failed, query, offset, limit }(limit 1–200) | HistoryPage { entries, matching, total }(查询,Backend.historyQuery(),docs/dictation.md §4.4) |
history_entry | { id } | HistoryEntry | null(查询,Backend.historyEntry()) |
history_stats | { boundaries: number[] }(递增的本地零点,2–43 个) | HistoryStats { buckets, total }(查询,Backend.historyStats(),docs/dictation.md §4.5) |
history_hits | — | HistoryHits { dictionary, rules }(查询,Backend.historyHits():全部历史的触发次数) |
事件:listen("voltip://event", (e) => UiEvent);听写相关事件为 dictation(DictationStatus 展平)、history({ entries })、engines(EngineStatus 展平),更新器事件为 update(UpdateStatus 展平,type 与 state 两个判别字段),词典与规则为 dictionary({ entries })与 rules({ rules },每次变更全量),UiState 有同名字段(schema.ts 用 .default() 兼容缺字段的旧核心)。
type ThemeId = "light" | "dark" | "warm" | "graphite";
type Platform = "windows" | "macos" | "linux" | "android" | "ios" | "other";
type ConnectionState = "disconnected" | "connecting" | "authenticating" | "connected" | "reconnecting" | "closed";
type OutputMode = "whole_take" | "streaming_final" | "live_inject"; // docs/dictation.md §12
type Activation = "hold" | "toggle" | "hold_or_toggle"; // docs/dictation.md §13
type ProviderId = "builtin" | "local" | "openai" | "groq" | "google" | "siliconflow" | "aliyun" | "deepseek" | "kimi" | "glm" | "volcengine" | "hunyuan" | "mimo" | "openrouter" | "ollama" | "custom"; // docs/dictation.md §3
interface EngineSettings { asr_provider: ProviderId /* default "builtin" */; llm_provider: ProviderId /* default "builtin" */; refine_enabled: boolean; providers: Partial<Record<ProviderId, { asr_model?: string; asr_url?: string; llm_model?: string; llm_url?: string }>>; local_model?: string | null; local_device: "auto" | "cpu" | "gpu"; local_gpu?: string | null; local_threads?: number | null /* §10.6 */; language?: string; provider_preview: boolean /* 服务商的实时文字,default true, §11.10 */; local_preview: boolean /* 本地模型预览,default true, §11.10 */; output_mode: OutputMode /* default "whole_take" */; vad_trim: boolean /* default false */; chinese_script: "simplified" | "traditional" | "as_is" /* default "simplified", §17 */; inject: "paste" | "clipboard_only" }
interface Settings { schema: 1; theme: ThemeId; follow_system_theme: boolean; relay_url?: string; relay_enabled: boolean; hotkey: string; engines: EngineSettings; locale: "system" | "zh-cn" | "en" /* default "system" */; auto_update: boolean /* default false */; activation: Activation /* default "hold" */; hold_threshold_ms: number /* default 300 */; extra_recording_ms: number /* new installs 150, absent 0 (docs/dictation.md §30.4) */; edit_hotkey: string | null /* §19,default "Ctrl+Alt+E",null = 关闭 */; solo_key: SoloKey | null /* §13.1,default null = 关闭 */; pairing_always_on: boolean /* docs/pairing.md「常开配对」,default false */ }
interface DeviceIdentityPublic { device_id: string; name: string; platform: Platform; public_key: string /* 64 hex */; fingerprint: string /* "A7:C4:19:8E · 3D:F2:61:09" */ }
interface RelayStatus { endpoint?: string; state: ConnectionState; attempts: number }
type PairingState = { state: "idle" | "creating_session" | "waiting_for_peer" | "key_exchange" | "awaiting_verification" | "trusted" | "expired" | "rejected" }
| { state: "failed"; reason: { kind: "timeout" | "replay" | "handshake" | "protocol" | "cancelled" | "peer_left" | "identity_changed" } | { kind: "relay"; code: string } };
interface SafetyCode { words: [string, string, string, string]; fingerprint: string }
interface DeviceInfo { device_id: string; name: string; platform: Platform }
interface Snapshot { state: PairingState; session_id?: string; code?: string /* "483 921" */; ticket_uri?: string; expires_at?: number; remaining_secs?: number; safety_code?: SafetyCode; peer?: DeviceInfo; local_confirmed: boolean; peer_confirmed: boolean }
interface TrustedDevice { device_id: string; name: string; platform: Platform; public_key: string; fingerprint: string; trusted_at: number; last_seen?: number }
type DeviceConnection = { state: "offline" } | { state: "connecting" } | { state: "online" } | { state: "identity_changed"; presented_fingerprint: string };
interface DeviceView { device: TrustedDevice; connection: DeviceConnection }
interface Segment { text: string; start_ms: number; end_ms: number }
type LiveSource = "cloud" | "local" | "stream" /* 内置服务的预览、本地模型、服务商的实时识别模型(§11.8–§11.10) */
interface LiveText { committed: Segment[]; current: string; degraded?: string; injected: number /* default 0:live_inject 已粘贴的句数(§12) */; source?: LiveSource /* 这些文字的来源,§11.10 */; provider?: ProviderId /* 给出文字的服务商,悬浮窗标签用它的短名 */ }
type DictationPhase = { phase: "idle" } | { phase: "listening"; started_at: number; ready: boolean /* default false: 设备尚未送出样本 */; live?: LiveText; locked: boolean /* default false:hold_or_toggle 短按锁定(§13),总在 wire 上 */ } | { phase: "processing"; stage: "transcribing" | "finalizing" | "refining" | "inserting"; started_at: number; preview?: string }
| { phase: "done"; text: string; raw_text: string; chars: number; via: "paste" | "clipboard"; refined: boolean; duration_ms: number; asr_ms: number; refine_ms?: number; refine_error?: string; mode: OutputMode /* default "whole_take" */; segments?: Segment[]; live_error?: string }
| { phase: "failed"; message: string; code?: "no_speech" | "audio" | "asr" | "refine" | "inject" | "no_selection" | "selection_too_long" | "selection" | "edit_unavailable" | "edit_in_terminal" | "unknown"; text?: string } | { phase: "cancelled"; injected_chars: number /* default 0 */ };
interface DictationStatus { session: number; phase: DictationPhase; context?: TakeContext /* §18:探针有答案后出现,回到 idle 清除 */; kind: "dictation" | "edit" /* §19,当前 / 上一次 take,default "dictation" */ }
interface HistoryEntry { id: string; at_ms: number; raw_text: string; text: string; refined: boolean; asr_model: string; refine_model?: string; duration_ms: number; asr_ms: number; refine_ms?: number; outcome: { kind: "inserted"; via: "paste" | "clipboard" } | { kind: "clipboard"; reason: string } | { kind: "failed"; reason: string }; starred: boolean; mode: OutputMode /* default "whole_take" */; segments?: Segment[]; live_error?: string; kind: "dictation" | "edit" /* §19,default "dictation" */; edit?: { instruction: string; selection: string } /* 只有语音编辑有 */ }
interface SecretState { set: boolean; source: "builtin" | "user" | "none" }
interface EngineStatus { asr_provider: ProviderId; asr_ready: boolean; asr_issue?: EngineIssue; asr_model: string; asr_host: string /* 用户填写的主机;内置服务与本机为 "" */; local_model?: string | null; local_ready: boolean; live_ready: boolean /* 有边说边识别的来源,§11.10 */; live_source?: LiveSource; preview_source?: LiveSource /* 悬浮窗显示谁的文字 */; preview_provider?: ProviderId; effective_output_mode: OutputMode /* 本次 dictation_start 真正会走的模式 */; language?: string; refine_enabled: boolean; llm_provider?: ProviderId; refine_ready: boolean; refine_issue?: EngineIssue; refine_model: string; refine_host: string; inject: "paste" | "clipboard_only"; providers: ProviderStatus[] /* 每家的模型、预设、密钥状态、是否在用 */ }
interface ModelState { id: string; name: string /* 核心的中文档位名;en 下前端按 id 查字典 */; engine: "transcribe_cpp" | "sense_voice" | "paraformer" | "zipformer_streaming" | "silero_vad"; tier: "balanced" | "accurate" | "light" | "streaming" | "auxiliary"; capabilities: ("offline" | "streaming" | "vad")[]; languages: string[]; size_bytes: number; description: string; recommended: boolean; active: boolean; state: { kind: "not_installed" } | { kind: "downloading"; received: number; total: number; file: string } | { kind: "verifying" } | { kind: "installed"; path: string; installed_at: number } | { kind: "failed"; message: string } }
type UpdateStatus = { state: "idle" } | { state: "checking" } | { state: "up_to_date"; version: string; checked_at: number } | { state: "available"; version: string; current: string; notes?: string; date?: string }
| { state: "downloading"; version: string; received: number; total?: number } | { state: "ready"; version: string } | { state: "installing"; version: string } | { state: "failed"; message: string } | { state: "disabled" };
interface DictionaryEntry { id: string; term: string; heard_as: string[]; enabled: boolean; source: { kind: "manual" } | { kind: "history"; history_id: string }; created_at_ms: number; updated_at_ms: number } // docs/dictation.md §16
interface ReplacementRule { id: string; name: string; kind: "literal" | "regex"; pattern: string; replacement: string; case_sensitive: boolean; enabled: boolean; created_at_ms: number; updated_at_ms: number }
interface SceneOverrides { refine_enabled?: boolean; refine_preset?: PresetId /* 内置预设名或自定义预设 UUID,docs/dictation.md §21 */; output_mode?: OutputMode; language?: string /* "auto" = 不带语言提示 */; chinese_script?: "simplified" | "traditional" | "as_is"; prompt?: string /* ≤ 500 */ } // 缺省 = 跟随全局,docs/dictation.md §18
interface Scene { id: string; name: string; enabled: boolean; match: { apps: string[] /* 规范化 id */; title_contains: string[] }; overrides: SceneOverrides; created_at_ms: number; updated_at_ms: number }
interface AppRef { id: string; name: string }
interface TakeContext { app: AppRef; scene?: { id: string; name: string } } // DictationStatus.context?、HistoryEntry.app? / scene?
interface ContextSharing { app_name: boolean /* default true */; window_title: boolean /* default false */ } // Settings.context_sharing
interface UiState { identity: DeviceIdentityPublic | null; settings: Settings; secret_backend: string; relay: RelayStatus; pairing: Snapshot; devices: DeviceView[]; hotkey: HotkeyStatus; dictation: DictationStatus; history_recent: HistoryEntry[] /* 最新 20 条,每行可带 vocabulary?: { corrections, rules }(命中的 id 与次数) */; history_total: number /* 全部条数,其余用 history_query 读 */; engines: EngineStatus; update: UpdateStatus; models: ModelState[]; dictionary: DictionaryEntry[]; rules: ReplacementRule[] }
type UiEvent = { type: "state" } & UiState | { type: "identity" } & DeviceIdentityPublic | { type: "settings" } & Settings | { type: "relay" } & RelayStatus
| { type: "pairing" } & Snapshot | { type: "devices"; 0?: never } /* payload is the array: see below */ | { type: "trusted" } & TrustedDevice
| { type: "identity_changed"; previous: TrustedDevice; presented_fingerprint: string } | { type: "message"; from: string; body: string } | { type: "error"; message: string }
| { type: "hotkey" } & HotkeyStatus | { type: "dictation" } & DictationStatus | { type: "history"; recent: HistoryEntry[]; total: number } | { type: "engines" } & EngineStatus | { type: "update" } & UpdateStatus
| { type: "models"; models: ModelState[] } | { type: "dictionary"; entries: DictionaryEntry[] } | { type: "rules"; rules: ReplacementRule[] }
| { type: "scenes"; scenes: Scene[] } /* UiState.scenes,整份替换 */;注意 serde 内部标签的细节:UiEvent::Devices(Vec<DeviceView>) 与 UiEvent::State(UiState) 等 newtype 变体在 JSON 中是 {"type":"devices", ...}?不是——serde 对 tagged newtype 变体的规则:结构体内容被展平(state、identity、settings、relay、pairing、trusted 都是结构体,会展平成 {type, ...fields}),而 devices 的内容是数组,serde 无法展平,Rust 侧已改为 {"type":"devices","devices":[...]}。用 zod 按上述形状校验,校验失败记录并忽略该事件。
Backend 接口:getState() invoke() on(event) audioDevices() meter(),以及查询 updateStatus() vocabularyPreview() vocabularySuggestions() vocabularySuggestionAccept() vocabularySuggestionUndo() rulesWarnings() rulesExport() recentApps() historyQuery() historyEntry() historyStats() historyHits() permissionsStatus() permissionsRequest() injectPreflight() pasteText();提供 TauriBackend(@tauri-apps/api 的 invoke/listen)与 MockBackend(内存模拟:完整走完配对状态机与倒计时、设备上下线、消息回显,以及确定性的听写流水线——dictation_start → listening(ready: false,150 ms 后 ready: true 并重取 started_at;live_ready 时每 400 ms 一条流式部分结果(带 source / provider,只在 preview_source 存在时推给悬浮窗):current 增长三步 → 端点提交一条 committed → 第二句增长,脚本 MOCK_LIVE_SCRIPT;simulateLiveDegraded(reason) 置 live.degraded 并停发部分结果),dictation_stop → 400 ms transcribing(带 preview = committed + current)→ 300 ms refining → done + 一条 HistoryEntry,2.5 s 后回 idle;输出模式(§12):engines.effective_output_mode = output_mode,流式模式在没有边说边识别的来源(本地模型未安装或「本地模型预览」关闭,且不是内置服务的预览或实时识别模型)时回落 whole_take;模式在 dictation_start 时定下——streaming_final 走 finalizing(120 ms,MOCK_FINALIZE_MS)→ refining? → done(mode、segments = committed + tail、raw_text = 预览文本、asr_ms = 120),live_inject 每个 committed 句立刻 live.injected += 1,松开后 finalizing → inserting → done 且不润色,取消时 cancelled.injected_chars = 已粘贴字数;流式取无文本或 degraded 时回落整段并记 live_error(MOCK_EMPTY_STREAM_ERROR);激活(§13):hotkey_edge 按 settings.activation 驱动——hold 按下开始 / 松开结束,toggle 按下切换 / 松开忽略,hold_or_toggle 松开时按住 ≥ hold_threshold_ms 结束、否则 listening.locked = true 并等下一次按下;source: "cli" 按下切换、松开取消;去抖、宽限与处理中 pending 不模拟。settings_set_activation 持久化三值并回发 settings,> 5000 ms 发 error 且不改;extra_recording_ms > 0 时 stop 延后关麦,窗口内第二次 stop 立即关、cancel 连录音一起丢弃;目录五条产品档位(qwen3-asr-0.6b 均衡 / qwen3-asr-1.7b 高精度 / sense-voice-small 轻量 / paraformer-zh 轻量 · 中文 / zipformer-stream-zh-en 实时预览,尺寸同真实文件),本地模型预览就绪 = local_preview && 流式模型已安装(live_ready 还包括内置服务的预览与实时识别模型,§11.10);settings_set_engines / provider_key_set / provider_probe / history_* 真实作用于内存状态;场景(§18):scenes_* / settings_set_context_sharing 按核心同样的上限与拒绝原文(packages/shared/src/scenes.ts),recentApps() 取内存历史;假探针 foregroundApp 选项 / setForegroundApp(app | null),每次 dictation_start 按核心规则清洗答复并匹配场景,覆盖输出方式(流式不可用时整段 + live_error)与润色开关,context 进状态、app / scene 进历史;语音编辑(§19):setSelection(text | null) 模拟前台选区(默认没有),hotkey_edge { purpose: "edit" } 按同一激活语义开始编辑的一次(另一种用途的一次进行中时边沿丢弃)——没有润色密钥即 failed{edit_unavailable} 不开麦;假探针 foregroundApp 的 id 属于身份平台的终端表(isTerminalApp,与 voltip_platform::foreground 同一份表,macOS 为空)即 failed{edit_in_terminal},不复制、不写历史;60 ms(MOCK_COPY_MS)后复制:无选区 / 只有空白 no_selection、超过 2000 字 selection_too_long;松开后 transcribing → refining → inserting → done(MOCK_EDIT_INSTRUCTION → MOCK_EDIT_TEXT,永远整段、规则不跑),写一条 kind: edit 的历史(带指令、原选区与探针的 app,不匹配场景);settings_set_edit_hotkey 与 settings_set_hotkey 互相拒绝对方的组合键,hotkey 状态带 edit_registered;内置服务在界面上只显示为「内置服务」,没有主机名;粘贴到上一个窗口(docs/dictation.md §4.1):pasteText 照壳层与核心的规则拒绝(空白或超长 invalid、录音或处理中 busy),其余记进 pastes 并答 pasteOutcome 选项(默认 pasted,setPasteOutcome 可改),不写历史;手机角色写进 phoneClipboard 并答 copied { clipboard_only },手机听写的结果同样写进 phoneClipboard,phone_share_text 记进 shared,手机的历史从空开始,只放它自己识别的条目,docs/dictation.md §20.7)。
MockBackend 与只在开发时用的页面(胶囊规格页 OverlaySheet)从 @voltip/shared/mock 与动态 import() 加载,只在 import.meta.env.DEV 且不在 Tauri 里时启用;发行包的前端里没有它们,scripts/check-web-bundle.sh 检查构建产物。
4. 页面与路由
桌面(apps/desktop/src,窗口 1152×720 起,最小 960×600,可缩放):浮动侧栏 232(距窗口上、左、下 16 px)+ 页头(标题与副标题,同时是标题栏)+ 主区;0.1.1 起按 Penpot「澄空 Glass · Voltip」画板 01,布局对每套主题都一样。
- 页头就是标题栏:主窗口无原生装饰(
tauri.conf.jsondecorations: false, shadow: true;macOS 由tauri.macos.conf.json保留交通灯titleBarStyle: "Overlay"、trafficLightPositionx 32 y 42(浮动侧栏品牌行的中线),并按 RFC 7396 完整重述窗口几何)。packages/uiTitleBar整块是data-tauri-drag-region="deep",侧栏品牌行另是一块拖拽区。页头从左到右:页标题(26 px 粗体)与其下的副标题(title-bar-subtitle:首页是「今天听写 N 次 · 节省 … · 历史记录 N 条」与「依据」弹层;其他页是role="group"的读数菜单,mono 11 px,语音模型 + 令牌状态灯(本地模型带「本机」标签)· 麦克风短名,窄时省略号截断,点击就地切换,见下条「切换菜单」)→Ctrl K命令栏(48 px 高的胶囊,「输入命令或页面…」与Ctrl K键帽,点开命令面板)→ AI 润色胶囊(polish-chip:wand图标 + 文字「AI 润色」/「AI Polish」+ 状态灯的开关,aria-label="AI 润色 · 开/关",tooltip 说明开关方向,点击真实写settings_set_engines { refine_enabled },aria-pressed跟随state.engines.refine_enabled;分隔线后是预设菜单,xl以上再有润色模型菜单data-testid="polish-model")→ Windows / Linux 右上角 40 px 宽的最小化 · 最大化/还原 · 关闭(apps/desktop/src/app/window.ts包一层getCurrentWindow(),浏览器预览无 Tauri 时不画按钮;平台由 UA 判定app/platform.ts,identity 只作兜底)。页头下方没有第二行页头(原PageHeader组件已删除),主区下方没有快捷键页脚(0.1.1 起;页面按钮自带键帽:配对的 Ctrl R、历史的 Ctrl F / Ctrl C / Del、规则与词典的 Ctrl N / Ctrl S / Ctrl ↵,全局快捷键在命令面板里);页头上没有示例数据 chip。不自写双击最大化;触控 / 笔用startDragging()兜底。已接受的降级:Windows 11 Snap Layouts 悬停面板不可用(WebView2 上游限制)。 - 侧栏(
packages/uiSidebar,apps/desktop/src/shell/ShellSidebar.tsx):浮动面板(rounded-panel、bg-sidebar,澄空与夜空带阴影和背景模糊),品牌行 64 px 是拖拽区(macOS 上文字从红绿灯右侧 16 px 起,TRAFFIC_LIGHTS_BRAND_INSET);导航行 44 px(SIDEBAR_ROW_CLASS,当前页是bg-nav-active加阴影、主色图标);其下是状态卡(sidebar-status-card:中继状态灯与文字、已配对与在线手机数或「尚未配对手机 · 去配对」、端到端加密说明,点开「手机」页);底部是「反馈」「主题」「设置」三个方形按钮(SidebarButton/ThemeSwitch),主题按钮在其上方打开跟随系统与六套主题的菜单。收起为 72 px 图标栏(macOS 84 px)时不显示状态卡。 - 设置是模态对话框:
/settings/:section仍是路由(深链、测试),但渲染为SettingsDialog(min(960px, 100vw-48px) × min(660px, 100vh-48px),左 200 px 分组导航 + 右侧流式内容),压在路由background(上一个非设置页面,默认首页)之上;Esc / 遮罩 / 关闭按钮回到 background;标题栏读数保持 background 页面的。 - 设置 › 听写(2026-09-29):插入方式(粘贴到光标处 / 仅复制到剪贴板,
settings_set_engines { inject })只在这里设置;首页听写卡的提示随之写「插入到当前光标处」或「复制到剪贴板」。 - 切换菜单(
apps/desktop/src/features/switchers/,2026-09-30 用户要求,2026-10-01 确认方案 A:预设与模型是两个下拉):标题栏与首页共用三个菜单,行与选中后的设置由纯函数switchers.ts生成(行 id 由choiceId/parseChoice往返)。SpeechModelMenu:「内置服务」组在前,然后是每个已设置、能运行的云端服务商(未设置密钥或缺少地址的服务商不列出,2026-10-01 用户决定),各列其模型;「本地模型」组列出已安装的识别模型(档位名 + 产品名,modelFamilyName);末行「管理语音模型…」进/speech。PolishModelMenu:按提供 LLM 的可用服务商分组列模型,末行「管理 AI 模型…」进/ai。选中写settings_set_engines(engineSettingsFor:内置服务只切换服务商;其他服务商经applyProviderDraft写入模型并保留已保存的地址;本地模型经activateLocalModel)。MicrophoneMenu:挂载时和每次打开时重读audio_devices(刚插上的 USB 麦克风也在),「系统默认({名称})」在前,再列各设备短名;所选设备未连接时仍列出,带「未连接」、选中且不可选;选中写settings_set_microphone;标题栏的菜单另有「录音来源」组(麦克风 / 电脑声音 / 混合,电脑声音不可用的平台不出现),末行「录音来源设置…」打开 设置 › 录音来源。首页:识别与润色卡的模型名(home-engine-model)、润色模型(home-refine-model)与麦克风设备名(home-mic-device;只录电脑声音时是纯文字)是这三个菜单的按钮;页头的两个菜单在首页以外的页面。 - 引擎页是服务商卡片:「语音识别」「文本润色」「识别设置」三组。前两组把服务商排成卡片网格(
packages/uiProviderCards,2026-10-10 用户在 Penpot「Voltip 服务商设置」选定方案 1:网格 + 右侧抽屉;按目录顺序,列宽auto-fill最小 280 px):每张卡片写服务商名、请求发往的主机(内置服务、本机、未填地址的自定义接口写一句说明,内置服务的主机从不显示,providerTagline)、状态徽标(在用的写「使用中」,有问题的写原因,其余写「就绪」,providerBadges)和所用模型(等宽字体,过长截断),右下角是「设为当前」(只切换,写settings_set_engines),在用的那张写「当前使用」并带浅强调色底与浅色图标块(用户 2026-10-10:实心蓝图标块不好看)。点卡片从右侧打开抽屉(Drawer:遮罩、Esc / 关闭按钮 / 点遮罩关闭、Tab 不出抽屉、关闭后焦点回到卡片;宽 480 px,本机 720 px,都不超过窗口宽减 24 px):标题行是图标块(厂商显示自己的标志,PROVIDER_MARKS;内置服务、本机、自定义接口用应用图标;卡片上同样)、服务商名、状态徽标和说明,下面按「语音识别 / AI 润色」「连接」两组每行一个字段——选模型(当前模型与预设 → 「其他模型…」(任意 id)→ 有服务商列表时「服务商提供(N)」一组,modelOptions,N 与测试连接的数量相同,新列表只追加在末尾;列表来自已保存的密钥,provider_models_refresh在打开抽屉、打开下拉框和保存之后刷新,或来自这次在表单里的测试连接;Select wrap让长模型 ID 在字段内换行,不再撑宽字段盖住旁边的接口地址,2026-10-10 用户报告)、接口地址、密钥(password字段,永远空着打开,只经provider_key_set写入;已保存时只显示「已保存」;显示 / 隐藏按钮在字段内;凭据有多个字段的服务商换成CredentialFields,每个字段一行,保密字段可显示 / 隐藏,火山的新旧控制台用分段选择切换,已保存的集留空表示保持,规则与草稿来自 shared 的checkCredentialDraft/credentialValue,docs/dictation.md §3.15)、密钥状态与「删除密钥」(阿里云百炼另有「查看额度」与一行额度说明);模型一组与「连接」之间是「配置方法」(SetupGuide,docs/dictation.md §3.16):服务商的 2–4 条步骤(setupSteps)和「开通服务」等开通页按钮(有ProviderStatus.setup时,文字取setupPageLabel)、「获取密钥」、「完整说明」(guide_open { page: "providers", provider },打开使用指南上这个服务商的一节),内置服务与本机没有这一组;识别卡片在名称下列出识别能力(「边说边识别」「热词」,电脑上内置服务另有「实时预览」,asrCardAbilities),模型下拉框给能边说边识别的模型加「· 边说边识别」(modelOptions的realtime,realtimeModel),模型下方是随所选模型变化的两行说明(asrAbilityHelp,docs/dictation.md §3.15);「测试连接」(provider_probe,未测时写「尚未测试」;多字段凭据先按同一条规则检查,缺字段时提示「请填写 …」、不发请求,checkProbeDraft);底部是「恢复默认」「保存」和「设为当前」(先按「保存」的规则检查并写入草稿,再切换并关闭抽屉);内置服务卡只写「内置服务的地址和密钥在构建时写进应用,这里不显示」。「本机」的抽屉里是真实的模型库(LocalModels,两列):最上面是「保存位置」(ModelsLocation,docs/dictation.md §10.9)——当前文件夹与默认 / 自定义徽标、「复制路径」、「更改位置…」(models_location_choose,壳的文件夹对话框)、自定义时「恢复默认」(先确认,models_location_reset)、移动时的进度与「取消移动」、上次移动的结果;移动期间卡片上的下载、重试、删除、检查并导入都禁用,有模型在下载时「更改位置…」禁用。卡片只列识别模型(capabilities含offline),按产品档位排序 均衡 → 高精度 → 轻量 → 轻量 · 中文(offlineModelsByTier,同档位保持核心顺序,listitem带data-tier),卡片标题在中文下用核心的name、英文下按 id 查字典(modelDisplayName),描述同理(modelDescription),下载 / 取消 / 重试 / 删除 / 使用此模型全部是真实核心命令。第三节「实时预览」(LivePreview,docs/dictation.md §11、§11.10):两个开关「服务商的实时文字」「本地模型预览」分别写settings_set_engines { provider_preview }/{ local_preview }(整块下发),状态灯行读state.engines.preview_source/preview_provider(使用中 · 来源 / 不显示 · 实时识别照常进行 / 暂不可用 / 模型未下载 / 已关闭,data-state、data-source),下方是流式模型zipformer-stream-zh-en的同款卡片——只有下载 / 取消 / 重试 / 删除,没有「使用此模型」(modelAction→installed:它不是识别模型,永不active)。第四节「输出方式」(OutputMode,docs/dictation.md §12,2026-09-26):三张OptionCard(role="listbox",aria-label="输出方式")——整段输出 / 流式定稿 / 实时注入,选中写settings_set_engines { output_mode };状态灯行与「当前生效」徽标读state.engines.effective_output_mode(data-mode= 所选,data-effective= 实际),流式模型未下载时两张流式卡带「模型未下载」徽标、所选流式卡下方出「流式模型未下载,当前按整段输出运行」(output-mode-fallback);output_mode = live_inject且润色开着时 LLM 润色卡内出「实时注入下不润色」(refine-live-inject)。第五节「静音裁剪」(VadTrim):Toggle写settings_set_engines { vad_trim },仅本机识别时可用,云端下禁用并注明「仅本地识别可用;云端识别不裁剪」(data-state= on / off / cloud)。「本机」抽屉最后是「运行设备」(LocalCompute,§10.6):自动 / CPU / GPU(没有可用 GPU 时禁用 GPU)、多块 GPU 时选哪块、推理线程。服务商列表之后是「额度用完后改用其他模型」(packages/uiFallbackSection,docs/dictation.md §3.5,识别与润色各一份,手机同用):开关与列表写settings_set_engines { asr_fallback | llm_fallback },每行的状态读state.engines.asr_fallback | llm_fallback(fallbackRows:使用中 / 可用 / 额度已用完 · 何时再试 / 服务商问题 / 跳过),「重新检查」=engines_quota_reset;候补顶替时服务商区块标题为「当前:服务商 · 模型(候补)」,隐私说明在候补链运行时加上额度用完后会收到数据的服务商(两者是packages/ui的CurrentService/ServicePrivacy,手机两页放在服务商卡片下方);顶替实时模型的是整段识别模型时,「实时预览」为「暂不可用 · 候补模型不支持实时识别」(data-state="paused"),「输出方式」按整段输出生效,不显示「模型未下载」。 - 自适应:页面根
mx-auto w-full max-w-[1440px](首页 1600),网格只用minmax(0,1fr)/auto-fill,没有固定像素面板宽;每页一条regression: … is fluid测试禁止w-[NNNpx]面板。 - 路由
/(首页)/history/dictionary/rules/engines/devices(配对面板 = 二维码 + 6 位码 + 倒计时 + 设备表)/settings/:section(对话框)/onboarding(权限 → 热键 → 引擎 → 试说,四步);Ctrl K命令菜单;悬浮胶囊作为独立路由/overlay?state=渲染单个胶囊(独立小窗,state=blank什么都不画,state=live跟随state.dictation:idle 不画;listening 波形来自audio_meter_start的实时电平 + 已用时长——ready: false(设备尚未送出样本)时计时停在00:00并显示「等待麦克风」提示,ready: true后从重取的started_at起计;有live时胶囊长高到两行(h-14,窗口 64 px 减 8 px 顶部内边距),波形上方一行两色实时字幕:committed正常色 +current弱化色(text-pill-muted),中日韩边界不加空格、拉丁边界加空格(liveTextGap),单行、只保留末尾 40 个字符(clipLiveCaption,前置省略号,data-clipped),旁边一枚预览标签(pill-live-tag),写明文字的来源:LiveText.source/provider→ 「预览 · 豆包语音」「预览 · 本地模型」(livePreviewTag,docs/dictation.md §11.10),没有来源时为「预览」;live.degraded胶囊不额外显示任何东西(首页引擎卡出一行「实时预览已中断 · 最终文本不受影响」);processing 有preview时用弱化色的预览文本(pill-preview,同样只留末尾 40 字)替代「转写中…」/「润色中…」直到最终结果;inserted 显示字数与via,failed 显示原因并在有text时提供真实「复制文本」,cancelled 用 danger 胶囊),§12 / §13(2026-09-26):listening.locked时锁标(pill-lock,aria-label「已锁定 · 再按一次结束」)替代状态灯、字幕与计时照旧;live.injected > 0时已粘贴的 committed 句以更淡样式(pill-live-injected,title「已打进前台应用」)排在字幕最前、裁剪时最先被裁;processing.stage = finalizing时模式 tag 显示「补齐最后一句…」而preview继续显示;cancelled.injected_chars > 0时文案为「已取消,之前打进去的 N 字保留」);/overlay不带参数是胶囊规格页(八态总览 + toast / 兜底卡 / 失败原因表;locked样例胶囊的「结束收音」是真实dictation_stop),只在开发构建里有,发行包里这个地址显示「找不到页面」。没有/bridge路由(Bridge & MCP 不在产品范围内);首页对应位置是由核心真实数据驱动的「手机麦克风 · 设备」卡。 - 客户端禁用浏览器右键菜单(
apps/desktop/src/app/context-menu.ts,仅 Tauri 内生效,可编辑控件与data-allow-context-menu除外)。
所有页面的数据都来自 Backend(state.dictation / state.history_recent 与历史查询 / state.dictionary / state.rules / state.engines / state.settings …);packages/shared/src/fixtures/ 只剩 IPC 契约夹具与测试数据。
移动端(apps/mobile/src,同一套组件,竖屏 390×844 起):Welcome → This Device(名称、指纹、平台)→ Pair Device([扫码] 或 输入 6 位码 _ _ _ _ _ _)→ Verify Device(对端名称/平台 + Safety Code 四词 + 指纹 + [Confirm & Trust]/[Reject])→ Devices(Device / Platform / Online·Offline / Last Seen / Trusted / Connection Type)。扫码用 @tauri-apps/plugin-barcode-scanner(在 Mock 下用文本输入代替)。Welcome 与 Devices 的顶部都是「用手机说话」(PhoneMic):有在线的已配对电脑时录音发给电脑(phone_take_*),没有时在手机上识别(dictation_*,结果进手机剪贴板),一次录音保持开始时的去向;下面的「最近结果」(RecentResults)读 state.history_recent,列出手机识别的结果和发送到电脑的听写,点一行打开记录详情,也可以直接复制(pasteText)或分享(phone_share_text),docs/dictation.md §20.7。
手机的样式(2026-10-03 用户要求:与桌面一致、去掉老式安卓控件):根字号 16 px(apps/mobile/src/index.css 的 --ui-font-size;桌面 13 px 起,由设置 › 外观调),所以共享组件的尺寸按标称值显示,h-11 即 44 px 的最小触控目标、p-4 即 16 px 页边距,文字大小两端都是像素。App 用 PresentationProvider value="touch" 包住整个应用:Select 不再用原生 <select>(Android WebView 会弹出系统老式的单选对话框),而是同样外观的触发按钮加页面内的选项列表(与 Menu 同一套样式,行高 44 px,下方放不下时翻到上方,点外面、Esc 和系统返回关闭,返回先关列表再关对话框,共用 components/layers.ts 的层栈);Button、IconButton、Toggle、Segmented、Menu 行与输入框去掉点按高亮,带按下状态,点按区域至少 44 × 44 px。没有提供者时为 native,桌面渲染与之前逐字节一致。页面沿用桌面的视觉语言:小号眉标、细线卡片、等宽读数、墨色主按钮、强调色文字链接;设置列表一种行样式(14 px 中等字重标签、12 px 说明、右侧控件或箭头)。useNow 是 Unix 秒,按天分组、统计与时间筛选的 hook 要毫秒(useNow() * 1000),桌面用 useTickingNow。
5. 质量门
pnpm -r run lint(oxlint 1.85 · 插件 typescript / react / react-hooks / import / unicorn / promise / vitest,--type-aware(oxlint-tsgolint);禁any、as unknown as、非空断言)+tsc --noEmit;pnpm -r run format:check(oxfmt 0.70,沿用仓库.oxfmtrc.json:printWidth 100 · 双引号 · trailingComma all)。- Vitest + jsdom + Testing Library;每个包
vitest.config.ts设coverage.thresholds行/分支/函数/语句 ≥ 90,providerv8,排除main.tsx、vite-env.d.ts、*.d.ts、fixtures。 - 功能测试:用
MockBackend驱动完整配对流程(桌面发起 → 手机扫码/输码 → 双端确认 → 设备列表 Online),断言 UI 文案与状态;回归测试:过期倒计时归零显示「已过期 · 重新生成」、拒绝、身份变化告警横幅、Relay 断连重连读数、主题切换写回data-theme。 - 无硬编码 Relay 地址;无
TODO骨架;每个页面至少一个渲染测试 + 一个交互测试。
6. 范围与诚实状态
听写流水线在 Rust 核心真实运行(docs/dictation.md):录音、识别、润色、剪贴板 + 粘贴注入、history.sqlite3、服务商与密钥。界面上没有「尚未接入」「示例」一类的占位:一个功能要么真的能用,要么不出现(packages/shared/src/labels.test.ts 的回归测试检查两份字典里都没有这类文案)。不做定时器模拟的进度、不伪造接受结果、不为没有发生的事弹成功 toast。
6.1 统一措辞(packages/shared/src/labels.ts)
| 常量 / 函数 | 文案 | 用途 |
|---|---|---|
dictationPhaseLabel / outcomeLabel / secretStateLabel / viaLabel / formatElapsed / formatMs / formatSeconds / hostOf | 待命 · 正在听… 00:03 · 转写中… · 已插入 42 字 · 粘贴 · 已润色 · 已内置 / 已设置 / 未设置 … | 首页、历史页、引擎页、胶囊共用的听写文案 |
dictationFailureText(phase, locale?) | no_speech → 没有听到声音 / No speech detected;audio / asr / refine / inject 同理;语音编辑的 no_selection / selection_too_long / edit_unavailable / edit_in_terminal 同理,selection 带核心的原因(去掉 selection: 前缀);unknown 或无 code → 核心的 message 原文 | |
takePhaseLabel(status, now, locale?) / takeFailureText(status, locale?) | 按 DictationStatus.kind:听写同 dictationPhaseLabel / dictationFailureText;语音编辑为 正在听编辑指令… 00:03 · 改写中… · 已替换 16 字 · 粘贴 / 改写结果已复制 16 字 · 剪贴板,refine 失败为「改写失败,选中文本未改动」 | 首页阶段行、胶囊(§19) |
所有 labels.ts 助手的文案来自 packages/shared/src/i18n/ 字典,最后一个参数是 locale(默认 zh-CN,无 provider 的调用方与既有测试保持中文);组件与页面通过 useI18n() 取 locale 传入。
剪贴板复制统一走 copyWithToast(apps/desktop/src/app/shell-context.tsx):只有 navigator.clipboard.writeText 成功才弹「已复制…」,失败弹 danger toast「无法访问剪贴板 · 未复制」。
6.2 各页面状态
| 路由 | 数据来源 | UI 如何表达 |
|---|---|---|
/ 首页 | 全部真实(0.1.1 起按画板 01):听写卡(home-hero):就绪状态读 state.engines + 麦克风(home-readiness,下面 home-phase 是实时状态 正在听… 00:03 / 转写中… / 已插入 42 字 · 粘贴 · 已润色 / 失败 · …),组合键大键帽读 state.settings.hotkey(home-hotkey),激活方式按钮(按住说话 / 按一下开始 · 再按结束 / 按住说话 · 短按锁定,点开 设置 › 快捷键)与提示(「松开后,文字插入到当前光标处 · 按 Esc 取消」等,随插入方式写「复制到剪贴板」)随 state.settings.activation 变化(docs/dictation.md §13),空态提示同理(activationHint);右侧圆形「开始听写」= dictation_start,听写中变「停止」= dictation_stop,另有「取消」= dictation_cancel;按钮外圈随这次录音或测试的强度放大,下方一行写麦克风状态(空闲 · 未打开麦克风 / 录音中 / 测试中 · N 秒)。识别与润色卡(home-engine):语音模型菜单与「本地」标签、preview_source 存在时的「实时预览」chip(home-live-preview)、listening 且 live.degraded 时一行「实时预览已中断 · 最终文本不受影响」(home-live-degraded,title 为核心原因);「配置」进语音模型页;AI 润色行是预设菜单(润色关闭时悬停写「AI 润色 关」)、润色模型菜单、隐私说明与开关(写 settings_set_engines);麦克风行是设备菜单、采样率与「测试麦克风」;本地模式的模型名中文用核心 name、英文按 id 查字典。四张统计卡(今天 / 本周 / 本月 / 总计:节省时间、听写次数与字数,点开历史)来自 history_stats(features/history/stats.ts、useHomeStats.ts,docs/dictation.md §4.5),「依据」弹层在页头副标题旁。未识别的录音(features/history/Recordings.tsx 的 HomeRecordings,有时才显示,docs/dictation.md §26.6)在最近的结果上方:最多 3 条、每条一行,「重新识别」用标题行「重新识别使用」的两个模型菜单(RetakeModels.tsx,只用于这次)、「删除录音」,「查看全部 N 条」与「清理」。最近的结果读 state.history_recent,按天分组,每行是应用图标、文本、「时间 · 应用 · 时长 · 识别 … ms · 润色 … ms」与结果标签,悬停或聚焦时出现复制、粘贴到上一个窗口与删除(先确认),点行打开历史详情;至少 4 行,窗口更高时按列表可用的高度增加行数,直到页面底部不再留空,最多为 history_recent 的全部 30 条(features/history/recent-rows.ts useRowsThatFit,2026-10-01 用户要求);行数只由窗口与记录算出,不从当前显示的行数往上加,新一天的日期标题也计入所需高度(2026-10-11:最大化窗口里曾在 16 与 17 行之间反复闪烁)。手机与中继的状态在侧栏状态卡 | 无 chip、无徽标;令牌缺失时「还不能开始听写」+ 按钮禁用并带原因;历史为空时是空态 + 热键提示 |
/history | 真实 history_query 分页读库(history.sqlite3,上限 2 万,docs/dictation.md §4.4) | 横幅写保留条数(settings.history.keep);分页显示(2026-10-13 用户要求,pages/history-list/):日志底部写「第 X / Y 页 · 共 N 条」(搜索时「找到 N 条」),每页 10 / 20 / 50 / 100 / 200 条(默认 10,这个窗口的 localStorage 记住,voltip.historyPage),多于一页时有 Pagination;换筛选、搜索或每页条数回到第 1 页,新听写不翻页,当前页删空后改为最后一页,从页底翻页时日志顶端回到视野里;选中的条目不在新的一页上时详情改为这一页的第一条;在筛选里搜索不到时空状态写明筛选,可清除搜索或在全部记录中查找;lg 以上日志和详情并排占满窗口在上方内容以下的高度(滚动区域 − 网格位置 − 1.5rem,至少 480 px;--scroll-area-h / --split-top,history-list/layout.ts 的 SPLIT_FILLS 与 useSplitHeight,2026-10-26 用户要求),日志的行在日志里滚动、页码留在下面,换页时从顶端开始(useRowsStartAtTop),详情在卡片内滚动、换一条记录时从顶端开始;上方的「未识别的录音」最多约 3 行高;筛选与搜索交给核心(搜索停止输入 200 ms 后发出);筛选 全部 / 今天 / 本周 / 本月 / 已收藏 / 未插入;搜索文本 / 原文 / 模型 / 应用 / 场景;带上下文的条目在日志行显示应用名 + 场景徽标(history-context),详情显示「应用:名称 · id」与「场景」(没有匹配为「未匹配场景」,docs/dictation.md §18.6);星标 = history_star,删除 = 确认 + history_delete(选中相邻项),全部清空 = 确认 + history_clear;复制走 copyWithToast;原文 / 润色后 / 对比 diff 来自 raw_text 与 text;mode 为流式定稿 / 实时注入的条目在日志行与详情页头带 Badge(history-mode / history-detail-mode),整段不显示;live_error 有值时详情里出「流式回退:…」(history-live-error);空态提示随 settings.activation;详情列出本次触发的词典纠正与规则(history-vocabulary,名称取当前列表,已删除的写「已删除的词条 / 规则」);「加入词典」打开对话框(曾听成用当前选区预填,正确写法手填)提交 dictionary_add { historyId },核心拒绝时对话框保留并显示原因;没有「重新插入」「保留时长」等无后端控件 |
/dictionary | 真实 state.dictionary(dictionary.json,上限 500,按匹配顺序) | 新建(表上方一行)/ 行内编辑(Enter 保存、Esc 取消)/ 启用开关 / 上移下移 / 删除(行内确认)= dictionary_*;输入时本地查空、超长、重复正确写法、写法过多,其余由核心判定(reject 原文留在编辑器,冲突为 error toast);「命中」列与「历史记录里的命中」chip 汇总 history[*].vocabulary.corrections;「试一试」= vocabulary_preview(防抖,列表变化重问),「用最近一次听写」取 history[0].raw_text;说明卡写明词表去向(云端识别与 AI 润色;本地模型不接收);Ctrl N 新建;没有 CSV、权重、复制为规则、示例页脚。页头有「词条」「学习建议(n)」两个视图(Tabs);学习建议视图占满整页宽度,不显示「试一试」:学习建议 = vocabulary_suggestions(词典、规则、历史记录或忽略列表变化时重问),每行「加入词典」= vocabulary_suggestion_accept、「忽略」= vocabulary_suggestion_ignore,处理过的行留在原处并可撤销(vocabulary_suggestion_undo / vocabulary_suggestion_unignore,只在页面打开期间),展开看示例与 vocabulary_backtest 的影响范围;显示范围默认「出现 2 次及以上」(页签的 n 跟随显示范围,表下统计写明显示与隐藏的组数),可选「全部」;勾选框(Checkbox)与「全选」,勾选后「加入词典」= vocabulary_suggestions_accept、「忽略」= vocabulary_suggestions_ignore,「全部忽略(n)」忽略当前显示、还没处理的行,每一批的结果列在表上方并可整批撤销(vocabulary_suggestions_undo);表下面「已忽略(n)」列出 state.vocabulary_ignored(最近忽略的在前,先列 5 组,「显示全部」),每行「取消忽略」= vocabulary_suggestion_unignore;拒绝按类型写原因;历史记录关闭、没有润色过的记录、没有新写法三种空状态(docs/dictation.md §16.10) |
/rules | 真实 state.rules(rules.json,上限 200,按执行顺序) | 一张表(序号 / 名称 / 类型 / 匹配 + Aa / 替换或「(删除)」/ 命中 / 启用);编辑器本地查空名称、重名、空匹配,再用 vocabulary_preview("", draft) 让核心编译检查(失败原文进状态行、禁用保存),Ctrl S 保存、Esc 取消、Ctrl N 新建;上移下移 = rules_reorder,删除 = 确认 + rules_remove;「试运行」(Ctrl ↵)= vocabulary_preview,可带正在编辑的草稿(「名称(未保存)」);「导入 TOML」文本对话框(合并 / 替换,核心整份校验,拒绝原文含行号)、「导出 TOML」对话框显示 rules_export 文本并可复制;每条规则下面一行写 rules_warnings 的提示(重复、互为反向替换、撤销某个词条,§16.10),规则或词典变化时重问;没有规则集切换、预算、作用域、逐阶段视图、示例页脚 |
/speech 语音模型页(旧链接 /engines、/settings/engine、/settings/speech 都进这里);/ai AI 模型页(旧链接 /settings/refine、/settings/ai) | 全部真实(state.engines / state.models / state.hardware / settings_set_engines / provider_key_set / provider_probe / model_download / model_cancel / model_remove) | 服务商卡片(见 §4);本地模型库按档位排序(均衡 → 高精度 → 轻量 → 轻量 · 中文);「实时预览」块:两个开关写 provider_preview / local_preview,状态行写明来源(使用中 · 来源 / 不显示 / 模型未下载 / 已关闭),流式模型卡无「使用此模型」;「输出方式」三卡写 output_mode,状态行 / 「当前生效」徽标读 effective_output_mode,模型缺失时说明回落;「静音裁剪」开关写 vad_trim,云端禁用并说明 |
/devices 手机麦克风 | 真实(配对、设备表、中继开关、忘记设备、手机正在进行的一次听写) | 配对面板底部是连接说明与中继状态;右侧「手机麦克风」面板在手机按住说话时显示真实电平与阶段(PCM 16 kHz 单声道、手机名、链路),「经过这条通道的」列出文字、手机音频、识别结果三类加密内容;「限制」写明手机不是输入法、中继只转发密文 |
/settings/appearance | Backend 真实数据(settings_set_theme)+ 本地外观 | 真实 |
/settings/hotkey | 真实:settings.hotkey + settings.edit_hotkey + settings.solo_key + state.hotkey(含 edit_registered / edit_error / solo_registered / solo_error / solo_loss / solo_recovered_at / solo_pressed / capabilities.solo_keys)+ settings.activation / hold_threshold_ms / extra_recording_ms | 录制走 useChordRecorder;「编辑选中文本」一行是第二个录制器(settings_set_edit_hotkey,与听写录制器互斥,「关闭 / 启用」,没有润色密钥时注明需要 AI 润色服务,§19);「激活方式」三张 OptionCard(按住说话 / 按一下开始,再按一下结束 / 按住或按一下,role="listbox")写 settings_set_activation(三值一起);「短按判定阈值」只在 按住或按一下 下显示(50–5000 ms,步进 50),「松开后继续录音」始终显示(0–5000 ms,0 = 立即停止);两个数字框本地起草、失焦 / 回车时吸附到 50 ms 网格并夹到范围内再下发(clampActivationMs);卡片下一行 mono 提示随模式与组合键(activation-hint) |
/feedback 反馈页 | 真实(feedback_diagnostics / feedback_submit) | 左边是类型、描述、可选联系方式与发送;右边列出随附的诊断;发送后清空并提示;没有反馈地址的构建改为在 GitHub 上反馈 |
/settings/scene 场景 | 真实:state.scenes(scenes.json,上限 50,按匹配顺序)+ settings.context_sharing + recent_apps | 顶部「发送给 AI 润色的上下文」两个 Toggle(应用名称默认开 / 窗口标题默认关,settings_set_context_sharing);场景卡片(role="article",序号 / 名称 / 启用开关 / 上移下移 = scenes_reorder / 编辑 / 删除确认 = scenes_remove;应用 id chip、标题关键词或「任何窗口」、覆盖摘要或「全部跟随全局设置」);SceneEditor 对话框:名称、应用 chip(输入 id 回车规范化,或点「最近的应用」)、标题关键词 chip、五个覆盖 Select 以「跟随全局」开头、补充要求带字数;缺项在第一次保存后提示、重名与超限输入时提示,核心拒绝原文留在对话框里,Ctrl S 保存、Esc 只关编辑器;标题栏读数「场景 N 个 · 启用 M · 上下文 应用名称 开 · 窗口标题 关」 |
/settings/general 通用 | 真实:settings.locale(settings_set_locale)、settings.auto_update(settings_set_auto_update)、state.update(update_check / update_install) | 语言 Segmented(跟随系统 / 简体中文 / English)+ 自动更新 Toggle + 更新状态行与唯一允许的动作按钮(§7) |
/settings/about 关于 | 真实:版本来自 state.app_version,许可 AGPL-3.0-or-later(APP_LICENSE),模型来源,更新行读 state.update | 只读读数行 + 同一条更新状态行(紧凑)+「源代码」「反馈问题」(project_link_open,壳层用浏览器打开仓库地址) |
/settings/privacy 隐私 | 真实:state.engines 与上下文开关推出的「现在离开这台电脑的内容」、settings.history(settings_set_history:开关与保留条数)、state.secret_backend | 读数行 + 历史开关与保留条数(改小立即裁剪)+「清空历史」(确认) |
/overlay | 规格页:波形为确定性伪造序列;/overlay?state=live:真实(见 §4:等待麦克风 / 两色实时字幕 + 写明来源的预览标签 / processing 的 preview / 锁标 / 已粘贴句更淡 / 「补齐最后一句…」/ 取消保留字数 / 模式标签后的场景名 pill-scene) | 规格页「复制文本」真实;locked 样例胶囊的「结束收音」= 真实 dictation_stop(锁定态由 hold_or_toggle 短按产生,§13);示例 toast 的「撤销」不做事 |
Ctrl K 命令菜单 | 真实 | 「开始听写 / 停止听写」= dictation_start / dictation_stop(处理中禁用并说明);「复制上一条结果」复制 state.history_recent[0].text;「删除全部历史…」确认后 history_clear;历史为空时两项禁用并写「历史记录为空」 |
/onboarding | 真实 | 第 1 步每秒轮询 permissions_status(docs/dictation.md §15.1),必需权限被拒时不能继续,Linux 直接通过;第 2 步读核心的组合键与 shell 的注册结果;第 3 步三选一(内置服务 / 本机模型,可当场下载推荐模型 / 其他服务商,填模型与密钥,密钥经 provider_key_set),「保存并继续」写 settings_set_engines;第 4 步「试说一句」= dictation_start,变「停止」= dictation_stop,电平表在录音期间跟随真实电平,done 的 text 显示在框里并说明文本也已按 via 送到光标处,「完成设置」始终可用;没有跳过链接 |
页头副标题的读数菜单(首页以外的页面;features/switchers/SwitcherMenus.tsx,按钮文字来自 shell/page-meta.ts engineReadout 与 audio_devices):语音模型一律来自 state.engines(Qwen3-ASR-1.7B,tooltip 主机 + 令牌状态;核心未报告时显示「等待核心…」),麦克风是所选设备(未选时为系统默认)的短名,设备列表未返回前用原生电平表枚举到的设备;pageMeta().readouts(历史条数、热词、中继 …)仍按页计算,供测试使用;设置对话框页头的读数来自 settingsReadouts(热键、外观、场景);热键行来自 state.settings.hotkey;侧栏历史计数为 state.history_total。
6.3 回归测试
apps/desktop / packages/* 中以 regression: 为前缀的测试锁定上述状态。关键回归用例:regression: the page header is board 01's (the title over its subtitle, the Ctrl K field, the AI 润色 chip) and there is no second header row 与 regression: the polish toggle carries the AI 润色 text label(apps/desktop/src/shell/Shell.test.tsx)、is the page header of board 01(packages/ui/src/components/TitleBar.test.tsx)、regression: every locale dictionary has the same key set(packages/shared/src/i18n/i18n.test.ts)、regression: switching the locale to English re-renders the shell, home, history, engines, settings and onboarding without CJK text 与 regression: dictation failure codes are localized(apps/desktop/src/app/locale.test.tsx、packages/shared/src/i18n/i18n.test.ts)、regression: changing the hotkey updates the 听写 card's keycaps immediately(Home.test.tsx)、regression: 开始听写 starts a real session and shows the phase; 停止 finishes with the inserted text(Home.test.tsx 与 packages/shared/src/mock-backend.test.ts)、regression: no page shows the phase-2 chip or the sample-data notice for dictation, history, engines or hotkey(Shell.test.tsx);另有 regression: another provider needs its key, which goes to provider_key_set and is never echoed(Engines.test.tsx)、regression: provider_key_set flips only the set/source flags and never stores or echoes the value(mock-backend.test.ts)、regression: the trial step runs a real dictation and shows the inserted text(Onboarding.test.tsx)、regression: the live pill paints nothing while idle, then listening with the real meter, the processing stage, and the inserted count 与 regression: with live preview ready the pill shows the two-tone caption and the 预览 chip while listening, the preview in place of 转写中… while processing, and nothing extra when degraded(Overlay.test.tsx)、regression: listening draws a two-tone live caption above the waveform with a 预览 chip; the capsule grows to two rows(packages/ui/src/components/Pill.test.tsx)、regression: the library lists recognition models by tier (均衡 → 高精度 → 轻量 → 轻量 · 中文) and the streaming model only in the 实时预览 block, without 使用此模型 与 regression: the 实时预览 block's two switches write provider_preview and local_preview through settings_set_engines, each on its own, and the state line names whose words the pill shows (docs/dictation.md section 11.10)(EnginesPane.test.tsx)、regression: with live preview ready the mock emits ready → throttled partials → a committed sentence → processing with the preview (docs/dictation.md §11)(mock-backend.test.ts)、regression: the model tiers, the 实时预览 block, the home chip and the live pill render in English under en(locale.test.tsx)、regression: the palette's dictation, copy-last and clear-history entries are real core actions(Shell.test.tsx)、regression: no string in either dictionary says a feature is not wired yet(labels.test.ts),以及 packages/shared/src/history-stats.test.ts 对今日 / 本周 / 本月的纯函数断言。2026-09-26(§12 / §13 接入):regression: section 12 and 13 dictation fields survive parsing(ipc-contract.test.ts)、regression: a core that always serialises the section 12 and 13 fields round-trips them(schema.test.ts)、regression: settings_set_activation persists all three values、regression: hold_or_toggle stops on a release held past the threshold、regression: extra_recording_ms keeps the microphone open after the stop、regression: streaming_final goes finalizing then refining then done、regression: live_inject counts pasted sentences(mock-backend.test.ts)、regression: the activation cards write settings_set_activation with all three values(Settings.test.tsx)、regression: the output mode cards(EnginesPane.test.tsx)、regression: a hold_or_toggle short press locks the take and the pill shows the lock mark(Overlay.test.tsx)、regression: streaming rows carry a mode badge(History.test.tsx)、regression: the activation chip, the hint and the empty-state hint follow settings.activation(Home.test.tsx)、regression: a locked take shows the lock mark in place of the lamp(Pill.test.tsx)。2026-09-26(§16 词典与规则、§17 中文字形接入):regression: the dictionary page renders the core entries with counts and history hits and no sample data or deferred controls、regression: add edit enable reorder and delete go through the dictionary commands and the list follows the core、regression: the test panel corrects through the core preview(Dictionary.test.tsx)、regression: the rules page renders the core rules in execution order、regression: the editor has the core check the draft、regression: the dry run is the core preview of the dictionary and every enabled rule、regression: TOML export shows the core text and import merges or replaces refusing a bad file with its position(Rules.test.tsx)、regression: the detail names the corrections and rules that fired and add to dictionary sends dictionary_add with the row id(History.test.tsx)、regression: the sidebar counts come from the core dictionary and rules(Shell.test.tsx)、regression: the Chinese script choice writes chinese_script through settings_set_engines(EnginesPane.test.tsx)、regression: dictionary commands behave like the core、regression: a mock take corrects the transcript with the dictionary and runs the rules last(mock-backend.test.ts)、regression: the dictionary and rules events and the history hits and chinese_script survive parsing from the Rust fixtures(ipc-contract.test.ts);英文扫描(locale.test.tsx)现在也覆盖 /dictionary 与 /rules。2026-09-26(§18 场景与上下文接入):regression: the 场景 group lists the scenes in matching order、regression: the context switches start at app name on and window title off and write settings_set_context_sharing、regression: 新建场景 builds a scene from a typed id and a recent app、regression: the editor refuses a duplicate name and a long prompt before sending、regression: 新建场景 is disabled at the 50-scene cap(settings/scenes/ScenesPane.test.tsx)、regression: the matched scene is a tag right after the mode tag(Pill.test.tsx)、regression: the live pill names the scene the take runs under(Overlay.test.tsx)、regression: a take with a context shows its app and scene in the row and the detail(History.test.tsx)、regression: scene commands behave like the core、regression: the fake probe picks the scene of a take、regression: the phone refuses every scene command and the query(mock-backend.test.ts)、regression: the scenes events, the take context, the history app and scene and the context switches survive parsing from the Rust fixtures(ipc-contract.test.ts);中英文两套扫描(locale.test.tsx)也覆盖 /settings/scene。
6.4 单语言界面
规则:同一时刻界面只说一种语言,由当前 locale 决定。眉题(eyebrow 工具类:JetBrains Mono 11 px、0.08 em 字距、uppercase)保留视觉角色,但文字来自字典——中文下是「录音来源」「识别引擎」「今日会话」「会话日志」「已配对设备」「实时电平」「状态同步」「局限 · 不是什么」「耗时 · 松开热键到插入」「历史记录里的命中」「边沿监测 · 按一下试试」「悬浮胶囊 · 八态」「胶囊解剖」……,英文下是 Recording source / Session log / Edge monitor · give it a press 等(CSS 负责大写)。眉题与其下方标题在中文里会重复的地方(设置对话框的 SETTINGS 与各分组 GENERAL / APPEARANCE / TRIGGER / ENGINE / PRIVACY / ABOUT,LOCAL MODELS · 本地模型,手机端「本机」卡)直接去掉眉题或只留一份,不再叠加;设置导航的分组标签不再带 general / hotkey 这类英文 key 后缀(id 仍用于 aria 与测试)。字典里「中文短语 · 英文注释」一类写法(语音识别 · ASR、访问令牌 · Bearer、LOCAL · 不传输任何数据)改为只保留中文;保留原样的只有产品名、模型名、按键名与数据(API 密钥、CSV、TOML、模型 id、主机名、Ctrl 键帽、平台名 Windows / macOS / Android、悬浮胶囊规格页的状态 id 前缀 armed · …)。ASR、LLM 和 *.json 文件名不再上屏,用词见 §8。原始来源值(builtin / user / none、relay.state)一律经 secretStateLabel / relayLabel / 字典渲染,不再裸露。锁定用例:apps/desktop/src/app/locale.test.tsx@regression: under zh-CN no page shows an ASCII-caps eyebrow or an English gloss after a Chinese title(渲染全部路由,收集 [class~="eyebrow"] 与设置导航 .mono,断言无 /^[A-Z][A-Z0-9 &·/'-]{2,}$/、无「中文 · 英文词」且不在专有名词白名单)与 apps/mobile/src/App.test.tsx@regression: under zh-CN no phone screen shows an ASCII-caps eyebrow or an English detail label or a raw relay state;英文方向由既有的无 CJK 扫描覆盖(已扩展到眉题)。
7. 国际化与自动更新
7.1 语言
- 来源:
state.settings.locale("system" | "zh-cn" | "en",核心持久化)。resolveLocale(setting, navigator.language)(packages/shared/src/i18n/runtime.ts):system在浏览器 / OS 语言以zh开头时解析为zh-CN,否则en;主窗口、悬浮胶囊窗口和手机端读同一字段,改一处全部跟随。<html lang>跟随解析结果(zh-CN/en-US)。 - 字典:
packages/shared/src/i18n/zh-CN.ts(键集的唯一真相,Messages = typeof zhCN)与en.ts(const en: Messages,缺键 / 多键都是类型错误)。嵌套键,叶子是字符串({name}插值)或{ one, other }复数对({n} 条vs{n} entry/{n} entries,中文永远取other)。t(key, params?)的key是MessageKey(LeafPaths<Messages>),拼错编译不过;运行时缺键返回键名本身,肉眼可见而不是空白。 - 接入:
packages/ui导出I18nProvider/useI18n()({ locale, tag, t, messages })/useT()/useLocale();没有 provider 时一律zh-CN,所以组件默认文案不变、既有测试不变。桌面App.tsx与手机App.tsx在BackendProvider内挂LocaleProvider(systemLanguageprop 供测试注入 OS 语言;renderApp默认zh-CN,因为 jsdom 自报en-US)。 - 覆盖范围:
packages/ui内含文案的组件(TitleBar 搜索 tooltip 与窗口控制、Sidebar、DialogEsc 取消、CommandPalette、Pill / LiveCaption、Banner、各可视化组件的 aria 默认名、ThemeTile 主题名);apps/desktop全部页面、shell、对话框、命令菜单、页脚快捷键、引导、胶囊文案、toast、page-meta标题与读数标签、labels.ts助手;apps/mobile全部屏幕。设置分组、权限表、触发方式、后端矩阵备注等原 fixture 文案迁入字典,fixture 只剩 id / 状态 / 深链。日期:Intl.DateTimeFormat(locale)(features/history/stats.tsdayLabel/todayLabel;今天 / 昨天 → Today / Yesterday)。 - 不翻译的东西:模型 id、主机名、命令名、
settings.json键、keycaps、voltip://链接、示例词条 / 规则 / 历史文本等用户或核心产生的数据(历史文本节点带data-user-text);语言选项以各自语言书写(简体中文/English)。 - 失败码:
DictationPhase.failed.code(no_speech | audio | asr | refine | inject | no_selection | selection_too_long | selection | edit_unavailable | edit_in_terminal | unknown)由dictationFailureText/takeFailureText映射为本地化短句,unknown或缺code回落到核心的message。 - 语音编辑(
docs/dictation.md§19):设置 › 热键的「编辑选中文本」行(第二个useChordRecorder,与听写录制器互斥;「关闭 / 启用」;edit_registered/edit_error;没有润色密钥时注明需要 AI 润色服务);胶囊tag="编辑"(KindTag,listening / locked / processing / inserted / error / cancel-armed 前导);首页阶段行与最近结果表、历史页的编辑行(「编辑」徽标、「指令 → 结果」、详情里可展开的原选区,复制取改写结果,搜索匹配指令与原选区)。
7.2 自动更新
- 状态:
state.update: UpdateStatus(§3),核心以update事件推送;Backend.updateStatus()对应查询update_status。 - UI(
apps/desktop/src/pages/settings/General.tsxUpdateControls,通用分组完整版,关于分组紧凑版):自动更新Toggle →settings_set_auto_update;状态行updateStatusLine:idle尚未检查更新 /checking正在检查更新… /up_to_date已是最新 · {version} · 检查于 {at} /available有新版本 {version} · 当前 {current} /downloading正在下载 {version} · {percent}%(无total时显示已收 MB)/ready{version} 已下载 · 重启后生效 /installing正在安装 {version}… /failed更新失败 · {message} /disabled此构建未配置更新源。按钮只有一个且随状态变化:检查更新(update_check;checking / installing / disabled 时禁用)、查看新版本(available / downloading → 打开更新对话框)、重启并更新(ready →update_install)。 - 更新对话框(
apps/desktop/src/features/update/UpdateDialog.tsx,参考 clash-verge 的做法):有新版本、下载中或待重启时,标题栏出现提示(新版本 {version}/下载中 {percent}/重启以更新),点它或设置里的「查看新版本」打开;待重启时(自动更新在后台下载好之后)点「重启以更新」同时开始安装(update_install),对话框直接显示「正在安装」,不再问一次,悬停提示为「重启 Voltip,完成更新到 {version}」(dictation.md §9「运行期间再检查」)。对话框写明当前版本与发布日期,提供「查看发布页」(project_link_open { link: "releases" },由壳层拼地址),更新内容取自更新器清单latest.json的notes(release-please 写的 markdown,按标题、列表、段落渲染成文本:不解释 HTML,链接只留文字,开头重复版本号的标题去掉)。「立即更新」之后同一个对话框显示下载进度、速度(最近 3 s 的读数)与剩余时间;「后台下载」只关对话框,不取消下载;下载校验完是「重启并更新」,失败时写原因并可「重试」。 - MockBackend:
update_check→checking→ 300 ms 后available(固定0.0.2,当前0.0.1,notes为 release-please 格式的MOCK_UPDATE_NOTES);update_install→ 三次 200 msdownloadingtick(received16 / 32 / 48 MB,total48 MB)→ready→ 200 ms →installing;simulateUpdate(status)供测试到达failed/disabled/up_to_date;构造参数update设初始状态。settings_set_locale/settings_set_auto_update只回发settings。
8. 界面文案
界面上的每句话都说明用户能做什么、看到什么、得到什么,不讲实现细节:不出现进程、后端、状态机、文件名和协议名。
- 语体(用户 2026-09-29 决定):规范、易读的书面语,减少 AI 腔,参照 macOS 和 Windows 中文界面的写法,如「未设置」「尚未下载」「无法连接」「请稍后重试」「仅保存在本机」。不用口语化说法(还没、没能、免得),不层层解释,不用拟人化的保证。英文同样使用标准界面用语,不写口语。
- 原文没有内部术语、本来就规范的句子保持原样;改动以替换术语和理顺语句为主。
- 一个概念只用一个词,用下表的写法。「本地」指在这台电脑上处理(本地模型、本地识别),「本机」指这台设备本身(本机指纹、仅保存在本机)。中文界面不夹英文术语,产品名、模型名、按键名和平台名除外(Voltip、Qwen3-ASR、Ctrl、Windows)。
- 密码学名称(X25519、ChaCha20-Poly1305、Noise XX)只出现在以「技术细节:」开头的一行里(
devices.syncPanel.crypto)。 - 核心和壳层的报错原文带有
scenes:、phone text:这类机器前缀,界面显示前由coreMessageText(packages/shared/src/labels.ts)去掉。 - 守卫:
packages/shared/src/i18n/copy.test.ts用下表左栏和一张口语词表扫描两种语言的全部叶子。只有胶囊规格页(/overlay,只在开发构建里加载)读取的键豁免,同一测试确认发行代码不读取这些键。
| 内部词 | 中文界面 | English UI |
|---|---|---|
| 核心 | 不出现;等待时写「正在启动…」 | nothing; "Starting…" |
| 热键、组合键 | 快捷键(组合方式仍称组合键、修饰键) | shortcut, key combination, modifier |
| 热键后端 | 快捷键方式(hotkeyMethodText:Windows、Linux · Wayland) | shortcut method |
| 边沿 | 按下 / 松开 | press / release |
| 激活方式 | 录音方式 | recording mode |
| 注入、投递、送出 | 插入、粘贴到光标处 | insert, paste at cursor |
| 输出方式:整段输出 / 流式定稿 / 实时注入 | 输出方式:整段输出 / 边说边识别 / 边说边输入 | output mode: All at once / While you speak / Type as you speak |
| 流式模型 | 实时识别模型 | live transcription model |
| LLM | AI、大模型 | AI, large language model |
| ASR | 语音识别、识别 | speech recognition, transcription |
| 握手、密钥协商 | 建立加密连接 | establish the encrypted connection |
| 对端 | 对方设备 | the other device |
| 票据、会话(配对) | 配对信息、配对 | pairing info, pairing |
| 枚举 | 检测 | detect |
| 持久化 | 保存 | save |
| TTL | 有效期 | valid for |
| VAD | 静音检测(功能名仍为「静音裁剪」) | silence detection |
| UIPI、提权窗口 | 以管理员身份运行的窗口 | a window that runs as administrator |
*.json 文件名 | 本机 | on this computer |
| 收音 | 录音 | recording |
| 悬浮胶囊 | 悬浮窗 | overlay |
前面各节引用的界面文字可能是改写前的旧说法;逐字以两份字典为准(§0)。