diff --git a/messages/en.json b/messages/en.json
index 1ec6b4191..64f9a461e 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -1757,9 +1757,9 @@
"organizeAs": "Organize as",
"template": "Template",
"setting": "Settings",
- "confirm": "确认",
- "cancel": "取消",
- "removeThinking": "移除思考过程",
+ "confirm": "Confirm",
+ "cancel": "Cancel",
+ "removeThinking": "Remove thinking process",
"stop": "Stop"
}
},
@@ -1800,11 +1800,11 @@
"noMatchingConversations": "No matching conversations found",
"noConversationHistory": "No conversation history yet",
"quickPrompts": {
- "title": "快速开始",
- "writeNote": "帮我写一篇笔记",
- "summarize": "帮我总结这段内容",
- "brainstorm": "帮我头脑风暴一些想法",
- "explain": "帮我解释这个概念"
+ "title": "Quick Start",
+ "writeNote": "Help me write a note",
+ "summarize": "Help me summarize this content",
+ "brainstorm": "Help me brainstorm some ideas",
+ "explain": "Help me explain this concept"
}
},
"newChat": "New Chat with New Tag",
diff --git a/package.json b/package.json
index 92a233ae3..402f63083 100644
--- a/package.json
+++ b/package.json
@@ -7,6 +7,8 @@
"build": "next build --turbopack",
"start": "next start --turbopack -p 3456",
"lint": "next lint",
+ "lint:i18n": "node scripts/check-i18n.js",
+ "audit:locales": "node scripts/audit-locales.js",
"tauri": "tauri",
"docs:build": "npm --prefix ./docs run build",
"sync-version": "./scripts/sync-version.sh",
diff --git a/scripts/audit-locales.js b/scripts/audit-locales.js
new file mode 100644
index 000000000..d519b0ead
--- /dev/null
+++ b/scripts/audit-locales.js
@@ -0,0 +1,58 @@
+/**
+ * Verifies that every key in messages/zh.json exists in all other locale files.
+ * Exits with code 1 if any locale is missing keys.
+ */
+
+const fs = require('fs');
+const path = require('path');
+
+const MESSAGES_DIR = path.join(__dirname, '..', 'messages');
+const LOCALES = ['en.json', 'ja.json', 'pt-BR.json', 'zh-TW.json'];
+
+function getAllKeys(obj, prefix = '') {
+ const keys = new Set();
+ for (const [k, v] of Object.entries(obj)) {
+ const full = prefix ? `${prefix}.${k}` : k;
+ if (typeof v === 'object' && v !== null && !Array.isArray(v)) {
+ for (const child of getAllKeys(v, full)) {
+ keys.add(child);
+ }
+ } else {
+ keys.add(full);
+ }
+ }
+ return keys;
+}
+
+const zh = JSON.parse(fs.readFileSync(path.join(MESSAGES_DIR, 'zh.json'), 'utf-8'));
+const zhKeys = getAllKeys(zh);
+
+let failed = false;
+for (const file of LOCALES) {
+ const fullPath = path.join(MESSAGES_DIR, file);
+ if (!fs.existsSync(fullPath)) {
+ console.error(`❌ Missing locale file: ${file}`);
+ failed = true;
+ continue;
+ }
+ const data = JSON.parse(fs.readFileSync(fullPath, 'utf-8'));
+ const keys = getAllKeys(data);
+ const missing = [];
+ for (const k of zhKeys) {
+ if (!keys.has(k)) {
+ missing.push(k);
+ }
+ }
+ if (missing.length > 0) {
+ console.error(`❌ ${file} missing ${missing.length} key(s):`);
+ missing.slice(0, 20).forEach((k) => console.error(' ' + k));
+ if (missing.length > 20) {
+ console.error(` ... and ${missing.length - 20} more`);
+ }
+ failed = true;
+ } else {
+ console.log(`✅ ${file} has all ${zhKeys.size} keys`);
+ }
+}
+
+process.exit(failed ? 1 : 0);
diff --git a/scripts/check-i18n.js b/scripts/check-i18n.js
new file mode 100644
index 000000000..bb470f950
--- /dev/null
+++ b/scripts/check-i18n.js
@@ -0,0 +1,99 @@
+/**
+ * Regression guard: scans src/ for Chinese characters in UI-facing code.
+ * Exits with code 1 if new hardcoded Chinese strings are found.
+ */
+
+const fs = require('fs');
+const path = require('path');
+
+const SRC_DIR = path.join(__dirname, '..', 'src');
+
+// Allow-list: strings that are intentionally Chinese (model names, comments, etc.)
+const ALLOWLIST = new Set([
+ '简体中文',
+ '日本語',
+]);
+
+// Extensions to scan
+const EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx']);
+
+function hasChinese(str) {
+ return /[\u4e00-\u9fff]/.test(str);
+}
+
+function extractStringLiterals(line) {
+ const strings = [];
+ const singleQuote = /'((?:[^'\\]|\\.)*)'/g;
+ const doubleQuote = /"((?:[^"\\]|\\.)*)"/g;
+ const backtick = /`((?:[^`\\]|\\.)*)`/g;
+ let m;
+ while ((m = singleQuote.exec(line)) !== null) strings.push(m[1]);
+ while ((m = doubleQuote.exec(line)) !== null) strings.push(m[1]);
+ while ((m = backtick.exec(line)) !== null) strings.push(m[1]);
+ return strings;
+}
+
+const violations = [];
+
+function scanFile(filePath) {
+ const content = fs.readFileSync(filePath, 'utf-8');
+ const lines = content.split('\n');
+ let inBlockComment = false;
+ lines.forEach((line, idx) => {
+ // Track block comment state
+ if (inBlockComment) {
+ if (line.includes('*/')) {
+ inBlockComment = false;
+ }
+ return;
+ }
+ if (line.includes('/*')) {
+ inBlockComment = !line.includes('*/');
+ if (inBlockComment) return;
+ }
+
+ // Skip import lines that load messages JSON (those contain locale codes, not UI)
+ if (line.includes('messages/') && line.includes('import')) return;
+ // Skip comment-only lines
+ if (line.trim().startsWith('//')) return;
+
+ const strings = extractStringLiterals(line);
+ for (const str of strings) {
+ if (!hasChinese(str)) continue;
+ if (ALLOWLIST.has(str)) continue;
+ violations.push(`${filePath}:${idx + 1}: ${str.slice(0, 60)}`);
+ }
+
+ // Also catch raw JSX text nodes (e.g. 保存)
+ const jsxTextRegex = />\s*([^<]*[\u4e00-\u9fff][^<]*)\s* 0) {
+ console.error(`❌ Found ${violations.length} hardcoded Chinese string(s):`);
+ violations.forEach((v) => console.error(' ' + v));
+ process.exit(1);
+} else {
+ console.log('✅ No hardcoded Chinese strings found in src/');
+ process.exit(0);
+}
diff --git a/src/lib/i18n.ts b/src/lib/i18n.ts
new file mode 100644
index 000000000..0b7baa1f2
--- /dev/null
+++ b/src/lib/i18n.ts
@@ -0,0 +1,65 @@
+/**
+ * Lightweight i18n helper for non-React modules (Zustand stores, sync utilities).
+ * Reads the active locale from localStorage (same key used by NextIntlProvider)
+ * and returns a synchronous t() function backed by the loaded messages.
+ *
+ * Messages are cached per locale to avoid repeated disk reads.
+ */
+
+const CACHE = new Map>();
+
+function getLocale(): string {
+ if (typeof localStorage !== 'undefined') {
+ return localStorage.getItem('app-language') || 'zh';
+ }
+ return 'zh';
+}
+
+function resolvePath(obj: any, path: string): string | undefined {
+ const parts = path.split('.');
+ let current = obj;
+ for (const part of parts) {
+ if (current == null || typeof current !== 'object') {
+ return undefined;
+ }
+ current = current[part];
+ }
+ return typeof current === 'string' ? current : undefined;
+}
+
+async function loadMessages(locale: string): Promise> {
+ if (CACHE.has(locale)) {
+ return CACHE.get(locale)!;
+ }
+ try {
+ const mod = await import(`../../messages/${locale}.json`);
+ const messages = mod.default || mod;
+ CACHE.set(locale, messages);
+ return messages;
+ } catch {
+ const fallback = await import(`../../messages/zh.json`);
+ const messages = fallback.default || fallback;
+ CACHE.set(locale, messages);
+ return messages;
+ }
+}
+
+export async function getI18n(): Promise<{ t: (key: string) => string }> {
+ const locale = getLocale();
+ const messages = await loadMessages(locale);
+ return {
+ t(key: string): string {
+ const value = resolvePath(messages, key);
+ if (typeof value === 'string') {
+ return value;
+ }
+ // fallback to zh if missing in target locale
+ const fallback = resolvePath(CACHE.get('zh') || messages, key);
+ return fallback ?? key;
+ },
+ };
+}
+
+export function clearI18nCache(): void {
+ CACHE.clear();
+}