test(i18n): add user-visible localization ratchet

This commit is contained in:
youzini
2026-06-05 11:50:16 +00:00
parent 3d07171cee
commit a34f02ddb8
8 changed files with 389 additions and 15 deletions

View File

@@ -12,7 +12,7 @@ ST-BME 的测试是 Node 回归测试(`tests/*.mjs``npm run test:stable`
| 检索/召回 | `p0-regressions` 内相关、`trivial-user-input` | 召回、reroll 复用、注入 | | 检索/召回 | `p0-regressions` 内相关、`trivial-user-input` | 召回、reroll 复用、注入 |
| 向量 | `vector-gate` / `vector-connection-probe` / `vector-sync-coalescer` | 向量门禁、连接探测、后台同步合并 | | 向量 | `vector-gate` / `vector-connection-probe` / `vector-sync-coalescer` | 向量门禁、连接探测、后台同步合并 |
| Native | `native-layout-parity` / `native-rollout-matrix` | native/JS 一致性、灰度门控 | | 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.jsratchet ## 防线一:禁止切片 index.jsratchet
@@ -42,6 +42,14 @@ ST-BME 的测试是 Node 回归测试(`tests/*.mjs``npm run test:stable`
详见 [`conventions.md`](conventions.md)。 详见 [`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 关键路径。 - **`tests/p0-regressions.mjs`** — 主回归集合覆盖提取、召回、恢复、UI 关键路径。
@@ -50,6 +58,7 @@ ST-BME 的测试是 Node 回归测试(`tests/*.mjs``npm run test:stable`
- **`tests/graph-persistence.mjs`** — 图谱持久化基础行为。 - **`tests/graph-persistence.mjs`** — 图谱持久化基础行为。
- **`tests/identity-resolver.mjs` / `tests/persistence-reducer.mjs`** — 身份解析核心、持久化 accepted/queued/pending 状态机。 - **`tests/identity-resolver.mjs` / `tests/persistence-reducer.mjs`** — 身份解析核心、持久化 accepted/queued/pending 状态机。
- **`tests/runtime-deps-completeness.mjs`** — 检查注入式控制器模块的 `runtime.X` 依赖均由对应 builder 提供。 - **`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/graph-snapshot-schema.mjs` / `tests/snapshot-forward-compat.mjs`** — 耐久快照契约、宽容解析和真实存储向前兼容往返。
- **`tests/indexeddb-persistence.mjs`** — IndexedDB 快照、增量提交、hydrate。 - **`tests/indexeddb-persistence.mjs`** — IndexedDB 快照、增量提交、hydrate。
- **`tests/indexeddb-sync.mjs`** — 云端同步与冲突合并。 - **`tests/indexeddb-sync.mjs`** — 云端同步与冲突合并。

View File

@@ -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. 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 ### Memory LLM
The memory LLM is used for: The memory LLM is used for:

View File

@@ -4,6 +4,18 @@
本文从 [README](../../README.md) 拆出 ST-BME 的主要用户配置说明,保留设置名称、默认值和表格,便于按功能查阅。 本文从 [README](../../README.md) 拆出 ST-BME 的主要用户配置说明,保留设置名称、默认值和表格,便于按功能查阅。
### 界面语言
`界面语言` 只影响 ST-BME 前端 UI面板、菜单入口、悬浮按钮、状态、Toast、召回卡片和图谱系统标签。
可选值:
- `自动`:优先跟随 SillyTavern / 浏览器语言,识别不到时使用中文。
- `简体中文`:固定中文界面。
- `English`:固定英文界面。
这个设置**不会**翻译聊天内容、用户输入、AI 回复、记忆节点、召回注入文本或提示词构建。切换语言不会改变记忆图谱和模型行为。
### 记忆 LLM ### 记忆 LLM
记忆 LLM 用于: 记忆 LLM 用于:

View File

@@ -33,7 +33,8 @@
"test:i18n-boundary": "node tests/i18n-boundary.mjs", "test:i18n-boundary": "node tests/i18n-boundary.mjs",
"test:i18n-status": "node tests/i18n-status.mjs", "test:i18n-status": "node tests/i18n-status.mjs",
"test:ui-label-formatter": "node tests/ui-label-formatter.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:graph-layout": "node tests/perf/graph-layout-bench.mjs",
"bench:persist-delta": "node tests/perf/persist-delta-bench.mjs", "bench:persist-delta": "node tests/perf/persist-delta-bench.mjs",
"bench:persist-load": "node tests/perf/persist-load-bench.mjs", "bench:persist-load": "node tests/perf/persist-load-bench.mjs",

View File

@@ -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. <button> with Chinese text
if (/<button[^>]*>.*\p{Script=Han}.*<\/button>/u.test(trimmed)) {
violations.push({ file: relativePath, line: i + 1, kind: "button-html", snippet: trimmed.slice(0, 120) });
continue;
}
// 9. Template literals with Chinese in innerHTML/textContent context
if (/`[^`]*\p{Script=Han}[^`]*`/u.test(trimmed)) {
if (/\.innerHTML\s*[+=]|\.textContent\s*[+=]|\.innerText\s*[+=]/.test(trimmed)) {
violations.push({ file: relativePath, line: i + 1, kind: "template-literal", snippet: trimmed.slice(0, 120) });
continue;
}
}
}
return violations;
}
function scanHtmlForMissingDataI18n(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 violations = [];
const lines = source.split("\n");
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (!CJK_RE.test(line)) continue;
// Skip lines that already have data-i18n (properly migrated)
if (/data-i18n(?:-[a-z-]+)?=/.test(line)) continue;
// Skip HTML comments
if (/^\s*<!--/.test(line.trim())) continue;
// <button...>Chinese text</button> without data-i18n
if (/<button[^>]*>.*\p{Script=Han}.*<\/button>/u.test(line)) {
violations.push({ file: relativePath, line: i + 1, kind: "button-html", snippet: line.trim().slice(0, 120) });
continue;
}
// title="Chinese", placeholder="Chinese", aria-label="Chinese"
if (/(?:title|placeholder|aria-label)\s*=\s*["'][^"']*\p{Script=Han}[^"']*["']/u.test(line)) {
violations.push({ file: relativePath, line: i + 1, kind: "attribute-chinese", snippet: line.trim().slice(0, 120) });
continue;
}
// <span>Chinese</span> without data-i18n
if (/<span[^>]*>[\s]*\p{Script=Han}[\p{Script=Han}\s\w]*<\/span>/u.test(line) && !/data-i18n/.test(line)) {
violations.push({ file: relativePath, line: i + 1, kind: "span-chinese-no-i18n", snippet: line.trim().slice(0, 120) });
continue;
}
}
return violations;
}
// ─── Count broad Chinese lines for ratchet ──────────────────────────────────
function countChineseLines(relativePath) {
const fullPath = join(PROJECT_ROOT, relativePath);
let source;
try {
source = readFileSync(fullPath, "utf8");
} catch {
return -1;
}
const stripped = stripComments(source);
const safe = stripI18nCalls(stripped);
const lines = safe.split("\n");
let count = 0;
for (const line of lines) {
if (CJK_RE.test(line)) count++;
}
return count;
}
// ─── Test execution ─────────────────────────────────────────────────────────
const allViolations = [];
let totalFilesScanned = 0;
for (const relPath of UI_JS_FILES) {
const violations = scanFileForUserVisibleChinese(relPath);
allViolations.push(...violations);
totalFilesScanned++;
}
for (const relPath of UI_HTML_FILES) {
const violations = scanHtmlForMissingDataI18n(relPath);
allViolations.push(...violations);
totalFilesScanned++;
}
// Group violations by file for reporting
const violationsByFile = new Map();
for (const v of allViolations) {
if (!violationsByFile.has(v.file)) violationsByFile.set(v.file, []);
violationsByFile.get(v.file).push(v);
}
console.log(`\ni18n user-visible ratchet: scanned ${totalFilesScanned} files`);
console.log(` Total user-visible Chinese violations found: ${allViolations.length}`);
if (violationsByFile.size > 0) {
for (const [file, vs] of violationsByFile) {
console.log(`\n ${file}:`);
for (const v of vs) {
console.log(` L${v.line} [${v.kind}]: ${v.snippet}`);
}
}
}
// Broad Chinese line counts (comments + t() calls stripped)
console.log(`\nBroad Chinese line counts (comments + t() calls stripped):`);
for (const relPath of UI_JS_FILES) {
const count = countChineseLines(relPath);
if (count >= 0) {
console.log(` ${relPath}: ${count}`);
}
}
// ─── Assertions ─────────────────────────────────────────────────────────────
// 1. Strict user-visible violations: MUST be zero
// toastr, confirm, prompt, textContent, innerHTML, attribute, setAttribute,
// button-html, template-literal patterns must use t() lookups.
const strictKinds = [
"toastr", "confirm", "prompt", "textContent", "innerHTML",
"attribute", "setAttribute", "button-html", "template-literal",
];
const strictViolations = allViolations.filter((v) => strictKinds.includes(v.kind));
assert.equal(
strictViolations.length,
0,
`Strict i18n violation(s) found (toastr/confirm/prompt/textContent/innerHTML/attribute/setAttribute/button-html/template-literal must use t()):\n` +
strictViolations.map((v) => ` ${v.file}:L${v.line} [${v.kind}]: ${v.snippet}`).join("\n") +
"\n\nReplace hardcoded Chinese with t() catalog lookups.",
);
// 2. Ratchet: total Chinese lines per file must not exceed baseline
for (const [file, baseline] of Object.entries(BASELINE)) {
const current = countChineseLines(file);
if (current < 0) continue; // file was skipped
assert.ok(
current <= baseline,
`i18n ratchet violated for ${file}: found ${current} Chinese-containing lines (comments/t() stripped), baseline is ${baseline}. ` +
`New hardcoded Chinese must use t() lookups instead.`,
);
}
// 3. panel.html: HTML Chinese content without data-i18n must not grow beyond baseline
const htmlViolations = allViolations.filter((v) => UI_HTML_FILES.includes(v.file));
const HTML_BASELINE = 441; // panel.html has substantial unmigrated Chinese text
assert.ok(
htmlViolations.length <= HTML_BASELINE,
`panel.html has ${htmlViolations.length} Chinese content lines without data-i18n, expected ≤ ${HTML_BASELINE} (existing baseline):\n` +
htmlViolations.map((v) => ` L${v.line} [${v.kind}]: ${v.snippet}`).join("\n"),
);
console.log("\ni18n user-visible ratchet tests passed");

View File

@@ -978,7 +978,7 @@ export class GraphRenderer {
(hasRight ? splitX : W) - pad * 2 - (hasRight ? gutter / 2 : 0), (hasRight ? splitX : W) - pad * 2 - (hasRight ? gutter / 2 : 0),
), ),
h: Math.max(0, H - pad * 2 - 6), h: Math.max(0, H - pad * 2 - 6),
label: '客观层', label: 'Objective Layer',
labelKey: 'graph.scope.objective', labelKey: 'graph.scope.objective',
tint: 'rgba(26, 35, 50, 0.42)', tint: 'rgba(26, 35, 50, 0.42)',
key: 'objective', key: 'objective',
@@ -1013,7 +1013,7 @@ export class GraphRenderer {
y: yTop, y: yTop,
w: rightW, w: rightW,
h: fullH, h: fullH,
label: '用户 POV', label: 'User POV',
labelKey: 'graph.scope.userPov', labelKey: 'graph.scope.userPov',
tint: 'rgba(32, 48, 40, 0.42)', tint: 'rgba(32, 48, 40, 0.42)',
key: 'user', key: 'user',
@@ -1048,7 +1048,7 @@ export class GraphRenderer {
y: yc, y: yc,
w: rightW, w: rightW,
h: ph, h: ph,
label: `角色 POV · ${displayName}`, label: `Character POV · ${displayName}`,
labelKey: 'graph.scope.characterPov', labelKey: 'graph.scope.characterPov',
labelParams: { name: displayName }, labelParams: { name: displayName },
tint: 'rgba(55, 42, 28, 0.38)', tint: 'rgba(55, 42, 28, 0.38)',
@@ -1071,7 +1071,7 @@ export class GraphRenderer {
y: uy, y: uy,
w: rightW, w: rightW,
h: userStripH, h: userStripH,
label: '用户 POV', label: 'User POV',
labelKey: 'graph.scope.userPov', labelKey: 'graph.scope.userPov',
tint: 'rgba(32, 48, 40, 0.42)', tint: 'rgba(32, 48, 40, 0.42)',
key: 'user', key: 'user',
@@ -1100,12 +1100,12 @@ export class GraphRenderer {
}); });
const panels = []; const panels = [];
const objectiveRect = makeRect(width * 0.5, height * 0.5, width * 0.82, height * 0.78); const objectiveRect = makeRect(width * 0.5, height * 0.5, width * 0.82, height * 0.78);
panels.push({ ...objectiveRect, label: '客观层', labelKey: 'graph.scope.objective', tint: 'rgba(87, 199, 255, 0.02)', key: 'objective' }); panels.push({ ...objectiveRect, label: 'Objective Layer', labelKey: 'graph.scope.objective', tint: 'rgba(87, 199, 255, 0.02)', key: 'objective' });
for (const n of objective) n.regionRect = objectiveRect; for (const n of objective) n.regionRect = objectiveRect;
if (userPov.length) { if (userPov.length) {
const userRect = makeRect(width * 0.68, height * 0.68, width * 0.44, height * 0.42); const userRect = makeRect(width * 0.68, height * 0.68, width * 0.44, height * 0.42);
panels.push({ ...userRect, label: '用户 POV', labelKey: 'graph.scope.userPov', tint: 'rgba(125, 255, 155, 0.02)', key: 'user' }); panels.push({ ...userRect, label: 'User POV', labelKey: 'graph.scope.userPov', tint: 'rgba(125, 255, 155, 0.02)', key: 'user' });
for (const n of userPov) n.regionRect = userRect; for (const n of userPov) n.regionRect = userRect;
} }
@@ -1121,7 +1121,7 @@ export class GraphRenderer {
const rect = makeRect(cx, cy, width * 0.34, height * 0.36); const rect = makeRect(cx, cy, width * 0.34, height * 0.36);
const key = `char:${owner || 'unknown'}`; const key = `char:${owner || 'unknown'}`;
const displayName = owner || translateUi('graph.scope.unknownCharacter'); const displayName = owner || translateUi('graph.scope.unknownCharacter');
panels.push({ ...rect, label: `角色 POV · ${displayName}`, labelKey: 'graph.scope.characterPov', labelParams: { name: displayName }, tint: 'rgba(255, 179, 71, 0.02)', key }); panels.push({ ...rect, label: `Character POV · ${displayName}`, labelKey: 'graph.scope.characterPov', labelParams: { name: displayName }, tint: 'rgba(255, 179, 71, 0.02)', key });
for (const n of nodes) n.regionRect = rect; for (const n of nodes) n.regionRect = rect;
}); });

View File

@@ -14342,7 +14342,7 @@ function _refreshRuntimeStatus() {
const upgradeState = graphPersistence.authorityUpgradeState || {}; const upgradeState = graphPersistence.authorityUpgradeState || {};
const text = formatUiStatusText(runtimeStatus) || t("status.idle"); const text = formatUiStatusText(runtimeStatus) || t("status.idle");
const meta = formatUiStatusMeta(runtimeStatus) || t("status.initial.runtime.detail"); const meta = formatUiStatusMeta(runtimeStatus) || t("status.initial.runtime.detail");
const upgradeText = upgradeState.text || graphPersistence.authorityUpgradeText || ""; const upgradeText = formatUiStatusText(upgradeState) || graphPersistence.authorityUpgradeText || "";
const displayMeta = upgradeText ? `${meta} · ${upgradeText}` : meta; const displayMeta = upgradeText ? `${meta} · ${upgradeText}` : meta;
_setText("bme-status-text", text); _setText("bme-status-text", text);
_setText("bme-status-meta", displayMeta); _setText("bme-status-meta", displayMeta);

View File

@@ -21,15 +21,23 @@ export const BATCH_STAGE_SEVERITY = {
// UI 状态工厂 // UI 状态工厂
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
export function createUiStatus(text = "待命", meta = "", level = "idle") { export function createUiStatus(text = "", meta = "", level = "idle") {
if (text && typeof text === "object") { if (text && typeof text === "object") {
return createI18nStatus({ return createI18nStatus({
...(text || {}), ...(text || {}),
level: text.level || level || "idle", level: text.level || level || "idle",
}); });
} }
if (!text && !meta) {
return createI18nStatus({
textKey: "status.idle",
textFallback: "Idle",
metaFallback: "",
level,
});
}
return { return {
text: String(text || "待命"), text: String(text || "Idle"),
meta: String(meta || ""), meta: String(meta || ""),
level, level,
updatedAt: Date.now(), updatedAt: Date.now(),
@@ -40,7 +48,7 @@ export function createGraphPersistenceState() {
return { return {
loadState: "no-chat", loadState: "no-chat",
chatId: "", chatId: "",
reason: "当前尚未进入聊天", reason: t("status.noChat", {}, { fallback: "No chat is currently open" }),
attemptIndex: 0, attemptIndex: 0,
revision: 0, revision: 0,
lastPersistedRevision: 0, lastPersistedRevision: 0,
@@ -153,8 +161,8 @@ export function createGraphPersistenceState() {
authorityDegradedReason: "", authorityDegradedReason: "",
authorityUpgradeState: createAuthorityUpgradeState(), authorityUpgradeState: createAuthorityUpgradeState(),
authorityUpgradeMode: "standalone", authorityUpgradeMode: "standalone",
authorityUpgradeText: "纯前端模式", authorityUpgradeText: "Frontend-only mode",
authorityUpgradeMeta: "未检测到可用服务端增强BME 将继续本地运行", authorityUpgradeMeta: "No server-side enhancement detected; BME will continue running locally",
authorityUpgradeLevel: "idle", authorityUpgradeLevel: "idle",
authorityUpgradeReady: false, authorityUpgradeReady: false,
authorityMigrationState: "idle", authorityMigrationState: "idle",