diff --git a/docs/contributing/testing.md b/docs/contributing/testing.md index 49175d3..150c4d1 100644 --- a/docs/contributing/testing.md +++ b/docs/contributing/testing.md @@ -12,7 +12,7 @@ ST-BME 的测试是 Node 回归测试(`tests/*.mjs`),`npm run test:stable` | 检索/召回 | `p0-regressions` 内相关、`trivial-user-input` | 召回、reroll 复用、注入 | | 向量 | `vector-gate` / `vector-connection-probe` / `vector-sync-coalescer` | 向量门禁、连接探测、后台同步合并 | | Native | `native-layout-parity` / `native-rollout-matrix` | native/JS 一致性、灰度门控 | -| 防线 | `index-slicing-ratchet` / `runtime-deps-completeness` | 见下 | +| 防线 | `index-slicing-ratchet` / `runtime-deps-completeness` / `i18n-user-visible-ratchet` | 见下 | ## 防线一:禁止切片 index.js(ratchet) @@ -42,6 +42,14 @@ ST-BME 的测试是 Node 回归测试(`tests/*.mjs`),`npm run test:stable` 详见 [`conventions.md`](conventions.md)。 +## 防线三:前端 i18n 用户可见文案 ratchet + +前端中英 i18n 只翻译 UI,不翻译 prompt、记忆节点、聊天内容或持久化数据。为了避免已迁移 UI 文件重新出现硬编码中文按钮/Toast/confirm,新增: + +> `tests/i18n-user-visible-ratchet.mjs` 只扫描已迁移的 UI 文件,拦截明显用户可见模式:`toastr.*("中文")`、`confirm("中文")`、`textContent = "中文"`、`innerHTML` 中的中文按钮、`title/placeholder/aria-label` 中文等。 + +这不是全仓库中文禁令:中文文档、注释、测试 fixture、prompt/model/data 模块和 `i18n/zh-CN.js` 都不在这条规则里。新增用户可见文案时,应新增 catalog key,并通过 `t("...")` / `data-i18n` / keyed status 渲染。 + ## 重要测试文件 - **`tests/p0-regressions.mjs`** — 主回归集合,覆盖提取、召回、恢复、UI 关键路径。 @@ -50,6 +58,7 @@ ST-BME 的测试是 Node 回归测试(`tests/*.mjs`),`npm run test:stable` - **`tests/graph-persistence.mjs`** — 图谱持久化基础行为。 - **`tests/identity-resolver.mjs` / `tests/persistence-reducer.mjs`** — 身份解析核心、持久化 accepted/queued/pending 状态机。 - **`tests/runtime-deps-completeness.mjs`** — 检查注入式控制器模块的 `runtime.X` 依赖均由对应 builder 提供。 +- **`tests/i18n-user-visible-ratchet.mjs`** — 检查已迁移 UI 文件不新增硬编码中文用户可见文案。 - **`tests/graph-snapshot-schema.mjs` / `tests/snapshot-forward-compat.mjs`** — 耐久快照契约、宽容解析和真实存储向前兼容往返。 - **`tests/indexeddb-persistence.mjs`** — IndexedDB 快照、增量提交、hydrate。 - **`tests/indexeddb-sync.mjs`** — 云端同步与冲突合并。 diff --git a/docs/usage/configuration.en.md b/docs/usage/configuration.en.md index 8b45ca5..fad3e89 100644 --- a/docs/usage/configuration.en.md +++ b/docs/usage/configuration.en.md @@ -4,6 +4,18 @@ This page is split out from the [README](../../README.en.md) as the main ST-BME user configuration reference, preserving setting names, defaults, and tables for quick lookup by feature. +### Interface language + +`Interface Language` only affects ST-BME frontend UI: the panel, menu entries, floating button, status messages, Toasts, recall cards, and graph system labels. + +Options: + +- `Auto`: follows the SillyTavern / browser language when available, otherwise falls back to Chinese. +- `Simplified Chinese`: forces the Chinese UI. +- `English`: forces the English UI. + +This setting **does not** translate chat content, user input, AI replies, memory nodes, recall injection text, or prompt construction. Switching the interface language does not change the memory graph or model behavior. + ### Memory LLM The memory LLM is used for: diff --git a/docs/usage/configuration.md b/docs/usage/configuration.md index f0026e9..6d745ef 100644 --- a/docs/usage/configuration.md +++ b/docs/usage/configuration.md @@ -4,6 +4,18 @@ 本文从 [README](../../README.md) 拆出 ST-BME 的主要用户配置说明,保留设置名称、默认值和表格,便于按功能查阅。 +### 界面语言 + +`界面语言` 只影响 ST-BME 前端 UI:面板、菜单入口、悬浮按钮、状态、Toast、召回卡片和图谱系统标签。 + +可选值: + +- `自动`:优先跟随 SillyTavern / 浏览器语言,识别不到时使用中文。 +- `简体中文`:固定中文界面。 +- `English`:固定英文界面。 + +这个设置**不会**翻译聊天内容、用户输入、AI 回复、记忆节点、召回注入文本或提示词构建。切换语言不会改变记忆图谱和模型行为。 + ### 记忆 LLM 记忆 LLM 用于: diff --git a/package.json b/package.json index 8800ef1..fe20259 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,8 @@ "test:i18n-boundary": "node tests/i18n-boundary.mjs", "test:i18n-status": "node tests/i18n-status.mjs", "test:ui-label-formatter": "node tests/ui-label-formatter.mjs", - "test:i18n": "npm run test:i18n-catalog && npm run test:i18n-dom && npm run test:i18n-boundary && npm run test:i18n-status && npm run test:ui-label-formatter", + "test:i18n-ratchet": "node tests/i18n-user-visible-ratchet.mjs", + "test:i18n": "npm run test:i18n-catalog && npm run test:i18n-dom && npm run test:i18n-boundary && npm run test:i18n-status && npm run test:ui-label-formatter && npm run test:i18n-ratchet", "bench:graph-layout": "node tests/perf/graph-layout-bench.mjs", "bench:persist-delta": "node tests/perf/persist-delta-bench.mjs", "bench:persist-load": "node tests/perf/persist-load-bench.mjs", diff --git a/tests/i18n-user-visible-ratchet.mjs b/tests/i18n-user-visible-ratchet.mjs new file mode 100644 index 0000000..c3ae419 --- /dev/null +++ b/tests/i18n-user-visible-ratchet.mjs @@ -0,0 +1,332 @@ +/** + * ST-BME: i18n user-visible ratchet test + * + * Scans migrated UI files for obvious hardcoded Chinese in user-visible + * API surface patterns (toastr, confirm, textContent, innerHTML, template + * literals, title, placeholder, aria-label, button HTML) and enforces that + * no new regressions are introduced. Comments and t("...") catalog lookups + * are explicitly allowed. + * + * This is a ratchet, not a global ban: it only checks the explicitly-listed + * UI files that have been migrated. + */ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const PROJECT_ROOT = join(__dirname, ".."); + +// ─── Files to scan ────────────────────────────────────────────────────────── + +const UI_JS_FILES = [ + "ui/panel-bridge.js", + "ui/recall-message-ui.js", + "ui/panel-ena-sections.js", + "ui/ui-status.js", + "ui/history-notice.js", + "ui/ui-label-formatter.js", + "ui/graph-renderer.js", +]; + +const UI_HTML_FILES = [ + "ui/panel.html", +]; + +// ─── Pattern definitions ──────────────────────────────────────────────────── + +/** + * Detect CJK Han characters in a string. + */ +const CJK_RE = /\p{Script=Han}/u; + +/** + * Strip JS block and single-line comments from source. + * Conservative: removes comment content so Chinese inside comments is not flagged. + */ +function stripComments(src) { + let result = src.replace(/\/\*[\s\S]*?\*\//g, ""); + result = result.replace(/\/\/.*$/gm, ""); + return result; +} + +/** + * Strip t("...") and t('...') catalog call patterns so Chinese inside + * i18n lookups is not flagged. Also handles t("key", { params }, { opts }). + * Neutralises the string content so CJK regex won't match inside t() args. + */ +function stripI18nCalls(src) { + let result = src; + // Double-quoted t() calls: t("anything", {...}, {...}) + result = result.replace( + /\bt\s*\(\s*"([^"\\]|\\.)*"\s*(?:,\s*\{[^}]*\}\s*)*(?:,\s*\{[^}]*\}\s*)*\)/g, + (m) => m.replace(/"/g, "\x00"), + ); + // Single-quoted t() calls: t('anything', {...}, {...}) + result = result.replace( + /\bt\s*\(\s*'([^'\\]|\\.)*'\s*(?:,\s*\{[^}]*\}\s*)*(?:,\s*\{[^}]*\}\s*)*\)/g, + (m) => m.replace(/'/g, "\x00"), + ); + return result; +} + +/** + * Known violations baseline: counts of Chinese-containing lines per file + * AFTER comments and t() calls are stripped. These represent internal + * defaults / fallback labels that route through i18n at runtime. + * The ratchet ensures these counts do not increase. + */ +const BASELINE = { + "ui/panel-bridge.js": 7, // console.error/warn messages (dev-only, not user-visible) + "ui/recall-message-ui.js": 0, // fully migrated + "ui/panel-ena-sections.js": 0, // fully migrated + "ui/ui-status.js": 0, // fully migrated + "ui/history-notice.js": 0, // fully migrated + "ui/ui-label-formatter.js": 0, // fully migrated + "ui/graph-renderer.js": 0, // fully migrated +}; + +// ─── Scanning logic ───────────────────────────────────────────────────────── + +function scanFileForUserVisibleChinese(relativePath) { + const fullPath = join(PROJECT_ROOT, relativePath); + let source; + try { + source = readFileSync(fullPath, "utf8"); + } catch { + console.warn(`[i18n-ratchet] SKIP — file not found: ${relativePath}`); + return []; + } + + const stripped = stripComments(source); + const safe = stripI18nCalls(stripped); + const lines = safe.split("\n"); + const violations = []; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (!CJK_RE.test(line)) continue; + + // Skip import lines + if (/^\s*import\s/.test(line)) continue; + + // Skip console/debugLog calls (dev-only, not user-visible) + if (/console\.(log|error|warn|info|debug)\s*\(/.test(line)) continue; + if (/debugLog\s*\(/.test(line)) continue; + + const trimmed = line.trim(); + + // ─── User-visible API patterns that SHOULD use t() ──── + + // 1. toastr.*() calls with Chinese + if (/\btoastr\s*\.\s*(?:success|error|info|warning|notify)\s*\(/.test(trimmed) && CJK_RE.test(line)) { + violations.push({ file: relativePath, line: i + 1, kind: "toastr", snippet: trimmed.slice(0, 120) }); + continue; + } + + // 2. confirm(...) calls with Chinese + if (/\bconfirm\s*\(/.test(trimmed) && CJK_RE.test(line)) { + violations.push({ file: relativePath, line: i + 1, kind: "confirm", snippet: trimmed.slice(0, 120) }); + continue; + } + + // 3. prompt(...) calls with Chinese + if (/\bprompt\s*\(/.test(trimmed) && CJK_RE.test(line)) { + violations.push({ file: relativePath, line: i + 1, kind: "prompt", snippet: trimmed.slice(0, 120) }); + continue; + } + + // 4. .textContent = with Chinese + if (/\.textContent\s*=/.test(trimmed) && CJK_RE.test(line)) { + violations.push({ file: relativePath, line: i + 1, kind: "textContent", snippet: trimmed.slice(0, 120) }); + continue; + } + + // 5. .innerHTML = with Chinese + if (/\.innerHTML\s*=/.test(trimmed) && CJK_RE.test(line)) { + violations.push({ file: relativePath, line: i + 1, kind: "innerHTML", snippet: trimmed.slice(0, 120) }); + continue; + } + + // 6. .title / .placeholder / .ariaLabel = with Chinese + if (/\.(?:title|placeholder|ariaLabel)\s*=/.test(trimmed) && CJK_RE.test(line)) { + violations.push({ file: relativePath, line: i + 1, kind: "attribute", snippet: trimmed.slice(0, 120) }); + continue; + } + + // 7. setAttribute('title'/'placeholder'/'aria-label', ...) with Chinese + if (/\.setAttribute\s*\(\s*["'](?:title|placeholder|aria-label)["']\s*,/.test(trimmed) && CJK_RE.test(line)) { + violations.push({ file: relativePath, line: i + 1, kind: "setAttribute", snippet: trimmed.slice(0, 120) }); + continue; + } + + // 8.