diff --git a/.gitignore b/.gitignore index 5f58c43..ee9ef41 100644 --- a/.gitignore +++ b/.gitignore @@ -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__/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 509a360..016dbc5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ### 新增 diff --git a/client/package.json b/client/package.json index 265d4f3..8697a2c 100644 --- a/client/package.json +++ b/client/package.json @@ -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": { diff --git a/client/src-tauri/tauri.conf.json b/client/src-tauri/tauri.conf.json index bc024e3..3228cd7 100644 --- a/client/src-tauri/tauri.conf.json +++ b/client/src-tauri/tauri.conf.json @@ -1,6 +1,6 @@ { "productName": "SayIt", - "version": "0.1.8", + "version": "0.1.9", "identifier": "com.sayit.app", "build": { "frontendDist": "../dist", diff --git a/client/src/components/history/AsrCorrectionDialog.tsx b/client/src/components/history/AsrCorrectionDialog.tsx index 292bcc0..d91f4cf 100644 --- a/client/src/components/history/AsrCorrectionDialog.tsx +++ b/client/src/components/history/AsrCorrectionDialog.tsx @@ -21,7 +21,7 @@ import { AsrDiffPreview } from './AsrDiffPreview' /** * 「纠正识别」面板。 * - * 三个刻意的设计,改之前先看 docs/decisions.md: + * 三个刻意的设计,改之前先看 .kiro/decisions.md: * 1. 可编辑的那一栏初值是 **record.asrText(识别原文)**,绝不是 llmText。 * 用户平时看到的是 AI 整理后的文本,如果照着它改(补标点、分段、改书面语), * 拿到的标注对 ASR 训练是噪声,因此纠正面板不展示 AI 整理后的文本。 diff --git a/client/src/components/history/HistoryRecordList.tsx b/client/src/components/history/HistoryRecordList.tsx index d307635..0afda47 100644 --- a/client/src/components/history/HistoryRecordList.tsx +++ b/client/src/components/history/HistoryRecordList.tsx @@ -131,7 +131,7 @@ function HistoryItem({ * 「纠正识别」入口的门槛。 * * 只有服务器模式:只有服务器上的模型是我们能改的,云 API / 本地模型认错了, - * 把用户音频收上来既没用又是一次隐私意外(见 docs/decisions.md)。 + * 把用户音频收上来既没用又是一次隐私意外(见 .kiro/decisions.md)。 * 必须有录音路径:没有音频的纠正对改进识别没有用。路径存在但文件已被保留期清掉 * 这种情况留给面板去说明 —— 列表渲染时不该为每一行去摸一次磁盘。 */ diff --git a/client/src/features/update/releaseHighlights.ts b/client/src/features/update/releaseHighlights.ts index 503a7b2..fdd3bb6 100644 --- a/client/src/features/update/releaseHighlights.ts +++ b/client/src/features/update/releaseHighlights.ts @@ -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'), ] }, } diff --git a/client/src/i18n/locales/en.json b/client/src/i18n/locales/en.json index 940903c..8ab1acc 100644 --- a/client/src/i18n/locales/en.json +++ b/client/src/i18n/locales/en.json @@ -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", diff --git a/client/src/i18n/locales/zh-CN.json b/client/src/i18n/locales/zh-CN.json index 29e8d7a..0d1483a 100644 --- a/client/src/i18n/locales/zh-CN.json +++ b/client/src/i18n/locales/zh-CN.json @@ -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": "服务未就绪", diff --git a/client/src/services/asrCorrection.ts b/client/src/services/asrCorrection.ts index f56a5f3..1685272 100644 --- a/client/src/services/asrCorrection.ts +++ b/client/src/services/asrCorrection.ts @@ -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)。 diff --git a/client/src/services/runtimeConfig.ts b/client/src/services/runtimeConfig.ts index 75fcc37..30d6008 100644 --- a/client/src/services/runtimeConfig.ts +++ b/client/src/services/runtimeConfig.ts @@ -70,7 +70,7 @@ export function getBackendBaseUrl(): string { * 检查更新时用的地址 —— **跟随** getBackendBaseUrl():服务器地址填哪个,就找哪个要更新。 * * 这么定是为了「填一个地址就能整体切到测试环境」这件事足够简单,不用第二个设置项。 - * 代价与配套约束(改这里之前先看 docs/decisions.md 的「更新通道」一节): + * 代价与配套约束(改这里之前先看 .kiro/decisions.md 的「更新通道」一节): * · 自建后端的用户,他的服务器上没有 manifest。**所以 checkVersionUpdate 必须保留 * 回落到官方地址的那一步** —— 没有它,这些用户会拿到 404、被静默当成"没有新版", * 从此永远收不到更新。那一步不是优化,是这个设计能成立的前提。 diff --git a/docs/decisions.md b/docs/decisions.md deleted file mode 100644 index f591506..0000000 --- a/docs/decisions.md +++ /dev/null @@ -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 —— 峰值磁盘接近模型大小的两倍;阶段化删除在持久化边界后即可释放空间。 -- 只靠前端禁用按钮避免并发 —— 无法覆盖直接或重复调用原生命令的路径。 diff --git a/docs/images/readme/wechat-group.jpg b/docs/images/readme/wechat-group.jpg index ac95755..fa404ac 100644 Binary files a/docs/images/readme/wechat-group.jpg and b/docs/images/readme/wechat-group.jpg differ diff --git a/server/backend/app/asr_corrections.py b/server/backend/app/asr_corrections.py index 5ca77b8..2486204 100644 --- a/server/backend/app/asr_corrections.py +++ b/server/backend/app/asr_corrections.py @@ -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 字节头。