chore: 发布 0.1.9,并把设计决策笔记移出公开仓库

四处版本号、CHANGELOG、关于页亮点与两份 locale 同步到 0.1.9。关于页那五条
按用户视角重写:只说用户得到什么,不描述我们改了哪些文案。

docs/decisions.md 从仓库移除——它是私人设计笔记,放在公开 docs/ 下是当初
建错了位置。源码注释里的引用路径一并更新。同时去掉 asr_corrections.py 注释
里的部署绝对路径,运维细节不该出现在公开源码里。

识别服务卡片的说明改成直接回答适用场景,并去掉每张卡都挂的「面向中国大陆
账号」,只留 Groq 需要海外网络这一条。
This commit is contained in:
crosswk committed 2026-08-30 09:07:29 +08:00
1 parent 23c188f7e2
commit fcb0cc2e9b
14 files changed
+193 -374

No files matched your search

+3
View File
@@ -37,6 +37,9 @@ node_modules/
# ── Rust build output ──
target/
# 应用在跑时它锁着 target/debug 里的 ggml DLL,cargo 会报 os error 32;
# 换个 target 目录编译是绕过它的标准做法(pitfalls #4),别让产物进仓库。
target-check/
# ── Python ──
__pycache__/
+15
View File
@@ -9,6 +9,21 @@
---
## [0.1.9] - 2026-08-28
### 新增
- **服务器模式下可以改用自己配置的 AI 来整理文本**:「AI 供应商」页顶部新增「本地执行 AI 整理」开关,只在服务器模式下显示。打开后服务器只做语音识别,整理改由本机直连你在这一页选中的 AI 供应商完成,API Key、AI 提示词以及上下文感知读到的文字都不会发送给 SayIt 服务器。默认关闭。
- **新增 WebDAV 自动备份**:在设置 → 通用里填好网盘地址和账号密码即可,支持坚果云等提供 WebDAV 的网盘。默认只备份配置,历史记录和录音可以另外打开;备份里含各服务商的 API Key,所以这个网盘要确保只有你能访问。可设为每天到每周自动备份,也能随时手动备份或从服务器恢复。
- **新增语音识别服务「千问 Audio 3.0 流式」**:边说边出字,悬浮窗里能实时看到字幕。要用实时字幕的话,它是准确率最高的一档,速度也快;只需要一个百炼 API Key,不用填业务空间 ID。热词交给这个模型自带的即时热词,可以带权重,最多 100 条。在设置 → 语音识别里选它;已经填过百炼 API Key 的话,升级后这张卡会自动出现。
### 优化
- **选语音识别服务不用再猜了**:每张卡上直接写明什么场景该选它——不开实时字幕时中文最准的是豆包,开着实时字幕则是千问 Audio 3.0。此前几家都写着「准确率高、速度快」,看完仍然不知道该选哪个。另外去掉了每张卡都挂一句的「面向中国大陆账号」,只留真正需要提醒的那条(Groq 需要海外网络环境)。
### 修复
- **修复悬浮窗在提示切换时上下跳动的问题**:麦克风来源提示消失后,或一直没有说话、结束录音并显示「未检测到有效声音」时,悬浮窗不再突然变矮或改变位置;不同状态会保持一致的可见高度,直到悬浮窗关闭。
---
## [0.1.8] - 2026-08-18
### 新增
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "sayit-tauri",
"version": "0.1.8",
"version": "0.1.9",
"description": "SayIt - Voice to Text Desktop App (Tauri)",
"type": "module",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"productName": "SayIt",
"version": "0.1.8",
"version": "0.1.9",
"identifier": "com.sayit.app",
"build": {
"frontendDist": "../dist",
@@ -21,7 +21,7 @@ import { AsrDiffPreview } from './AsrDiffPreview'
/**
* 「纠正识别」面板。
*
* 三个刻意的设计,改之前先看 docs/decisions.md:
* 三个刻意的设计,改之前先看 .kiro/decisions.md:
* 1. 可编辑的那一栏初值是 **record.asrText(识别原文)**,绝不是 llmText。
* 用户平时看到的是 AI 整理后的文本,如果照着它改(补标点、分段、改书面语),
* 拿到的标注对 ASR 训练是噪声,因此纠正面板不展示 AI 整理后的文本。
@@ -131,7 +131,7 @@ function HistoryItem({
* 「纠正识别」入口的门槛。
*
* 只有服务器模式:只有服务器上的模型是我们能改的,云 API / 本地模型认错了,
* 把用户音频收上来既没用又是一次隐私意外(见 docs/decisions.md)。
* 把用户音频收上来既没用又是一次隐私意外(见 .kiro/decisions.md)。
* 必须有录音路径:没有音频的纠正对改进识别没有用。路径存在但文件已被保留期清掉
* 这种情况留给面板去说明 —— 列表渲染时不该为每一行去摸一次磁盘。
*/
@@ -20,18 +20,15 @@ export interface ReleaseHighlights {
}
export const RELEASE_HIGHLIGHTS: ReleaseHighlights = {
version: '0.1.8',
version: '0.1.9',
// getter 防止模块加载时把语言冻结;About 已订阅 locale,重渲染后会重新读取。
get items() {
return [
t('release.0.1.8.6'),
t('release.0.1.8.1'),
t('release.0.1.8.7'),
t('release.0.1.8.2'),
t('release.0.1.8.3'),
t('release.0.1.8.4'),
t('release.0.1.8.5'),
t('release.0.1.8.8'),
t('release.0.1.9.1'),
t('release.0.1.9.2'),
t('release.0.1.9.3'),
t('release.0.1.9.4'),
t('release.0.1.9.5'),
]
},
}
+81 -21
View File
@@ -255,15 +255,16 @@
"aiProofread.onDesc": "Tidies up what you said: fixes misheard words and smooths out the wording",
"aiProofread.offDesc": "Inserts the raw speech recognition output, unchanged",
"aiProofread.shortcutLabel": "AI cleanup shortcut",
"aiProofread.shortcutDesc": "Set a shortcut to turn AI cleanup on or off. It is empty by default, which means no shortcut.",
"aiProofread.shortcutDesc": "Toggle AI cleanup any time, without opening settings.",
"aiProofread.shortcutConflictPtt": "Same as the Push-to-talk shortcut. Pick another key.",
"aiProofread.shortcutConflictHandsFree": "Same as the Hands-free shortcut. Pick another key.",
"aiProofread.shortcutConflictPreset": "That key already switches cleanup presets. Pick another one.",
"aiProofread.minDurationLabel": "Skip cleanup for short speech",
"aiProofread.minDurationDesc": "Speech shorter than this is transcribed by ASR only, without AI cleanup. The default is 0, which means every recording is cleaned up.",
"aiProofread.minDurationDesc": "Very short clips are usually simple commands; skipping cleanup gets the text out faster.",
"aiProofread.minDurationOff": "Always",
"aiProofread.minDurationDefaultHint": "Always apply AI cleanup",
"aiProofread.minDurationActiveHint": "Speech shorter than {seconds} seconds skips AI cleanup",
"aiProofread.minDurationDefaultHint": "Always cleans up",
"aiProofread.minDurationActiveHint": "Skipped under {seconds}s",
"aiProofread.minDurationDisabled": "AI cleanup is off, so this has no effect.",
"aiProofread.seconds": "sec",
"hotwordInject.title": "Send hotwords to the AI prompt",
"hotwordInject.desc": "Adds hotwords to the AI prompt to improve term accuracy. Requires AI cleanup.",
@@ -430,6 +431,8 @@
"dict.note.localQwen": "Not wired up in the GGML engine yet",
"dict.note.localOther": "Use AI cleanup to correct terms instead",
"dict.note.server": "Works well",
"dict.note.qwenAudio30": "Instant hot words with an adjustable weight, up to 100 entries",
"dict.model.qwenAudio30": "Qwen Audio 3.0 streaming",
"dict.model.qwenRealtime": "Qwen flash-realtime (streaming)",
"dict.model.qwenFlash": "Qwen flash (non-streaming)",
"dict.model.localQwen": "Local Qwen3-ASR",
@@ -696,7 +699,10 @@
"ai.status.availableSpoken": "Works · {grade} {latency}",
"ai.status.tooSlowNote": "over 2 seconds — too slow to dictate with",
"ai.pickAdvice": "Start with DeepSeek's deepseek-v4-flash or Qwen's qwen3.6-flash. Both are fast and inexpensive.",
"ai.serverModeHint": "Server mode handles AI cleanup on the server, so these configs are not used.\nThey take effect when you switch the processing mode to Cloud API or Local.",
"ai.serverSource.title": "Run AI cleanup locally",
"ai.serverSource.descManaged": "You're in Server mode. AI cleanup runs on SayIt's server.",
"ai.serverSource.descCustom": "You're in Server mode. AI cleanup runs through the AI provider selected below.",
"ai.serverSource.help": "In Server mode, AI cleanup can run on SayIt's server, or on an AI service you configure yourself.",
"ai.err.apiUrlEmpty": "The API URL can't be empty.",
"ai.err.ollamaUrlEmpty": "The Ollama URL can't be empty.",
"ai.err.saveFailed": "Couldn't save the config.",
@@ -723,7 +729,6 @@
"ai.editAria": "Edit {title}",
"ai.deleteAria": "Delete {title}",
"ai.title": "AI services",
"ai.serverModeBadge": "Not used in server mode",
"ai.helpAria": "About AI services",
"ai.desc": "Select a card to use its URL, key, and model.",
"ai.testAll": "Test all",
@@ -786,20 +791,21 @@
"asrPlatform.qwen": "Alibaba Cloud Bailian",
"asrPlatform.mimo": "Xiaomi",
"asrProvider.doubao": "Doubao ASR",
"asrProvider.doubaoBlurb": "Accurate and fast — the best default for everyday dictation.",
"asrProvider.doubaoBlurb": "The most accurate for Chinese with live captions off, and fast.",
"asrProvider.qwen": "Qwen ASR",
"asrProvider.qwenBlurb": "Good across languages. Text arrives once you finish speaking.",
"asrProvider.qwenBlurb": "Text arrives once you finish speaking. Even across languages, though not the top tier for Chinese.",
"asrProvider.qwenAudioStream": "Qwen Audio 3.0 streaming",
"asrProvider.qwenAudioStreamBlurb": "Pick this one for live captions: text appears as you speak, with the best accuracy and quick responses.",
"asrProvider.qwenRealtime": "Qwen ASR streaming",
"asrProvider.qwenRealtimeBlurb": "Text appears as you speak, with live captions on the overlay.",
"asrProvider.qwenRealtimeBlurb": "Also shows text as you speak, but Audio 3.0 does it better — and this one needs a workspace ID.",
"asrProvider.omniPlus": "Qwen 3.5 Omni Plus",
"asrProvider.omniPlusBlurb": "Recognition and cleanup in one step — no separate AI service needed.",
"asrProvider.omniFlash": "Qwen 3.5 Omni Flash",
"asrProvider.omniFlashBlurb": "Same idea, faster and cheaper, slightly lower quality.",
"asrProvider.omniFlashBlurb": "Like Omni Plus, faster and cheaper, slightly lower quality.",
"asrProvider.mimo": "Xiaomi MiMo",
"asrProvider.mimoBlurb": "An alternative. Needs a Xiaomi API key.",
"asrProvider.mimoBlurb": "A fallback. Accuracy is middling; worth a try if you already have a Xiaomi API key.",
"asrProvider.groq": "Groq Whisper",
"asrProvider.groqBlurb": "Multilingual transcription with fast responses. Requires access to the international internet.",
"asrProvider.regionMainlandChina": "For mainland China accounts.",
"asrProvider.groqBlurb": "Multilingual transcription with fast responses. Chinese is so-so, and it needs access to the international internet.",
"asrProvider.regionGlobal": "For international accounts.",
"asrProvider.missingKey": "no API key yet",
"server.asrNotReady": "{prefix}. It responds, but speech recognition isn't ready on that server, so you can't dictate with it yet. Check whether the ASR model loaded on the server.",
@@ -865,6 +871,62 @@
"backup.import": "Import",
"backup.fullTitle": "All data",
"backup.fullDesc": "Everything in Settings, plus your history and recordings — for moving to another machine",
"webdav.title": "WebDAV backup",
"webdav.desc": "Upload your settings to your own storage automatically. History and recordings are optional.",
"webdav.autoLabel": "Automatic",
"webdav.configure": "Set up server",
"webdav.settings": "Settings",
"webdav.settingsTitle": "Backup settings",
"webdav.done": "Done",
"webdav.state.ok": "Backed up",
"webdav.state.failed": "Failed",
"webdav.state.never": "Never run",
"webdav.url": "Address",
"webdav.urlHint": "Backups are stored in this folder, which is created if it does not exist.",
"webdav.username": "Account",
"webdav.password": "Password",
"webdav.jianguoyunHint": "Nutstore needs an app password, generated in the desktop client under Settings → Third-party apps → Add app.",
"webdav.test": "Test connection",
"webdav.testOk": "Connected. {count} backup(s) already there.",
"webdav.contentTitle": "What gets uploaded",
"webdav.includeConfig": "Settings, hotwords, text replacements, prompt presets",
"webdav.always": "Always included",
"webdav.includeHistory": "History",
"webdav.includeAudio": "Recordings",
"webdav.audioOnHint": "Recordings make backups orders of magnitude larger, and every run re-uploads all of them. Free plans on services like Nutstore cap monthly upload bandwidth, which this can exhaust quickly.",
"webdav.summary.config": "Settings only",
"webdav.summary.configHistory": "Settings and history",
"webdav.summary.configAudio": "Settings and recordings",
"webdav.summary.all": "Settings, history and recordings",
"webdav.intervalLabel": "Backup every",
"webdav.interval.daily": "Day",
"webdav.interval.every3Days": "3 days",
"webdav.interval.weekly": "Week",
"webdav.keepLabel": "Copies kept",
"webdav.keepHint": "After a successful backup, the oldest copies beyond this count are deleted from the server.",
"webdav.backupNow": "Back up now",
"webdav.backingUp": "Backing up",
"webdav.restore": "Restore from server",
"webdav.restoreTitle": "Pick a backup to restore",
"webdav.restoreEmpty": "No backups on the server yet.",
"webdav.restoreConfirmTitle": "Restore this backup?",
"webdav.restoreConfirmBody": "{name} will overwrite this machine's configuration. Whatever the backup contains gets replaced; what it does not contain (history, if only settings were backed up) stays as it is. SayIt restarts when it finishes.",
"webdav.restoreConfirmOk": "Restore and restart",
"webdav.restoreDoneTitle": "Restore complete",
"webdav.restoreDoneBody": "Restarting SayIt to apply the changes…",
"webdav.status.uploading": "Uploading…",
"webdav.status.verifying": "Verifying…",
"webdav.progressAria": "WebDAV backup progress",
"webdav.error.urlEmpty": "Enter a server address.",
"webdav.error.urlInsecure": "Plain http is not supported: WebDAV uses basic authentication, so the password would be sent in the clear. Use https instead.",
"webdav.error.urlScheme": "The address has to start with https://.",
"webdav.error.credentialsEmpty": "Enter an account and password.",
"webdav.error.unauthorized": "The server rejected the account or password. Nutstore requires an app password (Account Info → Security), not your login password.",
"webdav.error.mkcolConflict": "Could not create the folder. Create it manually in your storage, or check that its parent exists.",
"webdav.error.verifyMissing": "The file was not on the server after uploading, so the backup did not succeed. Check whether the storage is full.",
"webdav.error.verifySize": "The file on the server is a different size than the local one, so the backup may be incomplete. Try again.",
"webdav.error.emptyDownload": "The download from the server was empty, so there is nothing to restore.",
"webdav.error.badName": "That backup file name is not valid.",
"promptPreset.title": "Cleanup presets",
"promptPreset.desc": "Choose or customize how AI cleans up transcripts.",
"promptPreset.promptLanguage": "Built-in preset language",
@@ -1112,6 +1174,7 @@
"modeStatus.doubao": "Doubao",
"modeStatus.qwen": "Qwen",
"modeStatus.qwenRealtime": "Qwen Realtime",
"modeStatus.qwenAudioStream": "Qwen Audio 3.0",
"modeStatus.qwenOmni": "Qwen Omni",
"modeStatus.modelNotDownloaded": "The selected model hasn't been downloaded",
"modeStatus.modelListUnavailable": "Couldn't read the local model list",
@@ -1122,14 +1185,11 @@
"update.installingDesc": "SayIt will close automatically while the update is installed.",
"update.doNotClose": "Don't close SayIt",
"update.aboutTooltip": "About · v{version} is downloaded, open to install",
"release.0.1.8.1": "Server mode can now submit transcription corrections: replay the recording in History, fix the raw transcription to what you actually said, and send it to the author for experimental fine-tuning of the recognition model. You are told what gets sent before the first submission, and you can withdraw within 30 days",
"release.0.1.8.2": "Fixed the audio retention setting not taking effect: if you never changed it the interface said Forever, yet recordings older than 30 days were still deleted, leaving history entries whose text remains but that play no sound",
"release.0.1.8.3": "After you switch microphones or change the input source setting, the first recording shows which device is in use on the overlay",
"release.0.1.8.4": "App prompt rules can be reordered by dragging, and the order is the matching priority; enabling a rule moves it to the top",
"release.0.1.8.5": "The tray context menu toggles AI cleanup directly and shows its current state, so you don't need to open the main window",
"release.0.1.8.6": "New context-aware writing (off by default): turn it on and dictation picks up on the text near your cursor; select some text first and you can say things like translate, tighten, or rewrite to replace it directly",
"release.0.1.8.7": "Short speech can skip AI cleanup: set a number of seconds and anything shorter is only transcribed, with no wait for the AI. The default of 0 cleans up every recording as before",
"release.0.1.8.8": "AI cleanup can be toggled with a shortcut, either a key combination or a mouse side button, and the overlay confirms the new state",
"release.0.1.9.1": "New Qwen Audio 3.0 streaming recognition — text appears as you speak, and it is the most accurate of the live-caption options",
"release.0.1.9.2": "New automatic backup: your settings go to your own storage on a schedule (Nutstore and other WebDAV services work), so a new PC or a reinstall can restore them",
"release.0.1.9.3": "In server mode, cleanup can now run on your own AI provider, so your text no longer passes through the SayIt server",
"release.0.1.9.4": "Fixed the overlay sometimes jumping up and down",
"release.0.1.9.5": "Speech recognition services now explain what they suit, so you no longer have to guess which to pick",
"recorder.serverDisconnected": "The server isn't connected",
"recorder.modelNotDownloaded": "Download the selected model in Settings first",
"recorder.serviceNotReady": "The service isn't ready",
+80 -20
View File
@@ -255,15 +255,16 @@
"aiProofread.onDesc": "开启后,会自动整理口述内容,修正错字、整理语序",
"aiProofread.offDesc": "关闭后,将原样输出语音识别的文字,不做修改",
"aiProofread.shortcutLabel": "AI 整理快捷键",
"aiProofread.shortcutDesc": "设置快捷键用于开启或关闭 AI 整理,默认留空,表示无快捷键。",
"aiProofread.shortcutDesc": "随时切换 AI 整理,不用打开设置。",
"aiProofread.shortcutConflictPtt": "与「按住说话」的快捷键相同,请更换一个按键",
"aiProofread.shortcutConflictHandsFree": "与「免提模式」的快捷键相同,请更换一个按键",
"aiProofread.shortcutConflictPreset": "该按键已被润色模式切换快捷键占用,请更换",
"aiProofread.minDurationLabel": "短语音不整理",
"aiProofread.minDurationDesc": "短于此时长的语音只做 ASR 识别,不进行 AI 整理,默认为 0,表示始终整理。",
"aiProofread.minDurationDesc": "很短的语音多半是简单指令,跳过整理能更快出字。",
"aiProofread.minDurationOff": "始终",
"aiProofread.minDurationDefaultHint": "始终整理",
"aiProofread.minDurationActiveHint": "短于 {seconds} 秒不会进行 AI 整理",
"aiProofread.minDurationActiveHint": "短于 {seconds} 秒不整理",
"aiProofread.minDurationDisabled": "AI 整理已关闭,此项不生效。",
"aiProofread.seconds": "秒",
"hotwordInject.title": "热词注入提示词",
"hotwordInject.desc": "把热词表注入 AI 提示词,进一步提升这些热词的识别准确率。需先开启「AI 整理」。",
@@ -430,6 +431,8 @@
"dict.note.localQwen": "GGML 引擎暂未接入",
"dict.note.localOther": "可用 AI 整理辅助纠正",
"dict.note.server": "效果较好",
"dict.note.qwenAudio30": "支持即时热词,权重可调,最多 100 条",
"dict.model.qwenAudio30": "千问 Audio 3.0 流式",
"dict.model.qwenRealtime": "千问 flash-realtime(实时)",
"dict.model.qwenFlash": "千问 flash(非实时)",
"dict.model.localQwen": "本地 Qwen3-ASR",
@@ -696,7 +699,10 @@
"ai.status.availableSpoken": "可用 · {grade} {latency}",
"ai.status.tooSlowNote": "超过 2 秒,不建议用于口述",
"ai.pickAdvice": "首选 DeepSeek 的 deepseek-v4-flash,其次通义千问的 qwen3.6-flash。\n口述最看重出字快,这两个都够快也够便宜。",
"ai.serverModeHint": "服务器模式下 AI 整理由服务器完成,这一页的配置不参与。\n仍可正常新增、切换和测试;到「语音引擎」把工作模式切换为「云 API 模式」或「本地模式」后即生效。",
"ai.serverSource.title": "本地执行 AI 整理",
"ai.serverSource.descManaged": "当前是服务器模式,AI 整理由 SayIt 服务器完成。",
"ai.serverSource.descCustom": "当前是服务器模式,AI 整理由下方选中的 AI 供应商完成。",
"ai.serverSource.help": "服务器模式下,AI 整理可以交给 SayIt 的服务器完成,也可以改由你自己配置的 AI 完成。",
"ai.err.apiUrlEmpty": "API 地址不能为空。",
"ai.err.ollamaUrlEmpty": "Ollama 地址不能为空。",
"ai.err.saveFailed": "配置没能保存。",
@@ -723,7 +729,6 @@
"ai.editAria": "编辑 {title}",
"ai.deleteAria": "删除 {title}",
"ai.title": "AI 服务",
"ai.serverModeBadge": "服务器模式下不生效",
"ai.helpAria": "AI 服务说明",
"ai.desc": "点一张即启用,它的地址、密钥、模型会一起生效。",
"ai.testAll": "测试全部",
@@ -786,20 +791,21 @@
"asrPlatform.qwen": "阿里云百炼",
"asrPlatform.mimo": "小米",
"asrProvider.doubao": "豆包 ASR",
"asrProvider.doubaoBlurb": "准确率高、速度快,日常口述首选。",
"asrProvider.doubaoBlurb": "不开实时字幕时中文最准,速度也快。",
"asrProvider.qwen": "千问 ASR",
"asrProvider.qwenBlurb": "多语种表现好,录完一次性出字。",
"asrProvider.qwenBlurb": "录完一次性出字,多语种表现均衡;中文不是最准的一档。",
"asrProvider.qwenAudioStream": "千问 Audio 3.0 流式",
"asrProvider.qwenAudioStreamBlurb": "要实时字幕就选它:边说边出字,准确率最高、速度快。",
"asrProvider.qwenRealtime": "千问 ASR 流式",
"asrProvider.qwenRealtimeBlurb": "边说边出字,悬浮窗能看到实时字幕。",
"asrProvider.qwenRealtimeBlurb": "同样边说边出字,但效果不及 Audio 3.0,还要填业务空间 ID。",
"asrProvider.omniPlus": "千问 3.5 Omni Plus",
"asrProvider.omniPlusBlurb": "识别加整理一步完成,不用再配 AI 服务。",
"asrProvider.omniFlash": "千问 3.5 Omni Flash",
"asrProvider.omniFlashBlurb": "同上,更快更便宜,质量略低。",
"asrProvider.omniFlashBlurb": "同 Omni Plus,更快更便宜,质量略低。",
"asrProvider.mimo": "小米 MiMo",
"asrProvider.mimoBlurb": "备选,需要小米的 API Key。",
"asrProvider.mimoBlurb": "备选项,准确率一般;手上已有小米 API Key 时可以试试。",
"asrProvider.groq": "Groq Whisper",
"asrProvider.groqBlurb": "多语种转写,响应快;需要海外网络环境。",
"asrProvider.regionMainlandChina": "面向中国大陆账号。",
"asrProvider.groqBlurb": "多语种转写、响应快;中文表现一般,且需要海外网络环境。",
"asrProvider.regionGlobal": "面向海外账号。",
"asrProvider.missingKey": "还没填 API Key",
"server.asrNotReady": "{prefix},能连上,但这台服务器的语音识别没有就绪,现在还不能用来口述。检查服务端的 ASR 模型是否加载成功。",
@@ -865,6 +871,62 @@
"backup.import": "导入",
"backup.fullTitle": "全部数据",
"backup.fullDesc": "在配置基础上,额外包含历史记录与录音,适合完整迁移",
"webdav.title": "WebDAV 备份",
"webdav.desc": "自动把配置上传到你自己的网盘,历史记录和录音可选。",
"webdav.autoLabel": "自动备份",
"webdav.configure": "配置服务器",
"webdav.settings": "设置",
"webdav.settingsTitle": "备份设置",
"webdav.done": "完成",
"webdav.state.ok": "已备份",
"webdav.state.failed": "备份失败",
"webdav.state.never": "未备份过",
"webdav.url": "地址",
"webdav.urlHint": "备份文件会存放在这个目录里,目录不存在时自动创建。",
"webdav.username": "账号",
"webdav.password": "密码",
"webdav.jianguoyunHint": "坚果云的密码要填应用密码,在客户端「设置 → 第三方应用管理 → 添加应用」里生成。",
"webdav.test": "测试连接",
"webdav.testOk": "连接成功,已有 {count} 份备份",
"webdav.contentTitle": "备份内容",
"webdav.includeConfig": "配置、热词、文本替换、润色模式",
"webdav.always": "始终包含",
"webdav.includeHistory": "历史记录",
"webdav.includeAudio": "录音文件",
"webdav.audioOnHint": "录音会让备份大出几个数量级,而且每次都完整重传一次。坚果云等网盘的免费额度有每月上传流量上限,可能很快用完。",
"webdav.summary.config": "只备份配置",
"webdav.summary.configHistory": "配置和历史记录",
"webdav.summary.configAudio": "配置和录音",
"webdav.summary.all": "配置、历史记录和录音",
"webdav.intervalLabel": "备份间隔",
"webdav.interval.daily": "每天",
"webdav.interval.every3Days": "每 3 天",
"webdav.interval.weekly": "每周",
"webdav.keepLabel": "保留份数",
"webdav.keepHint": "备份成功后,超出这个数量的最旧几份会从服务器上删除。",
"webdav.backupNow": "立即备份",
"webdav.backingUp": "正在备份",
"webdav.restore": "从服务器恢复",
"webdav.restoreTitle": "选择要恢复的备份",
"webdav.restoreEmpty": "服务器上还没有备份。",
"webdav.restoreConfirmTitle": "确认恢复?",
"webdav.restoreConfirmBody": "将用 {name} 覆盖本机的配置。备份里包含的部分会被覆盖,没包含的(比如只备份了配置时的历史记录)保持不变。恢复完成后应用会自动重启。",
"webdav.restoreConfirmOk": "恢复并重启",
"webdav.restoreDoneTitle": "恢复完成",
"webdav.restoreDoneBody": "正在重启应用使更改生效…",
"webdav.status.uploading": "正在上传…",
"webdav.status.verifying": "正在校验…",
"webdav.progressAria": "WebDAV 备份进度",
"webdav.error.urlEmpty": "请填写服务器地址。",
"webdav.error.urlInsecure": "不支持 http 地址:WebDAV 用的是基础认证,密码会明文发送。请改用 https。",
"webdav.error.urlScheme": "地址要以 https:// 开头。",
"webdav.error.credentialsEmpty": "请填写账号和密码。",
"webdav.error.unauthorized": "账号或密码不对,服务器拒绝了请求。坚果云需要使用应用密码(在「账户信息 → 安全选项」里生成),不是登录密码。",
"webdav.error.mkcolConflict": "创建目录失败。请先在网盘里手动建好这个目录,或检查上级目录是否存在。",
"webdav.error.verifyMissing": "上传后在服务器上找不到这个文件,备份没有成功。请检查网盘空间是否已满。",
"webdav.error.verifySize": "上传后服务器上的文件大小和本地不一致,备份可能不完整。请重试一次。",
"webdav.error.emptyDownload": "从服务器下载到的是空文件,无法恢复。",
"webdav.error.badName": "备份文件名无效。",
"promptPreset.title": "润色模式",
"promptPreset.desc": "选择或自定义 AI 对语音文本的处理方式。",
"promptPreset.promptLanguage": "内置 Prompt 语言",
@@ -1112,6 +1174,7 @@
"modeStatus.doubao": "豆包",
"modeStatus.qwen": "千问",
"modeStatus.qwenRealtime": "千问实时",
"modeStatus.qwenAudioStream": "千问 Audio 3.0",
"modeStatus.qwenOmni": "千问 Omni",
"modeStatus.modelNotDownloaded": "选中的模型还没下载",
"modeStatus.modelListUnavailable": "读不到本地模型列表",
@@ -1122,14 +1185,11 @@
"update.installingDesc": "应用将自动关闭并完成安装,请稍候。",
"update.doNotClose": "请勿关闭应用",
"update.aboutTooltip": "关于 · 新版本 v{version} 已下载好,点进去安装",
"release.0.1.8.1": "服务器模式下可以提交识别纠错:在历史记录里回放录音、把识别原文改成你实际说过的话,发送给作者用于实验性地微调识别模型;首次提交前会先说明发送的内容,30 天内可以撤回",
"release.0.1.8.2": "修复「录音保留期」设置没有生效:没改过它时界面显示「永久保留」,30 天前的录音却仍被删掉,历史记录里的文字还在但点播放没有声音",
"release.0.1.8.3": "换了麦克风或改过音源设置后,第一次录音会在悬浮窗上提示这次用的是哪个设备",
"release.0.1.8.4": "应用 Prompt 规则可以拖动排序,顺序就是匹配优先级;启用一条规则会把它排到最前",
"release.0.1.8.5": "托盘右键菜单可以直接开关「AI 整理」,菜单里的开关一眼能看出当前状态,不必打开主窗口",
"release.0.1.8.6": "新增「上下文感知」(默认关闭):开启后口述会参考光标附近的文本自然衔接;选中一段文字再开口,还能直接说出翻译、精简、改写等要求替换原文",
"release.0.1.8.7": "可以让短语音跳过 AI 整理:填一个秒数,短于它的录音只做识别、不再等 AI,默认 0 表示照旧每段都整理",
"release.0.1.8.8": "「AI 整理」可以设快捷键随手开关,支持组合键与鼠标侧键,切换后悬浮窗会给一条确认提示",
"release.0.1.9.1": "新增「千问 Audio 3.0 流式」语音识别,边说边出字,实时字幕里它最准",
"release.0.1.9.2": "新增自动备份,配置会定期传到你自己的网盘(坚果云等 WebDAV 都支持),换电脑或重装能直接恢复",
"release.0.1.9.3": "服务器模式下,文字整理可以交给你自己配置的 AI,内容不再经过 SayIt 服务器",
"release.0.1.9.4": "修复悬浮窗有时会上下跳动的问题",
"release.0.1.9.5": "语音识别服务的说明写清楚了,不用再猜该选哪个",
"recorder.serverDisconnected": "服务器未连接",
"recorder.modelNotDownloaded": "模型未下载,请先在设置里下载",
"recorder.serviceNotReady": "服务未就绪",
+1 -1
View File
@@ -8,7 +8,7 @@ import { normalizeForDiff } from '@/lib/asrDiff'
* ASR 纠错反馈:用户主动把「这条识别错了,正确的是这样」连同当次录音提交给服务端。
*
* 与「服务端静默录音」不是一回事:这里的每一次上传都由用户在历史记录里点出来,
* 首次提交前还要过一次明确的同意确认。详见 docs/decisions.md。
* 首次提交前还要过一次明确的同意确认。详见 .kiro/decisions.md。
*
* 走 webview 的 fetch + FormData,不走 Rust:
* 浏览器会自带 User-Agent,天然避开"reqwest 不带 UA 被 WAF 403"那个坑(pitfalls #1)。
+1 -1
View File
@@ -70,7 +70,7 @@ export function getBackendBaseUrl(): string {
* 检查更新时用的地址 —— **跟随** getBackendBaseUrl():服务器地址填哪个,就找哪个要更新。
*
* 这么定是为了「填一个地址就能整体切到测试环境」这件事足够简单,不用第二个设置项。
* 代价与配套约束(改这里之前先看 docs/decisions.md 的「更新通道」一节):
* 代价与配套约束(改这里之前先看 .kiro/decisions.md 的「更新通道」一节):
* · 自建后端的用户,他的服务器上没有 manifest。**所以 checkVersionUpdate 必须保留
* 回落到官方地址的那一步** —— 没有它,这些用户会拿到 404、被静默当成"没有新版",
* 从此永远收不到更新。那一步不是优化,是这个设计能成立的前提。
-316
View File
@@ -1,316 +0,0 @@
# 设计决策记录
只记「为什么这么做、否掉了什么方案」。实现细节看代码,操作步骤看 `.kiro/skills/`。
新决策往后追加,不改旧条目(结论变了就新开一条并注明取代哪条)。
---
## 2026-08 自动更新:静默下载 + 退出时安装
**改之前是什么样**:启动 → 发现新版 → 自动下载 → 停 3 秒 → `app.exit(0)` 装掉重启。
全程不问用户,`UpdateDialog` 那个 `fixed inset-0` 遮罩没有关闭按钮。
坏处很具体:开机自启后用户正按住说话,会被一个关不掉的遮罩糊住,接着应用自己退出。
唯一能拒绝的入口是设置里的「自动检测更新」开关。
**现在的形状**:后台静默下载 → 停在待安装 → 侧栏「关于」图标变绿闪烁,用户点进关于页装,
或者什么都不做,**退出应用时兜底装**。
### 为什么"强制"落在退出路径上,而不是"必须点那个图标"
只靠点击等于把更新变成完全自愿,不点的人永远留在旧版 —— 那就不是强制更新了。
安装本来就要求应用退出,所以退出那一刻是天然的安装时机:用户平时完全不用管更新,
关掉再打开就是新版。变绿的「关于」图标只是给"我现在就想更新"的人的加速通道。
代价是 `install_downloaded_update` 必须能选择**不重新拉起应用**(新增 `relaunch` 参数)。
退出路径上如果还把应用拉起来,表现成"这软件关不掉"。
实现落在 `main.rs` 的 `RunEvent::Exit`(因此 `.run(ctx)` 改成了 `.build(ctx)?.run(闭包)`)。
两处必须让开这条路径,否则会起两个安装程序抢同一个 exe 的文件锁:
- `install_downloaded_update` 自己会 `app.exit(0)`,那次退出同样触发 Exit;
- `restart_app`(导入配置后重启)的意图是"重启回同一个版本",不是更新。
都靠 `INSTALLER_SPAWNED` 这个原子标志屏蔽。
### 为什么必须加周期检查
检查逻辑原来只挂在 `App.tsx` 的挂载 effect 上,全仓库没有任何定时检查。
SayIt 是常驻托盘 + 开机自启的应用,很多用户几周不重启 —— **只在启动时查一次,
等于绝大多数人永远发现不了新版本**。删掉设置里的开关并不能解决这件事,因为压根不触发。
现在 6 小时一次(`CHECK_INTERVAL_MS`),检查本身只是一个几百字节的 manifest 请求。
### 为什么「自动检测更新」开关只删 UI、保留读取
删掉整个设置项更干净,但被否掉了。0.0.8 那次更新器把用户锁在"装不上→重开→再更新"的
死循环里(见 pitfalls #3),当时能止血靠的就是这个开关。保留 DB 读取的成本几乎为零,
换来一条**不用发新版就能远程指导用户关掉更新**的通道 —— 更新链路自身出故障时,
发新版这条路正好是不通的。
附带理由:这是公开仓库,把唯一能关闭的对外网络请求开关整个删掉,在开源语境下容易被质疑。
### 为什么更新检查不跟随「后端地址」设置
> **已被后续决定推翻,见本文档末尾「2026-08 更新地址跟随服务器地址」。**
> 现在又跟随了,但补上了本节担心的那个坑的解法:拿不到 manifest 时回落到官方地址。
> 下面这段保留,因为它记录了「为什么不能只是简单跟随」—— 那个 404 静默陷阱是真的。
`updateChecker.ts` 原来用 `getBackendBaseUrl()`,而这个值优先取用户保存的 `backendBaseUrl`。
后果:任何把后端指向自建服务器的用户,检查更新会去问**他自己的服务器**,
拿到 404 后被当成"没配更新"静默返回(`resp.status === 404` 时 `error = null`),
于是永远收不到新版本。改用 `getUpdateBaseUrl()`,固定在官方地址上,与业务后端解耦。
仍然尊重 `VITE_BACKEND_BASE_URL`,否则开发时没法把更新通道指到测试服务器验流程。
### 为什么这次才需要校验 sha512、才需要下载幂等
这两件事都是"下载完立刻安装"掩盖掉的:
- 旧流程下安装包在磁盘上只存在几秒,被替换的窗口期极短;现在它要在 `%TEMP%\sayit-update`
里躺到用户点更新,可能是几天。manifest 早就给了 `sha512`(Base64,不是 hex),只是没人用。
- 旧流程下"下载了但没装"不存在;现在这是常态。`downloadedFilePath` 原来只在内存里,
进程一退就没了 —— 不落盘的话每次开机都会把整包重下一遍,服务器出网流量按用户数乘。
所以 `pendingUpdate`(version + filePath + sha512)写进 `app_settings`,
启动时用 `verify_update_package` 确认还能用再复用。
顺带修掉 `compareVersions` 的一个静默失效:非数字段会产生 `NaN`,而 `NaN > 0` 为 false,
表现成"有更新也不报"且没有任何报错。现在非数字段按 0 处理。
### phase 不许兼职表示「有包等着装」
第一版给 `AutoUpdatePhase` 加了一个 `'ready'`,同时又有 `pending` 字段,等于同一件事有
两个真相来源,立刻出了 bug:`restorePending` 恢复出包后设 `phase='ready'`,紧接着
`runCheckAndDownload` 一进来就 `setState({ phase: 'checking' })` 把它盖掉;而"包已存在、
跳过下载"那条早退分支没人负责把 phase 改回去 —— phase 永久停在 `'checking'`,
表现成关于页一直显示「正在检查更新...」、提醒图标永远不亮。
现在 `phase` 只表示活动(`idle | checking | downloading | installing`),
「有没有包等着装」只看 `pending`,两者正交。phase 的复位收在 `runCheckAndDownload` 的
`finally` 一处 —— 让每条早退分支各自记得复位,就是上面那个 bug 的成因。
排查过程本身也留了个教训:当时 `sayit.log` 里**一条更新日志都没有**,而
`debugLog.ts` 的 `shouldMirrorPayload` 对 info 级只放行 recorder / websocket / backend,
`source: 'update'` 全被过滤掉了。整条更新链路是用户看不见的(后台检查、后台下载、
退出时安装),没有日志就等于没有诊断手段,而且"没有新版本"和"服务压根没跑起来"
在日志里长得一模一样。现在 update 源全放行,并且检查结束无条件记一条
`{current, latest, hasUpdate, error}`。
### 提醒放在哪:复用「关于」图标,不新增第四枚图标
第一版做的是侧栏底部新增一枚更新图标,点它直接弹安装确认框。否掉了,改成
**让既有的「关于」图标自己变绿闪烁**,点击照旧进关于页,安装动作留在关于页的
「立即安装」按钮上(那个按钮**不再弹确认框**,见下)。
理由:
- 底部本来就有设置 / 关于 / 引擎指示三枚图标,第四枚既挤,又需要用户先学会它是什么意思;
- 关于页正是更新所在的位置,那一屏有版本号、说明、「立即安装」和"不装也会自动装"的提示,
是个信息完整的确认现场。图标只负责"这里有事",不负责承载一个会关掉软件十几秒的动作;
- 那枚图标的既有含义是导航。在它上面挂"点了就开始装",等于让同一个控件在不同时刻
做两件轻重完全不同的事。
外观上试过给绿图标垫一层 `bg-success/10` 做成徽标,在那排素图标里显得脏,去掉了 ——
颜色加 `animate-pulse` 已经足够醒目。下载期间**图标毫无变化**:那会儿没有任何需要
用户知道的事,静默才是本意。
绿色用主题的 `--success` 令牌,不写死 emerald,深浅主题各自有合适的明度。
注意这不违反 `ModeIndicator` 上方那条"不给任何好颜色"的规矩 —— 那条针对的是
我们没验证过的事(配置填完了 ≠ 真能用);"包已下载完、哈希校验过、随时可装"是事实。
### 「立即安装」不弹确认框
一度给它加过确认框,说明"会关闭十几秒、选稍后也不会漏掉"。撤了:用户是被侧栏那枚
变绿的图标引到关于页、看完状态说明之后才按下这个按钮的,按钮上写着「立即安装」,
再叠一层"你确定吗"只是多一次点击。那句"不装的话关闭应用时也会自动完成"移到了
按钮左侧的 `about.readyHint`,该说的信息没丢,只是不再拦人。
### 验证这类改动的特殊之处
**新的更新逻辑没法自己验证自己。** 用户手里的旧版升级时走的还是旧代码,新交互只在
"新版 → 更新版"之间生效。所以真机必须测两段:① 旧版能不能正常把这一版带上来(不砖);
② 造一个版本号 +1 的包推到 dev 通道,验新的下载 / 提醒 / 点击安装 / 退出兜底。
---
## 2026-08 应用 Prompt 规则:删掉 priority,顺序即优先级
**改之前**:`AppPromptRule.priority` 是个数字(内置 100/95/90/88…,用户自建固定 200),
界面按它降序排列,命中时也按它降序取第一条。用户**看得见顺序、却改不了顺序**,
而且 9 条内置规则默认全关,启用靠底部的一条(比如 QQ)后它仍埋在一堆未启用规则中间。
**现在**:`appPromptRules` 的**数组顺序就是优先级**(下标 0 最高),用户拖动手柄排;
启用一条规则会把它移到最前面。
### 为什么把 priority 整个删掉,而不是保留它、由排序推算出新的数值
保留就有两个真相来源。只要有任何一条读取路径忘了改(`getAppPromptRules` 的读、
`promptRouter` 的匹配、"恢复默认"会把 `fallback.priority` 一起写回),
就会出现"界面上排在第一,实际命中的是第三条"——而且不报错,只是结果不对。
数字与可见顺序打架这件事没法靠注释防住,只能把字段去掉。
代价是 `BUILTIN_APP_RULES` 里的数组顺序现在有语义了(默认优先级),
所以 `codex` 那条从数组末尾挪到了 `kiro` 后面 —— 保持和原先 priority 88 一样的位置。
### 为什么不需要写迁移代码
老存档虽然带着 priority,但**当时每条写入路径都是先按 priority 降序排好再存的**,
所以存档里的数组顺序已经等于用户看到的顺序,直接沿用即可。
自建规则(priority 200)在老存档里也已经排在内置之前,迁移后照旧最先命中。
反过来,新版导出的配置被旧版导入时,旧版读到 priority 缺失全按 0 处理、
`Array.prototype.sort` 稳定,顺序也不会乱。
### 为什么开关改成即时落库
以前开关只改草稿,要再点「保存规则」才算数 —— 状态类控件按下去却没生效,
用户会以为已经开了。而且"启用置顶"必须在按下的那一刻发生,否则动作没有反馈。
副作用是每次开关都会让 `rules` 变化、组件重跑同步 effect,为此有两处刻意的写法:
- 同步草稿时只接受外部的 `enabled`,其余字段保留用户正在编辑的内容
(否则一按开关就把没保存的附加提示词冲掉);
- 展开状态只在首次载入时按"已启用"推一次,不再跟着 `rules` 重算
(否则每按一下开关或拖一次,用户手动折叠/展开的状态就被弹回去)。
### 「恢复默认」不改位置
`fallback` 里带着默认位置的信息,但恢复只覆盖内容。用户排好的顺序不该被
一个写着"恢复默认提示词"的按钮顺手打乱。
---
## 2026-08 ASR 纠错反馈:公开版收样本,音频由用户主动上传
**需求**:用户在历史记录里挑一条识别不准的,把**识别原文**改成正确文本,连同当次录音一起提交。
积累起来先做识别质量评测集,将来才谈微调。
### 为什么由客户端上传音频,而不是复用服务器已经存下来的录音
实测:开发/记录服务器上跑的是带录音覆盖层的版本,`runtime/recordings/` 已有 164 个 wav,
`usage_sessions` 里也有 `asr_text` / `llm_text`。也就是说服务端本来就有 (音频, 原始ASR),
最小实现只需要客户端回传一个"修正文本 + 会话引用",几十行就完事。
这条路被否掉了,两个理由:
- 它只在开了录音覆盖层的部署上能工作。公开版 `main` 里会留下一个"没有录音就什么都干不了"
的空壳接口,反而把「这个功能依赖静默录音」固化进公开代码。
- 同意边界会变得说不清:用户点的是"提交这一条",实际发生的是"服务端去翻它自己存的东西"。
客户端主动上传在任何部署上都成立,用户点的按钮和实际发生的事一一对应。代价是要自己
造一条公网上传通道(大小/格式/幂等/限流/配额全得自己做),这部分成本是明知故犯。
### 代码边界:main 禁的是"静默",不是"保存文件"
`main` 那条红线的实质是**不许在用户不知情的情况下留存音频与识别文本**,
不是"服务端不许写文件"。用户点了按钮、看过同意文案之后上传的样本,允许进公开版。
这条必须写下来,否则下次看到 `main` 里有存 wav 的代码,会二选一地删掉功能或删掉护栏。
`.git/hooks/pre-commit` 的模式(`writeframes`、`recorder.save(all_pcm)`、`asr_text=result.get` …)
**不放宽**。新代码收到的已经是完整 WAV,直接写字节即可,不用 `wave` 模块,天然不撞规则。
### 几个刻意的取舍
- **只有服务器模式给入口。** 只有服务器上的模型是我们能改的;豆包/本地模型认错了,
把用户音频传到我们服务器既没用又是一次隐私意外。
- **正确文本从 `asrText` 初始化,绝不从 `llmText`。** 用户平时看到的是 AI 整理后的文本,
如果照着它去改(补标点、分段、改书面语),拿到的标注对 ASR 是噪声。所以纠正面板不展示
AI 整理后的文本,并明确写「只把听错的字词改对,不用补标点、分段或改得更书面」。
- **音频没了就不给提交**(置灰 + 说明原因),不做纯文本提交 —— 没有音频的样本对识别改进没用。
- **带上本次实际注入的热词。** 一大部分"识别不准"是热词引起的(见 pitfalls 热词幻觉)。
没有热词信息,样本既没法判断成因,还可能把错误标进数据集。热词里常含人名公司名,
所以同意文案里必须点明。窗口标题、进程名、AI Prompt 一律不收 —— 与 ASR 无因果关系。
- **同意文案直说"作者会用它实验性地微调语音识别模型"。**
我原来主张只写"分析识别问题、改进识别效果"、不提微调,理由是量级不够:服务器 6 周共
924 次真实使用、带录音的音频累计 0.57 小时,纠错样本只是其中愿意动手改的一小部分,
这个量先够攒一套评测集,还谈不上微调。**这个说法被否掉了** —— 意图本来就是拿它试微调,
写成别的等于把真实用途藏起来,而"实验性地"这三个字已经说明不保证结果。
同一段文案里必须回答"撤回之后服务器还留着录音吗"(用户会问的第一个问题),
所以明写"撤回后服务器上的录音和文本会被删除",并写明**仅作技术验证、不用于商业用途**。
量级那笔账仍然成立,只是它决定的是**我们自己的预期**(先当评测集),不是对外口径。
- **文案里不写保留期,写"30 天内可以撤回"。** 用户能自己动手的事比一个保存天数更有意义。
后端因此**不按 30 天拦撤回**:承诺是"30 天内可以撤回",放宽到永远可撤回不会违背承诺,
而加一道硬期限只会多出一个"点了没反应"的失败分支。常量在两处(后端 `WITHDRAW_DAYS`、
客户端 `ASR_CORRECTION_WITHDRAW_DAYS`),接口返回 `withdraw_days`,有测试钉住两者一致。
- **不做网页审核界面。** 网页审核必然要一个"回放用户音频"的端点,公开版不该有;
放私有分支又要长期维护两套。改成导出脚本拉到本地听,量级也就几十条,不值得做界面。
### 上传上限为什么是 10 MiB
16kHz / 单声道 / 16bit 一秒 32KB,五分钟 = 9.6MB(9.16 MiB)+ 44 字节头。
服务器模式的录音长度由 PTT 五分钟硬释放和服务端 `_MAX_PCM_BYTES = 10MiB` 两道兜住,
所以 10 MiB 一定装得下最长的一次录音,还剩约 0.9MB 余量。
注意 `_MAX_PCM_BYTES` **只管 WebSocket 的 PCM 缓冲**,HTTP 请求体在 backend 和 gateway
两层都没有任何上限 —— 新接口不自己数字节,就是一个任意大小的公网文件上传口。
### 顺带修掉的既有 bug
`audioRetentionDays` 前后端默认值不一致:`defaults.ts` 是 `-1`(界面显示"永久"),
而 `main.rs` 读不到该设置时 fallback 是 30 天并按文件 mtime 真删,删完还不清
`audioFilePath`。不修的话这个功能一半的入口是灰的,而用户以为音频都还在。
## 2026-08 更新地址跟随服务器地址(并保留回落到官方)
**背景。** 0.1.7 之前 `getUpdateBaseUrl()` 就是业务后端地址,所以「把服务器地址改成测试
服务器」顺带把更新检查也指过去了,这是当时测更新的手段。`79c3f8a6`(自动更新改造成后台
下载那次)把它写死成官方地址,测试手段随之失效 —— 而写死的理由是预防性推理,不是事故。
**决定。** 恢复跟随:`getUpdateBaseUrl()` 直接返回 `getBackendBaseUrl()`,填哪个地址就找哪个
要更新。**不增加任何设置项、不增加任何界面控件。** 保存服务器地址时顺带丢弃已下载的包并
重新检查一次(见下)。
理由是简洁压过了下面那条风险:多一个设置项就多一个要解释、要维护、用户要理解的东西,而这个
应用的取向是少而清楚。中间实现过一版「独立设置项 + 关于页折叠面板」,评审后认为界面复杂度
不值得,已撤掉。
**这个决定的代价,以及为什么仍然可接受。**
更新包没有签名(`main.rs` 的 updater 插件因此禁用),manifest 的 SHA-512 只证明"下载的字节与
manifest 一致"、不证明"这个包是我们发的" —— 也就是说这个地址同时决定了**谁能往这台机器上装
程序**。跟随之后,用户改服务器地址的动机是「让这台服务器处理我的语音」(数据权限),却顺带
交出了代码执行权限,而界面上只说了前一种。
判断是:能改这个地址的人本来就在做一个明确的技术决定(对接自建后端),不是随手点到的;配合
下面两条缓解,风险落在可接受范围。**如果将来给更新包加了签名,这条风险就基本消失了 ——
那是比在界面上加开关更值得做的事。**
**两条必须保留的配套,缺任何一条这个设计就不成立:**
- **回落到官方地址**(`updateChecker.ts` 的 `checkVersionUpdate`)。把服务器指向自建后端的
用户,那台机器上没有 manifest;没有回落,他们每次检查都拿到 404 并被静默当成"已是最新
版",从此永远收不到更新且毫无征兆。**这一步不是优化,是前提。**
只在"没拿到版本号"时回落;已经拿到就用它,哪怕比当前版本旧(那是指向旧通道测试的正常
情况,不该悄悄换成官方的答案)。
- **来源在日志里可查**。`update check finished` 带 `source`(实际命中的地址)与 `configured`
(配置里的地址)—— 两者不一致就说明发生了回落;地址偏离官方时启动还会记一条 info。
少了 `source`,"回落了"和"配置的就是官方"在日志里长得一模一样,这正是排查「为什么没收到
新版」时最先要区分的两种情况。
**界面上不显示更新来源**:评审时试过在关于页加一行灰字,判断是它对普通用户没有意义、
而需要它的人会去看日志。所以这条约束只落在日志层,别再往界面加。
**已知未处理:** `backendBaseUrl` 在配置导入范围内,所以导入一份配置可以间接改更新来源。
没有拦,因为「导入配置时同步服务器地址」是正常预期;导入本身需要用户在预览界面二次确认,
不是无感操作。曾为独立设置项加过 `NEVER_IMPORT` 守卫,跟随之后那个键不存在了,已移除。
**顺带否掉:只用构建期变量 `SAYIT_UPDATE_BASE_URL`。** 多机测试要么各自重新构建、要么分发
特制包,且它只能测 `SAYIT_FAKE_APP_VERSION` 特制包 —— 特制包用的是新版更新器,验证不了旧包
自身的更新器有没有 bug(0.0.8 那次正是旧更新器把用户锁死)。已移除。
**0.1.7 及更早的包测不了这条路。** 它们解析更新地址时不读数据库,只认编译期写死的官方地址,
所以改设置对它们无效 —— 要在那些版本上验证升级,只能从 DNS 层把那个名字指到别处。
具体做法在内部运维笔记里,不在这里。
---
## 2026-08 模型并发下载:独立分片恢复,再阶段化组装
**状态**:已实现
**背景**:模型文件要用多个 HTTP Range 并发下载,绕开 CDN 的单连接限速。最初方案让多个任务
写同一个预分配 `.par.part`,每次调用先重新截断文件;进程中断后无法区分哪些区间已经完整
落盘,也无法安全复用进度。
**为什么这样**:每段改用独立的 append-only chunk,JSON 状态同时绑定源 URL、总大小、
SHA-256、强 ETag、分片计划和下载/组装阶段。网络中断保留已完成字节;Range 协议不兼容、
远端表示变化或最终校验失败才清理并回落单流。组装按 chunk 依次复制,先
`flush + sync_data`,再持久化边界,最后删除已组装 chunk;重启时把输出回退到最后一个已记录
边界,因此不会把未确认字节当成完成。当前 catalog 的所有模型仍在最终 rename 前校验大小和
SHA-256。
下载、删除与模型目录迁移也必须在原生命令层互斥:前端按钮置灰不能阻止重复 Tauri command。
单个模型操作持有路径 lease 到校验、rename 和元数据写完;目录迁移持有全局 storage lease 到
路径切换和设置落库完成。
**被否掉的方案**:
- 共享预分配文件 —— 崩溃后稀疏空洞与有效的零字节不可区分,重试只能全部重下。
- 每次失败都立刻改走单流 —— 普通网络抖动会白白丢掉并发进度,还可能短时占用两份空间。
- 等完整组装成功后再统一删除全部 chunk —— 峰值磁盘接近模型大小的两倍;阶段化删除在持久化边界后即可释放空间。
- 只靠前端禁用按钮避免并发 —— 无法覆盖直接或重复调用原生命令的路径。
Binary file not shown.

Before

Width:  |  Height:  |  Size: 257 KiB

After

Width:  |  Height:  |  Size: 257 KiB

+2 -2
View File
@@ -1,7 +1,7 @@
"""ASR 纠错反馈:接收用户主动提交的 (音频, 原始 ASR 文本, 用户修正文本) 样本。
这条通道和「服务端静默录音」不是一回事:每一个字节都是用户在历史记录里点了
「提交纠正」、看过同意文案之后才发出来的,所以公开版允许存在(见 docs/decisions.md)。
「提交纠正」、看过同意文案之后才发出来的,所以公开版允许存在(见 .kiro/decisions.md)。
几条约束都不是随手加的:
@@ -35,7 +35,7 @@ router = APIRouter()
# 落盘根目录。用 __file__ 定位而不是跟着 telemetry.db_path,这样切到 PostgreSQL
# (db_path 变成连接串)时不会把音频写到莫名其妙的地方。
# 部署树是扁的(/root/Fun-ASR/SayIt/backend/app/…),parents[2] 正好是项目根。
# 部署树是扁的(backend/app/… 直接挂在项目根下),parents[2] 正好是项目根。
STORAGE_DIR = Path(__file__).resolve().parents[2] / "runtime" / "asr-corrections"
# 16kHz / 单声道 / 16bit 一秒 32KB,五分钟 = 9.6MB (9.16 MiB) + 44 字节头。