From df8ac2ce2950641de55d6ac9d3427cd7341d3f75 Mon Sep 17 00:00:00 2001 From: crosswk Date: Wed, 12 Aug 2026 09:42:00 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E6=88=90=E7=95=8C=E9=9D=A2?= =?UTF-8?q?=E8=8B=B1=E6=96=87=E5=8C=96=E5=B9=B6=E5=8A=A0=E5=BC=BA=E5=BF=AB?= =?UTF-8?q?=E6=8D=B7=E9=94=AE=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 +- client/package.json | 3 +- client/scripts/check-i18n.mjs | 175 +++ client/src-tauri/src/commands/backup.rs | 200 ++- client/src-tauri/src/commands/diagnostics.rs | 22 +- client/src-tauri/src/commands/export.rs | 4 +- client/src-tauri/src/commands/shortcuts.rs | 2 +- client/src-tauri/src/commands/system.rs | 59 +- client/src-tauri/src/error_protocol.rs | 24 + client/src-tauri/src/inject/mod.rs | 2 +- client/src-tauri/src/keyboard/mod.rs | 2 +- client/src-tauri/src/locale.rs | 123 ++ client/src-tauri/src/main.rs | 20 +- client/src-tauri/src/models/downloader.rs | 72 +- client/src-tauri/src/models/gguf_asr.rs | 38 +- client/src-tauri/src/models/local_asr.rs | 30 +- client/src-tauri/src/models/local_vad.rs | 4 +- client/src-tauri/src/models/registry.rs | 34 +- client/src-tauri/src/models/test_audio.rs | 14 +- client/src-tauri/src/providers/ai_ollama.rs | 26 +- .../src/providers/ai_openai_compat.rs | 42 +- client/src-tauri/src/providers/asr_doubao.rs | 33 +- .../src/providers/asr_doubao_realtime.rs | 49 +- .../src/providers/asr_doubao_stream.rs | 43 +- client/src-tauri/src/providers/asr_mimo.rs | 20 +- client/src-tauri/src/providers/asr_qwen.rs | 22 +- .../src-tauri/src/providers/asr_qwen_omni.rs | 58 +- .../src/providers/asr_qwen_realtime.rs | 48 +- client/src-tauri/src/providers/diag.rs | 71 +- client/src-tauri/src/providers/doubao_auth.rs | 20 +- .../src/providers/doubao_protocol.rs | 12 +- client/src-tauri/src/providers/registry.rs | 9 +- client/src-tauri/tauri.conf.json | 3 +- client/src/App.tsx | 1 - client/src/components/CopyButton.tsx | 6 +- client/src/components/FeedbackSection.tsx | 25 +- client/src/components/NoticeBanner.tsx | 9 +- client/src/components/Sidebar.tsx | 68 +- client/src/components/TextFormatSection.tsx | 64 +- .../src/components/TextReplacementSection.tsx | 303 +++-- client/src/components/TitleBar.tsx | 27 +- client/src/components/WelcomeGuide.tsx | 114 +- .../components/history/HistoryRecordList.tsx | 93 +- client/src/components/ui/modal.tsx | 4 +- client/src/components/ui/password-input.tsx | 4 +- client/src/components/ui/select.tsx | 6 +- client/src/components/ui/sortable.tsx | 4 +- client/src/features/debug/DebugCards.tsx | 27 +- client/src/features/debug/DebugPage.tsx | 14 +- client/src/features/debug/SessionCard.tsx | 40 +- client/src/features/debug/debugUtils.ts | 11 +- .../features/settings/AIInstructionsPage.tsx | 51 +- .../features/settings/AIProofreadToggle.tsx | 8 +- .../features/settings/AIProviderSection.tsx | 196 +-- .../src/features/settings/AIServicePage.tsx | 6 +- .../settings/AppPromptRulesSection.tsx | 115 +- client/src/features/settings/AppSection.tsx | 12 +- .../src/features/settings/AppearancePage.tsx | 92 +- .../src/features/settings/AsrTestSection.tsx | 42 +- .../src/features/settings/BackupSection.tsx | 76 +- .../src/features/settings/CloudAPISection.tsx | 206 +-- .../settings/ConfigTransferDialogs.tsx | 150 ++- .../src/features/settings/DiagnosticsPage.tsx | 140 +- .../settings/DiagnosticsReportPanel.tsx | 105 +- .../features/settings/GeneralSettingsPage.tsx | 110 +- .../settings/HotwordPromptInjectToggle.tsx | 6 +- .../features/settings/LocalModeSection.tsx | 219 +-- .../src/features/settings/LogViewerPanel.tsx | 34 +- .../features/settings/MicrophoneSection.tsx | 31 +- .../features/settings/PersonalizationPage.tsx | 99 +- .../features/settings/PromptPresetSection.tsx | 111 +- .../src/features/settings/ServerSection.tsx | 53 +- .../src/features/settings/SettingsDialog.tsx | 23 +- .../src/features/settings/ShortcutInputs.tsx | 34 +- .../features/settings/UserStatsSection.tsx | 60 +- .../src/features/settings/VoiceEnginePage.tsx | 10 +- .../src/features/settings/WorkModeSection.tsx | 55 +- .../settings/__tests__/aiProvider.test.ts | 34 +- .../__tests__/asrProviderCatalog.test.ts | 1 + .../features/settings/aiProviderCatalog.ts | 72 +- .../features/settings/asrProviderCatalog.ts | 62 +- client/src/features/update/UpdateDialog.tsx | 12 +- client/src/features/update/UpdateNotice.tsx | 30 +- .../src/features/update/releaseHighlights.ts | 25 +- client/src/i18n/RichText.tsx | 28 + client/src/i18n/__tests__/locales.test.ts | 206 +++ client/src/i18n/displayNames.ts | 121 ++ client/src/i18n/index.ts | 114 ++ client/src/i18n/locales/en.json | 1194 +++++++++++++++++ client/src/i18n/locales/zh-CN.json | 1194 +++++++++++++++++ client/src/i18n/useT.ts | 19 + client/src/index.css | 7 + .../src/lib/__tests__/errorMessages.test.ts | 17 + client/src/lib/__tests__/shortcutKeys.test.ts | 21 + client/src/lib/cloudAsrCreds.ts | 7 +- client/src/lib/errorMessages.ts | 154 ++- client/src/lib/shortcutKeys.ts | 132 +- client/src/main.tsx | 4 + client/src/overlay/Overlay.tsx | 34 +- client/src/pages/About.tsx | 44 +- client/src/pages/Dictionary.tsx | 79 +- client/src/pages/Favorites.tsx | 87 -- client/src/pages/History.tsx | 30 +- client/src/pages/Home.tsx | 77 +- client/src/pages/PTTLab.tsx | 99 +- client/src/pages/Welcome.tsx | 382 ------ client/src/pages/studio/RecordingOrb.tsx | 10 +- .../services/__tests__/builtinPrompts.test.ts | 123 ++ client/src/services/__tests__/notice.test.ts | 53 + client/src/services/audio.ts | 42 +- client/src/services/audioFileService.ts | 2 +- client/src/services/backup.ts | 16 +- client/src/services/bridge.ts | 11 + client/src/services/builtinPromptsEn.ts | 69 + client/src/services/debugLog.ts | 14 +- client/src/services/defaults.ts | 7 + client/src/services/diagnostics.ts | 9 +- client/src/services/feedback.ts | 11 +- client/src/services/hotwords/model.ts | 10 +- .../services/hotwords/useHotwordsManager.ts | 3 +- client/src/services/notice.ts | 55 +- .../src/services/personalization/defaults.ts | 22 +- .../services/personalization/promptRouter.ts | 14 +- .../src/services/personalization/userStats.ts | 17 +- .../src/services/recorder/OverlayService.ts | 19 +- .../services/recorder/RecorderOrchestrator.ts | 175 +-- client/src/services/recorder/types.ts | 24 +- client/src/services/runtimeConfig.ts | 3 +- client/src/services/store.ts | 142 +- client/src/services/textPostProcess.ts | 6 +- client/src/services/textReplacement.ts | 4 +- client/src/services/textSegmenter.ts | 2 + .../transcription/BufferedProvider.ts | 8 +- .../transcription/CloudAPIProvider.ts | 40 +- .../services/transcription/LocalProvider.ts | 16 +- client/src/services/transcription/index.ts | 8 +- client/src/services/websocket.ts | 36 +- .../stores/__tests__/languageDefaults.test.ts | 42 + client/src/stores/language.ts | 87 ++ client/src/stores/modeStatus.ts | 37 +- client/src/themes/claude.ts | 3 +- client/src/themes/dark.ts | 3 +- client/src/themes/light.ts | 3 +- client/src/themes/teal-dark.ts | 3 +- client/src/themes/teal.ts | 3 +- client/tsconfig.json | 1 + server/notices/notice.example.json | 7 + 147 files changed, 6964 insertions(+), 2633 deletions(-) create mode 100644 client/scripts/check-i18n.mjs create mode 100644 client/src-tauri/src/error_protocol.rs create mode 100644 client/src-tauri/src/locale.rs create mode 100644 client/src/i18n/RichText.tsx create mode 100644 client/src/i18n/__tests__/locales.test.ts create mode 100644 client/src/i18n/displayNames.ts create mode 100644 client/src/i18n/index.ts create mode 100644 client/src/i18n/locales/en.json create mode 100644 client/src/i18n/locales/zh-CN.json create mode 100644 client/src/i18n/useT.ts delete mode 100644 client/src/pages/Favorites.tsx delete mode 100644 client/src/pages/Welcome.tsx create mode 100644 client/src/services/__tests__/builtinPrompts.test.ts create mode 100644 client/src/services/__tests__/notice.test.ts create mode 100644 client/src/services/builtinPromptsEn.ts create mode 100644 client/src/stores/__tests__/languageDefaults.test.ts create mode 100644 client/src/stores/language.ts diff --git a/.gitignore b/.gitignore index 3ddae21..5f58c43 100644 --- a/.gitignore +++ b/.gitignore @@ -23,7 +23,8 @@ server/benchmarks/ server/mytestwav.txt # ── Client dev artifacts ── -client/scripts/ +client/scripts/* +!client/scripts/check-i18n.mjs client/dist/ # build.rs 从 transcribe-cpp 的构建产物拷进来的 DLL,纯生成物 client/src-tauri/transcribe-libs/ diff --git a/client/package.json b/client/package.json index d1a5f4b..3820de7 100644 --- a/client/package.json +++ b/client/package.json @@ -11,7 +11,8 @@ "test": "vitest run", "test:watch": "vitest", "lint": "eslint src --ext .ts,.tsx", - "format": "prettier --write src" + "format": "prettier --write src", + "i18n:check": "node scripts/check-i18n.mjs --strict" }, "dependencies": { "@tauri-apps/api": "^2.0.0", diff --git a/client/scripts/check-i18n.mjs b/client/scripts/check-i18n.mjs new file mode 100644 index 0000000..0cca6c8 --- /dev/null +++ b/client/scripts/check-i18n.mjs @@ -0,0 +1,175 @@ +#!/usr/bin/env node +/** + * 硬编码中文扫描 —— i18n 的防回归闸门。 + * + * 为什么必须有:P1 是一边翻译一边还在开发新功能。没有这道闸门, + * 翻译永远收不了口(翻完一片,另一片又进来新的中文串)。 + * + * 为什么用 Node 而不是 .ps1:这个脚本要进 CI,也要能被任何人在任何 shell 里跑; + * 而且本机的 PowerShell 5.1 对 UTF-8 无 BOM 的 .ps1 会按 GBK 读,中文注释一乱码 + * 就是语法错误(见 pitfalls 9)。Node 读文件永远按 UTF-8。 + * + * 只报**代码里的**中文:注释里的中文有 1700 多行,是项目的正常风格,全报出来 + * 就没人看了。所以先剥注释,再在剩下的部分里找。 + * + * 用法: + * node scripts/check-i18n.mjs # 只看未迁移的文件清单(摘要) + * node scripts/check-i18n.mjs --all # 逐行列出 + * node scripts/check-i18n.mjs --strict # 白名单外只要有一处就退出码 1(CI 用) + */ +import { readFileSync, readdirSync, statSync } from 'node:fs' +import { join, relative, sep } from 'node:path' +import { fileURLToPath } from 'node:url' +import ts from 'typescript' + +const SRC_ROOT = fileURLToPath(new URL('../src', import.meta.url)) +const CLIENT_ROOT = fileURLToPath(new URL('..', import.meta.url)) +const CJK = /[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff\u3000-\u303f\uff01-\uff5e]/ + +/** + * 允许含中文的路径(前缀匹配,相对 client/)。 + * + * 加白名单前先问一句:这里的中文是**给用户看的文案**,还是**被处理的数据**? + * 是文案就该进 locale 文件,不该进这个列表。 + */ +const ALLOWLIST = [ + // locale 文件本身就是中文的家 + 'src/i18n/locales/', +] + +/** + * 测试整体不扫。 + * + * 测试里的中文分两种,两种都不该由这个脚本管:中文文本处理的**被测数据** + * (textPostProcess / textReplacement / textSegmenter)翻掉等于废掉测试; + * 中文的 describe/it 标题是项目既有风格,也不是用户可见文案。 + * 真正要处理的是「断言了用户可见文案」的那几个测试 —— 它们由 i18n-todo.md 的 + * P1「每片都要遵守的三条」逐片改成断言 key,不靠这里扫。 + */ +const SKIP_DIRS = ['__tests__'] + +const args = new Set(process.argv.slice(2)) +const showAll = args.has('--all') +const strict = args.has('--strict') + +/** + * 少量中文是算法输入、错误匹配词或 Prompt,不是界面文案。它们必须就地标明边界: + * // i18n-allow-start: 原因 + * ... + * // i18n-allow-end + * 或在单行末尾写 `// i18n-allow: 原因`。 + * + * 不使用整文件白名单:同一个文件以后新增 UI 文案时,闸门仍然必须能拦住。 + */ +function collectAllowedLines(file, source) { + const allowed = new Set() + let inAllowedBlock = false + source.split('\n').forEach((line, index) => { + const lineNumber = index + 1 + if (line.includes('i18n-allow-start')) { + if (inAllowedBlock) throw new Error(`${file}:${lineNumber}: nested i18n allow block`) + inAllowedBlock = true + } + if (inAllowedBlock || line.includes('i18n-allow:')) allowed.add(lineNumber) + if (line.includes('i18n-allow-end')) { + if (!inAllowedBlock) throw new Error(`${file}:${lineNumber}: unmatched i18n-allow-end`) + inAllowedBlock = false + } + }) + if (inAllowedBlock) throw new Error(`${file}: unclosed i18n allow block`) + return allowed +} + +function isTextBearingNode(node) { + return ts.isStringLiteralLike(node) + || ts.isRegularExpressionLiteral(node) + || ts.isJsxText(node) + || node.kind === ts.SyntaxKind.TemplateHead + || node.kind === ts.SyntaxKind.TemplateMiddle + || node.kind === ts.SyntaxKind.TemplateTail +} + +/** 用 TypeScript AST 找字符串、模板、正则与 JSX 文本;注释天然不会进入结果。 */ +function findChinese(file, source) { + const scriptKind = file.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS + const sourceFile = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, scriptKind) + const sourceLines = source.split('\n') + const allowedLines = collectAllowedLines(file, source) + const results = new Map() + + const visit = (node) => { + if (isTextBearingNode(node)) { + const nodeText = node.getText(sourceFile) + const isJsxComment = ts.isJsxText(node) && /^\s*\{\/\*/.test(nodeText) + if (!isJsxComment && CJK.test(nodeText)) { + const start = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + nodeText.split('\n').forEach((part, offset) => { + const line = start + offset + const lineNumber = line + 1 + if (!allowedLines.has(lineNumber) && CJK.test(part)) { + results.set(lineNumber, { line: lineNumber, text: (sourceLines[line] ?? '').trim() }) + } + }) + } + } + if (!ts.isJsxText(node)) { + ts.forEachChild(node, visit) + } + } + visit(sourceFile) + return [...results.values()] +} + +function walk(dir, files = []) { + for (const entry of readdirSync(dir)) { + const full = join(dir, entry) + if (statSync(full).isDirectory()) { + if (!SKIP_DIRS.includes(entry)) walk(full, files) + } else if (/\.(ts|tsx)$/.test(entry)) { + files.push(full) + } + } + return files +} + +function isAllowed(relPath) { + const posix = relPath.split(sep).join('/') + return ALLOWLIST.some((prefix) => posix.startsWith(prefix)) +} + +const findings = [] +for (const file of walk(SRC_ROOT)) { + const relPath = relative(CLIENT_ROOT, file) + if (isAllowed(relPath)) continue + const source = readFileSync(file, 'utf8') + for (const item of findChinese(file, source)) { + findings.push({ file: relPath.split(sep).join('/'), ...item }) + } +} + +if (findings.length === 0) { + console.log('check-i18n: no hardcoded Chinese found outside the allowlist.') + process.exit(0) +} + +const byFile = new Map() +for (const item of findings) { + byFile.set(item.file, (byFile.get(item.file) ?? 0) + 1) +} + +if (showAll) { + for (const item of findings) { + console.log(`${item.file}:${item.line}: ${item.text}`) + } +} else { + for (const [file, count] of [...byFile].sort((a, b) => b[1] - a[1])) { + console.log(`${String(count).padStart(4)} ${file}`) + } +} + +console.log(`\ncheck-i18n: ${findings.length} line(s) in ${byFile.size} file(s) still hold Chinese in code.`) +if (strict) { + console.error('check-i18n: --strict is on, failing.') + process.exit(1) +} +process.exit(0) diff --git a/client/src-tauri/src/commands/backup.rs b/client/src-tauri/src/commands/backup.rs index e2a38c2..f895c28 100644 --- a/client/src-tauri/src/commands/backup.rs +++ b/client/src-tauri/src/commands/backup.rs @@ -45,13 +45,20 @@ pub struct ConfigExportSelection { #[serde(rename_all = "camelCase")] pub struct ConfigImportSectionPreview { kind: String, - label: String, total: usize, added: usize, updated: usize, skipped: usize, } +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ConfigImportWarning { + code: String, + current: Option, + limit: Option, +} + #[derive(Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct ConfigImportPreview { @@ -59,7 +66,7 @@ pub struct ConfigImportPreview { format_version: i64, import_token: String, sections: Vec, - warnings: Vec, + warnings: Vec, requires_restart: bool, } @@ -77,7 +84,7 @@ struct SelectedImportPlan { app_settings: Map, prompt_presets: Option>, sections: Vec, - warnings: Vec, + warnings: Vec, added: usize, updated: usize, skipped: usize, @@ -245,7 +252,7 @@ fn build_selected_config_value( } if items.is_empty() { - return Err("请至少选择一项要导出的配置".to_string()); + return Err("Select at least one configuration item to export".to_string()); } Ok(json!({ @@ -276,7 +283,7 @@ fn build_full_value(storage: &Storage) -> Value { fn check_kind_and_version(data: &Value, expected_kind: &str, max_version: i64) -> Result { let kind = data.get("kind").and_then(Value::as_str).unwrap_or(""); if kind != expected_kind { - return Err(format!("这不是有效的 SayIt {}文件", if expected_kind == "full" { "全部数据备份" } else { "配置" })); + return Err(format!("This is not a valid SayIt {} file", if expected_kind == "full" { "full-data backup" } else { "configuration" })); } let version = data .get("formatVersion") @@ -284,7 +291,7 @@ fn check_kind_and_version(data: &Value, expected_kind: &str, max_version: i64) - .unwrap_or(0); if version > max_version { return Err(format!( - "备份文件版本({})高于当前应用支持的版本({}),请升级 SayIt 后再导入。", + "The backup version ({}) is newer than this app supports ({}). Update SayIt before importing it.", version, max_version )); } @@ -324,7 +331,7 @@ fn apply_config_part( prompt_presets, app_prompt_rules, ) - .map_err(|error| format!("写入配置失败: {}", error)) + .map_err(|error| format!("Failed to write configuration: {}", error)) } /// 重写单条历史记录的音频路径:把目录段换成本机 audio 目录,文件名不变。 @@ -364,9 +371,9 @@ fn unique_import_name(base: &str, used_names: &mut HashSet) -> String { let mut index = 1usize; loop { let candidate = if index == 1 { - format!("{}(导入)", base) + format!("{} (Imported)", base) } else { - format!("{}(导入 {})", base, index) + format!("{} (Imported {})", base, index) }; if used_names.insert(candidate.to_lowercase()) { return candidate; @@ -379,7 +386,7 @@ fn build_selected_import_plan(storage: &Storage, data: &Value) -> Result Result Result MAX_HOTWORDS { - warnings.push(format!( - "启用的自定义热词将达到 {} 个,超过 {} 个后实际生效数量会受限制。", - active_words.len(), - MAX_HOTWORDS - )); + warnings.push(ConfigImportWarning { + code: "hotwordLimit".to_string(), + current: Some(active_words.len()), + limit: Some(MAX_HOTWORDS), + }); } app_settings.insert("customHotwordThemes".to_string(), Value::Array(local_groups)); app_settings.insert("customThemeActive".to_string(), Value::Object(active_map)); sections.push(ConfigImportSectionPreview { kind: "hotwords".to_string(), - label: "热词组".to_string(), total: imported_groups.len(), added, updated, @@ -500,7 +506,7 @@ fn build_selected_import_plan(storage: &Storage, data: &Value) -> Result Result Result Result Result f64 { #[tauri::command] pub fn get_backup_directory() -> Result { let directory = backup_dir(); - fs::create_dir_all(&directory).map_err(|e| format!("创建备份目录失败: {}", e))?; + fs::create_dir_all(&directory).map_err(|e| format!("Failed to create backup directory: {}", e))?; Ok(directory.to_string_lossy().to_string()) } @@ -736,15 +740,15 @@ pub async fn export_config( ) -> Result { let out_path = timestamped_backup_path("sayit-config", "json"); if let Some(parent) = out_path.parent() { - fs::create_dir_all(parent).map_err(|e| format!("创建备份目录失败: {}", e))?; + fs::create_dir_all(parent).map_err(|e| format!("Failed to create backup directory: {}", e))?; } let payload = match selection.mode.as_str() { "full" => build_config_value(storage.inner()), "selected" => build_selected_config_value(storage.inner(), &selection)?, - _ => return Err("不支持的配置导出方式".to_string()), + _ => return Err("Unsupported configuration export mode".to_string()), }; let content = serde_json::to_string_pretty(&payload).map_err(|e| e.to_string())?; - fs::write(&out_path, content).map_err(|e| format!("写入文件失败: {}", e))?; + fs::write(&out_path, content).map_err(|e| format!("Failed to write file: {}", e))?; let path = out_path.to_string_lossy().to_string(); log::info!("Config backup exported to {}", path); Ok(path) @@ -780,7 +784,7 @@ pub async fn export_full( let export_result = (|| -> Result<(), String> { if let Some(parent) = output.parent() { - fs::create_dir_all(parent).map_err(|e| format!("创建导出目录失败: {}", e))?; + fs::create_dir_all(parent).map_err(|e| format!("Failed to create export directory: {}", e))?; } let payload = build_full_value(storage.inner()); @@ -801,7 +805,7 @@ pub async fn export_full( }, ); - let file = fs::File::create(&temp_path).map_err(|e| format!("创建文件失败: {}", e))?; + let file = fs::File::create(&temp_path).map_err(|e| format!("Failed to create file: {}", e))?; let mut zip = zip::ZipWriter::new(file); let text_opts = zip::write::FileOptions::default() .compression_method(zip::CompressionMethod::Deflated); @@ -822,12 +826,12 @@ pub async fn export_full( zip.start_file(format!("audio/{}", audio.name), audio_opts) .map_err(|e| e.to_string())?; let mut source = fs::File::open(&audio.path) - .map_err(|e| format!("读取音频 {} 失败: {}", audio.name, e))?; + .map_err(|e| format!("Failed to read audio file {}: {}", audio.name, e))?; loop { let read = source .read(&mut buffer) - .map_err(|e| format!("读取音频 {} 失败: {}", audio.name, e))?; + .map_err(|e| format!("Failed to read audio file {}: {}", audio.name, e))?; if read == 0 { break; } @@ -890,9 +894,9 @@ pub async fn export_full( zip.finish().map_err(|e| e.to_string())?; if output.exists() { - fs::remove_file(&output).map_err(|e| format!("替换旧备份失败: {}", e))?; + fs::remove_file(&output).map_err(|e| format!("Failed to replace the previous backup: {}", e))?; } - fs::rename(&temp_path, &output).map_err(|e| format!("完成备份文件失败: {}", e))?; + fs::rename(&temp_path, &output).map_err(|e| format!("Failed to finalize the backup file: {}", e))?; Ok(()) })(); @@ -950,9 +954,9 @@ fn content_fingerprint(content: &str) -> String { } fn read_config_file(in_path: &str) -> Result<(Value, String), String> { - let content = fs::read_to_string(in_path).map_err(|e| format!("读取文件失败: {}", e))?; + let content = fs::read_to_string(in_path).map_err(|e| format!("Failed to read file: {}", e))?; let data = serde_json::from_str(&content) - .map_err(|e| format!("解析失败(文件可能损坏或格式不正确): {}", e))?; + .map_err(|e| format!("Failed to parse the file; it may be damaged or have an invalid format: {}", e))?; Ok((data, content_fingerprint(&content))) } @@ -973,7 +977,7 @@ fn config_import_token( "promptPresets": storage.get("promptPresets", None), }); let serialized = serde_json::to_string(&context) - .map_err(|error| format!("生成导入校验信息失败: {}", error))?; + .map_err(|error| format!("Failed to prepare import validation data: {}", error))?; Ok(content_fingerprint(&serialized)) } @@ -982,15 +986,34 @@ fn validate_config_collection(key: &str, items: &[Value]) -> Result<(), String> for (index, item) in items.iter().enumerate() { let object = item .as_object() - .ok_or_else(|| format!("配置文件中的 {} 第 {} 项格式不正确", key, index + 1))?; + .ok_or_else(|| format!("Item {} in {} has an invalid format", index + 1, key))?; let id = object .get("id") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) - .ok_or_else(|| format!("配置文件中的 {} 第 {} 项缺少 id", key, index + 1))?; - if !ids.insert(id.to_string()) { - return Err(format!("配置文件中的 {} 存在重复 id:{}", key, id)); + .ok_or_else(|| format!("Item {} in {} is missing id", index + 1, key))?; + + // 内置 Prompt 的中英文自定义内容共存在 promptPresets 中,因此同一个稳定 id + // 可以出现两次,但同一语言仍只能有一份。旧备份没有语言字段,只能是中文。 + let unique_id = if key == "promptPresets" && BUILTIN_PRESET_IDS.contains(&id) { + let language = object + .get("builtinPromptLanguage") + .and_then(Value::as_str) + .unwrap_or("zh-CN"); + if language != "zh-CN" && language != "en" { + return Err(format!( + "Item {} in {} has an invalid language", + index + 1, + key + )); + } + format!("{}:{}", id, language) + } else { + id.to_string() + }; + if !ids.insert(unique_id) { + return Err(format!("{} contains a duplicate id: {}", key, id)); } let required_fields: &[&str] = match key { @@ -1005,9 +1028,9 @@ fn validate_config_collection(key: &str, items: &[Value]) -> Result<(), String> .is_some_and(|value| !value.trim().is_empty()); if !valid { return Err(format!( - "配置文件中的 {} 第 {} 项缺少 {}", - key, + "Item {} in {} is missing {}", index + 1, + key, field )); } @@ -1022,11 +1045,10 @@ fn full_config_preview_sections(data: &Value) -> Result Result Result FORMAT_VERSION { - return Err("不支持的配置文件范围或版本".to_string()); + return Err("Unsupported configuration scope or version".to_string()); } let sections = full_config_preview_sections(&data)?; @@ -1096,10 +1117,11 @@ pub async fn inspect_config_import( format_version: version, import_token: file_fingerprint, sections, - warnings: vec![ - "完整配置会覆盖文件中包含的设置、供应商配置与密钥、热词和 Prompt。" - .to_string(), - ], + warnings: vec![ConfigImportWarning { + code: "fullOverwrite".to_string(), + current: None, + limit: None, + }], requires_restart: true, }) } @@ -1120,7 +1142,7 @@ pub async fn import_config( if is_selected_config(&data) { let actual_token = config_import_token(storage.inner(), &data, &file_fingerprint)?; if actual_token != expected_import_token { - return Err("配置文件或本地配置已发生变化,请重新选择文件并确认".to_string()); + return Err("The configuration file or local settings changed; select the file again and reconfirm".to_string()); } let plan = build_selected_import_plan(storage.inner(), &data)?; @@ -1128,7 +1150,7 @@ pub async fn import_config( .sections .iter() .filter(|section| section.added + section.updated > 0) - .map(|section| section.label.clone()) + .map(|section| section.kind.clone()) .collect::>(); storage .apply_config_transaction( @@ -1137,7 +1159,7 @@ pub async fn import_config( plan.prompt_presets.as_deref(), None, ) - .map_err(|error| format!("写入配置失败: {}", error))?; + .map_err(|error| format!("Failed to write configuration: {}", error))?; return Ok(ConfigImportResult { changed_sections, added: plan.added, @@ -1148,17 +1170,17 @@ pub async fn import_config( } if data.get("scope").is_some() || version > FORMAT_VERSION { - return Err("不支持的配置文件范围或版本".to_string()); + return Err("Unsupported configuration scope or version".to_string()); } full_config_preview_sections(&data)?; if file_fingerprint != expected_import_token { - return Err("配置文件已发生变化,请重新选择文件并确认".to_string()); + return Err("The configuration file changed; select it again and reconfirm".to_string()); } // 完整配置导入排除 stats,避免覆盖本机使用统计。 apply_config_part(storage.inner(), &data, CONFIG_EXCLUDE)?; Ok(ConfigImportResult { - changed_sections: vec!["完整配置".to_string()], + changed_sections: vec!["fullConfig".to_string()], added: 0, updated: 1, skipped: 0, @@ -1168,23 +1190,23 @@ pub async fn import_config( #[tauri::command] pub async fn import_full(in_path: String, storage: State<'_, Storage>) -> Result<(), String> { - let file = fs::File::open(&in_path).map_err(|e| format!("打开文件失败: {}", e))?; - let mut archive = zip::ZipArchive::new(file).map_err(|e| format!("不是有效的备份压缩包: {}", e))?; + let file = fs::File::open(&in_path).map_err(|e| format!("Failed to open file: {}", e))?; + let mut archive = zip::ZipArchive::new(file).map_err(|e| format!("Not a valid backup archive: {}", e))?; // 1. 读取并校验 backup.json let mut json_str = String::new(); { let mut entry = archive .by_name("backup.json") - .map_err(|_| "备份包缺少 backup.json,可能不是 SayIt 全量备份。".to_string())?; + .map_err(|_| "The archive is missing backup.json and may not be a SayIt full backup".to_string())?; entry.read_to_string(&mut json_str).map_err(|e| e.to_string())?; } - let data: Value = serde_json::from_str(&json_str).map_err(|e| format!("解析 backup.json 失败: {}", e))?; + let data: Value = serde_json::from_str(&json_str).map_err(|e| format!("Failed to parse backup.json: {}", e))?; check_kind_and_version(&data, "full", FORMAT_VERSION)?; // 2. 释放音频文件到本机 audio 目录 let adir = audio_dir(); - fs::create_dir_all(&adir).map_err(|e| format!("创建音频目录失败: {}", e))?; + fs::create_dir_all(&adir).map_err(|e| format!("Failed to create audio directory: {}", e))?; for i in 0..archive.len() { let mut entry = archive.by_index(i).map_err(|e| e.to_string())?; let name = entry.name().to_string(); @@ -1198,7 +1220,7 @@ pub async fn import_full(in_path: String, storage: State<'_, Storage>) -> Result } let mut buf = Vec::new(); entry.read_to_end(&mut buf).map_err(|e| e.to_string())?; - fs::write(adir.join(&base), &buf).map_err(|e| format!("写入音频 {} 失败: {}", base, e))?; + fs::write(adir.join(&base), &buf).map_err(|e| format!("Failed to write audio file {}: {}", base, e))?; } } @@ -1210,16 +1232,16 @@ pub async fn import_full(in_path: String, storage: State<'_, Storage>) -> Result let rewritten: Vec = history.iter().map(|rec| rewrite_audio_path(rec, &adir)).collect(); storage .set("history", &Value::Array(rewritten)) - .map_err(|e| format!("写入历史失败: {}", e))?; + .map_err(|e| format!("Failed to write history: {}", e))?; } if let Some(arr) = data.get("manualCorrections") { if arr.is_array() { - storage.set("manualCorrections", arr).map_err(|e| format!("写入人工校正失败: {}", e))?; + storage.set("manualCorrections", arr).map_err(|e| format!("Failed to write manual corrections: {}", e))?; } } if let Some(arr) = data.get("feedbackQueue") { if arr.is_array() { - storage.set("feedbackQueue", arr).map_err(|e| format!("写入反馈队列失败: {}", e))?; + storage.set("feedbackQueue", arr).map_err(|e| format!("Failed to write feedback queue: {}", e))?; } } Ok(()) @@ -1230,3 +1252,43 @@ pub async fn import_full(in_path: String, storage: State<'_, Storage>) -> Result pub fn restart_app(app: tauri::AppHandle) { app.restart(); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn prompt_overrides_allow_one_entry_per_builtin_language() { + let items = vec![ + json!({ + "id": "intent", + "name": "Intent cleanup", + "systemPrompt": "中文自定义", + "builtinPromptLanguage": "zh-CN", + }), + json!({ + "id": "intent", + "name": "Intent cleanup", + "systemPrompt": "English custom", + "builtinPromptLanguage": "en", + }), + ]; + + assert!(validate_config_collection("promptPresets", &items).is_ok()); + } + + #[test] + fn prompt_overrides_reject_duplicate_builtin_language() { + let items = vec![ + json!({ "id": "intent", "name": "Intent cleanup", "systemPrompt": "First" }), + json!({ + "id": "intent", + "name": "Intent cleanup", + "systemPrompt": "Second", + "builtinPromptLanguage": "zh-CN", + }), + ]; + + assert!(validate_config_collection("promptPresets", &items).is_err()); + } +} diff --git a/client/src-tauri/src/commands/diagnostics.rs b/client/src-tauri/src/commands/diagnostics.rs index 1ce9a66..0f9f416 100644 --- a/client/src-tauri/src/commands/diagnostics.rs +++ b/client/src-tauri/src/commands/diagnostics.rs @@ -157,12 +157,12 @@ fn parse_log_line(line: &str) -> Option { fn issue_window_label(occurrence: &str) -> &'static str { match occurrence { - "just_now" => "刚刚发生", - "within_1h" => "1 小时内", - "today" => "今天", - "yesterday" => "昨天", - "older" => "更早", - _ => "不确定", + "just_now" => "Just now", + "within_1h" => "Within 1 hour", + "today" => "Today", + "yesterday" => "Yesterday", + "older" => "Earlier", + _ => "Unknown", } } @@ -401,9 +401,9 @@ pub fn read_diagnostics_zip(path: String) -> Result>, String> { pub fn copy_diagnostics_zip(source: String, destination: String) -> Result<(), String> { let src = std::path::PathBuf::from(&source); if !src.exists() { - return Err("诊断文件不存在".to_string()); + return Err("The diagnostics file does not exist".to_string()); } - std::fs::copy(&src, &destination).map_err(|e| format!("复制失败: {}", e))?; + std::fs::copy(&src, &destination).map_err(|e| format!("Failed to copy diagnostics file: {}", e))?; // 清理临时文件 let _ = std::fs::remove_file(&src); Ok(()) @@ -460,21 +460,21 @@ pub fn open_log_folder() -> Result<(), String> { std::process::Command::new("explorer") .arg(dir.to_string_lossy().to_string()) .spawn() - .map_err(|e| format!("打开文件夹失败: {}", e))?; + .map_err(|e| format!("Failed to open folder: {}", e))?; } #[cfg(target_os = "macos")] { std::process::Command::new("open") .arg(dir.to_string_lossy().to_string()) .spawn() - .map_err(|e| format!("打开文件夹失败: {}", e))?; + .map_err(|e| format!("Failed to open folder: {}", e))?; } #[cfg(target_os = "linux")] { std::process::Command::new("xdg-open") .arg(dir.to_string_lossy().to_string()) .spawn() - .map_err(|e| format!("打开文件夹失败: {}", e))?; + .map_err(|e| format!("Failed to open folder: {}", e))?; } Ok(()) } diff --git a/client/src-tauri/src/commands/export.rs b/client/src-tauri/src/commands/export.rs index 6b1a9c3..1125fb0 100644 --- a/client/src-tauri/src/commands/export.rs +++ b/client/src-tauri/src/commands/export.rs @@ -148,7 +148,7 @@ pub async fn save_full_export( }); let zip_name = format!("audio/{}", fname); zip.start_file(&zip_name, audio_opts).map_err(|e| e.to_string())?; - let bytes = fs::read(audio_path).map_err(|e| format!("读取音频失败 {}: {}", fname, e))?; + let bytes = fs::read(audio_path).map_err(|e| format!("Failed to read audio file {}: {}", fname, e))?; zip.write_all(&bytes).map_err(|e| e.to_string())?; } @@ -159,5 +159,5 @@ pub async fn save_full_export( Ok(Some(clean)) }) .await - .map_err(|e| format!("导出异常: {}", e))? + .map_err(|e| format!("Export task failed: {}", e))? } diff --git a/client/src-tauri/src/commands/shortcuts.rs b/client/src-tauri/src/commands/shortcuts.rs index 7e19c77..9c13d8a 100644 --- a/client/src-tauri/src/commands/shortcuts.rs +++ b/client/src-tauri/src/commands/shortcuts.rs @@ -118,7 +118,7 @@ pub fn test_shortcut( true } Err(e) => { - log::info!("[shortcut] 组合键 '{}' 注册失败(可能被其他程序占用): {}", accelerator, e); + log::info!("[shortcut] Failed to register '{}'; another app may already use it: {}", accelerator, e); false } }; diff --git a/client/src-tauri/src/commands/system.rs b/client/src-tauri/src/commands/system.rs index 1ab40b8..2e9913f 100644 --- a/client/src-tauri/src/commands/system.rs +++ b/client/src-tauri/src/commands/system.rs @@ -221,6 +221,17 @@ pub fn get_client_runtime_info(storage: State) -> Result String { + crate::locale::system_ui_lang().tag().to_string() +} + #[tauri::command] pub fn get_auto_launch(app: AppHandle) -> Result { use tauri_plugin_autostart::ManagerExt; @@ -296,7 +307,7 @@ pub async fn download_update(app: AppHandle, url: String) -> Result Result Result Result Result Result Result<(), String> { let path = std::path::Path::new(&file_path); if !path.exists() { - return Err("安装包文件不存在".to_string()); + return Err("The installer file does not exist".to_string()); } // 启动 NSIS 安装程序(/S 静默安装)。静默模式下 NSIS 不会像交互式安装那样 @@ -376,7 +387,7 @@ pub fn install_downloaded_update(file_path: String, app: AppHandle) -> Result<() const CREATE_NO_WINDOW: u32 = 0x08000000; let current_exe = std::env::current_exe() - .map_err(|e| format!("获取当前程序路径失败: {}", e))?; + .map_err(|e| format!("Failed to get the current executable path: {}", e))?; let exe_str = current_exe.to_string_lossy().to_string(); // 用一个临时 .bat 脚本接管"等安装完成再拉起新版本"。 @@ -399,18 +410,18 @@ pub fn install_downloaded_update(file_path: String, app: AppHandle) -> Result<() ); let script_path = std::env::temp_dir().join("sayit-update-relaunch.bat"); std::fs::write(&script_path, script) - .map_err(|e| format!("写入更新重启脚本失败: {}", e))?; + .map_err(|e| format!("Failed to write the update restart script: {}", e))?; Command::new("cmd") .args(["/C", &script_path.to_string_lossy()]) .creation_flags(CREATE_NO_WINDOW) .spawn() - .map_err(|e| format!("启动安装看门人进程失败: {}", e))?; + .map_err(|e| format!("Failed to start the installer watchdog: {}", e))?; } #[cfg(not(target_os = "windows"))] { - return Err("当前平台不支持自动安装".to_string()); + return Err("Automatic installation is not supported on this platform".to_string()); } // 退出当前应用,让安装程序能够覆盖 exe 文件 @@ -473,17 +484,17 @@ pub fn append_debug_log(payload: Value) -> Result<(), String> { #[tauri::command] pub fn save_audio_to_downloads(base64_data: String, filename: String) -> Result { let downloads = dirs::download_dir() - .ok_or_else(|| "无法获取下载目录".to_string())?; + .ok_or_else(|| "Could not locate the Downloads directory".to_string())?; let bytes = base64::engine::general_purpose::STANDARD .decode(&base64_data) - .map_err(|e| format!("base64 解码失败: {}", e))?; + .map_err(|e| format!("Failed to decode base64 audio: {}", e))?; let dest = downloads.join(&filename); - std::fs::write(&dest, &bytes).map_err(|e| format!("写入文件失败: {}", e))?; + std::fs::write(&dest, &bytes).map_err(|e| format!("Failed to write file: {}", e))?; let path_str = dest.to_string_lossy().to_string(); - write_log_line(&format!("[INFO] [audio] 音频已保存到 {}", path_str)); + write_log_line(&format!("[INFO] [audio] Audio saved to {}", path_str)); Ok(path_str) } @@ -497,7 +508,7 @@ pub fn reveal_file_in_folder(file_path: String) -> Result<(), String> { .arg("/select,") .arg(&file_path) .spawn() - .map_err(|e| format!("打开文件夹失败: {}", e))?; + .map_err(|e| format!("Failed to open folder: {}", e))?; } #[cfg(target_os = "macos")] { @@ -505,7 +516,7 @@ pub fn reveal_file_in_folder(file_path: String) -> Result<(), String> { .arg("-R") .arg(&file_path) .spawn() - .map_err(|e| format!("打开文件夹失败: {}", e))?; + .map_err(|e| format!("Failed to open folder: {}", e))?; } #[cfg(target_os = "linux")] { @@ -514,7 +525,7 @@ pub fn reveal_file_in_folder(file_path: String) -> Result<(), String> { std::process::Command::new("xdg-open") .arg(dir.to_string_lossy().to_string()) .spawn() - .map_err(|e| format!("打开文件夹失败: {}", e))?; + .map_err(|e| format!("Failed to open folder: {}", e))?; } Ok(()) } @@ -524,26 +535,26 @@ pub fn reveal_file_in_folder(file_path: String) -> Result<(), String> { pub fn open_folder(folder_path: String) -> Result<(), String> { let path = std::path::PathBuf::from(&folder_path); if !path.is_dir() { - return Err(format!("文件夹不存在: {}", folder_path)); + return Err(format!("Folder does not exist: {}", folder_path)); } #[cfg(target_os = "windows")] std::process::Command::new("explorer") .arg(&path) .spawn() - .map_err(|e| format!("打开文件夹失败: {}", e))?; + .map_err(|e| format!("Failed to open folder: {}", e))?; #[cfg(target_os = "macos")] std::process::Command::new("open") .arg(&path) .spawn() - .map_err(|e| format!("打开文件夹失败: {}", e))?; + .map_err(|e| format!("Failed to open folder: {}", e))?; #[cfg(target_os = "linux")] std::process::Command::new("xdg-open") .arg(&path) .spawn() - .map_err(|e| format!("打开文件夹失败: {}", e))?; + .map_err(|e| format!("Failed to open folder: {}", e))?; Ok(()) } diff --git a/client/src-tauri/src/error_protocol.rs b/client/src-tauri/src/error_protocol.rs new file mode 100644 index 0000000..616fe75 --- /dev/null +++ b/client/src-tauri/src/error_protocol.rs @@ -0,0 +1,24 @@ +//! Stable error envelope shared by Rust commands and the web UI. +//! +//! Human-readable details are intentionally not the contract: providers may return them in +//! different languages, and our own diagnostics can be reworded. The frontend classifies by +//! `code` and only shows `detail` as secondary diagnostic text. + +const PREFIX: &str = "sayit_error:"; + +pub fn encode(code: &str, detail: impl AsRef) -> String { + format!("{}{}:{}", PREFIX, code, detail.as_ref()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn keeps_colons_and_unicode_in_detail() { + assert_eq!( + encode("provider_bad_key", "HTTP 401: 密钥无效"), + "sayit_error:provider_bad_key:HTTP 401: 密钥无效" + ); + } +} diff --git a/client/src-tauri/src/inject/mod.rs b/client/src-tauri/src/inject/mod.rs index 9b89996..b2b73ef 100644 --- a/client/src-tauri/src/inject/mod.rs +++ b/client/src-tauri/src/inject/mod.rs @@ -344,7 +344,7 @@ unsafe fn do_inject(target: HWND, focus: HWND, text: &str, restore_clipboard: bo strategy: Some("clipboard".to_string()), reason: Some("clipboard_blocked".to_string()), detail: Some(format!( - "pasteId={} 重试 5 次仍写不进剪贴板 inputGuards={}", + "pasteId={} failed to write to the clipboard after 5 attempts inputGuards={}", paste_id, guards )), uncertain: false, diff --git a/client/src-tauri/src/keyboard/mod.rs b/client/src-tauri/src/keyboard/mod.rs index 92189a9..8967bcc 100644 --- a/client/src-tauri/src/keyboard/mod.rs +++ b/client/src-tauri/src/keyboard/mod.rs @@ -72,7 +72,7 @@ pub fn set_escape_action_mode(mode: &str, token: u64) -> Result<(), String> { "cancel_recording" => (ESCAPE_MODE_CANCEL_RECORDING, 11 * 60 * 1000), "cancel_processing" => (ESCAPE_MODE_CANCEL_PROCESSING, 2 * 60 * 1000), "dismiss_fallback" => (ESCAPE_MODE_DISMISS_FALLBACK, 30 * 1000), - _ => return Err(format!("未知 Esc 动作模式: {mode}")), + _ => return Err(format!("Unknown Escape action mode: {mode}")), }; // 先关闭模式,再按 token/deadline/final mode 的顺序发布一组一致快照。 // 钩子只有看到最终非 off 模式后才会读取对应 token。 diff --git a/client/src-tauri/src/locale.rs b/client/src-tauri/src/locale.rs new file mode 100644 index 0000000..753e8a9 --- /dev/null +++ b/client/src-tauri/src/locale.rs @@ -0,0 +1,123 @@ +//! 界面语言:系统显示语言读取 + `ui.language` 偏好解析。 +//! +//! 为什么这件事必须有 Rust 侧的一份:**托盘菜单在 Rust 建**(main.rs 的 +//! TrayIconBuilder),要它开机就是正确语言,就不能等前端起来再告诉它。 +//! +//! 为什么不加 `sys-locale` 依赖:`Win32_Globalization` 已经在 Cargo.toml 的 +//! windows feature 列表里,需要的 API 直接可用。这是 Windows 独占应用, +//! 没必要为一次读取多背一个 crate。 +//! +//! ⚠️ **不要和 `commands/system.rs` 的 `get_system_locale()` 合并**,那个用的是 +//! `GetUserDefaultLocaleName`(**区域格式**,如日期货币怎么写),报给诊断用; +//! 这里用 `GetUserDefaultUILanguage`(**显示语言**,Windows 界面本身什么语言)。 +//! 两者可以不一致 —— 英文版 Windows 把区域设成中国是很常见的组合。 +//! 界面语言只能跟显示语言,跟区域格式会把英文用户判成中文。 + +use crate::storage::Storage; + +/// 受支持的界面语言。取值与前端 `Locale` 一一对应。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Lang { + ZhCn, + En, +} + +impl Lang { + /// 前端 `Locale` 用的标签,也是这个模块对外的唯一字符串形态。 + pub fn tag(self) -> &'static str { + match self { + Lang::ZhCn => "zh-CN", + Lang::En => "en", + } + } +} + +/// Windows 主语言 ID(`PRIMARYLANGID`)→ 界面语言。 +/// +/// 任何中文变体(简/繁/港澳台)都判为简体:他们现在看到的就是简体界面, +/// 推到英文属于倒退。其余一律英文。 +fn lang_from_primary_id(primary_id: u16) -> Lang { + const LANG_CHINESE: u16 = 0x04; + if primary_id == LANG_CHINESE { + Lang::ZhCn + } else { + Lang::En + } +} + +/// 系统显示语言。取不到时按英文处理(英文是更安全的默认:看不懂中文的人更多)。 +#[cfg(windows)] +pub fn system_ui_lang() -> Lang { + use windows::Win32::Globalization::GetUserDefaultUILanguage; + + let langid = unsafe { GetUserDefaultUILanguage() }; + if langid == 0 { + return Lang::En; + } + lang_from_primary_id(langid & 0x03ff) +} + +#[cfg(not(windows))] +pub fn system_ui_lang() -> Lang { + // 非 Windows 目前只有 cargo test 会走到(应用本身是 Windows 独占)。 + match std::env::var("LANG") { + Ok(value) if value.to_ascii_lowercase().starts_with("zh") => Lang::ZhCn, + _ => Lang::En, + } +} + +/// 当前实际生效的界面语言:`ui.language` 为 `auto`(或缺失/脏值)时按系统显示语言定。 +pub fn effective_lang(storage: &Storage) -> Lang { + match storage.get("ui.language", None).as_str() { + Some("zh-CN") => Lang::ZhCn, + Some("en") => Lang::En, + _ => system_ui_lang(), + } +} + +/// 托盘文案。刻意写死在 Rust 里而不去读前端的 locale JSON: +/// 只有三条,且托盘在前端加载之前就要建好,为它引入一条读文件的启动路径不值。 +pub struct TrayStrings { + pub show: &'static str, + pub quit: &'static str, + pub tooltip: &'static str, +} + +pub fn tray_strings(lang: Lang) -> TrayStrings { + match lang { + Lang::ZhCn => TrayStrings { + show: "显示主窗口", + quit: "退出 SayIt", + tooltip: "SayIt — 随口说,出色写", + }, + Lang::En => TrayStrings { + show: "Show main window", + quit: "Quit SayIt", + tooltip: "SayIt — Speak freely, write well", + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn chinese_primary_id_maps_to_simplified() { + assert_eq!(lang_from_primary_id(0x04), Lang::ZhCn); + } + + #[test] + fn other_primary_ids_map_to_english() { + // 0x09 = 英语,0x11 = 日语,0x12 = 韩语,0x07 = 德语 + for id in [0x09, 0x11, 0x12, 0x07, 0x00] { + assert_eq!(lang_from_primary_id(id), Lang::En, "primary_id={id:#x}"); + } + } + + #[test] + fn tags_match_frontend_locale_values() { + assert_eq!(Lang::ZhCn.tag(), "zh-CN"); + assert_eq!(Lang::En.tag(), "en"); + } +} diff --git a/client/src-tauri/src/main.rs b/client/src-tauri/src/main.rs index d5760f5..d418f36 100644 --- a/client/src-tauri/src/main.rs +++ b/client/src-tauri/src/main.rs @@ -2,6 +2,8 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] mod commands; +mod error_protocol; +mod locale; mod storage; mod window; mod keyboard; @@ -284,9 +286,9 @@ fn main() { .to_string(); std::thread::spawn(move || { match models::gguf_asr::preload(&model_id, &accelerator) { - Ok(()) => log::info!("启动预热本地模型完成: {}", model_id), + Ok(()) => log::info!("Startup local model warm-up completed: {}", model_id), // 模型没下载是正常情况(新装用户),不当错误刷日志 - Err(e) => log::info!("启动预热跳过 ({}): {}", model_id, e), + Err(e) => log::info!("Startup local model warm-up skipped ({}): {}", model_id, e), } }); } @@ -437,8 +439,15 @@ fn main() { // 系统托盘图标 { - let show_item = MenuItemBuilder::with_id("show", "显示主窗口").build(app)?; - let quit_item = MenuItemBuilder::with_id("quit", "退出 SayIt").build(app)?; + // 语言在建菜单之前就要定下来:托盘是开机即可见的 UI, + // 等前端起来再改文案会先闪一遍错误语言。 + let tray_lang = { + let storage: tauri::State = app.state(); + locale::effective_lang(storage.inner()) + }; + let tray_text = locale::tray_strings(tray_lang); + let show_item = MenuItemBuilder::with_id("show", tray_text.show).build(app)?; + let quit_item = MenuItemBuilder::with_id("quit", tray_text.quit).build(app)?; let tray_menu = MenuBuilder::new(app) .item(&show_item) .separator() @@ -451,7 +460,7 @@ fn main() { let _tray = TrayIconBuilder::new() .icon(icon) - .tooltip("SayIt — 随口说,出色写") + .tooltip(tray_text.tooltip) .menu(&tray_menu) .on_menu_event(|app, event| { match event.id().as_ref() { @@ -561,6 +570,7 @@ fn main() { commands::paste::copy_text, // System commands::system::get_client_runtime_info, + commands::system::get_system_ui_language, commands::system::get_auto_launch, commands::system::set_auto_launch, commands::system::get_update_status, diff --git a/client/src-tauri/src/models/downloader.rs b/client/src-tauri/src/models/downloader.rs index 3c559da..9aa26fc 100644 --- a/client/src-tauri/src/models/downloader.rs +++ b/client/src-tauri/src/models/downloader.rs @@ -5,6 +5,24 @@ use std::path::{Path, PathBuf}; use std::sync::RwLock; use tauri::{AppHandle, Emitter}; +use crate::error_protocol; + +fn download_error(code: &str, detail: impl AsRef) -> String { + error_protocol::encode(code, detail) +} + +fn download_io_error(context: &str, error: std::io::Error) -> String { + let code = if error.kind() == std::io::ErrorKind::PermissionDenied { + "download_permission" + } else if matches!(error.raw_os_error(), Some(28 | 112)) { + // ENOSPC on Unix and ERROR_DISK_FULL on Windows. + "download_no_space" + } else { + "download_failed" + }; + download_error(code, format!("{}: {}", context, error)) +} + #[derive(Debug, Clone, Serialize)] pub struct DownloadProgress { pub model_id: String, @@ -63,7 +81,7 @@ fn build_http_client() -> Result { reqwest::Client::builder() .user_agent("SayIt/1.0") .build() - .map_err(|e| format!("创建 HTTP 客户端失败: {}", e)) + .map_err(|e| download_error("download_network", format!("Failed to create HTTP client: {}", e))) } /// 下载单个文件,支持进度回调 @@ -82,10 +100,10 @@ pub async fn download_file( // 确保目录存在(file_name 可能含子路径,如 tokenizer/merges.txt,需创建其父目录) std::fs::create_dir_all(dest_dir) - .map_err(|e| format!("创建目录失败: {}", e))?; + .map_err(|e| download_io_error("Failed to create model directory", e))?; if let Some(parent) = dest_path.parent() { std::fs::create_dir_all(parent) - .map_err(|e| format!("创建目录失败: {}", e))?; + .map_err(|e| download_io_error("Failed to create model subdirectory", e))?; } // 检查是否已有部分下载(断点续传) @@ -106,7 +124,7 @@ pub async fn download_file( return Ok(()); } log::warn!( - "已存在的 {} 大小 {} 与期望 {} 不符,删除后重新下载", + "Existing {} has size {}, expected {}; deleting it before redownloading", file_name, size, expected_size @@ -142,10 +160,10 @@ pub async fn download_file( let resp = request .send() .await - .map_err(|e| format!("下载请求失败: {}", e))?; + .map_err(|e| download_error("download_network", format!("Download request failed: {}", e)))?; if !resp.status().is_success() && resp.status().as_u16() != 206 { - let msg = format!("下载失败 HTTP {}", resp.status()); + let msg = download_error("download_network", format!("Download failed with HTTP {}", resp.status())); emit_progress(&app, model_id, file_name, downloaded, expected_size, "failed", Some(&msg), file_index, file_count); return Err(msg); } @@ -164,7 +182,7 @@ pub async fn download_file( .create(true) .append(true) .open(&temp_path) - .map_err(|e| format!("打开文件失败: {}", e))?; + .map_err(|e| download_io_error("Failed to open partial download", e))?; let mut stream = resp.bytes_stream(); use futures_util::StreamExt; @@ -172,9 +190,9 @@ pub async fn download_file( let mut last_emit = std::time::Instant::now(); while let Some(chunk) = stream.next().await { - let chunk = chunk.map_err(|e| format!("下载中断: {}", e))?; + let chunk = chunk.map_err(|e| download_error("download_network", format!("Download interrupted: {}", e)))?; file.write_all(&chunk) - .map_err(|e| format!("写入文件失败: {}", e))?; + .map_err(|e| download_io_error("Failed to write partial download", e))?; downloaded += chunk.len() as u64; // 每 200ms 发送一次进度 @@ -184,12 +202,12 @@ pub async fn download_file( } } - file.flush().map_err(|e| format!("flush 失败: {}", e))?; + file.flush().map_err(|e| download_io_error("Failed to flush partial download", e))?; drop(file); // 下载完成,重命名 .part → 最终文件名 std::fs::rename(&temp_path, &dest_path) - .map_err(|e| format!("重命名文件失败: {}", e))?; + .map_err(|e| download_io_error("Failed to finalize downloaded file", e))?; emit_progress(&app, model_id, file_name, downloaded, total, "completed", None, file_index, file_count); log::info!("Download completed: {} ({} bytes)", file_name, downloaded); @@ -272,10 +290,10 @@ async fn download_archive_once( let resp = request .send() .await - .map_err(|e| format!("下载请求失败: {}", e))?; + .map_err(|e| download_error("download_network", format!("Download request failed: {}", e)))?; if !resp.status().is_success() && resp.status().as_u16() != 206 { - return Err(format!("下载失败 HTTP {}", resp.status())); + return Err(download_error("download_network", format!("Download failed with HTTP {}", resp.status()))); } let content_length = resp.content_length().unwrap_or(0); @@ -286,15 +304,15 @@ async fn download_archive_once( .create(true) .append(true) .open(temp_path) - .map_err(|e| format!("打开文件失败: {}", e))?; + .map_err(|e| download_io_error("Failed to open partial archive", e))?; let mut stream = resp.bytes_stream(); use futures_util::StreamExt; let mut last_emit = std::time::Instant::now(); while let Some(chunk) = stream.next().await { - let chunk = chunk.map_err(|e| format!("下载中断: {}", e))?; - file.write_all(&chunk).map_err(|e| format!("写入失败: {}", e))?; + let chunk = chunk.map_err(|e| download_error("download_network", format!("Download interrupted: {}", e)))?; + file.write_all(&chunk).map_err(|e| download_io_error("Failed to write partial archive", e))?; downloaded += chunk.len() as u64; if last_emit.elapsed().as_millis() >= 300 { @@ -303,7 +321,7 @@ async fn download_archive_once( } } - file.flush().map_err(|e| format!("flush 失败: {}", e))?; + file.flush().map_err(|e| download_io_error("Failed to flush partial archive", e))?; drop(file); Ok(()) } @@ -318,7 +336,7 @@ pub async fn download_and_extract_tar_bz2( let temp_path = dest_dir.with_extension("tar.bz2.part"); std::fs::create_dir_all(&dest_dir) - .map_err(|e| format!("创建目录失败: {}", e))?; + .map_err(|e| download_io_error("Failed to create model directory", e))?; // 如果已经解压过(目录中有 onnx 文件),跳过下载 // funasr-nano: encoder_adaptor.int8.onnx / paraformer: model.int8.onnx / qwen3-asr: encoder.int8.onnx @@ -333,13 +351,13 @@ pub async fn download_and_extract_tar_bz2( // 多源下载:GitHub 地址自动优先走国内加速代理,失败再回退直连。 // 各镜像内容一致,可跨源断点续传(沿用已有 .part)。 let candidates = build_archive_candidates(url); - let mut last_err = String::from("无可用下载源"); + let mut last_err = download_error("download_network", "No download source is available"); let mut ok = false; for (idx, cand) in candidates.iter().enumerate() { match download_archive_once(&app, model_id, cand, &temp_path).await { Ok(()) => { ok = true; break; } Err(e) => { - log::warn!("archive 源 {}/{} 失败: {}", idx + 1, candidates.len(), e); + log::warn!("Archive source {}/{} failed: {}", idx + 1, candidates.len(), e); last_err = e; } } @@ -350,20 +368,20 @@ pub async fn download_and_extract_tar_bz2( } std::fs::rename(&temp_path, &archive_path) - .map_err(|e| format!("重命名失败: {}", e))?; + .map_err(|e| download_io_error("Failed to finalize downloaded archive", e))?; log::info!("Archive downloaded: {}", model_id); emit_progress(&app, model_id, "extracting", 0, 0, "downloading", None, 1, 1); // 解压 tar.bz2 let archive_file = std::fs::File::open(&archive_path) - .map_err(|e| format!("打开压缩包失败: {}", e))?; + .map_err(|e| download_io_error("Failed to open downloaded archive", e))?; let bz_decoder = bzip2::read::BzDecoder::new(archive_file); let mut archive = tar::Archive::new(bz_decoder); - for entry in archive.entries().map_err(|e| format!("读取压缩包失败: {}", e))? { - let mut entry = entry.map_err(|e| format!("读取条目失败: {}", e))?; - let path = entry.path().map_err(|e| format!("读取路径失败: {}", e))?; + for entry in archive.entries().map_err(|e| download_error("download_failed", format!("Failed to read archive: {}", e)))? { + let mut entry = entry.map_err(|e| download_error("download_failed", format!("Failed to read archive entry: {}", e)))?; + let path = entry.path().map_err(|e| download_error("download_failed", format!("Failed to read archive path: {}", e)))?; let path_str = path.to_string_lossy().to_string(); // 跳过 test_wavs/ 和 README.md @@ -386,9 +404,9 @@ pub async fn download_and_extract_tar_bz2( std::fs::create_dir_all(parent).ok(); } let mut out = std::fs::File::create(&dest) - .map_err(|e| format!("创建文件失败 {:?}: {}", relative, e))?; + .map_err(|e| download_io_error(&format!("Failed to create extracted file {:?}", relative), e))?; std::io::copy(&mut entry, &mut out) - .map_err(|e| format!("解压文件失败 {:?}: {}", relative, e))?; + .map_err(|e| download_io_error(&format!("Failed to extract file {:?}", relative), e))?; } } diff --git a/client/src-tauri/src/models/gguf_asr.rs b/client/src-tauri/src/models/gguf_asr.rs index 48a496f..a64e186 100644 --- a/client/src-tauri/src/models/gguf_asr.rs +++ b/client/src-tauri/src/models/gguf_asr.rs @@ -179,7 +179,7 @@ pub async fn gguf_asr_diagnostics() -> Result { process_memory_mb: process_memory_mb(), }) .await - .map_err(|e| format!("读取本地引擎状态失败: {}", e)) + .map_err(|e| format!("Failed to read local engine state: {}", e)) } /// 当前进程的工作集,MB。用来回答"这个模型到底吃多少内存"——权重体积不等于 @@ -233,10 +233,10 @@ pub fn describe_devices() -> Vec { /// 所以不需要用户/前端记住文件名,避免换量化档时两头都要改。 fn find_gguf(dir: &Path) -> Result { if !dir.exists() { - return Err("模型尚未下载".into()); + return Err("The model has not been downloaded".into()); } let mut hits: Vec = std::fs::read_dir(dir) - .map_err(|e| format!("读取模型目录失败: {}", e))? + .map_err(|e| format!("Failed to read model directory: {}", e))? .flatten() .map(|e| e.path()) .filter(|p| { @@ -246,14 +246,14 @@ fn find_gguf(dir: &Path) -> Result { }) .collect(); match hits.len() { - 0 => Err("模型目录里没有 .gguf 文件".into()), + 0 => Err("The model directory contains no .gguf file".into()), 1 => Ok(hits.remove(0)), _ => { // 多个量化档共存时取最大的(通常是精度最高的那个),并留日志。 hits.sort_by_key(|p| std::fs::metadata(p).map(|m| m.len()).unwrap_or(0)); let pick = hits.pop().unwrap(); log::warn!( - "模型目录有多个 .gguf,选了最大的: {}", + "The model directory contains multiple .gguf files; selected the largest: {}", pick.file_name().unwrap_or_default().to_string_lossy() ); Ok(pick) @@ -272,7 +272,7 @@ fn resolve_backend(pref: &str) -> Backend { return b; } } - log::warn!("设置要求 GPU,但没有可用的 GPU 后端,回落 Auto"); + log::warn!("GPU was requested but no GPU backend is available; falling back to Auto"); Backend::Auto } _ => Backend::Auto, @@ -282,7 +282,7 @@ fn resolve_backend(pref: &str) -> Backend { /// 加载模型(若 key 未变则复用)。`accelerator` 取 "auto" | "cpu" | "gpu"。 /// 换加速器要重建:后端是 Model::load_with 时绑定的,之后改不了。 fn ensure_loaded(model_id: &str, accelerator: &str) -> Result<(), String> { - let mut cache = CACHE.lock().map_err(|e| format!("锁获取失败: {}", e))?; + let mut cache = CACHE.lock().map_err(|e| format!("Failed to acquire model cache lock: {}", e))?; if let Some(ref c) = *cache { if c.model_id == model_id && c.accelerator == accelerator { @@ -310,7 +310,7 @@ fn ensure_loaded(model_id: &str, accelerator: &str) -> Result<(), String> { gpu_device: 0, // 0 = 自动/首个匹配 }; let model = Model::load_with(&path, &options) - .map_err(|e| format!("加载模型失败 ({}): {}", model_id, e))?; + .map_err(|e| format!("Failed to load model ({}): {}", model_id, e))?; let backend = model.backend(); let caps = model.capabilities(); @@ -320,7 +320,7 @@ fn ensure_loaded(model_id: &str, accelerator: &str) -> Result<(), String> { let supports_pnc = model.supports(Feature::Pnc); let session = model .session_with(&SessionOptions::default()) - .map_err(|e| format!("创建 session 失败 ({}): {}", model_id, e))?; + .map_err(|e| format!("Failed to create session ({}): {}", model_id, e))?; let max_audio_ms = session .limits() @@ -393,7 +393,7 @@ fn warmup(entry: &mut Loaded) -> u128 { // 预热只为建 pipeline,语言无所谓,用自动检测即可 let opts = run_options("auto", entry.supports_itn, entry.supports_pnc); if let Err(e) = entry.session.run(&pcm, &opts) { - log::warn!("预热推理失败(不影响功能,只是第一次会慢): {}", e); + log::warn!("Warm-up inference failed; functionality is unaffected, but the first run will be slower: {}", e); } t0.elapsed().as_millis() } @@ -424,8 +424,8 @@ fn transcribe_with_cache( sample_rate: usize, language: &str, ) -> Result { - let mut cache = CACHE.lock().map_err(|e| format!("锁获取失败: {}", e))?; - let entry = cache.as_mut().ok_or("模型未加载")?; + let mut cache = CACHE.lock().map_err(|e| format!("Failed to acquire model cache lock: {}", e))?; + let entry = cache.as_mut().ok_or("Model is not loaded")?; // 守卫会在所有成功/错误返回路径上、释放 CACHE 锁之前刷新最后活动时间。 let _activity_guard = ActivityGuard; touch_activity(); @@ -442,12 +442,12 @@ fn transcribe_with_cache( .session .run(samples, &opts) .map(|t| t.text.trim().to_string()) - .map_err(|e| format!("推理失败: {}", e)); + .map_err(|e| format!("Inference failed: {}", e)); } // 极长音频(超过模型上下文)才走分段。日常口述不会走到这里。 log::warn!( - "音频 {:.1}s 超过模型上限 {:.1}s,按上限分段解码", + "Audio length {:.1}s exceeds the model limit {:.1}s; decoding in segments", samples.len() as f64 / sample_rate as f64, limit as f64 / sample_rate as f64 ); @@ -456,7 +456,7 @@ fn transcribe_with_cache( let text = entry .session .run(chunk, &opts) - .map_err(|e| format!("推理失败: {}", e))?; + .map_err(|e| format!("Inference failed: {}", e))?; let t = text.text.trim(); if t.is_empty() { continue; @@ -545,9 +545,9 @@ static IDLE_UNLOADER_STARTED: std::sync::Once = std::sync::Once::new(); pub fn set_idle_unload_minutes(idle_minutes: u64) { IDLE_UNLOAD_MINUTES.store(idle_minutes, std::sync::atomic::Ordering::SeqCst); if idle_minutes == 0 { - log::info!("本地模型空闲卸载已关闭,模型将常驻内存"); + log::info!("Local model idle unload is disabled; the model will remain in memory"); } else { - log::info!("本地模型空闲卸载已设为 {} 分钟", idle_minutes); + log::info!("Local model idle unload interval set to {} minutes", idle_minutes); } } @@ -571,7 +571,7 @@ pub fn spawn_idle_unloader(initial_idle_minutes: u64) { let mut cache = match CACHE.lock() { Ok(cache) => cache, Err(error) => { - log::warn!("本地模型空闲检查获取锁失败: {}", error); + log::warn!("Failed to acquire lock during local model idle check: {}", error); continue; } }; @@ -592,7 +592,7 @@ pub fn spawn_idle_unloader(initial_idle_minutes: u64) { if let Some(entry) = cache.take() { log::info!( - "本地模型空闲 {} 分钟,卸载释放内存: {}", + "Local model was idle for {} minutes; unloaded to free memory: {}", idle_minutes, entry.model_id ); diff --git a/client/src-tauri/src/models/local_asr.rs b/client/src-tauri/src/models/local_asr.rs index db57b3d..a0aaceb 100644 --- a/client/src-tauri/src/models/local_asr.rs +++ b/client/src-tauri/src/models/local_asr.rs @@ -28,11 +28,11 @@ fn decode_pcm(audio_b64: &str) -> Result, String> { &base64::engine::general_purpose::STANDARD, audio_b64, ) - .map_err(|e| format!("base64 解码失败: {}", e))?; + .map_err(|e| format!("Failed to decode base64 audio: {}", e))?; if pcm_bytes.len() % 2 != 0 { return Err(format!( - "PCM 数据长度必须是 16-bit 样本的整数倍,实际为 {} 字节", + "PCM data length must be a multiple of a 16-bit sample; received {} bytes", pcm_bytes.len() )); } @@ -54,7 +54,7 @@ pub async fn local_transcribe( crate::providers::diag::fail("local/asr", "decode_pcm", e) })?; if samples.is_empty() { - crate::providers::diag::empty_result("local/asr", "客户端送来的音频是空的,没有跑模型"); + crate::providers::diag::empty_result("local/asr", "Input audio was empty; model inference was skipped"); return Ok(LocalAsrResult { text: String::new(), elapsed_ms: 0 }); } @@ -80,7 +80,7 @@ pub async fn local_transcribe( // 「未检测到有效声音」,只有日志能区分 diag::empty_result( "local/asr", - &format!("VAD 未检测到人声,没跑模型 audio_sec={:.1}", audio_sec), + &format!("VAD detected no speech; model inference was skipped audio_sec={:.1}", audio_sec), ); return Ok(LocalAsrResult { text: String::new(), @@ -93,7 +93,7 @@ pub async fn local_transcribe( return Err(diag::fail( "local/asr", "vad", - format!("语音检测失败,已取消本次识别: {e}"), + format!("Voice activity detection failed; recognition was canceled: {e}"), )); } }; @@ -105,7 +105,7 @@ pub async fn local_transcribe( diag::empty_result( "local/asr", &format!( - "VAD 判定有人声但模型没有输出 model={} accel={} audio_sec={:.1} elapsed={}ms", + "VAD detected speech but the model produced no output model={} accel={} audio_sec={:.1} elapsed={}ms", model_id, accel, audio_sec, elapsed_ms ), ); @@ -116,7 +116,7 @@ pub async fn local_transcribe( }) .await .map_err(|e| { - crate::providers::diag::fail("local/asr", "join", format!("推理异常: {}", e)) + crate::providers::diag::fail("local/asr", "join", format!("Inference task failed: {}", e)) })? } @@ -129,10 +129,10 @@ pub async fn preload_local_model( let start = Instant::now(); let accel = accelerator.as_deref().unwrap_or("auto"); gguf_asr::preload(&model_id, accel)?; - Ok(format!("模型已加载 ({}ms)", start.elapsed().as_millis())) + Ok(format!("Model loaded ({}ms)", start.elapsed().as_millis())) }) .await - .map_err(|e| format!("预加载异常: {}", e))? + .map_err(|e| format!("Preload task failed: {}", e))? } /// 释放本地 ASR 模型占用的内存。切换到云 API / 服务器模式时调用, @@ -141,7 +141,7 @@ pub async fn preload_local_model( pub async fn unload_local_model() -> Result<(), String> { tokio::task::spawn_blocking(gguf_asr::unload) .await - .map_err(|e| format!("释放异常: {}", e)) + .map_err(|e| format!("Release task failed: {}", e)) } /// 动态调整本地模型的空闲卸载时间。0 = 常驻;其余值由设置页限制为 @@ -149,7 +149,7 @@ pub async fn unload_local_model() -> Result<(), String> { #[tauri::command] pub fn set_local_model_idle_unload(idle_minutes: u64) -> Result<(), String> { if !matches!(idle_minutes, 0 | 10 | 30 | 60) { - return Err(format!("不支持的模型空闲卸载时间: {idle_minutes}")); + return Err(format!("Unsupported model idle unload interval: {idle_minutes}")); } gguf_asr::set_idle_unload_minutes(idle_minutes); Ok(()) @@ -239,12 +239,12 @@ pub fn reclaim_legacy_models() -> u64 { Ok(()) => { freed += size; log::info!( - "回收旧引擎模型 {} ({:.1} MB)", + "Removed legacy engine model {} ({:.1} MB)", id, size as f64 / 1024.0 / 1024.0 ); } - Err(e) => log::warn!("回收旧引擎模型 {} 失败: {}", id, e), + Err(e) => log::warn!("Failed to remove legacy engine model {}: {}", id, e), } } @@ -254,12 +254,12 @@ pub fn reclaim_legacy_models() -> u64 { let size = std::fs::metadata(&vad).map(|m| m.len()).unwrap_or(0); if std::fs::remove_file(&vad).is_ok() { freed += size; - log::info!("回收旧 VAD 权重 silero_vad.onnx"); + log::info!("Removed legacy VAD weights silero_vad.onnx"); } } if freed > 0 { - log::info!("旧引擎模型共回收 {:.1} MB", freed as f64 / 1024.0 / 1024.0); + log::info!("Removed {:.1} MB of legacy engine models", freed as f64 / 1024.0 / 1024.0); } freed } diff --git a/client/src-tauri/src/models/local_vad.rs b/client/src-tauri/src/models/local_vad.rs index 8e4ec6c..9d4942f 100644 --- a/client/src-tauri/src/models/local_vad.rs +++ b/client/src-tauri/src/models/local_vad.rs @@ -35,11 +35,11 @@ pub fn detect_speech_span( } let mut vad = SileroVad::with_sample_rate(sample_rate) - .map_err(|e| format!("创建 Silero VAD 失败: {e}"))?; + .map_err(|e| format!("Failed to create Silero VAD: {e}"))?; let window_size_samples = vad.source_window_samples(); let probabilities = vad .forward_audio(samples) - .map_err(|e| format!("Silero VAD 推理失败: {e}"))?; + .map_err(|e| format!("Silero VAD inference failed: {e}"))?; let segments = get_timestamps_from_probs_with_config( &probabilities, diff --git a/client/src-tauri/src/models/registry.rs b/client/src-tauri/src/models/registry.rs index e11abbb..ad6953d 100644 --- a/client/src-tauri/src/models/registry.rs +++ b/client/src-tauri/src/models/registry.rs @@ -7,6 +7,8 @@ use serde::Serialize; use std::path::Path; use tauri::AppHandle; +use crate::error_protocol; + /// 列出所有可用模型 #[tauri::command] pub fn list_available_models() -> Vec { @@ -117,7 +119,7 @@ pub async fn download_model( let model = catalog .iter() .find(|m| m.id == model_id) - .ok_or_else(|| format!("未知模型: {}", model_id))?; + .ok_or_else(|| error_protocol::encode("download_failed", format!("Unknown model: {}", model_id)))?; let dest_dir = downloader::model_dir(&model_id); @@ -135,7 +137,7 @@ pub async fn download_model( .iter() .find(|s| s.source == source) .or_else(|| model.sources.first()) - .ok_or_else(|| format!("模型 {} 没有可用的下载源", model_id))?; + .ok_or_else(|| error_protocol::encode("download_network", format!("Model {} has no available download source", model_id)))?; let file_count = download_source.files.len() as u32; for (i, file) in download_source.files.iter().enumerate() { @@ -163,7 +165,7 @@ pub async fn download_model( }); let meta_path = dest_dir.join("meta.json"); std::fs::write(&meta_path, serde_json::to_string_pretty(&meta).unwrap_or_default()) - .map_err(|e| format!("写入 meta.json 失败: {}", e))?; + .map_err(|e| error_protocol::encode("download_failed", format!("Failed to write meta.json: {}", e)))?; Ok(()) } @@ -174,7 +176,7 @@ pub fn delete_model(model_id: String) -> Result<(), String> { let model_path = downloader::model_dir(&model_id); if model_path.exists() { std::fs::remove_dir_all(&model_path) - .map_err(|e| format!("删除模型失败: {}", e))?; + .map_err(|e| format!("Failed to delete model: {}", e))?; log::info!("Deleted model: {}", model_id); } Ok(()) @@ -184,7 +186,7 @@ pub fn delete_model(model_id: String) -> Result<(), String> { #[tauri::command] pub fn open_models_folder() -> Result { let dir = downloader::models_dir(); - std::fs::create_dir_all(&dir).map_err(|e| format!("创建目录失败: {}", e))?; + std::fs::create_dir_all(&dir).map_err(|e| format!("Failed to create directory: {}", e))?; let path = dir.to_string_lossy().to_string(); #[cfg(target_os = "windows")] { @@ -227,13 +229,13 @@ pub fn get_models_dir() -> ModelsDirInfo { /// 递归复制目录/文件(跨盘 rename 失败时的兜底)。 fn copy_path_recursive(src: &Path, dst: &Path) -> Result<(), String> { if src.is_dir() { - std::fs::create_dir_all(dst).map_err(|e| format!("创建目录失败: {}", e))?; - for entry in std::fs::read_dir(src).map_err(|e| format!("读取目录失败: {}", e))? { - let entry = entry.map_err(|e| format!("读取条目失败: {}", e))?; + std::fs::create_dir_all(dst).map_err(|e| format!("Failed to create directory: {}", e))?; + for entry in std::fs::read_dir(src).map_err(|e| format!("Failed to read directory: {}", e))? { + let entry = entry.map_err(|e| format!("Failed to read directory entry: {}", e))?; copy_path_recursive(&entry.path(), &dst.join(entry.file_name()))?; } } else { - std::fs::copy(src, dst).map_err(|e| format!("复制文件失败: {}", e))?; + std::fs::copy(src, dst).map_err(|e| format!("Failed to copy file: {}", e))?; } Ok(()) } @@ -242,8 +244,8 @@ fn copy_path_recursive(src: &Path, dst: &Path) -> Result<(), String> { /// 优先 rename(同盘瞬时);跨盘失败则递归复制后删除源。 /// 目标已存在同名条目则跳过(不覆盖新目录已有内容)。 fn move_dir_contents(from: &Path, to: &Path) -> Result<(), String> { - for entry in std::fs::read_dir(from).map_err(|e| format!("读取旧目录失败: {}", e))? { - let entry = entry.map_err(|e| format!("读取条目失败: {}", e))?; + for entry in std::fs::read_dir(from).map_err(|e| format!("Failed to read old directory: {}", e))? { + let entry = entry.map_err(|e| format!("Failed to read directory entry: {}", e))?; let src = entry.path(); let dst = to.join(entry.file_name()); if dst.exists() { @@ -293,10 +295,10 @@ pub async fn set_models_dir( // fs 操作(可能是跨盘大文件复制)放到阻塞线程池,避免占用异步运行时线程。 tokio::task::spawn_blocking(move || -> Result<(), String> { std::fs::create_dir_all(&new_for_task) - .map_err(|e| format!("无法创建目录: {}", e))?; + .map_err(|e| format!("Failed to create directory: {}", e))?; // 可写性探测 let probe = new_for_task.join(".sayit_write_test"); - std::fs::write(&probe, b"ok").map_err(|e| format!("目录不可写: {}", e))?; + std::fs::write(&probe, b"ok").map_err(|e| format!("Directory is not writable: {}", e))?; let _ = std::fs::remove_file(&probe); if move_existing && old_for_task.exists() { move_dir_contents(&old_for_task, &new_for_task)?; @@ -304,7 +306,7 @@ pub async fn set_models_dir( Ok(()) }) .await - .map_err(|e| format!("迁移任务异常: {}", e))??; + .map_err(|e| format!("Model migration task failed: {}", e))??; } // 更新进程内生效路径:自定义 → Some,恢复默认 → None @@ -317,7 +319,7 @@ pub async fn set_models_dir( }; storage .set("localAsr.modelsDir", &value) - .map_err(|e| format!("保存设置失败: {}", e))?; + .map_err(|e| format!("Failed to save setting: {}", e))?; Ok(new_dir.to_string_lossy().to_string()) } @@ -326,7 +328,7 @@ pub async fn set_models_dir( #[tauri::command] pub fn open_model_folder(model_id: String) -> Result { let dir = downloader::model_dir(&model_id); - std::fs::create_dir_all(&dir).map_err(|e| format!("创建目录失败: {}", e))?; + std::fs::create_dir_all(&dir).map_err(|e| format!("Failed to create directory: {}", e))?; let path = dir.to_string_lossy().to_string(); #[cfg(target_os = "windows")] { diff --git a/client/src-tauri/src/models/test_audio.rs b/client/src-tauri/src/models/test_audio.rs index 546d6b4..1ba2466 100644 --- a/client/src-tauri/src/models/test_audio.rs +++ b/client/src-tauri/src/models/test_audio.rs @@ -24,19 +24,19 @@ pub async fn run_asr_benchmark( ) -> Result { let pcm_bytes = if let Some(b64) = audio_b64 { base64::Engine::decode(&base64::engine::general_purpose::STANDARD, &b64) - .map_err(|e| format!("base64 解码失败: {}", e))? + .map_err(|e| format!("Failed to decode base64 audio: {}", e))? } else { // 读取内置测试音频(WAV 格式,需要跳过 44 字节 header 提取 PCM) let resource_path = app_handle.path() .resolve("resources/test_zh.wav", tauri::path::BaseDirectory::Resource) - .map_err(|e| format!("找不到测试音频: {}", e))?; + .map_err(|e| format!("Could not locate test audio: {}", e))?; let wav_bytes = std::fs::read(&resource_path) - .map_err(|e| format!("读取测试音频失败: {}", e))?; + .map_err(|e| format!("Failed to read test audio: {}", e))?; // 跳过 WAV header(44 字节) if wav_bytes.len() > 44 { wav_bytes[44..].to_vec() } else { - return Err("测试音频文件无效".into()); + return Err("The test audio file is invalid".into()); } }; @@ -71,7 +71,7 @@ pub async fn run_asr_benchmark( }) }) .await - .map_err(|e| format!("测试异常: {}", e))? + .map_err(|e| format!("Test failed: {}", e))? } /// 获取内置测试音频的 base64 WAV 数据(供前端播放) @@ -79,8 +79,8 @@ pub async fn run_asr_benchmark( pub async fn get_test_audio_b64(app_handle: tauri::AppHandle) -> Result { let resource_path = app_handle.path() .resolve("resources/test_zh.wav", tauri::path::BaseDirectory::Resource) - .map_err(|e| format!("找不到测试音频: {}", e))?; + .map_err(|e| format!("Could not locate test audio: {}", e))?; let wav_bytes = std::fs::read(&resource_path) - .map_err(|e| format!("读取测试音频失败: {}", e))?; + .map_err(|e| format!("Failed to read test audio: {}", e))?; Ok(base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &wav_bytes)) } diff --git a/client/src-tauri/src/providers/ai_ollama.rs b/client/src-tauri/src/providers/ai_ollama.rs index 61cdf72..7fdd0c9 100644 --- a/client/src-tauri/src/providers/ai_ollama.rs +++ b/client/src-tauri/src/providers/ai_ollama.rs @@ -55,7 +55,7 @@ pub async fn polish( .timeout(std::time::Duration::from_secs(90)) .send() .await - .map_err(|e| diag::fail(SCOPE, "http_send", format!("Ollama 请求失败: {}", e)))?; + .map_err(|e| diag::fail(SCOPE, "http_send", format!("Ollama request failed: {}", e)))?; let elapsed_ms = start.elapsed().as_millis() as u64; let http_summary = diag::http_summary(resp.status(), resp.headers()); @@ -67,7 +67,7 @@ pub async fn polish( SCOPE, "http_status", format!( - "Ollama 返回错误 {} [{}]: {}", + "Ollama returned error {} [{}]: {}", status, http_summary, diag::truncate(&body_text, 200) @@ -78,13 +78,13 @@ pub async fn polish( let body_text = resp .text() .await - .map_err(|e| diag::fail(SCOPE, "read_body", format!("读取响应失败: {}", e)))?; + .map_err(|e| diag::fail(SCOPE, "read_body", format!("Failed to read response: {}", e)))?; let data: serde_json::Value = serde_json::from_str(&body_text).map_err(|e| { diag::fail( SCOPE, "parse_json", format!( - "解析 Ollama 响应失败: {} 响应片段: {}", + "Failed to parse Ollama response: {} response excerpt: {}", e, diag::truncate(&body_text, 200) ), @@ -104,7 +104,7 @@ pub async fn polish( SCOPE, "empty_response_fallback_to_input", &format!( - "Ollama 没有返回内容,已回落为原文 model={} {}", + "Ollama returned no content; returned the original text model={} {}", model, diag::describe_json(&body_text) ), @@ -129,7 +129,7 @@ pub async fn test_connection(config: &AiProviderConfig) -> TestResult { &config.model }; - let prompt = "只回复「连接正常」四个字,不要输出任何其他内容。"; + let prompt = "Reply with OK only. Do not output anything else."; let body = serde_json::json!({ "model": model, @@ -160,13 +160,13 @@ pub async fn test_connection(config: &AiProviderConfig) -> TestResult { .trim() .to_string(); let detail = format!( - "耗时: {}ms\n模型: {}\n发送: \"{}\"\n回复: {}", + "Elapsed: {}ms\nModel: {}\nSent: \"{}\"\nReply: {}", elapsed_ms, model, prompt, - if reply.is_empty() { "(空)" } else { &reply } + if reply.is_empty() { "(empty)" } else { &reply } ); TestResult { ok: true, - message: format!("连接成功 ({}ms)", elapsed_ms), + message: format!("Connection successful ({}ms)", elapsed_ms), elapsed_ms, detail, } @@ -180,20 +180,20 @@ pub async fn test_connection(config: &AiProviderConfig) -> TestResult { "ai/ollama-test", "http_status", format!( - "Ollama 返回 {} : {}", + "Ollama returned {}: {}", status, diag::truncate(&body_text, 100) ), ), elapsed_ms, - detail: format!("模型: {}\n请求地址: {}", model, url), + detail: format!("Model: {}\nRequest URL: {}", model, url), } } Err(e) => TestResult { ok: false, - message: diag::fail("ai/ollama-test", "http_send", format!("连接失败: {}", e)), + message: diag::fail("ai/ollama-test", "http_send", format!("Connection failed: {}", e)), elapsed_ms, - detail: format!("模型: {}\n请求地址: {}", model, url), + detail: format!("Model: {}\nRequest URL: {}", model, url), }, } } diff --git a/client/src-tauri/src/providers/ai_openai_compat.rs b/client/src-tauri/src/providers/ai_openai_compat.rs index 57fcf70..af32766 100644 --- a/client/src-tauri/src/providers/ai_openai_compat.rs +++ b/client/src-tauri/src/providers/ai_openai_compat.rs @@ -110,7 +110,7 @@ pub async fn polish( diag::fail( SCOPE, "http_send", - format!("HTTP 请求失败: {}", describe_reqwest_error(&e)), + format!("HTTP request failed: {}", describe_reqwest_error(&e)), ) })?; @@ -124,7 +124,7 @@ pub async fn polish( SCOPE, "http_status", format!( - "API 返回错误 {} [{}]: {}", + "API returned error {} [{}]: {}", status, http_summary, diag::truncate(&body_text, 200) @@ -135,13 +135,13 @@ pub async fn polish( let body_text = resp .text() .await - .map_err(|e| diag::fail(SCOPE, "read_body", format!("读取响应失败: {}", e)))?; + .map_err(|e| diag::fail(SCOPE, "read_body", format!("Failed to read response: {}", e)))?; let data: serde_json::Value = serde_json::from_str(&body_text).map_err(|e| { diag::fail( SCOPE, "parse_json", format!( - "解析响应失败: {} [{}] 响应片段: {}", + "Failed to parse response: {} [{}] response excerpt: {}", e, http_summary, diag::truncate(&body_text, 200) @@ -158,7 +158,7 @@ pub async fn polish( SCOPE, "no_content_fallback_to_input", &format!( - "响应里没有可用内容,已回落为原文 [{}] {}", + "Response contained no usable content; returned the original text [{}] {}", http_summary, diag::describe_json(&body_text) ), @@ -175,7 +175,7 @@ pub async fn polish( SCOPE, "empty_after_strip_thinking", &format!( - "去掉思考段后为空,已回落为原文 model={} raw_chars={}", + "Output was empty after removing the reasoning block; returned the original text model={} raw_chars={}", config.model, result_text.chars().count() ), @@ -195,8 +195,8 @@ pub async fn test_connection(config: &AiProviderConfig) -> TestResult { let base_url = normalize_base_url(&config.api_url); let url = format!("{}/chat/completions", base_url); - let system_prompt = "只回复「连接正常」四个字,不要输出任何其他内容。"; - let user_prompt = "测试"; + let system_prompt = "Reply with OK only. Do not output anything else."; + let user_prompt = "Connection test"; let mut body = serde_json::json!({ "model": config.model, @@ -240,13 +240,13 @@ pub async fn test_connection(config: &AiProviderConfig) -> TestResult { .to_string(); let reply = strip_thinking(&raw_reply); let detail = format!( - "耗时: {}ms\n模型: {}\n发送: system=\"{}\" user=\"{}\"\n回复: {}", + "Elapsed: {}ms\nModel: {}\nSent: system=\"{}\" user=\"{}\"\nReply: {}", elapsed_ms, config.model, system_prompt, user_prompt, - if reply.is_empty() { "(空)" } else { &reply } + if reply.is_empty() { "(empty)" } else { &reply } ); TestResult { ok: true, - message: format!("连接成功 ({}ms)", elapsed_ms), + message: format!("Connection successful ({}ms)", elapsed_ms), elapsed_ms, detail, } @@ -261,14 +261,14 @@ pub async fn test_connection(config: &AiProviderConfig) -> TestResult { "ai/openai-compat-test", "http_status", format!( - "API 返回 {} [{}]: {}", + "API returned {} [{}]: {}", status, summary, diag::truncate(&body, 100) ), ), elapsed_ms, - detail: format!("模型: {}\n请求地址: {}", config.model, url), + detail: format!("Model: {}\nRequest URL: {}", config.model, url), } } Err(e) => TestResult { @@ -276,35 +276,35 @@ pub async fn test_connection(config: &AiProviderConfig) -> TestResult { message: diag::fail( "ai/openai-compat-test", "http_send", - format!("连接失败: {}", describe_reqwest_error(&e)), + format!("Connection failed: {}", describe_reqwest_error(&e)), ), elapsed_ms, - detail: format!("模型: {}\n请求地址: {}", config.model, url), + detail: format!("Model: {}\nRequest URL: {}", config.model, url), }, } } -/// 将 reqwest 错误转为用户友好的中文描述 +/// Convert reqwest errors into concise diagnostic details. fn describe_reqwest_error(e: &reqwest::Error) -> String { let raw = format!("{}", e); if e.is_timeout() { - return "请求超时,请检查网络或 API 地址是否正确".to_string(); + return "Request timed out; check the network connection and API URL".to_string(); } if e.is_connect() { // 尝试区分 DNS / TLS / 连接拒绝 let lower = raw.to_lowercase(); if lower.contains("dns") || lower.contains("resolve") || lower.contains("getaddrinfo") { - return format!("DNS 解析失败,域名可能不存在或网络不通: {}", raw); + return format!("DNS lookup failed; the host may not exist or the network may be unavailable: {}", raw); } if lower.contains("ssl") || lower.contains("tls") || lower.contains("certificate") || lower.contains("handshake") || lower.contains("schannel") { - return format!("TLS/SSL 握手失败,可能是证书问题: {}", raw); + return format!("TLS/SSL handshake failed; check the certificate: {}", raw); } if lower.contains("refused") { - return format!("连接被拒绝,服务可能未启动: {}", raw); + return format!("Connection refused; the service may not be running: {}", raw); } - return format!("无法连接到服务器: {}", raw); + return format!("Could not connect to the server: {}", raw); } raw } diff --git a/client/src-tauri/src/providers/asr_doubao.rs b/client/src-tauri/src/providers/asr_doubao.rs index 506fee5..98ede62 100644 --- a/client/src-tauri/src/providers/asr_doubao.rs +++ b/client/src-tauri/src/providers/asr_doubao.rs @@ -55,10 +55,10 @@ pub async fn transcribe( &base64::engine::general_purpose::STANDARD, audio_pcm_b64, ) - .map_err(|e| diag::fail(SCOPE, "decode_b64", format!("base64 解码失败: {}", e)))?; + .map_err(|e| diag::fail(SCOPE, "decode_b64", format!("Failed to decode base64 audio: {}", e)))?; if pcm_data.is_empty() { - diag::empty_result(SCOPE, "客户端送来的音频是空的,没有发起请求"); + diag::empty_result(SCOPE, "Input audio was empty; provider request was skipped"); return Ok(AsrResult { text: String::new(), elapsed_ms: 0, @@ -71,10 +71,11 @@ pub async fn transcribe( let auth = DoubaoAuth::from_config(config); if let Some(missing) = auth.missing_field() { - return Err(diag::fail( + return Err(diag::fail_code( SCOPE, "credentials", - format!("豆包 ASR 缺少{},请在设置里填好", missing), + "provider_bad_key", + format!("Doubao ASR is missing {}; complete it in Settings", missing), )); } @@ -129,7 +130,7 @@ pub async fn transcribe( .timeout(std::time::Duration::from_secs(60)) .send() .await - .map_err(|e| diag::fail(SCOPE, "http_send", format!("HTTP 请求失败: {}", e)))?; + .map_err(|e| diag::fail(SCOPE, "http_send", format!("HTTP request failed: {}", e)))?; let elapsed_ms = start.elapsed().as_millis() as u64; let http_summary = diag::http_summary(resp.status(), resp.headers()); @@ -160,7 +161,7 @@ pub async fn transcribe( SCOPE, "http_status", format!( - "豆包 ASR 请求失败 HTTP {} [{} status={} msg={}]: {}", + "Doubao ASR request failed with HTTP {} [{} status={} msg={}]: {}", status, http_summary, if status_code.is_empty() { "-" } else { &status_code }, @@ -173,13 +174,13 @@ pub async fn transcribe( let body_text = resp .text() .await - .map_err(|e| diag::fail(SCOPE, "read_body", format!("读取响应失败: {}", e)))?; + .map_err(|e| diag::fail(SCOPE, "read_body", format!("Failed to read response: {}", e)))?; let data: serde_json::Value = serde_json::from_str(&body_text).map_err(|e| { diag::fail( SCOPE, "parse_json", format!( - "解析响应失败: {} [{}] 响应片段: {}", + "Failed to parse response: {} [{}] response excerpt: {}", e, http_summary, diag::truncate(&body_text, 200) @@ -200,7 +201,7 @@ pub async fn transcribe( SCOPE, "api_status", format!( - "豆包 ASR 错误 {}{}: {} [{}]", + "Doubao ASR error {}{}: {} [{}]", status_code, hint, api_message, http_summary ), )); @@ -216,7 +217,7 @@ pub async fn transcribe( SCOPE, "no_result_field", format!( - "豆包返回的响应里没有识别结果 [{} status={}] {}", + "Doubao response contained no recognition result [{} status={}] {}", http_summary, if status_code.is_empty() { "-" } else { &status_code }, diag::describe_json(&body_text) @@ -234,7 +235,7 @@ pub async fn transcribe( diag::empty_result( SCOPE, &format!( - "有 result 但 text 为空 audio_sec={:.1} elapsed={}ms [{} status={} msg={}]", + "Result contained an empty transcript audio_sec={:.1} elapsed={}ms [{} status={} msg={}]", audio_sec, elapsed_ms, http_summary, @@ -260,7 +261,7 @@ pub async fn test_connection(config: &AsrProviderConfig) -> TestResult { if let Some(missing) = auth.missing_field() { return TestResult { ok: false, - message: format!("还没填{}", missing), + message: format!("{} has not been configured", missing), elapsed_ms: 0, detail: String::new(), }; @@ -310,14 +311,14 @@ pub async fn test_connection(config: &AsrProviderConfig) -> TestResult { // 20000003 = 静音音频,也算连接成功 TestResult { ok: true, - message: format!("连接成功 ({}ms)", elapsed_ms), + message: format!("Connection successful ({}ms)", elapsed_ms), elapsed_ms, detail: String::new(), } } else if resp.status().is_success() { TestResult { ok: true, - message: format!("连接成功 ({}ms),状态码: {}", elapsed_ms, status_code), + message: format!("Connection successful ({}ms), status code: {}", elapsed_ms, status_code), elapsed_ms, detail: String::new(), } @@ -329,7 +330,7 @@ pub async fn test_connection(config: &AsrProviderConfig) -> TestResult { message: diag::fail( "doubao/flash-test", "http_status", - format!("API 错误 {}: {}", status, diag::truncate(&body, 100)), + format!("API error {}: {}", status, diag::truncate(&body, 100)), ), elapsed_ms, detail: String::new(), @@ -341,7 +342,7 @@ pub async fn test_connection(config: &AsrProviderConfig) -> TestResult { message: diag::fail( "doubao/flash-test", "http_send", - format!("连接失败: {}", e), + format!("Connection failed: {}", e), ), elapsed_ms, detail: String::new(), diff --git a/client/src-tauri/src/providers/asr_doubao_realtime.rs b/client/src-tauri/src/providers/asr_doubao_realtime.rs index 39b1e2b..ea70ccc 100644 --- a/client/src-tauri/src/providers/asr_doubao_realtime.rs +++ b/client/src-tauri/src/providers/asr_doubao_realtime.rs @@ -7,6 +7,7 @@ // 后台 reader 持续读取并通过 `asr-partial` 事件上抛给前端做悬浮窗实时上屏。 // 识别完成后,finish 返回累计的最终文本,仍会照常交给 AI 处理。 +use super::diag; use super::doubao_auth::{self, DoubaoAuth}; use super::doubao_protocol; use super::types::AsrProviderConfig; @@ -120,7 +121,7 @@ fn build_handshake_request( } builder .body(()) - .map_err(|e| format!("构建请求失败: {}", e)) + .map_err(|e| format!("Failed to build request: {}", e)) } /// 把 WebSocket 握手错误展开成可读字符串;HTTP 错误会带上状态码和响应体,方便定位 400 的真实原因。 @@ -165,7 +166,12 @@ pub async fn doubao_stream_open( let auth = DoubaoAuth::from_config(&config); if let Some(missing) = auth.missing_field() { - return Err(format!("豆包 ASR 缺少{},请在设置里填好", missing)); + return Err(diag::fail_code( + "doubao/realtime", + "credentials", + "provider_bad_key", + format!("Doubao ASR is missing {}; complete it in Settings", missing), + )); } let uid = auth.uid().to_string(); @@ -187,9 +193,10 @@ pub async fn doubao_stream_open( let mut connected: Option = None; let mut chosen_url = WS_URL_NOSTREAM; - let mut last_err = String::from("未知错误"); + let mut last_err = String::from("Unknown error"); for (url, resource_id) in candidates { - let request = build_handshake_request(url, &auth, resource_id)?; + let request = build_handshake_request(url, &auth, resource_id) + .map_err(|e| diag::fail("doubao/realtime", "build_request", e))?; match tokio_tungstenite::connect_async(request).await { Ok((sock, response)) => { doubao_auth::log_ws_logid( @@ -215,22 +222,23 @@ pub async fn doubao_stream_open( } } } - let mut ws = connected.ok_or_else(|| format!("WebSocket 连接失败: {}", last_err))?; + let mut ws = connected.ok_or_else(|| diag::fail("doubao/realtime", "connect", format!("WebSocket connection failed: {}", last_err)))?; // 发送 full client request let client_request = build_client_request_json(&uid, sample_rate, &hotwords); let frame = doubao_protocol::build_full_client_request(&client_request); ws.send(tungstenite::Message::Binary(frame.into())) .await - .map_err(|e| format!("发送请求失败: {}", e))?; + .map_err(|e| diag::fail("doubao/realtime", "send_request", format!("Failed to send request: {}", e)))?; // 等待服务端确认(第一包 server response) if let Some(msg) = ws.next().await { - let msg = msg.map_err(|e| format!("接收确认失败: {}", e))?; + let msg = msg.map_err(|e| diag::fail("doubao/realtime", "recv_ack", format!("Failed to receive acknowledgement: {}", e)))?; if let tungstenite::Message::Binary(data) = msg { - let resp = doubao_protocol::parse_server_response(&data)?; + let resp = doubao_protocol::parse_server_response(&data) + .map_err(|e| diag::fail("doubao/realtime", "parse_ack", e))?; if resp.is_error { - return Err(format!("服务端错误: {}", resp.payload)); + return Err(diag::fail("doubao/realtime", "server_error_on_ack", format!("Server error: {}", resp.payload))); } } } @@ -358,24 +366,24 @@ async fn cleanup_realtime() { #[tauri::command] pub async fn doubao_stream_send(pcm_b64: String) -> Result<(), String> { let pcm = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, &pcm_b64) - .map_err(|e| format!("base64 解码失败: {}", e))?; + .map_err(|e| diag::fail("doubao/realtime", "decode_b64", format!("Failed to decode base64 audio: {}", e)))?; let frame = doubao_protocol::build_audio_request(&pcm, false); if RT_ACTIVE.load(Ordering::SeqCst) { let mut sink = RT_SINK.lock().await; - let s = sink.as_mut().ok_or("会话未建立")?; + let s = sink.as_mut().ok_or_else(|| diag::fail("doubao/realtime", "send_without_session", "Session is not open".to_string()))?; s.send(tungstenite::Message::Binary(frame.into())) .await - .map_err(|e| format!("发送音频失败: {}", e))?; + .map_err(|e| diag::fail("doubao/realtime", "send_audio", format!("Failed to send audio: {}", e)))?; return Ok(()); } let mut session = SESSION.lock().await; - let ws = session.as_mut().ok_or("会话未建立")?; + let ws = session.as_mut().ok_or_else(|| diag::fail("doubao/realtime", "send_without_session", "Session is not open".to_string()))?; ws.send(tungstenite::Message::Binary(frame.into())) .await - .map_err(|e| format!("发送音频失败: {}", e))?; + .map_err(|e| diag::fail("doubao/realtime", "send_audio", format!("Failed to send audio: {}", e)))?; Ok(()) } @@ -409,7 +417,7 @@ pub async fn doubao_stream_finish() -> Result { if let Some(err) = st.error.clone() { drop(st); cleanup_realtime().await; - return Err(format!("识别错误: {}", err)); + return Err(diag::fail("doubao/realtime", "recognition_error", format!("Recognition error: {}", err))); } let text = st.text.clone(); drop(st); @@ -431,27 +439,28 @@ pub async fn doubao_stream_finish() -> Result { // 普通模式(nostream):整条会话同步收结果 let mut session = SESSION.lock().await; - let ws = session.as_mut().ok_or("会话未建立")?; + let ws = session.as_mut().ok_or_else(|| diag::fail("doubao/realtime", "finish_without_session", "Session is not open".to_string()))?; // 发送空的最后一包(负包) let frame = doubao_protocol::build_audio_request(&[], true); ws.send(tungstenite::Message::Binary(frame.into())) .await - .map_err(|e| format!("发送最后一包失败: {}", e))?; + .map_err(|e| diag::fail("doubao/realtime", "send_final_audio", format!("Failed to send the final audio packet: {}", e)))?; // 接收最终结果 let mut final_text = String::new(); while let Some(msg) = ws.next().await { - let msg = msg.map_err(|e| format!("接收结果失败: {}", e))?; + let msg = msg.map_err(|e| diag::fail("doubao/realtime", "recv", format!("Failed to receive result: {}", e)))?; match msg { tungstenite::Message::Binary(data) => { - let resp = doubao_protocol::parse_server_response(&data)?; + let resp = doubao_protocol::parse_server_response(&data) + .map_err(|e| diag::fail("doubao/realtime", "parse_result", e))?; if resp.is_error { // 关闭会话 let _ = ws.close(None).await; *session = None; - return Err(format!("识别错误: {}", resp.payload)); + return Err(diag::fail("doubao/realtime", "recognition_error", format!("Recognition error: {}", resp.payload))); } if let Ok(json) = serde_json::from_str::(&resp.payload) { diff --git a/client/src-tauri/src/providers/asr_doubao_stream.rs b/client/src-tauri/src/providers/asr_doubao_stream.rs index 10bcdbf..90a61e4 100644 --- a/client/src-tauri/src/providers/asr_doubao_stream.rs +++ b/client/src-tauri/src/providers/asr_doubao_stream.rs @@ -20,7 +20,7 @@ async fn connect( auth: &DoubaoAuth<'_>, scope: &str, ) -> Result<(tokio_tungstenite::WebSocketStream>, &'static str), String> { - let mut last_err = String::from("未知错误"); + let mut last_err = String::from("Unknown error"); for resource_id in doubao_auth::SAUC_RESOURCE_CANDIDATES { let mut builder = tungstenite::http::Request::builder() .uri(WS_URL) @@ -39,7 +39,7 @@ async fn connect( } let request = builder .body(()) - .map_err(|e| format!("构建请求失败: {}", e))?; + .map_err(|e| diag::fail(scope, "build_request", format!("Failed to build request: {}", e)))?; match tokio_tungstenite::connect_async(request).await { Ok((ws, response)) => { @@ -69,7 +69,7 @@ async fn connect( Err(diag::fail( scope, "connect", - format!("WebSocket 连接失败: {}", last_err), + format!("WebSocket connection failed: {}", last_err), )) } @@ -83,19 +83,20 @@ pub async fn transcribe( &base64::engine::general_purpose::STANDARD, audio_pcm_b64, ) - .map_err(|e| diag::fail(SCOPE, "decode_b64", format!("base64 解码失败: {}", e)))?; + .map_err(|e| diag::fail(SCOPE, "decode_b64", format!("Failed to decode base64 audio: {}", e)))?; if pcm_data.is_empty() { - diag::empty_result(SCOPE, "客户端送来的音频是空的,没有发起请求"); + diag::empty_result(SCOPE, "Input audio was empty; provider request was skipped"); return Ok(AsrResult { text: String::new(), elapsed_ms: 0 }); } let auth = DoubaoAuth::from_config(config); if let Some(missing) = auth.missing_field() { - return Err(diag::fail( + return Err(diag::fail_code( SCOPE, "credentials", - format!("豆包 ASR 缺少{},请在设置里填好", missing), + "provider_bad_key", + format!("Doubao ASR is missing {}; complete it in Settings", missing), )); } let uid = auth.uid().to_string(); @@ -149,12 +150,12 @@ pub async fn transcribe( ); ws.send(tungstenite::Message::Binary(request_frame.into())) .await - .map_err(|e| diag::fail(SCOPE, "send_request", format!("发送请求失败: {}", e)))?; + .map_err(|e| diag::fail(SCOPE, "send_request", format!("Failed to send request: {}", e)))?; // 等待服务端确认 if let Some(msg) = ws.next().await { let msg = - msg.map_err(|e| diag::fail(SCOPE, "recv_ack", format!("接收确认失败: {}", e)))?; + msg.map_err(|e| diag::fail(SCOPE, "recv_ack", format!("Failed to receive acknowledgement: {}", e)))?; if let tungstenite::Message::Binary(data) = msg { let resp = doubao_protocol::parse_server_response(&data) .map_err(|e| diag::fail(SCOPE, "parse_ack", e))?; @@ -164,7 +165,7 @@ pub async fn transcribe( SCOPE, "server_error_on_ack", format!( - "服务端错误: {}{}", + "Server error: {}{}", resp.payload, doubao_auth::explain_payload(&resp.payload) ), @@ -178,7 +179,7 @@ pub async fn transcribe( let audio_frame = doubao_protocol::build_audio_request(&pcm_data, true); ws.send(tungstenite::Message::Binary(audio_frame.into())) .await - .map_err(|e| diag::fail(SCOPE, "send_audio", format!("发送音频失败: {}", e)))?; + .map_err(|e| diag::fail(SCOPE, "send_audio", format!("Failed to send audio: {}", e)))?; // 3. 接收结果(bigmodel_async 双向流式:每包输入对应一包返回,取最终结果) let mut final_text = String::new(); @@ -188,10 +189,10 @@ pub async fn transcribe( // 以前两种都返回 Ok(空文本),于是失败被显示成「未检测到有效声音」, // 日志里一个字都没有 —— 排查一次要来回问用户好几轮。 let mut saw_last = false; - let mut last_payload_desc = String::from("(没收到任何结果包)"); + let mut last_payload_desc = String::from("(no result packet received)"); while let Some(msg) = ws.next().await { - let msg = msg.map_err(|e| diag::fail(SCOPE, "recv", format!("接收结果失败: {}", e)))?; + let msg = msg.map_err(|e| diag::fail(SCOPE, "recv", format!("Failed to receive result: {}", e)))?; match msg { tungstenite::Message::Binary(data) => { let resp = doubao_protocol::parse_server_response(&data) @@ -201,7 +202,7 @@ pub async fn transcribe( SCOPE, "server_error", format!( - "识别错误: {}{}", + "Recognition error: {}{}", resp.payload, doubao_auth::explain_payload(&resp.payload) ), @@ -227,7 +228,7 @@ pub async fn transcribe( tungstenite::Message::Close(frame) => { let reason = frame .map(|f| format!("code={} reason={}", f.code, diag::truncate(&f.reason, 200))) - .unwrap_or_else(|| "无详情".to_string()); + .unwrap_or_else(|| "No details".to_string()); if !saw_last { let _ = ws.close(None).await; // 服务端主动断开且没给最终结果 —— 额度耗尽、资源未开通、超限都长这样。 @@ -235,7 +236,7 @@ pub async fn transcribe( return Err(diag::fail( SCOPE, "closed_before_result", - format!("豆包服务端提前关闭了连接({}),本次识别未完成", reason), + format!("Doubao closed the connection before recognition completed ({})", reason), )); } diag::log(SCOPE, "close", &reason); @@ -256,7 +257,7 @@ pub async fn transcribe( SCOPE, "stream_ended_without_result", format!( - "豆包连接中断,没有收到最终结果(已收 {} 字符,耗时 {}ms)", + "Doubao connection ended without a final result (received_chars={} elapsed={}ms)", final_text.chars().count(), elapsed_ms ), @@ -267,7 +268,7 @@ pub async fn transcribe( diag::empty_result( SCOPE, &format!( - "服务端给出最终结果但文本为空 resourceId={} audio_sec={:.1} elapsed={}ms {}", + "Server returned a final result with an empty transcript resourceId={} audio_sec={:.1} elapsed={}ms {}", resource_id, audio_sec, elapsed_ms, last_payload_desc ), ); @@ -286,7 +287,7 @@ pub async fn test_connection(config: &AsrProviderConfig) -> TestResult { if let Some(missing) = auth.missing_field() { return TestResult { ok: false, - message: format!("还没填{}", missing), + message: format!("{} has not been configured", missing), elapsed_ms: 0, detail: String::new(), }; @@ -300,10 +301,10 @@ pub async fn test_connection(config: &AsrProviderConfig) -> TestResult { let elapsed_ms = start.elapsed().as_millis() as u64; TestResult { ok: true, - message: format!("连接成功 ({}ms)", elapsed_ms), + message: format!("Connection successful ({}ms)", elapsed_ms), elapsed_ms, // 资源 ID 决定计费方式(小时版/并发版),测通了顺手告诉用户命中的是哪个 - detail: format!("资源: {}", resource_id), + detail: format!("Resource: {}", resource_id), } } Err(e) => { diff --git a/client/src-tauri/src/providers/asr_mimo.rs b/client/src-tauri/src/providers/asr_mimo.rs index 5e4a35f..f28eefe 100644 --- a/client/src-tauri/src/providers/asr_mimo.rs +++ b/client/src-tauri/src/providers/asr_mimo.rs @@ -90,10 +90,10 @@ pub async fn transcribe( &base64::engine::general_purpose::STANDARD, audio_pcm_b64, ) - .map_err(|e| diag::fail(SCOPE, "decode_b64", format!("base64 解码失败: {}", e)))?; + .map_err(|e| diag::fail(SCOPE, "decode_b64", format!("Failed to decode base64 audio: {}", e)))?; if pcm.is_empty() { - diag::empty_result(SCOPE, "客户端送来的音频是空的,没有发起请求"); + diag::empty_result(SCOPE, "Input audio was empty; provider request was skipped"); return Ok(AsrResult { text: String::new(), elapsed_ms: 0 }); } @@ -127,7 +127,7 @@ pub async fn transcribe( .timeout(std::time::Duration::from_secs(60)) .send() .await - .map_err(|e| diag::fail(SCOPE, "http_send", format!("请求失败: {}", e)))?; + .map_err(|e| diag::fail(SCOPE, "http_send", format!("Request failed: {}", e)))?; let elapsed_ms = start.elapsed().as_millis() as u64; let http_summary = diag::http_summary(resp.status(), resp.headers()); @@ -140,7 +140,7 @@ pub async fn transcribe( "http_status", // 原来是 `&body[..body.len().min(300)]`,中文报错体会切在汉字中间 panic format!( - "MiMo ASR 错误 {} [{}]: {}", + "MiMo ASR error {} [{}]: {}", status, http_summary, diag::truncate(&body, 300) @@ -151,13 +151,13 @@ pub async fn transcribe( let body_text = resp .text() .await - .map_err(|e| diag::fail(SCOPE, "read_body", format!("读取响应失败: {}", e)))?; + .map_err(|e| diag::fail(SCOPE, "read_body", format!("Failed to read response: {}", e)))?; let data: serde_json::Value = serde_json::from_str(&body_text).map_err(|e| { diag::fail( SCOPE, "parse_json", format!( - "解析响应失败: {} [{}] 响应片段: {}", + "Failed to parse response: {} [{}] response excerpt: {}", e, http_summary, diag::truncate(&body_text, 200) @@ -170,7 +170,7 @@ pub async fn transcribe( diag::empty_result( SCOPE, &format!( - "文本为空 audio_sec={:.1} elapsed={}ms [{}] {}", + "Response contained no transcript audio_sec={:.1} elapsed={}ms [{}] {}", audio_sec, elapsed_ms, http_summary, @@ -208,7 +208,7 @@ pub async fn test_connection(config: &AsrProviderConfig) -> TestResult { match result { Ok(resp) if resp.status().is_success() => TestResult { ok: true, - message: format!("连接成功 ({}ms)", elapsed_ms), + message: format!("Connection successful ({}ms)", elapsed_ms), elapsed_ms, detail: String::new(), }, @@ -222,7 +222,7 @@ pub async fn test_connection(config: &AsrProviderConfig) -> TestResult { "mimo/asr-test", "http_status", format!( - "API 错误 {} [{}]: {}", + "API error {} [{}]: {}", status, summary, diag::truncate(&body, 100) @@ -234,7 +234,7 @@ pub async fn test_connection(config: &AsrProviderConfig) -> TestResult { } Err(e) => TestResult { ok: false, - message: diag::fail("mimo/asr-test", "http_send", format!("连接失败: {}", e)), + message: diag::fail("mimo/asr-test", "http_send", format!("Connection failed: {}", e)), elapsed_ms, detail: String::new(), }, diff --git a/client/src-tauri/src/providers/asr_qwen.rs b/client/src-tauri/src/providers/asr_qwen.rs index 17a58ac..162efbc 100644 --- a/client/src-tauri/src/providers/asr_qwen.rs +++ b/client/src-tauri/src/providers/asr_qwen.rs @@ -103,10 +103,10 @@ pub async fn transcribe( ) -> Result { let pcm = base64::Engine::decode( &base64::engine::general_purpose::STANDARD, audio_pcm_b64, - ).map_err(|e| diag::fail(SCOPE, "decode_b64", format!("base64 解码失败: {}", e)))?; + ).map_err(|e| diag::fail(SCOPE, "decode_b64", format!("Failed to decode base64 audio: {}", e)))?; if pcm.is_empty() { - diag::empty_result(SCOPE, "客户端送来的音频是空的,没有发起请求"); + diag::empty_result(SCOPE, "Input audio was empty; provider request was skipped"); return Ok(AsrResult { text: String::new(), elapsed_ms: 0 }); } @@ -162,7 +162,7 @@ pub async fn transcribe( .timeout(std::time::Duration::from_secs(60)) .send() .await - .map_err(|e| diag::fail(SCOPE, "http_send", format!("请求失败: {}", e)))?; + .map_err(|e| diag::fail(SCOPE, "http_send", format!("Request failed: {}", e)))?; let elapsed_ms = start.elapsed().as_millis() as u64; let http_summary = diag::http_summary(resp.status(), resp.headers()); @@ -176,7 +176,7 @@ pub async fn transcribe( // 曾经这里是 `&body[..body.len().min(300)]`:DashScope 的报错体是中文, // 按字节切会切在汉字中间直接 panic —— 一出错就崩,反而什么都查不到 format!( - "千问 ASR 错误 {} [{}]: {}", + "Qwen ASR error {} [{}]: {}", status, http_summary, diag::truncate(&body, 300) @@ -187,13 +187,13 @@ pub async fn transcribe( let body_text = resp .text() .await - .map_err(|e| diag::fail(SCOPE, "read_body", format!("读取响应失败: {}", e)))?; + .map_err(|e| diag::fail(SCOPE, "read_body", format!("Failed to read response: {}", e)))?; let data: serde_json::Value = serde_json::from_str(&body_text).map_err(|e| { diag::fail( SCOPE, "parse_json", format!( - "解析响应失败: {} [{}] 响应片段: {}", + "Failed to parse response: {} [{}] response excerpt: {}", e, http_summary, diag::truncate(&body_text, 200) @@ -223,7 +223,7 @@ pub async fn transcribe( diag::log( SCOPE, "hotword_echo_stripped", - &format!("原文 {} 字被判为热词回显并清空 hotwords={}", raw_chars, words.len()), + &format!("Stripped a {}-character hotword echo hotwords={}", raw_chars, words.len()), ); } @@ -231,7 +231,7 @@ pub async fn transcribe( diag::empty_result( SCOPE, &format!( - "文本为空 audio_sec={:.1} elapsed={}ms [{}] {}", + "Response contained no transcript audio_sec={:.1} elapsed={}ms [{}] {}", audio_sec, elapsed_ms, http_summary, @@ -277,7 +277,7 @@ pub async fn test_connection(config: &AsrProviderConfig) -> TestResult { match result { Ok(resp) if resp.status().is_success() => TestResult { ok: true, - message: format!("连接成功 ({}ms)", elapsed_ms), + message: format!("Connection successful ({}ms)", elapsed_ms), elapsed_ms, detail: String::new(), }, @@ -291,7 +291,7 @@ pub async fn test_connection(config: &AsrProviderConfig) -> TestResult { "qwen/asr-flash-test", "http_status", format!( - "API 错误 {} [{}]: {}", + "API error {} [{}]: {}", status, summary, diag::truncate(&body, 100) @@ -306,7 +306,7 @@ pub async fn test_connection(config: &AsrProviderConfig) -> TestResult { message: diag::fail( "qwen/asr-flash-test", "http_send", - format!("连接失败: {}", e), + format!("Connection failed: {}", e), ), elapsed_ms, detail: String::new(), diff --git a/client/src-tauri/src/providers/asr_qwen_omni.rs b/client/src-tauri/src/providers/asr_qwen_omni.rs index 139aad8..c4a5138 100644 --- a/client/src-tauri/src/providers/asr_qwen_omni.rs +++ b/client/src-tauri/src/providers/asr_qwen_omni.rs @@ -70,10 +70,10 @@ pub async fn transcribe( ) -> Result { let pcm = base64::engine::general_purpose::STANDARD .decode(audio_pcm_b64) - .map_err(|e| diag::fail(SCOPE, "decode_b64", format!("base64 解码失败: {}", e)))?; + .map_err(|e| diag::fail(SCOPE, "decode_b64", format!("Failed to decode base64 audio: {}", e)))?; if pcm.is_empty() { - diag::empty_result(SCOPE, "客户端送来的音频是空的,没有发起请求"); + diag::empty_result(SCOPE, "Input audio was empty; provider request was skipped"); return Ok(AsrResult { text: String::new(), elapsed_ms: 0, @@ -99,7 +99,7 @@ pub async fn transcribe( .header("Upgrade", "websocket") .header("Host", "dashscope.aliyuncs.com") .body(()) - .map_err(|e| diag::fail(SCOPE, "build_request", format!("构建请求失败: {}", e)))?; + .map_err(|e| diag::fail(SCOPE, "build_request", format!("Failed to build request: {}", e)))?; let audio_sec = pcm.len() as f64 / 32000.0; // 16kHz / 16bit / mono diag::log( @@ -118,7 +118,7 @@ pub async fn transcribe( let (mut ws, _) = tokio_tungstenite::connect_async(request) .await - .map_err(|e| diag::fail(SCOPE, "connect", format!("WebSocket 连接失败: {}", e)))?; + .map_err(|e| diag::fail(SCOPE, "connect", format!("WebSocket connection failed: {}", e)))?; // 等待 session.created wait_for_event(&mut ws, "session.created", SCOPE).await?; @@ -136,7 +136,7 @@ pub async fn transcribe( ws.send(tungstenite::Message::Text(session_update.to_string().into())) .await .map_err(|e| { - diag::fail(SCOPE, "send_session_update", format!("发送 session.update 失败: {}", e)) + diag::fail(SCOPE, "send_session_update", format!("Failed to send session.update: {}", e)) })?; // 等待 session.updated @@ -160,7 +160,7 @@ pub async fn transcribe( diag::fail( SCOPE, "send_audio", - format!("发送音频失败(第 {}/{} 包): {}", idx + 1, total_chunks, e), + format!("Failed to send audio chunk {}/{}: {}", idx + 1, total_chunks, e), ) })?; } @@ -169,13 +169,13 @@ pub async fn transcribe( let commit = serde_json::json!({ "type": "input_audio_buffer.commit" }); ws.send(tungstenite::Message::Text(commit.to_string().into())) .await - .map_err(|e| diag::fail(SCOPE, "send_commit", format!("发送 commit 失败: {}", e)))?; + .map_err(|e| diag::fail(SCOPE, "send_commit", format!("Failed to send commit: {}", e)))?; let create_response = serde_json::json!({ "type": "response.create" }); ws.send(tungstenite::Message::Text(create_response.to_string().into())) .await .map_err(|e| { - diag::fail(SCOPE, "send_response_create", format!("发送 response.create 失败: {}", e)) + diag::fail(SCOPE, "send_response_create", format!("Failed to send response.create: {}", e)) })?; diag::log(SCOPE, "audio_sent", &format!("chunks={}", total_chunks)); @@ -193,7 +193,7 @@ pub async fn transcribe( SCOPE, "recv_timeout", format!( - "等待响应超时 (60s)(音频 {:.1}s,已收 {} 字符)", + "Timed out waiting for a response (60s); audio={:.1}s received_chars={}", audio_sec, result_text.chars().count() ), @@ -206,11 +206,11 @@ pub async fn transcribe( diag::log( SCOPE, "recv_error_after_result", - &format!("已有结果,按成功处理: {}", diag::truncate(&e.to_string(), 200)), + &format!("A result was already received; treating as success: {}", diag::truncate(&e.to_string(), 200)), ); break; } - return Err(diag::fail(SCOPE, "recv", format!("接收消息失败: {}", e))); + return Err(diag::fail(SCOPE, "recv", format!("Failed to receive message: {}", e))); } Ok(Some(Ok(m))) => m, }; @@ -263,7 +263,7 @@ pub async fn transcribe( .get("error") .and_then(|e| e.get("message")) .and_then(|m| m.as_str()) - .unwrap_or("未知错误"); + .unwrap_or("Unknown error"); let err_code = event .get("error") .and_then(|e| e.get("code")) @@ -273,7 +273,7 @@ pub async fn transcribe( return Err(diag::fail( SCOPE, "server_error", - format!("Qwen Omni 错误 [{}]: {}", err_code, err_msg), + format!("Qwen Omni error [{}]: {}", err_code, err_msg), )); } _ => {} @@ -282,7 +282,7 @@ pub async fn transcribe( tungstenite::Message::Close(frame) => { let reason = frame .map(|f| format!("code={} reason={}", f.code, diag::truncate(&f.reason, 200))) - .unwrap_or_else(|| "无详情".to_string()); + .unwrap_or_else(|| "No details".to_string()); diag::log(SCOPE, "close", &reason); break; } @@ -305,7 +305,7 @@ pub async fn transcribe( diag::empty_result( SCOPE, &format!( - "对话结束但没有拿到任何文本 audio_sec={:.1} elapsed={}ms model={} chunks={}", + "Conversation ended without any transcript audio_sec={:.1} elapsed={}ms model={} chunks={}", audio_sec, elapsed_ms, model, total_chunks ), ); @@ -313,7 +313,7 @@ pub async fn transcribe( diag::ok(SCOPE, elapsed_ms, final_text.chars().count()); if used_input_transcript { // 模型没给回答、只给了输入转写。结果可用,但说明 instructions 可能没生效 - diag::log(SCOPE, "used_input_transcript", "模型未输出文本,回落到输入转写"); + diag::log(SCOPE, "used_input_transcript", "Model produced no output; used the input transcript fallback"); } } @@ -337,18 +337,18 @@ async fn wait_for_event( return Err(diag::fail( scope, &stage, - format!("等待 {} 超时", expected_type), + format!("Timed out waiting for {}", expected_type), )) } Ok(None) => { return Err(diag::fail( scope, &stage, - format!("等待 {} 时连接关闭", expected_type), + format!("Connection closed while waiting for {}", expected_type), )) } Ok(Some(Err(e))) => { - return Err(diag::fail(scope, &stage, format!("接收消息失败: {}", e))) + return Err(diag::fail(scope, &stage, format!("Failed to receive message: {}", e))) } Ok(Some(Ok(m))) => m, }; @@ -356,7 +356,7 @@ async fn wait_for_event( match msg { tungstenite::Message::Text(text) => { let event: serde_json::Value = serde_json::from_str(&text).map_err(|e| { - diag::fail(scope, &stage, format!("解析事件失败: {}", e)) + diag::fail(scope, &stage, format!("Failed to parse event: {}", e)) })?; let event_type = event @@ -369,7 +369,7 @@ async fn wait_for_event( .get("error") .and_then(|e| e.get("message")) .and_then(|m| m.as_str()) - .unwrap_or("未知错误"); + .unwrap_or("Unknown error"); let err_code = event .get("error") .and_then(|e| e.get("code")) @@ -378,7 +378,7 @@ async fn wait_for_event( return Err(diag::fail( scope, &stage, - format!("Qwen Omni 错误 [{}]: {}", err_code, err_msg), + format!("Qwen Omni error [{}]: {}", err_code, err_msg), )); } @@ -389,11 +389,11 @@ async fn wait_for_event( tungstenite::Message::Close(frame) => { let reason = frame .map(|f| format!("code={}, reason={}", f.code, diag::truncate(&f.reason, 200))) - .unwrap_or_else(|| "无详情".to_string()); + .unwrap_or_else(|| "No details".to_string()); return Err(diag::fail( scope, &stage, - format!("等待 {} 时服务端关闭了连接 ({})", expected_type, reason), + format!("Server closed the connection while waiting for {} ({})", expected_type, reason), )); } _ => {} @@ -427,15 +427,17 @@ pub async fn test_connection(config: &AsrProviderConfig) -> TestResult { match result { Ok(_) => TestResult { ok: true, - message: format!("连接成功,模型: {} ({}ms)", model, elapsed_ms), + message: format!("Connection successful, model: {} ({}ms)", model, elapsed_ms), elapsed_ms, detail: String::new(), }, Err(e) => TestResult { ok: false, - message: format!("连接后握手失败: {} ({}ms)", e, elapsed_ms), + // wait_for_event already returns a stable sayit_error envelope. Do not + // bury it inside another sentence or the frontend can no longer decode it. + message: e, elapsed_ms, - detail: String::new(), + detail: format!("Protocol handshake failed after {}ms", elapsed_ms), }, } } @@ -443,7 +445,7 @@ pub async fn test_connection(config: &AsrProviderConfig) -> TestResult { let elapsed_ms = start.elapsed().as_millis() as u64; TestResult { ok: false, - message: diag::fail("qwen/omni-test", "connect", format!("连接失败: {}", e)), + message: diag::fail("qwen/omni-test", "connect", format!("Connection failed: {}", e)), elapsed_ms, detail: String::new(), } diff --git a/client/src-tauri/src/providers/asr_qwen_realtime.rs b/client/src-tauri/src/providers/asr_qwen_realtime.rs index cfcc0b1..07787cd 100644 --- a/client/src-tauri/src/providers/asr_qwen_realtime.rs +++ b/client/src-tauri/src/providers/asr_qwen_realtime.rs @@ -6,7 +6,7 @@ // - 实时显示模式(realtime=true):后台 reader 持续读取识别事件,把中间结果通过 // `asr-partial` 事件上抛给前端做悬浮窗实时上屏;finish 返回累计的最终文本。 -use super::types::AsrProviderConfig; +use super::{diag, types::AsrProviderConfig}; use futures_util::stream::{SplitSink, SplitStream}; use futures_util::{SinkExt, StreamExt}; use std::sync::atomic::{AtomicBool, Ordering}; @@ -108,11 +108,11 @@ pub async fn qwen_stream_open( let mut request = url .as_str() .into_client_request() - .map_err(|e| format!("构建请求失败: {}", e))?; + .map_err(|e| diag::fail("qwen/realtime", "build_request", format!("Failed to build request: {}", e)))?; request.headers_mut().insert( AUTHORIZATION, HeaderValue::from_str(&format!("Bearer {}", config.api_key)) - .map_err(|e| format!("Authorization 请求头无效: {}", e))?, + .map_err(|e| diag::fail_code("qwen/realtime", "authorization_header", "provider_bad_key", format!("Invalid Authorization header: {}", e)))?, ); request.headers_mut().insert( USER_AGENT, @@ -121,7 +121,7 @@ pub async fn qwen_stream_open( let (mut ws, response) = tokio_tungstenite::connect_async(request) .await - .map_err(|e| format!("WebSocket 连接失败: {}", e))?; + .map_err(|e| diag::fail("qwen/realtime", "connect", format!("WebSocket connection failed: {}", e)))?; crate::commands::system::write_log_line(&format!( "[RUST] [qwen_stream] connected status={} model={} realtime={}", response.status(), @@ -160,14 +160,14 @@ pub async fn qwen_stream_open( serde_json::to_string(&session_update).unwrap().into(), )) .await - .map_err(|e| format!("发送 session.update 失败: {}", e))?; + .map_err(|e| diag::fail("qwen/realtime", "send_session_update", format!("Failed to send session.update: {}", e)))?; // 必须等到 session.updated 才算就绪(说明配置被接受、模型可用)。 // 若额度耗尽/无权限,服务端会在 session.created 后直接关闭连接(携带原因), // 此时下面循环拿不到 session.updated → 返回 Err → 上层回退到一次性识别。 let mut session_ready = false; while let Some(msg) = ws.next().await { - let msg = msg.map_err(|e| format!("接收确认失败: {}", e))?; + let msg = msg.map_err(|e| diag::fail("qwen/realtime", "recv_ack", format!("Failed to receive acknowledgement: {}", e)))?; match msg { tungstenite::Message::Text(text) => { if let Ok(data) = serde_json::from_str::(&text) { @@ -182,26 +182,26 @@ pub async fn qwen_stream_open( .get("error") .and_then(|e| e.get("message")) .and_then(|m| m.as_str()) - .unwrap_or("未知错误"); - return Err(format!("服务端错误: {}", err_msg)); + .unwrap_or("Unknown error"); + return Err(diag::fail("qwen/realtime", "server_error", format!("Server error: {}", err_msg))); } } } tungstenite::Message::Close(frame) => { let reason = frame .map(|f| f.reason.to_string()) - .unwrap_or_else(|| "无原因".to_string()); + .unwrap_or_else(|| "No reason".to_string()); crate::commands::system::write_log_line(&format!( "[RUST] [qwen_stream] closed before ready: {}", reason )); - return Err(format!("会话被服务端关闭: {}", reason)); + return Err(diag::fail("qwen/realtime", "closed_before_ready", format!("Server closed the session: {}", reason))); } _ => {} } } if !session_ready { - return Err("WebSocket 在就绪前已关闭".to_string()); + return Err(diag::fail("qwen/realtime", "closed_before_ready", "WebSocket closed before the session became ready".to_string())); } if realtime { @@ -327,7 +327,7 @@ async fn run_realtime_reader( .get("error") .and_then(|e| e.get("message")) .and_then(|m| m.as_str()) - .unwrap_or("未知错误") + .unwrap_or("Unknown error") .to_string(); let mut s = state.lock().await; s.error = Some(err_msg); @@ -370,18 +370,18 @@ pub async fn qwen_stream_send(pcm_b64: String) -> Result<(), String> { if RT_ACTIVE.load(Ordering::SeqCst) { let mut sink = RT_SINK.lock().await; - let s = sink.as_mut().ok_or("会话未建立")?; + let s = sink.as_mut().ok_or_else(|| diag::fail("qwen/realtime", "send_without_session", "Session is not open".to_string()))?; s.send(tungstenite::Message::Text(payload.into())) .await - .map_err(|e| format!("发送音频失败: {}", e))?; + .map_err(|e| diag::fail("qwen/realtime", "send_audio", format!("Failed to send audio: {}", e)))?; return Ok(()); } let mut session = SESSION.lock().await; - let ws = session.as_mut().ok_or("会话未建立")?; + let ws = session.as_mut().ok_or_else(|| diag::fail("qwen/realtime", "send_without_session", "Session is not open".to_string()))?; ws.send(tungstenite::Message::Text(payload.into())) .await - .map_err(|e| format!("发送音频失败: {}", e))?; + .map_err(|e| diag::fail("qwen/realtime", "send_audio", format!("Failed to send audio: {}", e)))?; Ok(()) } @@ -399,10 +399,10 @@ pub async fn qwen_stream_finish() -> Result { if RT_ACTIVE.load(Ordering::SeqCst) { { let mut sink = RT_SINK.lock().await; - let s = sink.as_mut().ok_or("会话未建立")?; + let s = sink.as_mut().ok_or_else(|| diag::fail("qwen/realtime", "finish_without_session", "Session is not open".to_string()))?; s.send(tungstenite::Message::Text(finish_payload.into())) .await - .map_err(|e| format!("发送 finish 失败: {}", e))?; + .map_err(|e| diag::fail("qwen/realtime", "send_finish", format!("Failed to send finish event: {}", e)))?; } let deadline = Instant::now() + Duration::from_secs(15); @@ -413,7 +413,7 @@ pub async fn qwen_stream_finish() -> Result { if let Some(err) = st.error.clone() { drop(st); cleanup_realtime().await; - return Err(format!("识别错误: {}", err)); + return Err(diag::fail("qwen/realtime", "recognition_error", format!("Recognition error: {}", err))); } // display() = 已确定分句 + 尚未 completed 的当前句,避免丢掉最后一句 let text = st.display(); @@ -435,17 +435,17 @@ pub async fn qwen_stream_finish() -> Result { // 普通模式:整条会话同步收结果 let mut session = SESSION.lock().await; - let ws = session.as_mut().ok_or("会话未建立")?; + let ws = session.as_mut().ok_or_else(|| diag::fail("qwen/realtime", "finish_without_session", "Session is not open".to_string()))?; ws.send(tungstenite::Message::Text(finish_payload.into())) .await - .map_err(|e| format!("发送 finish 失败: {}", e))?; + .map_err(|e| diag::fail("qwen/realtime", "send_finish", format!("Failed to send finish event: {}", e)))?; let mut final_text = String::new(); let mut completed_count = 0usize; while let Some(msg) = ws.next().await { - let msg = msg.map_err(|e| format!("接收结果失败: {}", e))?; + let msg = msg.map_err(|e| diag::fail("qwen/realtime", "recv", format!("Failed to receive result: {}", e)))?; if let tungstenite::Message::Text(text) = msg { if let Ok(data) = serde_json::from_str::(&text) { let event_type = data.get("type").and_then(|t| t.as_str()).unwrap_or(""); @@ -470,10 +470,10 @@ pub async fn qwen_stream_finish() -> Result { .get("error") .and_then(|e| e.get("message")) .and_then(|m| m.as_str()) - .unwrap_or("未知错误"); + .unwrap_or("Unknown error"); let _ = ws.close(None).await; *session = None; - return Err(format!("识别错误: {}", err_msg)); + return Err(diag::fail("qwen/realtime", "recognition_error", format!("Recognition error: {}", err_msg))); } _ => {} } diff --git a/client/src-tauri/src/providers/diag.rs b/client/src-tauri/src/providers/diag.rs index 71d1a29..2026fe5 100644 --- a/client/src-tauri/src/providers/diag.rs +++ b/client/src-tauri/src/providers/diag.rs @@ -16,6 +16,7 @@ // 把一个看得懂的错误变成一次没有日志的崩溃。 use crate::commands::system::write_log_line; +use crate::error_protocol; /// 统一前缀。所有 provider 日志共用一个标签,排查时 `Select-String '\[provider\]'` /// 一次就能把整条调用链捞出来,不必知道用户用的是哪家。 @@ -64,7 +65,63 @@ pub fn fail(scope: &str, stage: &str, user_msg: String) -> String { stage, truncate(&user_msg, 400) )); - user_msg + error_protocol::encode(classify_failure(stage, &user_msg), user_msg) +} + +/// Explicit variant for failures whose semantic category is known at the call site. +pub fn fail_code(scope: &str, stage: &str, code: &str, user_msg: String) -> String { + write_log_line(&format!( + "{} {} {} FAILED code={} {}", + TAG, + scope, + stage, + code, + truncate(&user_msg, 400) + )); + error_protocol::encode(code, user_msg) +} + +fn contains_http_status(message: &str, expected: &[u16]) -> bool { + message + .split(|ch: char| !ch.is_ascii_digit()) + .filter_map(|part| (part.len() == 3).then(|| part.parse::().ok()).flatten()) + .any(|status| expected.contains(&status)) +} + +fn classify_failure(stage: &str, message: &str) -> &'static str { + let lower = message.to_ascii_lowercase(); + if contains_http_status(message, &[401, 403]) + || lower.contains("unauthorized") + || lower.contains("invalid api key") + || message.contains("认证失败") + || message.contains("鉴权") + { + return "provider_bad_key"; + } + if contains_http_status(message, &[429]) + || lower.contains("rate limit") + || lower.contains("quota") + || message.contains("欠费") + || message.contains("余额") + { + return "provider_rate_limit"; + } + if contains_http_status(message, &[404]) + || lower.contains("model not found") + || lower.contains("model does not exist") + { + return "provider_no_model"; + } + if lower.contains("timeout") || lower.contains("timed out") || message.contains("超时") { + return "provider_timeout"; + } + if matches!( + stage, + "http_send" | "connect" | "send_request" | "recv" | "recv_ack" + ) { + return "provider_unreachable"; + } + "connect_failed" } /// 记一条「服务端返回了空结果」。 @@ -141,7 +198,7 @@ fn collect_safe_fields(value: &serde_json::Value, depth: usize, out: &mut Vec String { let Ok(json) = serde_json::from_str::(raw) else { - return format!("body_bytes={} (非 JSON)", raw.len()); + return format!("body_bytes={} (non-JSON)", raw.len()); }; let mut parts = vec![format!("body_bytes={}", raw.len())]; if let Some(obj) = json.as_object() { @@ -180,6 +237,14 @@ pub fn http_summary( mod tests { use super::*; + #[test] + fn classifies_provider_failures_before_the_detail_is_translated() { + assert_eq!(classify_failure("http_status", "HTTP 401 Unauthorized"), "provider_bad_key"); + assert_eq!(classify_failure("http_status", "HTTP 429 Too Many Requests"), "provider_rate_limit"); + assert_eq!(classify_failure("http_send", "request timed out"), "provider_timeout"); + assert_eq!(classify_failure("connect", "socket closed"), "provider_unreachable"); + } + /// 关键回归:按字节切中文会 panic。历史上 doubao / ollama / qwen / mimo 四处 /// 各有一份 `&s[..n]` 的实现,只要供应商回一段中文报错就崩 —— 而那正是出错的时刻。 #[test] @@ -242,6 +307,6 @@ mod tests { #[test] fn describe_json_handles_non_json() { let out = describe_json("502 Bad Gateway"); - assert!(out.contains("非 JSON"), "{}", out); + assert!(out.contains("non-JSON"), "{}", out); } } diff --git a/client/src-tauri/src/providers/doubao_auth.rs b/client/src-tauri/src/providers/doubao_auth.rs index 54e92cc..37f9757 100644 --- a/client/src-tauri/src/providers/doubao_auth.rs +++ b/client/src-tauri/src/providers/doubao_auth.rs @@ -103,13 +103,13 @@ impl<'a> DoubaoAuth<'a> { /// 错误的地方去排查。 pub fn explain_status_code(code: &str) -> Option<&'static str> { match code { - "20000003" => Some("音频被判定为静音,没有检测到语音"), - "45000030" => Some("这个账号没有开通所用的资源,或额度已用完,请到火山引擎控制台确认"), + "20000003" => Some("The audio was classified as silence; no speech was detected"), + "45000030" => Some("This account has not enabled the requested resource or has exhausted its quota; check the Volcengine console"), _ => { if code.starts_with("45") { - Some("请求被拒绝(参数、音频格式或额度问题),请到火山引擎控制台确认") + Some("The request was rejected; check parameters, audio format, and quota in the Volcengine console") } else if code.starts_with("55") { - Some("火山服务端处理失败,可稍后重试") + Some("Volcengine failed to process the request; try again later") } else { None } @@ -232,15 +232,15 @@ mod tests { /// 有实据的两个码要给出具体说明。 #[test] fn explains_the_codes_we_have_evidence_for() { - assert!(explain_status_code("20000003").unwrap().contains("静音")); - assert!(explain_status_code("45000030").unwrap().contains("额度")); + assert!(explain_status_code("20000003").unwrap().contains("silence")); + assert!(explain_status_code("45000030").unwrap().contains("quota")); } /// 没实据的码按前缀给方向,不编造具体含义。 #[test] fn falls_back_to_code_family() { - assert!(explain_status_code("45000001").unwrap().contains("请求被拒绝")); - assert!(explain_status_code("55000031").unwrap().contains("服务端")); + assert!(explain_status_code("45000001").unwrap().contains("rejected")); + assert!(explain_status_code("55000031").unwrap().contains("Volcengine")); } /// 成功码和无法识别的码不该产生任何提示。 @@ -254,8 +254,8 @@ mod tests { /// 返回体里的 code 可能是数字也可能是字符串,两种都要认。 #[test] fn explains_payload_with_either_code_type() { - assert!(explain_payload(r#"{"code":45000030}"#).contains("额度")); - assert!(explain_payload(r#"{"code":"45000030"}"#).contains("额度")); + assert!(explain_payload(r#"{"code":45000030}"#).contains("quota")); + assert!(explain_payload(r#"{"code":"45000030"}"#).contains("quota")); } /// 拿不到码就返回空串,调用方直接拼接不会多出括号。 diff --git a/client/src-tauri/src/providers/doubao_protocol.rs b/client/src-tauri/src/providers/doubao_protocol.rs index 60c546b..7b6ff16 100644 --- a/client/src-tauri/src/providers/doubao_protocol.rs +++ b/client/src-tauri/src/providers/doubao_protocol.rs @@ -28,7 +28,7 @@ fn gzip_compress(data: &[u8]) -> Vec { fn gzip_decompress(data: &[u8]) -> Result, String> { let mut decoder = GzDecoder::new(data); let mut result = Vec::new(); - decoder.read_to_end(&mut result).map_err(|e| format!("gzip 解压失败: {}", e))?; + decoder.read_to_end(&mut result).map_err(|e| format!("Failed to decompress gzip payload: {}", e))?; Ok(result) } @@ -80,7 +80,7 @@ pub struct ServerResponse { pub fn parse_server_response(data: &[u8]) -> Result { if data.len() < 4 { - return Err("帧太短".into()); + return Err("Frame is too short".into()); } let msg_type = data[1] & 0xF0; @@ -91,7 +91,7 @@ pub fn parse_server_response(data: &[u8]) -> Result { if msg_type == MSG_ERROR { // Error frame: 4 bytes header + 4 bytes error code + 4 bytes msg size + msg if data.len() < 12 { - return Err("错误帧太短".into()); + return Err("Error frame is too short".into()); } let error_code = u32::from_be_bytes([data[4], data[5], data[6], data[7]]); let msg_size = u32::from_be_bytes([data[8], data[9], data[10], data[11]]) as usize; @@ -109,7 +109,7 @@ pub fn parse_server_response(data: &[u8]) -> Result { } if msg_type != MSG_SERVER_RESP { - return Err(format!("未知消息类型: 0x{:02X}", msg_type)); + return Err(format!("Unknown message type: 0x{:02X}", msg_type)); } // 根据 flags 判断是否有 sequence 字段 @@ -118,7 +118,7 @@ pub fn parse_server_response(data: &[u8]) -> Result { let header_len: usize = if has_sequence { 12 } else { 8 }; // 4 header + (4 seq) + 4 payload_size if data.len() < header_len { - return Err("响应帧太短".into()); + return Err("Response frame is too short".into()); } let payload_size_offset = if has_sequence { 8 } else { 4 }; @@ -132,7 +132,7 @@ pub fn parse_server_response(data: &[u8]) -> Result { let payload_offset = payload_size_offset + 4; if data.len() < payload_offset + payload_size { - return Err(format!("payload 不完整: 期望 {} 字节,实际 {}", payload_size, data.len() - payload_offset)); + return Err(format!("Incomplete payload: expected {} bytes, received {}", payload_size, data.len() - payload_offset)); } let raw_payload = &data[payload_offset..payload_offset + payload_size]; diff --git a/client/src-tauri/src/providers/registry.rs b/client/src-tauri/src/providers/registry.rs index d935dc7..7969369 100644 --- a/client/src-tauri/src/providers/registry.rs +++ b/client/src-tauri/src/providers/registry.rs @@ -2,6 +2,7 @@ use super::types::*; use super::{ai_openai_compat, ai_ollama, asr_doubao, asr_doubao_stream, asr_qwen, asr_qwen_omni, asr_mimo}; +use crate::error_protocol; /// 云端 AI 校对(Tauri command) #[tauri::command] @@ -24,7 +25,7 @@ pub async fn cloud_polish(request: CloudPolishRequest) -> Result Err(format!("未知的 AI 供应商: {}", other)), + other => Err(error_protocol::encode("connect_failed", format!("Unknown AI provider: {}", other))), } } @@ -38,7 +39,7 @@ pub async fn test_ai_connection(config: AiProviderConfig) -> Result { Ok(ai_ollama::test_connection(&config).await) } - other => Err(format!("未知的 AI 供应商: {}", other)), + other => Err(error_protocol::encode("connect_failed", format!("Unknown AI provider: {}", other))), } } @@ -92,7 +93,7 @@ pub async fn cloud_transcribe(request: CloudTranscribeRequest) -> Result Err(format!("ASR 供应商 \"{}\" 尚未实现", other)), + other => Err(error_protocol::encode("connect_failed", format!("ASR provider \"{}\" is not implemented", other))), } } @@ -105,6 +106,6 @@ pub async fn test_asr_connection(config: AsrProviderConfig) -> Result Ok(asr_qwen::test_connection(&config).await), "qwen_omni" => Ok(asr_qwen_omni::test_connection(&config).await), "mimo" => Ok(asr_mimo::test_connection(&config).await), - other => Err(format!("ASR 供应商 \"{}\" 尚未实现", other)), + other => Err(error_protocol::encode("connect_failed", format!("ASR provider \"{}\" is not implemented", other))), } } diff --git a/client/src-tauri/tauri.conf.json b/client/src-tauri/tauri.conf.json index c8be6ea..d93546d 100644 --- a/client/src-tauri/tauri.conf.json +++ b/client/src-tauri/tauri.conf.json @@ -54,7 +54,8 @@ "installMode": "currentUser", "displayLanguageSelector": false, "languages": [ - "SimpChinese" + "SimpChinese", + "English" ], "installerIcon": "icons/icon.ico" }, diff --git a/client/src/App.tsx b/client/src/App.tsx index a5cb263..3d13a0e 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -6,7 +6,6 @@ import TitleBar from './components/TitleBar' import WelcomeGuide from './components/WelcomeGuide' import Home from './pages/Home' import History from './pages/History' -import Favorites from './pages/Favorites' import Dictionary from './pages/Dictionary' import Settings from './pages/Settings' import VoiceEnginePage from './features/settings/VoiceEnginePage' diff --git a/client/src/components/CopyButton.tsx b/client/src/components/CopyButton.tsx index 03801ea..2eb305a 100644 --- a/client/src/components/CopyButton.tsx +++ b/client/src/components/CopyButton.tsx @@ -2,6 +2,7 @@ import { useState } from 'react' import { Copy, Check } from 'lucide-react' import { Tooltip } from '@/components/ui/tooltip' import * as bridge from '@/services/bridge' +import { useT } from '@/i18n/useT' interface CopyButtonProps { text: string @@ -9,6 +10,7 @@ interface CopyButtonProps { } export default function CopyButton({ text, className = '' }: CopyButtonProps) { + const t = useT() const [copied, setCopied] = useState(false) const handleCopy = async () => { @@ -23,11 +25,11 @@ export default function CopyButton({ text, className = '' }: CopyButtonProps) { } return ( - + @@ -103,7 +106,7 @@ export default function FeedbackSection() { if (message) setMessage(null) }} onKeyDown={handleKeyDown} - placeholder="如何改进 SayIt?输入你的反馈建议..." + placeholder={t('feedback.placeholder')} rows={1} maxLength={1000} className="w-full resize-none border-0 bg-transparent p-0 text-sm outline-none placeholder:text-muted-foreground/50" @@ -115,8 +118,8 @@ export default function FeedbackSection() {
{submitted ? (

- 发送成功,感谢您的反馈。 - 待作者通过后,可访问{' '} + {t('feedback.sent')} + {t('feedback.sentAfter')}{' '} - ,查看反馈进度 + {t('feedback.sentLinkSuffix')}

) : ( message && ( @@ -139,7 +142,7 @@ export default function FeedbackSection() { disabled={sending || feedbackText.trim().length < 2} className="shrink-0 rounded-full bg-secondary px-4 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-foreground hover:text-background disabled:opacity-40" > - {sending ? '发送中...' : '发送反馈'} + {sending ? t('feedback.sending') : t('feedback.send')}
diff --git a/client/src/components/NoticeBanner.tsx b/client/src/components/NoticeBanner.tsx index 0eb499d..97c50c4 100644 --- a/client/src/components/NoticeBanner.tsx +++ b/client/src/components/NoticeBanner.tsx @@ -4,6 +4,8 @@ import { useEffect, useState } from 'react' import { Info, AlertTriangle, Megaphone, X } from 'lucide-react' import { open as shellOpen } from '@tauri-apps/plugin-shell' +import { t } from '@/i18n' +import { useLocale } from '@/i18n/useT' import { fetchActiveNotice, dismissNotice, @@ -39,12 +41,13 @@ function prettyUrl(url: string): string { } export default function NoticeBanner() { + const locale = useLocale() const [notice, setNotice] = useState(null) useEffect(() => { let alive = true const load = () => { - void fetchActiveNotice(__APP_VERSION__).then((n) => { + void fetchActiveNotice(__APP_VERSION__, locale).then((n) => { if (alive) setNotice(n) }) } @@ -54,7 +57,7 @@ export default function NoticeBanner() { alive = false clearInterval(timer) } - }, []) + }, [locale]) if (!notice) return null @@ -107,7 +110,7 @@ export default function NoticeBanner() { type="button" onClick={handleDismiss} className="shrink-0 rounded-full p-1 text-muted-foreground/60 transition-colors hover:bg-foreground/10 hover:text-foreground" - aria-label="关闭通知" + aria-label={t('notice.dismiss')} > diff --git a/client/src/components/Sidebar.tsx b/client/src/components/Sidebar.tsx index d526eec..93018f4 100644 --- a/client/src/components/Sidebar.tsx +++ b/client/src/components/Sidebar.tsx @@ -5,23 +5,33 @@ import { cn } from '@/lib/utils' import { Tooltip } from '@/components/ui/tooltip' import { useConnectionStatus } from '@/hooks/useConnectionStatus' import { getModeStatus, refreshModeStatus, subscribeModeStatus } from '@/stores/modeStatus' +import type { TranslationKey } from '@/i18n' +import { useT } from '@/i18n/useT' +// 导航项存 key 而不是文案:切语言时这些常量不会重新求值(模块级只算一次), +// 存成中文串就会永远停在启动时那个语言。 const dailyNavItems = [ - { to: '/', icon: Home, label: '首页' }, - { to: '/history', icon: Clock, label: '历史' }, -] + { to: '/', icon: Home, labelKey: 'nav.home' }, + { to: '/history', icon: Clock, labelKey: 'nav.history' }, +] as const satisfies readonly NavItemDef[] const configNavItems = [ - { to: '/voice-engine', icon: AudioLines, label: '语音引擎' }, - { to: '/hotwords', icon: BookOpen, label: '热词' }, - { to: '/ai-instructions', icon: Wand2, label: 'AI 整理' }, - { to: '/ai-service', icon: Sparkles, label: 'AI 供应商' }, -] + { to: '/voice-engine', icon: AudioLines, labelKey: 'nav.voiceEngine' }, + { to: '/hotwords', icon: BookOpen, labelKey: 'nav.hotwords' }, + { to: '/ai-instructions', icon: Wand2, labelKey: 'nav.aiInstructions' }, + { to: '/ai-service', icon: Sparkles, labelKey: 'nav.aiService' }, +] as const satisfies readonly NavItemDef[] const footerNavItems = [ - { to: '/settings', icon: Settings, label: '设置' }, - { to: '/about', icon: Info, label: '关于' }, -] + { to: '/settings', icon: Settings, labelKey: 'nav.settings' }, + { to: '/about', icon: Info, labelKey: 'nav.about' }, +] as const satisfies readonly NavItemDef[] + +interface NavItemDef { + to: string + icon: typeof Home + labelKey: TranslationKey +} function NavItem({ to, @@ -82,11 +92,11 @@ function IconOnlyNavItem({ * 本地/云 API 没有这种持续探测,见 ModeIndicator 的注释。 */ const statusConfig = { - connected: { icon: Wifi, color: 'text-success', label: '后端已连接' }, - connecting: { icon: Wifi, color: 'text-warning animate-pulse', label: '正在连接…' }, - disconnected: { icon: WifiOff, color: 'text-muted-foreground', label: '后端未连接' }, - error: { icon: WifiOff, color: 'text-destructive', label: '连接失败' }, -} as const + connected: { icon: Wifi, color: 'text-success', labelKey: 'connection.connected' }, + connecting: { icon: Wifi, color: 'text-warning animate-pulse', labelKey: 'connection.connecting' }, + disconnected: { icon: WifiOff, color: 'text-muted-foreground', labelKey: 'connection.disconnected' }, + error: { icon: WifiOff, color: 'text-destructive', labelKey: 'connection.error' }, +} as const satisfies Record /** * 左下角的引擎指示:始终只占一个图标位,细节全在悬停提示里。 @@ -101,15 +111,16 @@ const statusConfig = { * 曾经在这里点过绿灯,等于替一件没测过的事作保。 */ function ModeIndicator() { + const t = useT() const status = useConnectionStatus() const { mode, detail, ready, blockedReason } = useSyncExternalStore(subscribeModeStatus, getModeStatus) useEffect(() => { void refreshModeStatus() }, []) if (mode === 'server') { - const { icon: StatusIcon, color, label } = statusConfig[status] + const { icon: StatusIcon, color, labelKey } = statusConfig[status] return ( - +
@@ -118,11 +129,13 @@ function ModeIndicator() { } const Icon = mode === 'local' ? Cpu : Cloud - const title = mode === 'local' ? '本地模式' : '云 API 模式' + const title = mode === 'local' ? t('mode.local') : t('mode.cloudApi') const notReady = ready === false + // blockedReason 目前是 Rust/服务层给的中文串(P2-1 会改成 code 再本地化)。 + // 这里只保证**外壳**跟随语言,不假装里面那句已经翻好了。 const tip = notReady - ? `${title} · 待配置(${blockedReason || '配置未填完'})` - : detail ? `${title} · ${detail}` : title + ? t('mode.tooltipNotReady', { mode: title, reason: blockedReason || t('mode.notReadyFallback') }) + : detail ? t('mode.tooltipDetail', { mode: title, detail }) : title return ( @@ -134,26 +147,27 @@ function ModeIndicator() { } export default function Sidebar() { + const t = useT() return (