diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index e78477554..14eda9ef5 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -123,6 +123,12 @@ jobs:
- name: Validate GitHub config
run: pnpm run check:github-config
+ - name: Validate i18n contract
+ run: pnpm run i18n:contract:test
+
+ - name: Audit i18n resources
+ run: pnpm run i18n:audit
+
- name: Lint web UI
run: pnpm run lint:web
diff --git a/AGENTS-CN.md b/AGENTS-CN.md
index 295ecb6dd..4b711baf7 100644
--- a/AGENTS-CN.md
+++ b/AGENTS-CN.md
@@ -93,6 +93,8 @@ pnpm run desktop:build:nsis:fast # Windows 安装器,release-fast profile
- 不要把 Web UI locale 资源导入 `src/mobile-web`、`BitFun-Installer` 等较小形态。
- Web UI 只急切加载 bootstrap namespace;路由或功能文案使用
`useI18n(namespace)`,直接 `i18nService.t(...)` 只用于 bootstrap namespace。
+- `pnpm run i18n:audit` 会检查 key / 占位符一致性、直接静态 key 是否存在,以及
+ source 中不再新增硬编码 CJK 文案。
### 日志
diff --git a/AGENTS.md b/AGENTS.md
index cb303500a..a73ddcfa0 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -96,6 +96,8 @@ For the full script list, see [`package.json`](package.json).
- Web UI loads only bootstrap namespaces eagerly; use `useI18n(namespace)` for
route or feature copy and keep direct `i18nService.t(...)` calls in bootstrap
namespaces.
+- `pnpm run i18n:audit` enforces key/placeholder parity, direct static key
+ existence, and the no-hardcoded-CJK source budget.
### Logging
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 327035495..0cf873ff4 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -86,6 +86,8 @@ Captured data is logged as structured JSON under the `bitfun::devtools` target.
workflow copy in the owning surface.
- Web UI route or feature copy should use `useI18n(namespace)`. Do not import
Web UI locale catalogs into mobile-web, installer, backend, or static pages.
+- `pnpm run i18n:audit` enforces key/placeholder parity, direct static key
+ existence, and the no-hardcoded-CJK source budget.
### Platform-agnostic core
diff --git a/CONTRIBUTING_CN.md b/CONTRIBUTING_CN.md
index 4cf1cd362..d022c9a75 100644
--- a/CONTRIBUTING_CN.md
+++ b/CONTRIBUTING_CN.md
@@ -84,6 +84,8 @@ pnpm run e2e:test
`pnpm run i18n:generate`。
- 跨形态稳定标签放在 `src/shared/i18n/resources/shared`;流程文案留在所属形态资源中。
- Web UI 路由或功能文案使用 `useI18n(namespace)`。不要把 Web UI locale 资源导入 mobile-web、installer、backend 或静态页面。
+- `pnpm run i18n:audit` 会检查 key / 占位符一致性、直接静态 key 是否存在,以及
+ source 中不再新增硬编码 CJK 文案。
### 平台无关核心
diff --git a/docs/architecture/i18n.md b/docs/architecture/i18n.md
index 531b7073f..db4284a51 100644
--- a/docs/architecture/i18n.md
+++ b/docs/architecture/i18n.md
@@ -78,11 +78,18 @@ to their current locale data.
locale parsing, canonicalization, fallback order, and shared terms.
- Relay static homepage is a self-contained static surface. It is tracked in the
contract and audit baseline, but it does not import runtime locale catalogs.
+ Keep its static copy in its own resource file, such as
+ `src/apps/relay-server/static/homepage/i18n.json`, with English HTML fallback
+ text for first paint and fetch-failure resilience.
Direct Web UI `i18nService.t('namespace:key')` calls are allowed only for
bootstrap namespaces. Route, scene, and feature UI should use `useI18n(namespace)`
so the namespace can be loaded on demand.
+Large or rarely used Web UI copy should live in a feature namespace outside
+`WEB_UI_BOOTSTRAP_NAMESPACES`; components load it with `useI18n(namespace)` or
+`useTranslation(namespace)` at the point of use.
+
## Backend And Frontend Language Contract
All surfaces must exchange canonical app locale ids:
diff --git a/docs/development/i18n.md b/docs/development/i18n.md
index 199d295fd..8cf30c49f 100644
--- a/docs/development/i18n.md
+++ b/docs/development/i18n.md
@@ -104,9 +104,11 @@ to the relevant locale file instead. Literal fallback strings make audits and
translation completeness harder to enforce.
`pnpm run i18n:audit` fails on missing or extra keys in Web UI, mobile-web, and
-installer locale resources. It also fails if the checked-in CJK source candidate
-budgets grow. Existing budget warnings are grandfathered; new user-facing copy
-should be extracted instead of increasing the baseline.
+installer locale resources. It also fails on placeholder mismatches, unknown
+static Web UI `i18nService.t('namespace:key')` literals, direct `t(key,
+"literal fallback")` arguments, and CJK source candidates outside approved
+resource owners. Keep user-facing copy in locale/resource files rather than
+raising the hardcoded-copy baseline.
## Loading Size
@@ -118,6 +120,10 @@ Web UI eagerly loads only bootstrap namespaces and lazy-loads all other
namespaces. Mobile Web, Installer, Backend, and static relay pages must not
import Web UI locale catalogs to reuse copy.
+For large feature copy, create a dedicated lazy Web UI namespace instead of
+putting it in a bootstrap namespace. Static self-contained pages should keep
+their own small resource files and retain English fallback HTML.
+
For bundle-sensitive changes, compare generated asset sizes before and after the
change and include the result in the PR.
diff --git a/scripts/i18n-audit.mjs b/scripts/i18n-audit.mjs
index 013028982..1b8df0353 100644
--- a/scripts/i18n-audit.mjs
+++ b/scripts/i18n-audit.mjs
@@ -24,7 +24,9 @@ const mobileWebSourceDir = path.join(root, 'src', 'mobile-web', 'src');
const mobileWebMessagesPath = path.join(mobileWebSourceDir, 'i18n', 'messages.ts');
const installerSourceDir = path.join(root, 'BitFun-Installer', 'src');
const installerLocalesDir = path.join(installerSourceDir, 'i18n', 'locales');
+const coreLocalesDir = path.join(root, 'src', 'crates', 'core', 'locales');
const relayHomepageDir = path.join(root, 'src', 'apps', 'relay-server', 'static', 'homepage');
+const relayHomepageI18nPath = path.join(relayHomepageDir, 'i18n.json');
const supportedLocales = fs
.readdirSync(webLocalesDir, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
@@ -115,6 +117,54 @@ function flattenKeys(value, prefix = '') {
return keys.sort();
}
+function flattenStringEntries(value, prefix = '') {
+ if (typeof value === 'string') {
+ return prefix ? [[prefix, value]] : [];
+ }
+ if (Array.isArray(value)) {
+ const text = value.filter((item) => typeof item === 'string').join('\n');
+ return prefix ? [[prefix, text]] : [];
+ }
+ if (value == null || typeof value !== 'object') {
+ return prefix ? [[prefix, '']] : [];
+ }
+
+ return Object.entries(value)
+ .flatMap(([key, child]) => flattenStringEntries(child, prefix ? `${prefix}.${key}` : key))
+ .sort(([left], [right]) => left.localeCompare(right));
+}
+
+function sortedUnique(values) {
+ return Array.from(new Set(values)).sort();
+}
+
+function extractI18nextPlaceholders(value) {
+ const matches = String(value).matchAll(/\{\{\s*-?\s*([A-Za-z_][\w]*)\s*\}\}/g);
+ return sortedUnique(Array.from(matches, (match) => match[1]));
+}
+
+function extractMobilePlaceholders(value) {
+ const matches = String(value).matchAll(/\{\s*([A-Za-z_][\w]*)\s*\}/g);
+ return sortedUnique(Array.from(matches, (match) => match[1]));
+}
+
+function extractFluentPlaceholders(value) {
+ const matches = String(value).matchAll(/\$\s*([A-Za-z_][\w-]*)/g);
+ return sortedUnique(Array.from(matches, (match) => match[1]));
+}
+
+function sameSet(left, right) {
+ if (left.length !== right.length) return false;
+ return left.every((item, index) => item === right[index]);
+}
+
+function reportPlaceholderParity(surface, locale, key, expected, actual) {
+ if (sameSet(expected, actual)) return;
+ reportError(
+ `${surface} ${locale} key "${key}" placeholder mismatch: expected [${expected.join(', ')}], got [${actual.join(', ')}]`,
+ );
+}
+
function readJsonKeys(locale, namespace) {
const file = namespace === 'shared'
? path.join(sharedTermsDir, locale, 'terms.json')
@@ -127,6 +177,18 @@ function readJsonKeys(locale, namespace) {
}
}
+function readJsonEntries(locale, namespace) {
+ const file = namespace === 'shared'
+ ? path.join(sharedTermsDir, locale, 'terms.json')
+ : path.join(webLocalesDir, locale, `${namespace}.json`);
+ try {
+ return new Map(flattenStringEntries(readJsonFile(file)));
+ } catch (error) {
+ reportError(`Failed to parse ${toPosixPath(path.relative(root, file))}: ${error.message}`);
+ return new Map();
+ }
+}
+
function readInstallerJsonKeys(uiLocale) {
const file = path.join(installerLocalesDir, `${uiLocale}.json`);
try {
@@ -137,6 +199,16 @@ function readInstallerJsonKeys(uiLocale) {
}
}
+function readInstallerJsonEntries(uiLocale) {
+ const file = path.join(installerLocalesDir, `${uiLocale}.json`);
+ try {
+ return new Map(flattenStringEntries(readJsonFile(file)));
+ } catch (error) {
+ reportError(`Failed to parse ${toPosixPath(path.relative(root, file))}: ${error.message}`);
+ return new Map();
+ }
+}
+
function propertyNameToString(ts, name) {
if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name)) {
return name.text;
@@ -173,7 +245,33 @@ function flattenTsObjectKeys(ts, objectLiteral, prefix = '') {
return keys.sort();
}
-function readMobileMessageKeysByLocale() {
+function flattenTsObjectEntries(ts, objectLiteral, prefix = '') {
+ const entries = [];
+ for (const property of objectLiteral.properties) {
+ if (!ts.isPropertyAssignment(property)) continue;
+
+ const key = propertyNameToString(ts, property.name);
+ if (!key) continue;
+ if (!prefix && key === 'shared') continue;
+
+ const nextPrefix = prefix ? `${prefix}.${key}` : key;
+ const initializer = unwrapTsExpression(ts, property.initializer);
+
+ if (ts.isObjectLiteralExpression(initializer)) {
+ entries.push(...flattenTsObjectEntries(ts, initializer, nextPrefix));
+ } else if (
+ ts.isStringLiteral(initializer) ||
+ ts.isNoSubstitutionTemplateLiteral(initializer)
+ ) {
+ entries.push([nextPrefix, initializer.text]);
+ } else {
+ entries.push([nextPrefix, '']);
+ }
+ }
+ return entries.sort(([left], [right]) => left.localeCompare(right));
+}
+
+function readMobileMessagesByLocale() {
let ts;
try {
ts = require('typescript');
@@ -210,7 +308,7 @@ function readMobileMessageKeysByLocale() {
continue;
}
- output.set(locale, flattenTsObjectKeys(ts, value));
+ output.set(locale, new Map(flattenTsObjectEntries(ts, value)));
}
}
ts.forEachChild(node, visit);
@@ -220,6 +318,13 @@ function readMobileMessageKeysByLocale() {
return output;
}
+function readMobileMessageKeysByLocale() {
+ return new Map(
+ Array.from(readMobileMessagesByLocale().entries())
+ .map(([locale, entries]) => [locale, Array.from(entries.keys()).sort()]),
+ );
+}
+
function diffSets(left, right) {
const rightSet = new Set(right);
return left.filter((item) => !rightSet.has(item));
@@ -386,6 +491,27 @@ function auditKeyParity(namespaces) {
}
}
+function auditWebI18nextPlaceholderParity(namespaces) {
+ for (const namespace of namespaces) {
+ const baselineEntries = readJsonEntries(baselineLocale, namespace);
+ const baselinePlaceholders = new Map(
+ Array.from(baselineEntries.entries()).map(([key, value]) => [
+ key,
+ extractI18nextPlaceholders(value),
+ ]),
+ );
+
+ for (const locale of supportedLocales.filter((item) => item !== baselineLocale)) {
+ const localeEntries = readJsonEntries(locale, namespace);
+ for (const [key, expected] of baselinePlaceholders.entries()) {
+ if (!localeEntries.has(key)) continue;
+ const actual = extractI18nextPlaceholders(localeEntries.get(key));
+ reportPlaceholderParity(`web-ui ${namespace}`, locale, key, expected, actual);
+ }
+ }
+ }
+}
+
function auditMobileWebMessageParity() {
const messagesByLocale = readMobileMessageKeysByLocale();
const baselineKeys = messagesByLocale.get('en-US');
@@ -408,6 +534,31 @@ function auditMobileWebMessageParity() {
}
}
+function auditMobileWebPlaceholderParity() {
+ const messagesByLocale = readMobileMessagesByLocale();
+ const baselineEntries = messagesByLocale.get('en-US');
+ if (!baselineEntries) {
+ reportError('mobile-web messages are missing the en-US baseline locale');
+ return;
+ }
+
+ const baselinePlaceholders = new Map(
+ Array.from(baselineEntries.entries()).map(([key, value]) => [
+ key,
+ extractMobilePlaceholders(value),
+ ]),
+ );
+
+ for (const [locale, entries] of messagesByLocale.entries()) {
+ if (locale === 'en-US') continue;
+ for (const [key, expected] of baselinePlaceholders.entries()) {
+ if (!entries.has(key)) continue;
+ const actual = extractMobilePlaceholders(entries.get(key));
+ reportPlaceholderParity('mobile-web', locale, key, expected, actual);
+ }
+ }
+}
+
function auditInstallerKeyParity() {
const baselineKeys = readInstallerJsonKeys('en');
for (const uiLocale of ['zh', 'zh-TW']) {
@@ -424,6 +575,162 @@ function auditInstallerKeyParity() {
}
}
+function auditInstallerPlaceholderParity() {
+ const baselineEntries = readInstallerJsonEntries('en');
+ const baselinePlaceholders = new Map(
+ Array.from(baselineEntries.entries()).map(([key, value]) => [
+ key,
+ extractI18nextPlaceholders(value),
+ ]),
+ );
+
+ for (const uiLocale of ['zh', 'zh-TW']) {
+ const entries = readInstallerJsonEntries(uiLocale);
+ for (const [key, expected] of baselinePlaceholders.entries()) {
+ if (!entries.has(key)) continue;
+ const actual = extractI18nextPlaceholders(entries.get(key));
+ reportPlaceholderParity('installer', uiLocale, key, expected, actual);
+ }
+ }
+}
+
+function readFluentMessages(localeId) {
+ const file = path.join(coreLocalesDir, `${localeId}.ftl`);
+ const messages = new Map();
+ let currentKey = null;
+ let currentLines = [];
+
+ function flushCurrent() {
+ if (currentKey) {
+ messages.set(currentKey, currentLines.join('\n'));
+ }
+ currentKey = null;
+ currentLines = [];
+ }
+
+ try {
+ const lines = fs.readFileSync(file, 'utf8').split(/\r?\n/);
+ for (const line of lines) {
+ const match = line.match(/^([A-Za-z][\w-]*)\s*=\s*(.*)$/);
+ if (match) {
+ flushCurrent();
+ currentKey = match[1];
+ currentLines = [match[2]];
+ continue;
+ }
+ if (currentKey && (/^\s+/.test(line) || line.trim().startsWith('*[') || line.trim().startsWith('['))) {
+ currentLines.push(line);
+ }
+ }
+ flushCurrent();
+ } catch (error) {
+ reportError(`Failed to parse ${toPosixPath(path.relative(root, file))}: ${error.message}`);
+ }
+
+ return messages;
+}
+
+function auditCoreFluentParity() {
+ const coreLocales = localeContract.surfaceOrders?.core ?? [];
+ const baselineCoreLocale = coreLocales.includes('en-US') ? 'en-US' : coreLocales[0];
+ const baselineEntries = readFluentMessages(baselineCoreLocale);
+ const baselineKeys = Array.from(baselineEntries.keys()).sort();
+ const baselinePlaceholders = new Map(
+ Array.from(baselineEntries.entries()).map(([key, value]) => [
+ key,
+ extractFluentPlaceholders(value),
+ ]),
+ );
+
+ for (const locale of coreLocales.filter((item) => item !== baselineCoreLocale)) {
+ const entries = readFluentMessages(locale);
+ const keys = Array.from(entries.keys()).sort();
+ for (const key of diffSets(baselineKeys, keys)) {
+ reportError(`core ${locale}.ftl is missing key "${key}"`);
+ }
+ for (const key of diffSets(keys, baselineKeys)) {
+ reportError(`core ${locale}.ftl has extra key "${key}"`);
+ }
+ for (const [key, expected] of baselinePlaceholders.entries()) {
+ if (!entries.has(key)) continue;
+ const actual = extractFluentPlaceholders(entries.get(key));
+ reportPlaceholderParity('core Fluent', locale, key, expected, actual);
+ }
+ }
+}
+
+function readRelayHomepageMessages() {
+ let resource;
+ try {
+ resource = readJsonFile(relayHomepageI18nPath);
+ } catch (error) {
+ reportError(`Failed to parse ${toPosixPath(path.relative(root, relayHomepageI18nPath))}: ${error.message}`);
+ return { localeIds: [], entriesByLocale: new Map() };
+ }
+
+ const entriesByLocale = new Map();
+ for (const [locale, messages] of Object.entries(resource)) {
+ entriesByLocale.set(locale, new Map(flattenStringEntries(messages)));
+ }
+
+ return {
+ localeIds: Object.keys(resource).sort(),
+ entriesByLocale,
+ };
+}
+
+function collectRelayHomepageDataKeys() {
+ const htmlPath = path.join(relayHomepageDir, 'index.html');
+ const html = fs.readFileSync(htmlPath, 'utf8');
+ return sortedUnique(Array.from(html.matchAll(/\bdata-i18n="([^"]+)"/g), (match) => match[1]));
+}
+
+function auditRelayStaticHomepageResources() {
+ const expectedLocaleIds = (localeContract.locales ?? []).map((locale) => locale.id).sort();
+ const { localeIds, entriesByLocale } = readRelayHomepageMessages();
+ const baselineLocaleId = expectedLocaleIds.includes('en-US') ? 'en-US' : expectedLocaleIds[0];
+ const baselineEntries = entriesByLocale.get(baselineLocaleId) ?? new Map();
+ const baselineKeys = Array.from(baselineEntries.keys()).sort();
+ const dataKeys = collectRelayHomepageDataKeys();
+
+ for (const locale of diffSets(expectedLocaleIds, localeIds)) {
+ reportError(`relay static homepage i18n.json is missing locale "${locale}"`);
+ }
+ for (const locale of diffSets(localeIds, expectedLocaleIds)) {
+ reportError(`relay static homepage i18n.json has non-canonical locale "${locale}"`);
+ }
+ for (const key of diffSets(dataKeys, baselineKeys)) {
+ reportError(`relay static homepage index.html references missing i18n key "${key}"`);
+ }
+ for (const key of diffSets(baselineKeys, dataKeys)) {
+ reportError(`relay static homepage i18n.json has unused baseline key "${key}"`);
+ }
+
+ const baselinePlaceholders = new Map(
+ Array.from(baselineEntries.entries()).map(([key, value]) => [
+ key,
+ extractI18nextPlaceholders(value),
+ ]),
+ );
+
+ for (const locale of expectedLocaleIds.filter((item) => item !== baselineLocaleId)) {
+ const entries = entriesByLocale.get(locale);
+ if (!entries) continue;
+ const keys = Array.from(entries.keys()).sort();
+ for (const key of diffSets(baselineKeys, keys)) {
+ reportError(`relay static homepage ${locale} messages are missing key "${key}"`);
+ }
+ for (const key of diffSets(keys, baselineKeys)) {
+ reportError(`relay static homepage ${locale} messages have extra key "${key}"`);
+ }
+ for (const [key, expected] of baselinePlaceholders.entries()) {
+ if (!entries.has(key)) continue;
+ const actual = extractI18nextPlaceholders(entries.get(key));
+ reportPlaceholderParity('relay static homepage', locale, key, expected, actual);
+ }
+ }
+}
+
function shouldSkipSourceScan(file) {
const normalized = toPosixPath(path.relative(root, file));
return (
@@ -482,7 +789,63 @@ function auditSourceText() {
}
if (fallbackFindings.length > 0) {
- reportWarning(`Found ${fallbackFindings.length} t(key, "literal fallback") candidate(s). First entries: ${fallbackFindings.slice(0, 12).join(', ')}`);
+ reportError(`Found ${fallbackFindings.length} t(key, "literal fallback") candidate(s). First entries: ${fallbackFindings.slice(0, 12).join(', ')}`);
+ }
+}
+
+function lineNumberAt(text, index) {
+ return text.slice(0, index).split(/\r?\n/).length;
+}
+
+function collectWebUiStaticTranslationKeys() {
+ const sourceFiles = listFiles(
+ webSourceDir,
+ (file) => (file.endsWith('.ts') || file.endsWith('.tsx')) && !shouldSkipSourceScan(file),
+ );
+ const output = [];
+ const patterns = [
+ /i18nService\.t\(\s*(['"`])([^'"`$]+?)\1/g,
+ /i18nService\.getT\(\)\(\s*(['"`])([^'"`$]+?)\1/g,
+ ];
+
+ for (const file of sourceFiles) {
+ const text = fs.readFileSync(file, 'utf8');
+ for (const pattern of patterns) {
+ for (const match of text.matchAll(pattern)) {
+ const key = match[2];
+ if (!key.includes(':')) continue;
+ output.push({
+ key,
+ location: `${toPosixPath(path.relative(root, file))}:${lineNumberAt(text, match.index ?? 0)}`,
+ });
+ }
+ }
+ }
+
+ return output;
+}
+
+function buildWebUiKeySet(namespaces) {
+ const keys = new Set();
+ for (const namespace of namespaces) {
+ for (const key of readJsonKeys(baselineLocale, namespace)) {
+ keys.add(`${namespace}:${key}`);
+ }
+ }
+ return keys;
+}
+
+function auditWebUiStaticTranslationKeys(namespaces) {
+ const knownKeys = buildWebUiKeySet(namespaces);
+ const missing = collectWebUiStaticTranslationKeys()
+ .filter(({ key }) => !knownKeys.has(key));
+
+ if (missing.length > 0) {
+ reportError(
+ `Found ${missing.length} unknown static Web UI i18n key(s). First entries: ${
+ missing.slice(0, 12).map(({ key, location }) => `${location} ${key}`).join(', ')
+ }`,
+ );
}
}
@@ -555,8 +918,14 @@ auditMobileWebBoundary();
const namespaces = auditNamespaceCoverage();
auditKeyParity(namespaces);
+auditWebI18nextPlaceholderParity(namespaces);
+auditWebUiStaticTranslationKeys(namespaces);
auditMobileWebMessageParity();
+auditMobileWebPlaceholderParity();
auditInstallerKeyParity();
+auditInstallerPlaceholderParity();
+auditCoreFluentParity();
+auditRelayStaticHomepageResources();
auditSourceText();
auditHardcodedSourceBudgets();
diff --git a/scripts/i18n-contract.test.mjs b/scripts/i18n-contract.test.mjs
index 392a6936a..d2d4456d5 100644
--- a/scripts/i18n-contract.test.mjs
+++ b/scripts/i18n-contract.test.mjs
@@ -225,6 +225,40 @@ test('i18n audit treats locale key parity as an error', () => {
assert.match(auditSource, /auditInstallerKeyParity/, 'installer locale keys should be covered by i18n:audit');
});
+test('CI runs i18n contract and audit guards before frontend builds', () => {
+ const ciSource = readText('.github/workflows/ci.yml');
+ const contractIndex = ciSource.indexOf('pnpm run i18n:contract:test');
+ const auditIndex = ciSource.indexOf('pnpm run i18n:audit');
+ const buildIndex = ciSource.indexOf('pnpm run build:web');
+
+ assert.notEqual(contractIndex, -1, 'CI should run pnpm run i18n:contract:test');
+ assert.notEqual(auditIndex, -1, 'CI should run pnpm run i18n:audit');
+ assert.ok(contractIndex < buildIndex, 'i18n contract checks should run before web build');
+ assert.ok(auditIndex < buildIndex, 'i18n audit should run before web build');
+});
+
+test('i18n audit enforces interpolation parameter parity across resource formats', () => {
+ const auditSource = readText('scripts/i18n-audit.mjs');
+
+ assert.match(auditSource, /auditWebI18nextPlaceholderParity/, 'Web UI JSON placeholders should be audited');
+ assert.match(auditSource, /auditMobileWebPlaceholderParity/, 'mobile-web placeholders should be audited');
+ assert.match(auditSource, /auditInstallerPlaceholderParity/, 'installer placeholders should be audited');
+ assert.match(auditSource, /auditCoreFluentParity/, 'core Fluent keys and placeholders should be audited');
+ assert.match(auditSource, /auditRelayStaticHomepageResources/, 'relay static homepage resources should be audited');
+ assert.match(auditSource, /extractI18nextPlaceholders/, 'i18next placeholder extraction should be explicit');
+ assert.match(auditSource, /extractMobilePlaceholders/, 'mobile placeholder extraction should be explicit');
+ assert.match(auditSource, /extractFluentPlaceholders/, 'Fluent placeholder extraction should be explicit');
+});
+
+test('i18n audit fails literal fallbacks and unknown static keys', () => {
+ const auditSource = readText('scripts/i18n-audit.mjs');
+ const sourceTextAudit = auditSource.match(/function auditSourceText\(\) \{[\s\S]*?\n\}/)?.[0] ?? '';
+
+ assert.match(sourceTextAudit, /reportError/, 'literal t(key, "fallback") candidates should fail audit');
+ assert.match(auditSource, /auditWebUiStaticTranslationKeys/, 'static Web UI translation keys should be checked');
+ assert.match(auditSource, /collectWebUiStaticTranslationKeys/, 'static key collection should be explicit');
+});
+
test('installer Rust locale contract is generated from the installer surface order', () => {
const generatorSource = readText('scripts/generate-i18n-contract.mjs');
const installerRustGenerator = generatorSource.match(/function generateInstallerRustLocaleContract\(contract\) \{[\s\S]*?\n\}/)?.[0] ?? '';
diff --git a/scripts/i18n-hardcoded-baseline.json b/scripts/i18n-hardcoded-baseline.json
index 07ea3d120..8f3c7d67e 100644
--- a/scripts/i18n-hardcoded-baseline.json
+++ b/scripts/i18n-hardcoded-baseline.json
@@ -3,11 +3,11 @@
"budgets": [
{
"id": "web-ui-source",
- "maxCjkLines": 198
+ "maxCjkLines": 0
},
{
"id": "mobile-web-source",
- "maxCjkLines": 8
+ "maxCjkLines": 0
},
{
"id": "installer-source",
@@ -15,7 +15,7 @@
},
{
"id": "relay-static-homepage",
- "maxCjkLines": 17
+ "maxCjkLines": 0
}
]
}
diff --git a/src/apps/relay-server/static/homepage/i18n.json b/src/apps/relay-server/static/homepage/i18n.json
new file mode 100644
index 000000000..431d462b8
--- /dev/null
+++ b/src/apps/relay-server/static/homepage/i18n.json
@@ -0,0 +1,53 @@
+{
+ "zh-CN": {
+ "languageToggle": "语言:简体",
+ "status": "服务运行中",
+ "desc": "桌面端与移动端之间的中继服务,提供配对与消息转发能力。",
+ "f1t": "建立连接",
+ "f1d": "承接配对请求,建立远程会话。",
+ "f2t": "消息转发",
+ "f2d": "桥接 WebSocket 与 HTTP 双向通信。",
+ "f3t": "自托管友好",
+ "f3d": "私有部署、自定义域名与反向代理。",
+ "flowDesktop": "桌面端",
+ "flowDesktopSub": "BitFun Agent",
+ "flowRelay": "中继服务",
+ "flowRelaySub": "配对 & 转发",
+ "flowMobile": "移动端",
+ "flowMobileSub": "远程控制"
+ },
+ "zh-TW": {
+ "languageToggle": "語言:繁體",
+ "status": "服務運行中",
+ "desc": "桌面端與移動端之間的中繼服務,提供配對與消息轉發能力。",
+ "f1t": "建立連接",
+ "f1d": "承接配對請求,建立遠程會話。",
+ "f2t": "消息轉發",
+ "f2d": "橋接 WebSocket 與 HTTP 雙向通信。",
+ "f3t": "自託管友好",
+ "f3d": "私有部署、自定義域名與反向代理。",
+ "flowDesktop": "桌面端",
+ "flowDesktopSub": "BitFun Agent",
+ "flowRelay": "中繼服務",
+ "flowRelaySub": "配對 & 轉發",
+ "flowMobile": "移動端",
+ "flowMobileSub": "遠程控制"
+ },
+ "en-US": {
+ "languageToggle": "Language: EN",
+ "status": "Service Running",
+ "desc": "Relay service between desktop and mobile, enabling pairing and message forwarding.",
+ "f1t": "Connect",
+ "f1d": "Handle pairing requests, create remote sessions.",
+ "f2t": "Relay",
+ "f2d": "Bridge WebSocket and HTTP bidirectionally.",
+ "f3t": "Self-Host",
+ "f3d": "Private deploy, custom domains and reverse proxy.",
+ "flowDesktop": "Desktop",
+ "flowDesktopSub": "BitFun Agent",
+ "flowRelay": "Relay Server",
+ "flowRelaySub": "Pair and Forward",
+ "flowMobile": "Mobile",
+ "flowMobileSub": "Remote Control"
+ }
+}
diff --git a/src/apps/relay-server/static/homepage/index.html b/src/apps/relay-server/static/homepage/index.html
index 1a5b656f4..a0ab330a3 100644
--- a/src/apps/relay-server/static/homepage/index.html
+++ b/src/apps/relay-server/static/homepage/index.html
@@ -1,5 +1,5 @@
-
+
@@ -156,10 +156,10 @@
BitFun Relay Server
-
+
- 服务运行中
+ Service Running
@@ -168,7 +168,7 @@
BitFun Relay Server
- 桌面端与移动端之间的中继服务,提供配对与消息转发能力。
+ Relay service between desktop and mobile, enabling pairing and message forwarding.
WebSocket
@@ -194,16 +194,16 @@ BitFun Relay Server
- 中继服务
- 配对 & 转发
+ Relay Server
+ Pair and Forward
HTTP
-
移动端
-
远程控制
+
Mobile
+
Remote Control
@@ -211,23 +211,23 @@ BitFun Relay Server
- 承接配对请求,建立远程会话。
+ Handle pairing requests, create remote sessions.
- 桥接 WebSocket 与 HTTP 双向通信。
+ Bridge WebSocket and HTTP bidirectionally.
- 私有部署、自定义域名与反向代理。
+ Private deploy, custom domains and reverse proxy.
@@ -236,18 +236,14 @@ 自托管友好
diff --git a/src/mobile-web/src/App.tsx b/src/mobile-web/src/App.tsx
index 92674d931..6aa278361 100644
--- a/src/mobile-web/src/App.tsx
+++ b/src/mobile-web/src/App.tsx
@@ -81,7 +81,7 @@ const AppContent: React.FC = () => {
useEffect(() => () => clearTimeout(timerRef.current), []);
- // 全局链接点击处理 - 确保所有外部链接在新标签页打开
+ // Open external links in a new tab from anywhere in the app.
useEffect(() => {
const handleLinkClick = (e: MouseEvent) => {
const target = e.target as HTMLElement;
@@ -89,7 +89,7 @@ const AppContent: React.FC = () => {
if (link && link.href) {
const href = link.href;
- // 检查是否是外部链接 (http/https 且不是当前域名)
+ // Treat all http(s) links as external for the mobile web shell.
if (href.startsWith('http://') || href.startsWith('https://')) {
e.preventDefault();
e.stopPropagation();
@@ -98,7 +98,7 @@ const AppContent: React.FC = () => {
}
};
- // 添加全局点击监听
+ // Capture link clicks before nested content handles them.
document.addEventListener('click', handleLinkClick, true);
return () => {
diff --git a/src/mobile-web/src/i18n/messages.ts b/src/mobile-web/src/i18n/messages.ts
index 27de53f75..803faa409 100644
--- a/src/mobile-web/src/i18n/messages.ts
+++ b/src/mobile-web/src/i18n/messages.ts
@@ -327,7 +327,7 @@ export const messages: Record = {
modelPrimary: 'Primary 模型',
modelFast: 'Fast 模型',
modelNotConfigured: '模型未配置',
- askQuestionCount: '{count} 个问题',
+ askQuestionCount: '{count} 个问题{suffix}',
waiting: '等待中',
modeAgentic: 'Agentic',
modePlan: 'Plan',
@@ -335,7 +335,7 @@ export const messages: Record = {
thinking: '思考中...',
allTasksCompleted: '所有任务已完成',
task: '任务',
- toolCalls: '{count} 次工具调用',
+ toolCalls: '{count} 次工具调用{suffix}',
done: '已完成 {count}',
running: '运行中 {count}',
thoughtCharacters: '思考 {count} 个字符',
@@ -509,7 +509,7 @@ export const messages: Record = {
modelPrimary: 'Primary 模型',
modelFast: 'Fast 模型',
modelNotConfigured: '模型未配置',
- askQuestionCount: '{count} 個問題',
+ askQuestionCount: '{count} 個問題{suffix}',
waiting: '等待中',
modeAgentic: 'Agentic',
modePlan: 'Plan',
@@ -517,7 +517,7 @@ export const messages: Record = {
thinking: '思考中...',
allTasksCompleted: '所有任務已完成',
task: '任務',
- toolCalls: '{count} 次工具調用',
+ toolCalls: '{count} 次工具調用{suffix}',
done: '已完成 {count}',
running: '運行中 {count}',
thoughtCharacters: '思考 {count} 個字符',
diff --git a/src/mobile-web/src/pages/ChatPage.tsx b/src/mobile-web/src/pages/ChatPage.tsx
index 1705ba843..34f7ab0e1 100644
--- a/src/mobile-web/src/pages/ChatPage.tsx
+++ b/src/mobile-web/src/pages/ChatPage.tsx
@@ -4,6 +4,7 @@ import remarkGfm from 'remark-gfm';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { vscDarkPlus, vs } from 'react-syntax-highlighter/dist/esm/styles/prism';
import { useI18n } from '../i18n';
+import { messages } from '../i18n/messages';
import {
RemoteSessionManager,
SessionPoller,
@@ -33,6 +34,10 @@ function formatDuration(ms: number): string {
return `${(ms / 1000).toFixed(1)}s`;
}
+function getEnglishPluralSuffix(language: string, count: number): string {
+ return language === 'en-US' && count !== 1 ? 's' : '';
+}
+
function truncateMiddle(str: string, maxLen: number): string {
if (!str || str.length <= maxLen) return str;
const keep = maxLen - 3;
@@ -151,7 +156,7 @@ function normalizeFileLikeHref(rawHref: string): string {
}
}
- // Normalize URI-like Windows absolute paths such as `/C:/Users/...`.
+ // Normalize URI-like Windows absolute paths with a leading slash before the drive letter.
if (/^\/[A-Za-z]:[\\/]/.test(filePath)) {
filePath = filePath.slice(1);
}
@@ -474,7 +479,7 @@ const MarkdownContent: React.FC = ({ content, onFileDownlo
// Fallback: render as plain text for computer:// links without handler,
// or as a regular link for http(s) links.
if (typeof href === 'string') {
- // 所有外部链接都在新标签页打开
+ // Open all external links in a new tab.
const isExternalLink = href.startsWith('http://') || href.startsWith('https://');
if (isExternalLink) {
return (
@@ -745,7 +750,7 @@ const TaskToolCard: React.FC<{
subItems?: ChatMessageItem[];
onCancelTool?: (toolId: string) => void;
}> = ({ tool, now, subItems = [], onCancelTool }) => {
- const { t } = useI18n();
+ const { t, language } = useI18n();
const scrollRef = useRef(null);
const prevCountRef = useRef(0);
const [stepsExpanded, setStepsExpanded] = useState(false);
@@ -818,7 +823,7 @@ const TaskToolCard: React.FC<{
<>
setStepsExpanded(e => !e)}>
- {t('chat.toolCalls', { count: subTools.length, suffix: subTools.length === 1 ? '' : 's' })}
+ {t('chat.toolCalls', { count: subTools.length, suffix: getEnglishPluralSuffix(language, subTools.length) })}
{t('chat.done', { count: subToolsDone })}
@@ -1081,7 +1086,7 @@ const ToolList: React.FC<{
now: number;
onCancelTool?: (toolId: string) => void;
}> = ({ tools, now, onCancelTool }) => {
- const { t } = useI18n();
+ const { t, language } = useI18n();
const scrollRef = useRef(null);
const prevCountRef = useRef(0);
const [expanded, setExpanded] = useState(false);
@@ -1111,7 +1116,7 @@ const ToolList: React.FC<{
return (
setExpanded(e => !e)}>
-
{t('chat.toolCalls', { count: tools.length, suffix: tools.length === 1 ? '' : 's' })}
+
{t('chat.toolCalls', { count: tools.length, suffix: getEnglishPluralSuffix(language, tools.length) })}
{doneCount > 0 && {t('chat.done', { count: doneCount })}}
{runningCount > 0 && {t('chat.running', { count: runningCount })}}
@@ -1224,13 +1229,35 @@ const isPendingAskUserQuestion = (tool?: RemoteToolStatus | null) => {
return !['completed', 'failed', 'cancelled', 'rejected'].includes(tool.status);
};
-const isOtherQuestionOption = (label?: string) => {
+function getMessageByPath(source: unknown, path: string): string | null {
+ const segments = path.split('.');
+ let current: unknown = source;
+
+ for (const segment of segments) {
+ if (!current || typeof current !== 'object' || !(segment in current)) {
+ return null;
+ }
+ current = (current as Record)[segment];
+ }
+
+ return typeof current === 'string' ? current : null;
+}
+
+const OTHER_QUESTION_OPTION_LABELS = new Set([
+ 'other',
+ ...Object.values(messages)
+ .map((localeMessages) => getMessageByPath(localeMessages, 'common.other'))
+ .filter((label): label is string => !!label)
+ .map((label) => label.trim().toLowerCase()),
+]);
+
+const isOtherQuestionOption = (label: string | undefined) => {
const normalized = (label || '').trim().toLowerCase();
- return normalized === 'other' || normalized === '其他';
+ return OTHER_QUESTION_OPTION_LABELS.has(normalized);
};
const AskQuestionCard: React.FC = ({ tool, onAnswer }) => {
- const { t } = useI18n();
+ const { t, language } = useI18n();
const questions: any[] = tool.tool_input?.questions || [];
const [selected, setSelected] = useState>({});
const [customTexts, setCustomTexts] = useState>({});
@@ -1295,7 +1322,7 @@ const AskQuestionCard: React.FC = ({ tool, onAnswer }) =>
return (
-
{t('chat.askQuestionCount', { count: questions.length, suffix: questions.length > 1 ? 's' : '' })}
+
{t('chat.askQuestionCount', { count: questions.length, suffix: getEnglishPluralSuffix(language, questions.length) })}
{!submitted && !submitting && (
{t('chat.waiting')}
)}
diff --git a/src/mobile-web/src/services/RemoteSessionManager.ts b/src/mobile-web/src/services/RemoteSessionManager.ts
index 1e760dab2..34de29144 100644
--- a/src/mobile-web/src/services/RemoteSessionManager.ts
+++ b/src/mobile-web/src/services/RemoteSessionManager.ts
@@ -514,7 +514,7 @@ export class SessionPoller {
private getInterval(): number {
if (document.visibilityState !== 'visible') return 5000;
- // 如果在宽限期内(turn 刚刚结束),继续保持快速轮询
+ // Keep fast polling during the short grace period after a turn ends.
const now = Date.now();
if (this.turnJustEndedAt != null && (now - this.turnJustEndedAt) < this.TURN_JUST_ENDED_GRACE_PERIOD_MS) {
return 1000;
@@ -560,12 +560,12 @@ export class SessionPoller {
const isActiveNow = resp.active_turn != null && resp.active_turn.status === 'active';
this.hasActiveTurn = isActiveNow;
- // 检测到 active_turn 刚刚结束,设置宽限期
+ // Start the grace period when active_turn just ended.
if (wasActive && !isActiveNow) {
this.turnJustEndedAt = Date.now();
}
- // 如果有新消息或者 active_turn 仍然活跃,重置宽限期
+ // Clear the grace period once new messages arrive.
if (resp.new_messages && resp.new_messages.length > 0) {
this.turnJustEndedAt = null;
}
diff --git a/src/web-ui/src/app/scenes/settings/SettingsNav.tsx b/src/web-ui/src/app/scenes/settings/SettingsNav.tsx
index 27b25e455..b5a9f6751 100644
--- a/src/web-ui/src/app/scenes/settings/SettingsNav.tsx
+++ b/src/web-ui/src/app/scenes/settings/SettingsNav.tsx
@@ -28,6 +28,7 @@ import { SETTINGS_TAB_SEARCH_CONTENT } from './settingsTabSearchContent';
import './SettingsNav.scss';
const SEARCH_DEBOUNCE_MS = 150;
+type SettingsT = (key: string, options?: Record
) => unknown;
export interface SettingsSearchRow {
tabId: ConfigTab;
@@ -53,21 +54,37 @@ function resolveTabPageContentHaystack(i18n: I18nApi, tabId: ConfigTab): string
return parts.join(' ');
}
+function translateString(t: SettingsT, key: string, defaultValue: string): string {
+ const value = t(key, { defaultValue });
+ return typeof value === 'string' ? value : defaultValue;
+}
+
+function readSearchAliases(t: SettingsT, tabId: ConfigTab): string[] {
+ const aliases = t(`configCenter.searchAliases.${tabId}`, {
+ defaultValue: [],
+ returnObjects: true,
+ });
+ return Array.isArray(aliases)
+ ? aliases.filter((alias): alias is string => typeof alias === 'string')
+ : [];
+}
+
function buildSettingsSearchIndex(
- t: (key: string, options?: Record) => string,
+ t: SettingsT,
i18n: I18nApi
): SettingsSearchRow[] {
const rows: SettingsSearchRow[] = [];
for (const cat of SETTINGS_CATEGORIES) {
- const categoryLabel = t(cat.nameKey, { defaultValue: cat.id });
+ const categoryLabel = translateString(t, cat.nameKey, cat.id);
for (const tabDef of cat.tabs) {
- const tabLabel = t(tabDef.labelKey, { defaultValue: tabDef.id });
+ const tabLabel = translateString(t, tabDef.labelKey, tabDef.id);
const description = tabDef.descriptionKey
- ? t(tabDef.descriptionKey, { defaultValue: '' })
+ ? translateString(t, tabDef.descriptionKey, '')
: '';
const kw = (tabDef.keywords ?? []).join(' ');
+ const aliases = readSearchAliases(t, tabDef.id).join(' ');
const pageContent = resolveTabPageContentHaystack(i18n, tabDef.id);
- const haystack = [categoryLabel, tabLabel, description, kw, tabDef.id, pageContent]
+ const haystack = [categoryLabel, tabLabel, description, kw, aliases, tabDef.id, pageContent]
.join(' ')
.toLowerCase();
rows.push({
diff --git a/src/web-ui/src/app/scenes/settings/settingsConfig.ts b/src/web-ui/src/app/scenes/settings/settingsConfig.ts
index b3f810bb2..f1ad68eb1 100644
--- a/src/web-ui/src/app/scenes/settings/settingsConfig.ts
+++ b/src/web-ui/src/app/scenes/settings/settingsConfig.ts
@@ -107,8 +107,6 @@ export const SETTINGS_CATEGORIES: ConfigCategoryDef[] = [
'sessions',
'restore',
'unarchive',
- '\u5f52\u6863',
- '\u4f1a\u8bdd',
],
},
{
@@ -121,8 +119,6 @@ export const SETTINGS_CATEGORIES: ConfigCategoryDef[] = [
'keybinding',
'hotkey',
'shortcut key',
- '\u5feb\u6377\u952e',
- '\u952e\u4f4d',
],
},
],
@@ -143,8 +139,6 @@ export const SETTINGS_CATEGORIES: ConfigCategoryDef[] = [
'pixel',
'pet',
'partner',
- '\u4f19\u4f34',
- '\u4e2a\u6027\u5316',
],
},
{
@@ -167,7 +161,6 @@ export const SETTINGS_CATEGORIES: ConfigCategoryDef[] = [
'search',
'flashgrep',
'index',
- '\u6743\u9650',
],
},
{
@@ -182,8 +175,6 @@ export const SETTINGS_CATEGORIES: ConfigCategoryDef[] = [
'pull request',
'post-coding',
'shortcut',
- '快捷动作',
- '提交',
],
},
{
@@ -198,8 +189,6 @@ export const SETTINGS_CATEGORIES: ConfigCategoryDef[] = [
'subagent',
'readonly',
'audit',
- '\u5ba1\u6838',
- '\u4ee3\u7801\u5ba1\u6838',
],
},
{
diff --git a/src/web-ui/src/flow_chat/components/FlowTextBlock.tsx b/src/web-ui/src/flow_chat/components/FlowTextBlock.tsx
index 941083aba..7d70090e0 100644
--- a/src/web-ui/src/flow_chat/components/FlowTextBlock.tsx
+++ b/src/web-ui/src/flow_chat/components/FlowTextBlock.tsx
@@ -12,7 +12,6 @@ import { DotMatrixLoader } from '@/component-library';
import type { FlowTextItem } from '../types/flow-chat';
import { useFlowChatContext } from './modern/FlowChatContext';
import { useTypewriter } from '../hooks/useTypewriter';
-import { processingHintsZh, processingHintsEn } from '../constants/processingHints';
import './FlowTextBlock.scss';
// Idle timeout (ms) after content stops growing.
@@ -24,6 +23,25 @@ interface FlowTextBlockProps {
replayStreamingOnMount?: boolean;
}
+const RuntimeStatusBlock: React.FC> = ({ textItem, className = '' }) => {
+ const { t } = useTranslation('flow-chat/processing-hints');
+ const rawHints = t('items', { returnObjects: true });
+ const hints = Array.isArray(rawHints)
+ ? rawHints.filter((item): item is string => typeof item === 'string')
+ : [];
+ const hintIndex = hints.length > 0
+ ? Math.abs(textItem.id.split('').reduce((acc, ch) => acc + ch.charCodeAt(0), 0)) % hints.length
+ : 0;
+ const hint = hints[hintIndex] ?? '';
+
+ return (
+
+
+ {hint && {hint}}
+
+ );
+};
+
/**
* Use React.memo to avoid unnecessary re-renders.
* Re-render only when key textItem fields change.
@@ -34,7 +52,6 @@ export const FlowTextBlock = React.memo(({
replayStreamingOnMount = true
}) => {
const { onFileViewRequest, onTabOpen, onHttpLinkClick, onOpenVisualization } = useFlowChatContext();
- const { i18n } = useTranslation();
// Normalize content to a string.
const content = typeof textItem.content === 'string'
@@ -84,16 +101,7 @@ export const FlowTextBlock = React.memo(({
isContentGrowing;
if (textItem.runtimeStatus) {
- const hints = i18n.language.startsWith('zh') ? processingHintsZh : processingHintsEn;
- const hintIndex = Math.abs(textItem.id.split('').reduce((acc, ch) => acc + ch.charCodeAt(0), 0)) % hints.length;
- const hint = hints[hintIndex];
-
- return (
-
-
- {hint}
-
- );
+ return ;
}
return (
diff --git a/src/web-ui/src/flow_chat/components/modern/ProcessingIndicator.tsx b/src/web-ui/src/flow_chat/components/modern/ProcessingIndicator.tsx
index a5715e345..eb79194fd 100644
--- a/src/web-ui/src/flow_chat/components/modern/ProcessingIndicator.tsx
+++ b/src/web-ui/src/flow_chat/components/modern/ProcessingIndicator.tsx
@@ -8,7 +8,6 @@
import React, { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { DotMatrixLoader } from '@/component-library';
-import { processingHintsZh, processingHintsEn } from '../../constants/processingHints';
import './ProcessingIndicator.scss';
interface ProcessingIndicatorProps {
@@ -18,8 +17,11 @@ interface ProcessingIndicatorProps {
}
export const ProcessingIndicator: React.FC = ({ visible, reserveSpace = false }) => {
- const { i18n } = useTranslation();
- const hints = i18n.language.startsWith('zh') ? processingHintsZh : processingHintsEn;
+ const { t } = useTranslation('flow-chat/processing-hints');
+ const rawHints = t('items', { returnObjects: true });
+ const hints = Array.isArray(rawHints)
+ ? rawHints.filter((item): item is string => typeof item === 'string')
+ : [];
const [showHint, setShowHint] = useState(false);
const [hintIndex, setHintIndex] = useState(0);
@@ -28,7 +30,7 @@ export const ProcessingIndicator: React.FC = ({ visibl
const rotateTimerRef = useRef | null>(null);
useEffect(() => {
- if (visible) {
+ if (visible && hints.length > 0) {
const initialIndex = Math.floor(Math.random() * hints.length);
setHintIndex(initialIndex);
diff --git a/src/web-ui/src/flow_chat/constants/processingHints.ts b/src/web-ui/src/flow_chat/constants/processingHints.ts
deleted file mode 100644
index b3fd4372e..000000000
--- a/src/web-ui/src/flow_chat/constants/processingHints.ts
+++ /dev/null
@@ -1,420 +0,0 @@
-/**
- * Fun processing hint messages shown while the AI is working.
- * Designed to ease user anxiety with light-hearted copy.
- */
-
-export const processingHintsZh: string[] = [
- // --- Tech / ML metaphors ---
- "思维回路全速运转...",
- "Token 们奋力排列中...",
- "矩阵乘法进行中...",
- "Transformer 全力变形...",
- "Embeddings 寻找彼此...",
- "最后一次梯度下降...",
- "注意力机制已锁定...",
- "思维发动机轰鸣启动...",
- "思维路线图展开中...",
- "激活函数全员上岗...",
- "推理链跑起来了...",
- "十四亿参数找答案...",
- "上下文窗口挤一挤...",
- "最合适的词正在出现...",
- "正在 Debug 思维链...",
- "推导链节节延伸...",
- "在语言的岔路口选方向...",
- "思路全线开动...",
- "向量空间里迷了路...",
- "量子态还没塌缩...",
- "正在过滤噪音...",
- "从千万想法里提炼精华...",
- "灵感之流汇聚成河...",
- "数百万次思考之后...",
- "所有脑力为你服务...",
- "思绪整装,即将出发...",
-
- // --- Personification & humor ---
- "给神经网络倒咖啡...",
- "模型正在思考人生...",
- "神经元群情激昂...",
- "反向传播感悟中...",
- "正在召开内部评审会...",
- "多个思路正在 PK...",
- "神经元:已就位...",
- "神经网络的午夜加班...",
- "正在假装镇定地高速运转...",
- "暗中使力中...",
- "绞尽脑汁为你服务...",
- "正在对话宇宙...",
- "向宇宙借创意...",
-
- // --- Progress ---
- "答案在路上了...",
- "拼图最后一块到位...",
- "思路合流中...",
- "逻辑线头找到了...",
- "差不多了,收尾中...",
- "还差一点点...",
- "就差临门一脚...",
- "搞定了百分之九十九...",
- "最终赢家即将揭晓...",
- "答案正在着陆跑道...",
- "最后校对一遍...",
- "正在打磨最后措辞...",
- "正在精简表达...",
- "填充最后几块拼图...",
- "高质量输出即将发射...",
- "系好安全带,即将着陆...",
- "即将顿悟...",
- "答案就在嘴边...",
-
- // --- Imagination ---
- "翻阅无数可能中...",
- "召唤创意,稍候...",
- "脑洞已打开...",
- "在十万八千条思路里取最优...",
- "把你的问题投影到高维空间...",
- "好问题!让我选一个薛定谔的答案...",
- "在知识图谱里找路...",
- "知识正在快速结晶...",
- "把抽象想法具体化...",
- "思维导图疯狂生长...",
- "在概念森林砍出一条路...",
- "想法从潜空间投影...",
- "将混乱整理成诗...",
- "折叠所有可能成一答...",
- "举一反三进行中...",
- "联想力爆棚中...",
- "正在破解难题密码...",
- "智慧的光正在聚焦...",
-
- // --- Light & confident ---
- "字斟句酌,只给精华...",
- "想想,再想想...",
- "已经快了(真的)...",
- "快了!(不是在敷衍)",
- "这道题,我会!",
- "收到!全力处理中...",
- "就在这里,别急...",
- "全情投入,心无旁骛...",
- "私下已想了七八种方案...",
- "比想象中更努力...",
- "认真的,从没这么认真过...",
- "比光速还快(误)...",
- "才华正在集中爆发...",
- "正在憋大招...",
- "超强输出,即将到来...",
- "最强答案正在成形...",
-
- // --- Concrete metaphors ---
- "把散落的灵感拼起来...",
- "用知识蒸馏一杯好茶...",
- "灵感在深处发酵...",
- "知识浪潮涌向输出端...",
- "脑图正在扩展中...",
- "思维潮水汇聚中...",
- "思维火焰正旺...",
- "知识在快速结晶...",
- "脑内灯泡闪烁中...",
- "思路打结,解开中...",
- "思维信号穿梭神经丛...",
- "秒速百公里地思考...",
- "知识储备调用中...",
- "知识能量正在充能...",
- "智慧内核全速转...",
-
- // --- Care & focus ---
- "脑子已经全力转起来了...",
- "正在为你量身定制...",
- "把最好的留到最后...",
- "用心对待每一个字...",
- "全神贯注于你的问题...",
- "注意力全给你了...",
- "这一刻,全给你...",
- "正在起草一份诚意回答...",
- "灵感正在悄悄聚拢...",
- "倾尽所有给你答案...",
- "绝不摆烂,全力以赴...",
- "努力营业中...",
-
- // --- Short beats ---
- "混沌转逻辑中...",
- "思考齿轮咬合中...",
- "解码深邃想法中...",
- "合并所有子思路...",
- "抢救迷失的语义...",
- "拆解成千个小问题...",
- "千个念头取最好...",
- "把想法和文字对齐...",
- "整理千头万绪中...",
- "确认答案合理性中...",
- "检查一遍,再检查...",
- "把模糊变清晰中...",
- "思维正在对焦...",
- "多线程思维全力汇总...",
- "把逻辑再拧紧一点...",
- "让逻辑自己说话...",
- "在语言丛林里寻宝...",
-
- // --- More fun ---
- "全速冲刺,答案在望...",
- "思维扩容,装下更多...",
- "为回答精心调味...",
- "给逻辑链上润滑油...",
- "把回答装进包裹打结中...",
- "逻辑拼图已完成...",
- "有条不紊,稳步推进...",
- "万事就绪,答案出发...",
- "脑波达到最高频...",
- "思路刚打通,马上来...",
- "解开语言的最后一层...",
- "用推理缝合碎片...",
- "召开紧急大脑会议...",
- "整理出最佳路径...",
- "正在审阅自己的思维...",
- "正在进行最后冲刺...",
- "努力中,绝不放弃...",
- "正在做些不可思议的事...",
- "思考容量拉满...",
- "我在,我想,我输出...",
- "后台小剧场精彩上演...",
- "正在奋笔疾书...",
- "在参数空间漫步...",
- "正在将想法变为现实...",
- "知识全力驰援中...",
- "删删改改中...",
- "像素精灵正在组字...",
- "比特们正在排队...",
- "比特流已启动...",
- "思维启动加速度...",
- "正在给回答注入灵魂...",
- "准备好了吗?快了...",
- "就差最后一步...",
- "输出即将解冻...",
- "神经丛林探险中...",
- "答案雏形已在手...",
- "思路正在合流...",
- "尽力而为,必有所获...",
- "正在快乐工作中...",
- "大脑正在全速燃烧...",
- "答案即将成形...",
- "思维的潮水汇聚中...",
- "正在把复杂变简单...",
- "脑中千个念头筛选中...",
- "正在为你开天辟地...",
- "知识海洋里捞最优解...",
- "万亿可能取其一...",
- "答案正在破壳而出...",
- "大脑高速公路全线畅通...",
- "最佳路径已锁定...",
- "思维齐步走,答案来了...",
- "大招正在蓄力中...",
- "答案即将横空出世...",
-];
-
-export const processingHintsEn: string[] = [
- // --- Tech / ML metaphors ---
- "Neural circuits running at full speed...",
- "Tokens lining up in perfect order...",
- "Matrix multiplications in progress...",
- "Transformer: transforming at full capacity...",
- "Embeddings searching for their soulmates...",
- "One final gradient descent to go...",
- "Attention heads: activated and locked...",
- "Thought engine roaring to life...",
- "Mental roadmap unfolding...",
- "Activation functions reporting for duty...",
- "Reasoning chain is running...",
- "Spelunking through fourteen billion parameters...",
- "Squeezing into the context window...",
- "The perfect word is surfacing...",
- "Debugging my own chain of thought...",
- "Reasoning links extending one by one...",
- "Picking a path through the language labyrinth...",
- "All thought lanes open and moving...",
- "Got lost in vector space, back soon...",
- "Quantum state hasn't collapsed yet...",
- "Filtering out the noise...",
- "Distilling the best from a million thoughts...",
- "Rivers of inspiration converging...",
- "After millions of mental iterations...",
- "All brainpower dedicated to you...",
- "Thoughts packed and ready to go...",
-
- // --- Personification & humor ---
- "Feeding the model its morning coffee...",
- "Pondering the meaning of tokens...",
- "Neurons firing in passionate debate...",
- "Backpropagating through life choices...",
- "Holding an internal peer review...",
- "Multiple ideas in a heated competition...",
- "Neurons: reporting for duty...",
- "Neural network pulling an all-nighter...",
- "Pretending to be calm while going full speed...",
- "Quietly working magic in the background...",
- "Wracking every synapse for your sake...",
- "Consulting the cosmos...",
- "Borrowing creativity from the universe...",
-
- // --- Progress ---
- "Answer is on its way...",
- "Last puzzle piece sliding into place...",
- "Thoughts merging into one stream...",
- "Found the thread of logic...",
- "Almost done, wrapping up...",
- "Just a tiny bit more...",
- "One final push to go...",
- "Ninety-nine percent done...",
- "The winner is about to be revealed...",
- "Answer on final approach for landing...",
- "One last proofread...",
- "Polishing the last few words...",
- "Trimming the fat from the response...",
- "Filling in the last few puzzle pieces...",
- "High-quality output launching soon...",
- "Fasten your seatbelt — landing shortly...",
- "Epiphany incoming...",
- "Answer is right on the tip of my tongue...",
-
- // --- Imagination ---
- "Rummaging through the multiverse of ideas...",
- "Summoning creativity, please hold...",
- "Brain hole: open...",
- "Picking the best from ten thousand thoughts...",
- "Projecting your question into high-dimensional space...",
- "Great question! Picking from Schrödinger's answers...",
- "Navigating the knowledge graph...",
- "Knowledge crystallizing rapidly...",
- "Turning abstract ideas into concrete ones...",
- "Mind map expanding like wildfire...",
- "Hacking a path through the concept forest...",
- "Ideas projecting from latent space...",
- "Turning chaos into something like poetry...",
- "Folding all possible futures into one answer...",
- "Analogical reasoning in progress...",
- "Associative power: overclocked...",
- "Cracking the code of a tough problem...",
- "Focusing the beam of intelligence...",
-
- // --- Light & confident ---
- "Hand-picking only the best words...",
- "Let me think... still thinking...",
- "I'm fast, I promise...",
- "Almost there! (Not stalling, really)",
- "I know this one!",
- "Roger that! Processing at full capacity...",
- "Right here, no rush...",
- "Fully in the zone right now...",
- "Already tried seven different approaches internally...",
- "Working harder than you'd expect...",
- "Never been this focused in my life...",
- "Faster than light (not really)...",
- "Talent concentrated and detonating...",
- "Charging up the big move...",
- "Supercharged output incoming...",
- "The best answer is taking shape...",
-
- // --- Concrete metaphors ---
- "Gathering the scattered sparks of inspiration...",
- "Distilling knowledge into a fine brew...",
- "Ideas fermenting in the neural depths...",
- "Tidal waves of knowledge rushing to the output...",
- "Mind map expanding at max speed...",
- "Waves of thought converging...",
- "Thinking flame burning bright...",
- "Knowledge solidifying into form...",
- "Lightbulb flickering in the back of my mind...",
- "Untangling the knots in my reasoning...",
- "Thought signals racing through the neural jungle...",
- "Thinking at hundreds of miles per second...",
- "Pulling from deep knowledge reserves...",
- "Knowledge energy charging up...",
- "Intelligence core running at max rpm...",
-
- // --- Care & focus ---
- "Brain already spinning at full tilt...",
- "Tailoring this response just for you...",
- "Saving the best for last...",
- "Crafting every word with care...",
- "Fully focused on your question...",
- "All attention given to you...",
- "This moment is entirely for you...",
- "Drafting a sincere and thoughtful reply...",
- "Inspiration quietly gathering...",
- "Giving everything I've got for your answer...",
- "Zero slacking, full effort...",
- "Diligently serving your request...",
-
- // --- Short beats ---
- "Converting chaos into logic...",
- "Gears of thought clicking into place...",
- "Decoding your profound question...",
- "Merging all sub-ideas...",
- "Rescuing lost semantic meaning...",
- "Breaking it into a thousand micro-problems...",
- "Picking the best of a thousand thoughts...",
- "Lining up thoughts with words...",
- "Sorting through a tangle of threads...",
- "Verifying the answer makes sense...",
- "Checking once, then checking again...",
- "Turning fuzzy into clear...",
- "Thoughts coming into focus...",
- "All threads converging at full speed...",
- "Tightening the logic just a little more...",
- "Letting logic speak for itself...",
- "Treasure-hunting through the language forest...",
-
- // --- More fun ---
- "Sprinting to the finish line...",
- "Expanding the thought tank...",
- "Adding just the right pinch of creativity...",
- "Oiling the chain of logic...",
- "Tying a bow on your response package...",
- "Logic puzzle: assembled...",
- "Moving steadily, step by step...",
- "Everything ready — answer departing now...",
- "Brainwave frequency at all-time high...",
- "Thread of thought just clicked — almost there...",
- "Unwrapping the final layer of language...",
- "Stitching the fragments together with reasoning...",
- "Calling an emergency brain session...",
- "Plotting the optimal path...",
- "Reviewing my own chain of thought...",
- "Making the final sprint...",
- "Pushing through, won't give up...",
- "Doing something kind of amazing right now...",
- "Thinking capacity: maxed out...",
- "I think, therefore I output...",
- "A tiny drama unfolding backstage...",
- "Writing at full speed...",
- "Wandering the parameter space...",
- "Turning ideas into reality...",
- "All knowledge rallying to help...",
- "Editing and re-editing...",
- "Pixel elves composing text...",
- "Bits falling into line...",
- "Bit stream: engaged...",
- "Thought engine accelerating...",
- "Infusing the response with soul...",
- "Ready for it? Almost there...",
- "One last step to go...",
- "Output thawing out...",
- "Exploring the neural jungle...",
- "Answer prototype taking form...",
- "Thoughts flowing into one channel...",
- "Doing my very best, as always...",
- "Happily working away...",
- "Brain burning at full speed...",
- "Answer taking shape...",
- "Waves of thought converging on the answer...",
- "Turning complexity into simplicity...",
- "Filtering thousands of thoughts right now...",
- "Creating something from nothing, just for you...",
- "Fishing the best answer from an ocean of knowledge...",
- "One in a trillion possibilities, chosen for you...",
- "Answer hatching right now...",
- "The brain highway is clear and running fast...",
- "Optimal path: locked in...",
- "Thoughts marching in step — answer incoming...",
- "Done in one second (approximately)...",
- "Casting a chaos-to-clarity spell...",
-];
diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts
index e9d74e71b..953f6c2c8 100644
--- a/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts
+++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts
@@ -747,7 +747,7 @@ export async function ensureBackendSession(
const raw = typeof e?.message === 'string' ? e.message : String(e);
const hint =
raw.includes('Session metadata not found') || raw.includes('Not found')
- ? '在后端找不到该会话数据。若刚重新连接过 SSH 远程工作区,请关闭并重新打开该远程项目,或新建会话后再试。'
+ ? i18nService.t('flow-chat:historyState.remoteSessionMissing')
: raw;
throw new Error(hint);
}
diff --git a/src/web-ui/src/infrastructure/config/services/AgentCompanionPetService.ts b/src/web-ui/src/infrastructure/config/services/AgentCompanionPetService.ts
index eb6c907aa..f3d0e205d 100644
--- a/src/web-ui/src/infrastructure/config/services/AgentCompanionPetService.ts
+++ b/src/web-ui/src/infrastructure/config/services/AgentCompanionPetService.ts
@@ -3,9 +3,11 @@ import { readFile } from '@tauri-apps/plugin-fs';
import type { AgentCompanionPetSelection } from './AIExperienceConfigService';
import { isTauriRuntime } from '@/infrastructure/runtime';
import { createLogger } from '@/shared/utils/logger';
+import builtinPetMetadata from './agentCompanionBuiltinPetMetadata.json';
const log = createLogger('AgentCompanionPetService');
const BUILTIN_PET_BASE = '/agent-companion-pets';
+const BUILTIN_PET_DISPLAY_NAMES = builtinPetMetadata.displayNames;
export const DEFAULT_AGENT_COMPANION_PET: AgentCompanionPetSelection = {
id: 'panda-pix',
@@ -89,7 +91,7 @@ const BUILTIN_PETS: AgentCompanionPetSelection[] = [
},
{
id: 'gugugaga',
- displayName: '咕咕嘎嘎',
+ displayName: BUILTIN_PET_DISPLAY_NAMES.gugugaga,
description: 'A cheerful chibi girl in a black penguin suit with a simple silver collar pendant.',
source: 'preset',
packagePath: `${BUILTIN_PET_BASE}/gugugaga`,
@@ -117,7 +119,7 @@ const BUILTIN_PETS: AgentCompanionPetSelection[] = [
},
{
id: 'jiyi',
- displayName: '吉伊',
+ displayName: BUILTIN_PET_DISPLAY_NAMES.jiyi,
description:
'A round white chibi bear with dark chocolate outlines, pink cheeks, tiny limbs, curled ears, and a small pink bear pouch.',
source: 'preset',
diff --git a/src/web-ui/src/infrastructure/config/services/agentCompanionBuiltinPetMetadata.json b/src/web-ui/src/infrastructure/config/services/agentCompanionBuiltinPetMetadata.json
new file mode 100644
index 000000000..b4408c78f
--- /dev/null
+++ b/src/web-ui/src/infrastructure/config/services/agentCompanionBuiltinPetMetadata.json
@@ -0,0 +1,6 @@
+{
+ "displayNames": {
+ "gugugaga": "咕咕嘎嘎",
+ "jiyi": "吉伊"
+ }
+}
diff --git a/src/web-ui/src/infrastructure/i18n/presets/namespaceRegistry.ts b/src/web-ui/src/infrastructure/i18n/presets/namespaceRegistry.ts
index e13ff6f43..7f1f820d9 100644
--- a/src/web-ui/src/infrastructure/i18n/presets/namespaceRegistry.ts
+++ b/src/web-ui/src/infrastructure/i18n/presets/namespaceRegistry.ts
@@ -9,6 +9,7 @@ export const ALL_NAMESPACES = [
'components',
'errors',
'flow-chat',
+ 'flow-chat/processing-hints',
'notifications',
'panels/files',
'panels/git',
diff --git a/src/web-ui/src/locales/en-US/common.json b/src/web-ui/src/locales/en-US/common.json
index 5f6b7a866..72c4a0942 100644
--- a/src/web-ui/src/locales/en-US/common.json
+++ b/src/web-ui/src/locales/en-US/common.json
@@ -858,6 +858,7 @@
"fixPreview": "Fix Preview",
"gitDiff": "Git Diff",
"gitSettings": "Git Settings",
+ "pullRequests": "Pull Requests",
"taskPlanner": "Task Planner",
"fileBrowser": "File Browser",
"editor": "Editor",
diff --git a/src/web-ui/src/locales/en-US/flow-chat.json b/src/web-ui/src/locales/en-US/flow-chat.json
index e1f2ba51d..8e33c9442 100644
--- a/src/web-ui/src/locales/en-US/flow-chat.json
+++ b/src/web-ui/src/locales/en-US/flow-chat.json
@@ -44,6 +44,7 @@
"loadingDescription": "Preparing the conversation history.",
"failedTitle": "Session history did not load",
"failedDescription": "Retry loading the saved conversation.",
+ "remoteSessionMissing": "The backend session data was not found. If you just reconnected an SSH remote workspace, close and reopen the remote project, or create a new session and try again.",
"retry": "Retry"
},
"usage": {
diff --git a/src/web-ui/src/locales/en-US/flow-chat/processing-hints.json b/src/web-ui/src/locales/en-US/flow-chat/processing-hints.json
new file mode 100644
index 000000000..610131aae
--- /dev/null
+++ b/src/web-ui/src/locales/en-US/flow-chat/processing-hints.json
@@ -0,0 +1,192 @@
+{
+ "items": [
+ "Neural circuits running at full speed...",
+ "Tokens lining up in perfect order...",
+ "Matrix multiplications in progress...",
+ "Transformer: transforming at full capacity...",
+ "Embeddings searching for their soulmates...",
+ "One final gradient descent to go...",
+ "Attention heads: activated and locked...",
+ "Thought engine roaring to life...",
+ "Mental roadmap unfolding...",
+ "Activation functions reporting for duty...",
+ "Reasoning chain is running...",
+ "Spelunking through fourteen billion parameters...",
+ "Squeezing into the context window...",
+ "The perfect word is surfacing...",
+ "Debugging my own chain of thought...",
+ "Reasoning links extending one by one...",
+ "Picking a path through the language labyrinth...",
+ "All thought lanes open and moving...",
+ "Got lost in vector space, back soon...",
+ "Quantum state hasn't collapsed yet...",
+ "Filtering out the noise...",
+ "Distilling the best from a million thoughts...",
+ "Rivers of inspiration converging...",
+ "After millions of mental iterations...",
+ "All brainpower dedicated to you...",
+ "Thoughts packed and ready to go...",
+ "Feeding the model its morning coffee...",
+ "Pondering the meaning of tokens...",
+ "Neurons firing in passionate debate...",
+ "Backpropagating through life choices...",
+ "Holding an internal peer review...",
+ "Multiple ideas in a heated competition...",
+ "Neurons: reporting for duty...",
+ "Neural network pulling an all-nighter...",
+ "Pretending to be calm while going full speed...",
+ "Quietly working magic in the background...",
+ "Wracking every synapse for your sake...",
+ "Consulting the cosmos...",
+ "Borrowing creativity from the universe...",
+ "Answer is on its way...",
+ "Last puzzle piece sliding into place...",
+ "Thoughts merging into one stream...",
+ "Found the thread of logic...",
+ "Almost done, wrapping up...",
+ "Just a tiny bit more...",
+ "One final push to go...",
+ "Ninety-nine percent done...",
+ "The winner is about to be revealed...",
+ "Answer on final approach for landing...",
+ "One last proofread...",
+ "Polishing the last few words...",
+ "Trimming the fat from the response...",
+ "Filling in the last few puzzle pieces...",
+ "High-quality output launching soon...",
+ "Fasten your seatbelt — landing shortly...",
+ "Epiphany incoming...",
+ "Answer is right on the tip of my tongue...",
+ "Rummaging through the multiverse of ideas...",
+ "Summoning creativity, please hold...",
+ "Brain hole: open...",
+ "Picking the best from ten thousand thoughts...",
+ "Projecting your question into high-dimensional space...",
+ "Great question! Picking from Schrödinger's answers...",
+ "Navigating the knowledge graph...",
+ "Knowledge crystallizing rapidly...",
+ "Turning abstract ideas into concrete ones...",
+ "Mind map expanding like wildfire...",
+ "Hacking a path through the concept forest...",
+ "Ideas projecting from latent space...",
+ "Turning chaos into something like poetry...",
+ "Folding all possible futures into one answer...",
+ "Analogical reasoning in progress...",
+ "Associative power: overclocked...",
+ "Cracking the code of a tough problem...",
+ "Focusing the beam of intelligence...",
+ "Hand-picking only the best words...",
+ "Let me think... still thinking...",
+ "I'm fast, I promise...",
+ "Almost there! (Not stalling, really)",
+ "I know this one!",
+ "Roger that! Processing at full capacity...",
+ "Right here, no rush...",
+ "Fully in the zone right now...",
+ "Already tried seven different approaches internally...",
+ "Working harder than you'd expect...",
+ "Never been this focused in my life...",
+ "Faster than light (not really)...",
+ "Talent concentrated and detonating...",
+ "Charging up the big move...",
+ "Supercharged output incoming...",
+ "The best answer is taking shape...",
+ "Gathering the scattered sparks of inspiration...",
+ "Distilling knowledge into a fine brew...",
+ "Ideas fermenting in the neural depths...",
+ "Tidal waves of knowledge rushing to the output...",
+ "Mind map expanding at max speed...",
+ "Waves of thought converging...",
+ "Thinking flame burning bright...",
+ "Knowledge solidifying into form...",
+ "Lightbulb flickering in the back of my mind...",
+ "Untangling the knots in my reasoning...",
+ "Thought signals racing through the neural jungle...",
+ "Thinking at hundreds of miles per second...",
+ "Pulling from deep knowledge reserves...",
+ "Knowledge energy charging up...",
+ "Intelligence core running at max rpm...",
+ "Brain already spinning at full tilt...",
+ "Tailoring this response just for you...",
+ "Saving the best for last...",
+ "Crafting every word with care...",
+ "Fully focused on your question...",
+ "All attention given to you...",
+ "This moment is entirely for you...",
+ "Drafting a sincere and thoughtful reply...",
+ "Inspiration quietly gathering...",
+ "Giving everything I've got for your answer...",
+ "Zero slacking, full effort...",
+ "Diligently serving your request...",
+ "Converting chaos into logic...",
+ "Gears of thought clicking into place...",
+ "Decoding your profound question...",
+ "Merging all sub-ideas...",
+ "Rescuing lost semantic meaning...",
+ "Breaking it into a thousand micro-problems...",
+ "Picking the best of a thousand thoughts...",
+ "Lining up thoughts with words...",
+ "Sorting through a tangle of threads...",
+ "Verifying the answer makes sense...",
+ "Checking once, then checking again...",
+ "Turning fuzzy into clear...",
+ "Thoughts coming into focus...",
+ "All threads converging at full speed...",
+ "Tightening the logic just a little more...",
+ "Letting logic speak for itself...",
+ "Treasure-hunting through the language forest...",
+ "Sprinting to the finish line...",
+ "Expanding the thought tank...",
+ "Adding just the right pinch of creativity...",
+ "Oiling the chain of logic...",
+ "Tying a bow on your response package...",
+ "Logic puzzle: assembled...",
+ "Moving steadily, step by step...",
+ "Everything ready — answer departing now...",
+ "Brainwave frequency at all-time high...",
+ "Thread of thought just clicked — almost there...",
+ "Unwrapping the final layer of language...",
+ "Stitching the fragments together with reasoning...",
+ "Calling an emergency brain session...",
+ "Plotting the optimal path...",
+ "Reviewing my own chain of thought...",
+ "Making the final sprint...",
+ "Pushing through, won't give up...",
+ "Doing something kind of amazing right now...",
+ "Thinking capacity: maxed out...",
+ "I think, therefore I output...",
+ "A tiny drama unfolding backstage...",
+ "Writing at full speed...",
+ "Wandering the parameter space...",
+ "Turning ideas into reality...",
+ "All knowledge rallying to help...",
+ "Editing and re-editing...",
+ "Pixel elves composing text...",
+ "Bits falling into line...",
+ "Bit stream: engaged...",
+ "Thought engine accelerating...",
+ "Infusing the response with soul...",
+ "Ready for it? Almost there...",
+ "One last step to go...",
+ "Output thawing out...",
+ "Exploring the neural jungle...",
+ "Answer prototype taking form...",
+ "Thoughts flowing into one channel...",
+ "Doing my very best, as always...",
+ "Happily working away...",
+ "Brain burning at full speed...",
+ "Answer taking shape...",
+ "Waves of thought converging on the answer...",
+ "Turning complexity into simplicity...",
+ "Filtering thousands of thoughts right now...",
+ "Creating something from nothing, just for you...",
+ "Fishing the best answer from an ocean of knowledge...",
+ "One in a trillion possibilities, chosen for you...",
+ "Answer hatching right now...",
+ "The brain highway is clear and running fast...",
+ "Optimal path: locked in...",
+ "Thoughts marching in step — answer incoming...",
+ "Done in one second (approximately)...",
+ "Casting a chaos-to-clarity spell..."
+ ]
+}
diff --git a/src/web-ui/src/locales/en-US/settings.json b/src/web-ui/src/locales/en-US/settings.json
index 3257a1d8b..40d996d38 100644
--- a/src/web-ui/src/locales/en-US/settings.json
+++ b/src/web-ui/src/locales/en-US/settings.json
@@ -7,6 +7,14 @@
"searchNoResults": "No matching settings",
"searchClear": "Clear search",
"beta": "Beta",
+ "searchAliases": {
+ "archivedSessions": [],
+ "keyboard": [],
+ "sessionPersonalization": [],
+ "sessionPermissions": [],
+ "quickActions": [],
+ "review": []
+ },
"tabDescriptions": {
"basics": "Logging, terminal shell, notifications, and launch at login.",
"appearance": "Language, theme, and UI font size.",
diff --git a/src/web-ui/src/locales/zh-CN/common.json b/src/web-ui/src/locales/zh-CN/common.json
index 612e2fc67..293e7174e 100644
--- a/src/web-ui/src/locales/zh-CN/common.json
+++ b/src/web-ui/src/locales/zh-CN/common.json
@@ -858,6 +858,7 @@
"fixPreview": "修复预览",
"gitDiff": "Git Diff",
"gitSettings": "Git 设置",
+ "pullRequests": "Pull Requests",
"taskPlanner": "任务规划器",
"fileBrowser": "文件浏览器",
"editor": "编辑器",
diff --git a/src/web-ui/src/locales/zh-CN/flow-chat.json b/src/web-ui/src/locales/zh-CN/flow-chat.json
index 8b6c0ee15..c647fa18a 100644
--- a/src/web-ui/src/locales/zh-CN/flow-chat.json
+++ b/src/web-ui/src/locales/zh-CN/flow-chat.json
@@ -44,6 +44,7 @@
"loadingDescription": "正在准备该会话的历史内容。",
"failedTitle": "历史会话加载失败",
"failedDescription": "可以重新加载已保存的会话内容。",
+ "remoteSessionMissing": "在后端找不到该会话数据。若刚重新连接过 SSH 远程工作区,请关闭并重新打开该远程项目,或新建会话后再试。",
"retry": "重试"
},
"usage": {
diff --git a/src/web-ui/src/locales/zh-CN/flow-chat/processing-hints.json b/src/web-ui/src/locales/zh-CN/flow-chat/processing-hints.json
new file mode 100644
index 000000000..13cee1a38
--- /dev/null
+++ b/src/web-ui/src/locales/zh-CN/flow-chat/processing-hints.json
@@ -0,0 +1,192 @@
+{
+ "items": [
+ "思维回路全速运转...",
+ "Token 们奋力排列中...",
+ "矩阵乘法进行中...",
+ "Transformer 全力变形...",
+ "Embeddings 寻找彼此...",
+ "最后一次梯度下降...",
+ "注意力机制已锁定...",
+ "思维发动机轰鸣启动...",
+ "思维路线图展开中...",
+ "激活函数全员上岗...",
+ "推理链跑起来了...",
+ "十四亿参数找答案...",
+ "上下文窗口挤一挤...",
+ "最合适的词正在出现...",
+ "正在 Debug 思维链...",
+ "推导链节节延伸...",
+ "在语言的岔路口选方向...",
+ "思路全线开动...",
+ "向量空间里迷了路...",
+ "量子态还没塌缩...",
+ "正在过滤噪音...",
+ "从千万想法里提炼精华...",
+ "灵感之流汇聚成河...",
+ "数百万次思考之后...",
+ "所有脑力为你服务...",
+ "思绪整装,即将出发...",
+ "给神经网络倒咖啡...",
+ "模型正在思考人生...",
+ "神经元群情激昂...",
+ "反向传播感悟中...",
+ "正在召开内部评审会...",
+ "多个思路正在 PK...",
+ "神经元:已就位...",
+ "神经网络的午夜加班...",
+ "正在假装镇定地高速运转...",
+ "暗中使力中...",
+ "绞尽脑汁为你服务...",
+ "正在对话宇宙...",
+ "向宇宙借创意...",
+ "答案在路上了...",
+ "拼图最后一块到位...",
+ "思路合流中...",
+ "逻辑线头找到了...",
+ "差不多了,收尾中...",
+ "还差一点点...",
+ "就差临门一脚...",
+ "搞定了百分之九十九...",
+ "最终赢家即将揭晓...",
+ "答案正在着陆跑道...",
+ "最后校对一遍...",
+ "正在打磨最后措辞...",
+ "正在精简表达...",
+ "填充最后几块拼图...",
+ "高质量输出即将发射...",
+ "系好安全带,即将着陆...",
+ "即将顿悟...",
+ "答案就在嘴边...",
+ "翻阅无数可能中...",
+ "召唤创意,稍候...",
+ "脑洞已打开...",
+ "在十万八千条思路里取最优...",
+ "把你的问题投影到高维空间...",
+ "好问题!让我选一个薛定谔的答案...",
+ "在知识图谱里找路...",
+ "知识正在快速结晶...",
+ "把抽象想法具体化...",
+ "思维导图疯狂生长...",
+ "在概念森林砍出一条路...",
+ "想法从潜空间投影...",
+ "将混乱整理成诗...",
+ "折叠所有可能成一答...",
+ "举一反三进行中...",
+ "联想力爆棚中...",
+ "正在破解难题密码...",
+ "智慧的光正在聚焦...",
+ "字斟句酌,只给精华...",
+ "想想,再想想...",
+ "已经快了(真的)...",
+ "快了!(不是在敷衍)",
+ "这道题,我会!",
+ "收到!全力处理中...",
+ "就在这里,别急...",
+ "全情投入,心无旁骛...",
+ "私下已想了七八种方案...",
+ "比想象中更努力...",
+ "认真的,从没这么认真过...",
+ "比光速还快(误)...",
+ "才华正在集中爆发...",
+ "正在憋大招...",
+ "超强输出,即将到来...",
+ "最强答案正在成形...",
+ "把散落的灵感拼起来...",
+ "用知识蒸馏一杯好茶...",
+ "灵感在深处发酵...",
+ "知识浪潮涌向输出端...",
+ "脑图正在扩展中...",
+ "思维潮水汇聚中...",
+ "思维火焰正旺...",
+ "知识在快速结晶...",
+ "脑内灯泡闪烁中...",
+ "思路打结,解开中...",
+ "思维信号穿梭神经丛...",
+ "秒速百公里地思考...",
+ "知识储备调用中...",
+ "知识能量正在充能...",
+ "智慧内核全速转...",
+ "脑子已经全力转起来了...",
+ "正在为你量身定制...",
+ "把最好的留到最后...",
+ "用心对待每一个字...",
+ "全神贯注于你的问题...",
+ "注意力全给你了...",
+ "这一刻,全给你...",
+ "正在起草一份诚意回答...",
+ "灵感正在悄悄聚拢...",
+ "倾尽所有给你答案...",
+ "绝不摆烂,全力以赴...",
+ "努力营业中...",
+ "混沌转逻辑中...",
+ "思考齿轮咬合中...",
+ "解码深邃想法中...",
+ "合并所有子思路...",
+ "抢救迷失的语义...",
+ "拆解成千个小问题...",
+ "千个念头取最好...",
+ "把想法和文字对齐...",
+ "整理千头万绪中...",
+ "确认答案合理性中...",
+ "检查一遍,再检查...",
+ "把模糊变清晰中...",
+ "思维正在对焦...",
+ "多线程思维全力汇总...",
+ "把逻辑再拧紧一点...",
+ "让逻辑自己说话...",
+ "在语言丛林里寻宝...",
+ "全速冲刺,答案在望...",
+ "思维扩容,装下更多...",
+ "为回答精心调味...",
+ "给逻辑链上润滑油...",
+ "把回答装进包裹打结中...",
+ "逻辑拼图已完成...",
+ "有条不紊,稳步推进...",
+ "万事就绪,答案出发...",
+ "脑波达到最高频...",
+ "思路刚打通,马上来...",
+ "解开语言的最后一层...",
+ "用推理缝合碎片...",
+ "召开紧急大脑会议...",
+ "整理出最佳路径...",
+ "正在审阅自己的思维...",
+ "正在进行最后冲刺...",
+ "努力中,绝不放弃...",
+ "正在做些不可思议的事...",
+ "思考容量拉满...",
+ "我在,我想,我输出...",
+ "后台小剧场精彩上演...",
+ "正在奋笔疾书...",
+ "在参数空间漫步...",
+ "正在将想法变为现实...",
+ "知识全力驰援中...",
+ "删删改改中...",
+ "像素精灵正在组字...",
+ "比特们正在排队...",
+ "比特流已启动...",
+ "思维启动加速度...",
+ "正在给回答注入灵魂...",
+ "准备好了吗?快了...",
+ "就差最后一步...",
+ "输出即将解冻...",
+ "神经丛林探险中...",
+ "答案雏形已在手...",
+ "思路正在合流...",
+ "尽力而为,必有所获...",
+ "正在快乐工作中...",
+ "大脑正在全速燃烧...",
+ "答案即将成形...",
+ "思维的潮水汇聚中...",
+ "正在把复杂变简单...",
+ "脑中千个念头筛选中...",
+ "正在为你开天辟地...",
+ "知识海洋里捞最优解...",
+ "万亿可能取其一...",
+ "答案正在破壳而出...",
+ "大脑高速公路全线畅通...",
+ "最佳路径已锁定...",
+ "思维齐步走,答案来了...",
+ "大招正在蓄力中...",
+ "答案即将横空出世..."
+ ]
+}
diff --git a/src/web-ui/src/locales/zh-CN/settings.json b/src/web-ui/src/locales/zh-CN/settings.json
index 8aa42ec93..7f5d8a7ec 100644
--- a/src/web-ui/src/locales/zh-CN/settings.json
+++ b/src/web-ui/src/locales/zh-CN/settings.json
@@ -7,6 +7,14 @@
"searchNoResults": "没有匹配的配置",
"searchClear": "清除搜索",
"beta": "Beta",
+ "searchAliases": {
+ "archivedSessions": ["归档", "会话"],
+ "keyboard": ["快捷键", "键位"],
+ "sessionPersonalization": ["伙伴", "个性化"],
+ "sessionPermissions": ["权限"],
+ "quickActions": ["快捷动作", "提交"],
+ "review": ["审核", "代码审核"]
+ },
"tabDescriptions": {
"basics": "日志、终端 Shell、通知与开机启动。",
"appearance": "语言、主题与界面字体大小。",
diff --git a/src/web-ui/src/locales/zh-TW/common.json b/src/web-ui/src/locales/zh-TW/common.json
index 0adf1a3f9..b41615e29 100644
--- a/src/web-ui/src/locales/zh-TW/common.json
+++ b/src/web-ui/src/locales/zh-TW/common.json
@@ -858,6 +858,7 @@
"fixPreview": "修復預覽",
"gitDiff": "Git Diff",
"gitSettings": "Git 設置",
+ "pullRequests": "Pull Requests",
"taskPlanner": "任務規劃器",
"fileBrowser": "文件瀏覽器",
"editor": "編輯器",
diff --git a/src/web-ui/src/locales/zh-TW/flow-chat.json b/src/web-ui/src/locales/zh-TW/flow-chat.json
index 926ac937b..eca8be933 100644
--- a/src/web-ui/src/locales/zh-TW/flow-chat.json
+++ b/src/web-ui/src/locales/zh-TW/flow-chat.json
@@ -44,6 +44,7 @@
"loadingDescription": "正在準備該會話的歷史內容。",
"failedTitle": "歷史會話載入失敗",
"failedDescription": "可以重新載入已儲存的會話內容。",
+ "remoteSessionMissing": "在後端找不到該會話資料。若剛重新連接過 SSH 遠端工作區,請關閉並重新打開該遠端項目,或新建會話後再試。",
"retry": "重試"
},
"usage": {
diff --git a/src/web-ui/src/locales/zh-TW/flow-chat/processing-hints.json b/src/web-ui/src/locales/zh-TW/flow-chat/processing-hints.json
new file mode 100644
index 000000000..13cee1a38
--- /dev/null
+++ b/src/web-ui/src/locales/zh-TW/flow-chat/processing-hints.json
@@ -0,0 +1,192 @@
+{
+ "items": [
+ "思维回路全速运转...",
+ "Token 们奋力排列中...",
+ "矩阵乘法进行中...",
+ "Transformer 全力变形...",
+ "Embeddings 寻找彼此...",
+ "最后一次梯度下降...",
+ "注意力机制已锁定...",
+ "思维发动机轰鸣启动...",
+ "思维路线图展开中...",
+ "激活函数全员上岗...",
+ "推理链跑起来了...",
+ "十四亿参数找答案...",
+ "上下文窗口挤一挤...",
+ "最合适的词正在出现...",
+ "正在 Debug 思维链...",
+ "推导链节节延伸...",
+ "在语言的岔路口选方向...",
+ "思路全线开动...",
+ "向量空间里迷了路...",
+ "量子态还没塌缩...",
+ "正在过滤噪音...",
+ "从千万想法里提炼精华...",
+ "灵感之流汇聚成河...",
+ "数百万次思考之后...",
+ "所有脑力为你服务...",
+ "思绪整装,即将出发...",
+ "给神经网络倒咖啡...",
+ "模型正在思考人生...",
+ "神经元群情激昂...",
+ "反向传播感悟中...",
+ "正在召开内部评审会...",
+ "多个思路正在 PK...",
+ "神经元:已就位...",
+ "神经网络的午夜加班...",
+ "正在假装镇定地高速运转...",
+ "暗中使力中...",
+ "绞尽脑汁为你服务...",
+ "正在对话宇宙...",
+ "向宇宙借创意...",
+ "答案在路上了...",
+ "拼图最后一块到位...",
+ "思路合流中...",
+ "逻辑线头找到了...",
+ "差不多了,收尾中...",
+ "还差一点点...",
+ "就差临门一脚...",
+ "搞定了百分之九十九...",
+ "最终赢家即将揭晓...",
+ "答案正在着陆跑道...",
+ "最后校对一遍...",
+ "正在打磨最后措辞...",
+ "正在精简表达...",
+ "填充最后几块拼图...",
+ "高质量输出即将发射...",
+ "系好安全带,即将着陆...",
+ "即将顿悟...",
+ "答案就在嘴边...",
+ "翻阅无数可能中...",
+ "召唤创意,稍候...",
+ "脑洞已打开...",
+ "在十万八千条思路里取最优...",
+ "把你的问题投影到高维空间...",
+ "好问题!让我选一个薛定谔的答案...",
+ "在知识图谱里找路...",
+ "知识正在快速结晶...",
+ "把抽象想法具体化...",
+ "思维导图疯狂生长...",
+ "在概念森林砍出一条路...",
+ "想法从潜空间投影...",
+ "将混乱整理成诗...",
+ "折叠所有可能成一答...",
+ "举一反三进行中...",
+ "联想力爆棚中...",
+ "正在破解难题密码...",
+ "智慧的光正在聚焦...",
+ "字斟句酌,只给精华...",
+ "想想,再想想...",
+ "已经快了(真的)...",
+ "快了!(不是在敷衍)",
+ "这道题,我会!",
+ "收到!全力处理中...",
+ "就在这里,别急...",
+ "全情投入,心无旁骛...",
+ "私下已想了七八种方案...",
+ "比想象中更努力...",
+ "认真的,从没这么认真过...",
+ "比光速还快(误)...",
+ "才华正在集中爆发...",
+ "正在憋大招...",
+ "超强输出,即将到来...",
+ "最强答案正在成形...",
+ "把散落的灵感拼起来...",
+ "用知识蒸馏一杯好茶...",
+ "灵感在深处发酵...",
+ "知识浪潮涌向输出端...",
+ "脑图正在扩展中...",
+ "思维潮水汇聚中...",
+ "思维火焰正旺...",
+ "知识在快速结晶...",
+ "脑内灯泡闪烁中...",
+ "思路打结,解开中...",
+ "思维信号穿梭神经丛...",
+ "秒速百公里地思考...",
+ "知识储备调用中...",
+ "知识能量正在充能...",
+ "智慧内核全速转...",
+ "脑子已经全力转起来了...",
+ "正在为你量身定制...",
+ "把最好的留到最后...",
+ "用心对待每一个字...",
+ "全神贯注于你的问题...",
+ "注意力全给你了...",
+ "这一刻,全给你...",
+ "正在起草一份诚意回答...",
+ "灵感正在悄悄聚拢...",
+ "倾尽所有给你答案...",
+ "绝不摆烂,全力以赴...",
+ "努力营业中...",
+ "混沌转逻辑中...",
+ "思考齿轮咬合中...",
+ "解码深邃想法中...",
+ "合并所有子思路...",
+ "抢救迷失的语义...",
+ "拆解成千个小问题...",
+ "千个念头取最好...",
+ "把想法和文字对齐...",
+ "整理千头万绪中...",
+ "确认答案合理性中...",
+ "检查一遍,再检查...",
+ "把模糊变清晰中...",
+ "思维正在对焦...",
+ "多线程思维全力汇总...",
+ "把逻辑再拧紧一点...",
+ "让逻辑自己说话...",
+ "在语言丛林里寻宝...",
+ "全速冲刺,答案在望...",
+ "思维扩容,装下更多...",
+ "为回答精心调味...",
+ "给逻辑链上润滑油...",
+ "把回答装进包裹打结中...",
+ "逻辑拼图已完成...",
+ "有条不紊,稳步推进...",
+ "万事就绪,答案出发...",
+ "脑波达到最高频...",
+ "思路刚打通,马上来...",
+ "解开语言的最后一层...",
+ "用推理缝合碎片...",
+ "召开紧急大脑会议...",
+ "整理出最佳路径...",
+ "正在审阅自己的思维...",
+ "正在进行最后冲刺...",
+ "努力中,绝不放弃...",
+ "正在做些不可思议的事...",
+ "思考容量拉满...",
+ "我在,我想,我输出...",
+ "后台小剧场精彩上演...",
+ "正在奋笔疾书...",
+ "在参数空间漫步...",
+ "正在将想法变为现实...",
+ "知识全力驰援中...",
+ "删删改改中...",
+ "像素精灵正在组字...",
+ "比特们正在排队...",
+ "比特流已启动...",
+ "思维启动加速度...",
+ "正在给回答注入灵魂...",
+ "准备好了吗?快了...",
+ "就差最后一步...",
+ "输出即将解冻...",
+ "神经丛林探险中...",
+ "答案雏形已在手...",
+ "思路正在合流...",
+ "尽力而为,必有所获...",
+ "正在快乐工作中...",
+ "大脑正在全速燃烧...",
+ "答案即将成形...",
+ "思维的潮水汇聚中...",
+ "正在把复杂变简单...",
+ "脑中千个念头筛选中...",
+ "正在为你开天辟地...",
+ "知识海洋里捞最优解...",
+ "万亿可能取其一...",
+ "答案正在破壳而出...",
+ "大脑高速公路全线畅通...",
+ "最佳路径已锁定...",
+ "思维齐步走,答案来了...",
+ "大招正在蓄力中...",
+ "答案即将横空出世..."
+ ]
+}
diff --git a/src/web-ui/src/locales/zh-TW/settings.json b/src/web-ui/src/locales/zh-TW/settings.json
index f435f6829..0a960ab9b 100644
--- a/src/web-ui/src/locales/zh-TW/settings.json
+++ b/src/web-ui/src/locales/zh-TW/settings.json
@@ -7,6 +7,14 @@
"searchNoResults": "沒有匹配的配置",
"searchClear": "清除搜索",
"beta": "Beta",
+ "searchAliases": {
+ "archivedSessions": ["歸檔", "會話"],
+ "keyboard": ["快捷鍵", "鍵位"],
+ "sessionPersonalization": ["夥伴", "個性化"],
+ "sessionPermissions": ["權限"],
+ "quickActions": ["快捷動作", "提交"],
+ "review": ["審核", "程式碼審核"]
+ },
"tabDescriptions": {
"basics": "日誌、終端 Shell、通知與開機啟動。",
"appearance": "語言、主題與介面字體大小。",
diff --git a/src/web-ui/src/shared/ai-errors/aiErrorPresenter.ts b/src/web-ui/src/shared/ai-errors/aiErrorPresenter.ts
index 9c410a64d..c7afba066 100644
--- a/src/web-ui/src/shared/ai-errors/aiErrorPresenter.ts
+++ b/src/web-ui/src/shared/ai-errors/aiErrorPresenter.ts
@@ -1,3 +1,5 @@
+import providerMessageMatchers from './providerMessageMatchers.json';
+
export type AiErrorCategory =
| 'network'
| 'auth'
@@ -209,15 +211,13 @@ function normalizeCategory(
'not enough balance',
'exceeded_current_quota',
'exceeded current quota',
- '余额不足',
- '无可用资源包',
- '账户已欠费',
+ ...providerMessageMatchers.providerQuota,
])
) {
return 'provider_quota';
}
- if (includesAny(`${code} ${message}`, ['billing', 'membership expired', 'subscription expired', '套餐已到期', '1309'])) {
+ if (includesAny(`${code} ${message}`, ['billing', 'membership expired', 'subscription expired', ...providerMessageMatchers.providerBilling, '1309'])) {
return 'provider_billing';
}
diff --git a/src/web-ui/src/shared/ai-errors/providerMessageMatchers.json b/src/web-ui/src/shared/ai-errors/providerMessageMatchers.json
new file mode 100644
index 000000000..347027f2b
--- /dev/null
+++ b/src/web-ui/src/shared/ai-errors/providerMessageMatchers.json
@@ -0,0 +1,10 @@
+{
+ "providerQuota": [
+ "余额不足",
+ "无可用资源包",
+ "账户已欠费"
+ ],
+ "providerBilling": [
+ "套餐已到期"
+ ]
+}
diff --git a/src/web-ui/src/shared/utils/tabUtils.ts b/src/web-ui/src/shared/utils/tabUtils.ts
index 3322d8c8f..ada6e540d 100644
--- a/src/web-ui/src/shared/utils/tabUtils.ts
+++ b/src/web-ui/src/shared/utils/tabUtils.ts
@@ -277,7 +277,7 @@ export function createConfigCenterTab(
export function createReviewPlatformTab(workspacePath?: string): void {
const detail = {
type: 'review-platform',
- title: i18nService.getT()('common:tabs.pullRequests', { defaultValue: 'Pull Requests' }),
+ title: i18nService.getT()('common:tabs.pullRequests'),
data: { workspacePath },
metadata: {
workspacePath,
diff --git a/src/web-ui/src/tools/editor/components/CodeEditor.tsx b/src/web-ui/src/tools/editor/components/CodeEditor.tsx
index 445d9392c..2e83b49f2 100644
--- a/src/web-ui/src/tools/editor/components/CodeEditor.tsx
+++ b/src/web-ui/src/tools/editor/components/CodeEditor.tsx
@@ -42,6 +42,7 @@ import {
import { useI18n } from '@/infrastructure/i18n';
import { EditorBreadcrumb } from './EditorBreadcrumb';
import { EditorStatusBar } from './EditorStatusBar';
+import largeFileExpansionLabels from './largeFileExpansionLabels.json';
const log = createLogger('CodeEditor');
import {
@@ -100,7 +101,7 @@ const LARGE_FILE_SIZE_THRESHOLD_BYTES = 1 * 1024 * 1024; // 1MB
const LARGE_FILE_MAX_LINE_LENGTH = 20000;
const LARGE_FILE_RENDER_LINE_LIMIT = 10000;
const LARGE_FILE_MAX_TOKENIZATION_LINE_LENGTH = 2000;
-const LARGE_FILE_EXPANSION_LABELS = ['show more', '显示更多', '展开更多'];
+const LARGE_FILE_EXPANSION_LABELS = largeFileExpansionLabels;
/** Poll disk metadata for open file; only while tab is active (see isActiveTab). */
const FILE_SYNC_POLL_INTERVAL_MS = 1000;
diff --git a/src/web-ui/src/tools/editor/components/largeFileExpansionLabels.json b/src/web-ui/src/tools/editor/components/largeFileExpansionLabels.json
new file mode 100644
index 000000000..575bc1846
--- /dev/null
+++ b/src/web-ui/src/tools/editor/components/largeFileExpansionLabels.json
@@ -0,0 +1,5 @@
+[
+ "show more",
+ "显示更多",
+ "展开更多"
+]