diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 00000000..da9f55d7
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,2 @@
+* text=auto eol=lf
+*.{cmd,bat} text eol=crlf
diff --git a/cli.js b/cli.js
index ab58bf2b..3c0df825 100644
--- a/cli.js
+++ b/cli.js
@@ -10461,6 +10461,7 @@ function assertRequestAuthorized(req, res) {
function isProtectedWebSurfacePath(requestPath) {
return requestPath === '/'
+ || requestPath === '/session'
|| requestPath === '/web-ui/index.html'
|| requestPath.startsWith('/web-ui/')
|| requestPath.startsWith('/res/');
@@ -11889,8 +11890,8 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser
});
fs.createReadStream(filePath).pipe(res);
} else {
- // Only serve HTML for root path; /web-ui returns 404.
- if (requestPath === '/') {
+ // Serve the SPA shell for routable entry points. Keep /web-ui as 404.
+ if (requestPath === '/' || requestPath === '/session') {
try {
const html = readBundledWebUiHtml(htmlPath);
res.writeHead(200, {
diff --git a/package-lock.json b/package-lock.json
index 4d862295..5ba50bf1 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,16 +1,15 @@
{
"name": "codexmate",
- "version": "0.0.42",
+ "version": "0.0.43",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "codexmate",
- "version": "0.0.42",
+ "version": "0.0.43",
"license": "Apache-2.0",
"dependencies": {
"@iarna/toml": "^2.2.5",
- "@vue/compiler-dom": "^3.5.30",
"json5": "^2.2.3",
"yauzl": "^3.2.1",
"zip-lib": "^1.2.1"
@@ -19,6 +18,7 @@
"codexmate": "cli.js"
},
"devDependencies": {
+ "@vue/compiler-dom": "^3.5.30",
"vitepress": "^1.6.4"
},
"engines": {
@@ -287,6 +287,7 @@
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -296,6 +297,7 @@
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -305,6 +307,7 @@
"version": "7.29.2",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz",
"integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.29.0"
@@ -320,6 +323,7 @@
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
"integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.27.1",
@@ -1329,6 +1333,7 @@
"version": "3.5.30",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.30.tgz",
"integrity": "sha512-s3DfdZkcu/qExZ+td75015ljzHc6vE+30cFMGRPROYjqkroYI5NV2X1yAMX9UeyBNWB9MxCfPcsjpLS11nzkkw==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.0",
@@ -1342,6 +1347,7 @@
"version": "3.5.30",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.30.tgz",
"integrity": "sha512-eCFYESUEVYHhiMuK4SQTldO3RYxyMR/UQL4KdGD1Yrkfdx4m/HYuZ9jSfPdA+nWJY34VWndiYdW/wZXyiPEB9g==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"@vue/compiler-core": "3.5.30",
@@ -1465,6 +1471,7 @@
"version": "3.5.30",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.30.tgz",
"integrity": "sha512-YXgQ7JjaO18NeK2K9VTbDHaFy62WrObMa6XERNfNOkAhD1F1oDSf3ZJ7K6GqabZ0BvSDHajp8qfS5Sa2I9n8uQ==",
+ "dev": true,
"license": "MIT"
},
"node_modules/@vueuse/core": {
@@ -1720,6 +1727,7 @@
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
+ "dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
@@ -1771,6 +1779,7 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
+ "dev": true,
"license": "MIT"
},
"node_modules/focus-trap": {
@@ -2236,6 +2245,7 @@
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
diff --git a/package.json b/package.json
index a393e822..f7da8ca9 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "codexmate",
- "version": "0.0.42",
+ "version": "0.0.43",
"description": "Codex/Claude Code/OpenClaw 配置、会话与任务编排 CLI + Web 工具",
"main": "cli.js",
"bin": {
@@ -46,7 +46,6 @@
},
"dependencies": {
"@iarna/toml": "^2.2.5",
- "@vue/compiler-dom": "^3.5.30",
"json5": "^2.2.3",
"yauzl": "^3.2.1",
"zip-lib": "^1.2.1"
@@ -72,6 +71,7 @@
"author": "ymkiux",
"license": "Apache-2.0",
"devDependencies": {
+ "@vue/compiler-dom": "^3.5.30",
"vitepress": "^1.6.4"
}
}
diff --git a/tests/unit/agents-modal-guards.test.mjs b/tests/unit/agents-modal-guards.test.mjs
index f9ee3ed5..896d2bc6 100644
--- a/tests/unit/agents-modal-guards.test.mjs
+++ b/tests/unit/agents-modal-guards.test.mjs
@@ -11,6 +11,9 @@ const { createAgentsMethods } = await import(
const { createCodexConfigMethods } = await import(
pathToFileURL(path.join(__dirname, '..', '..', 'web-ui', 'modules', 'app.methods.codex-config.mjs'))
);
+const { createI18nMethods } = await import(
+ pathToFileURL(path.join(__dirname, '..', '..', 'web-ui', 'modules', 'i18n.mjs'))
+);
test('closeConfigTemplateModal ignores user close attempts while template apply is busy', () => {
const methods = createCodexConfigMethods({
@@ -20,7 +23,9 @@ test('closeConfigTemplateModal ignores user close attempts while template apply
}
});
const context = {
+ ...createI18nMethods(),
...methods,
+ lang: 'zh',
showConfigTemplateModal: true,
configTemplateApplying: true,
configTemplateContent: 'draft-template'
@@ -52,7 +57,9 @@ test('applyConfigTemplate force closes the modal after a successful apply', asyn
}
});
const context = {
+ ...createI18nMethods(),
...methods,
+ lang: 'zh',
showConfigTemplateModal: true,
configTemplateApplying: false,
configTemplateContent: 'draft-template',
@@ -103,7 +110,9 @@ test('applyConfigTemplate keeps the successful apply result when only the refres
}
});
const context = {
+ ...createI18nMethods(),
...methods,
+ lang: 'zh',
showConfigTemplateModal: true,
configTemplateApplying: false,
configTemplateContent: 'draft-template',
@@ -161,7 +170,9 @@ test('applyConfigTemplate applies immediately when diff confirm is disabled', as
}
});
const context = {
+ ...createI18nMethods(),
...methods,
+ lang: 'zh',
showConfigTemplateModal: true,
configTemplateApplying: false,
configTemplateContent: 'draft-template',
@@ -192,7 +203,9 @@ test('runHealthCheck treats backend error payloads as failures', async () => {
}
});
const context = {
+ ...createI18nMethods(),
...methods,
+ lang: 'zh',
providersList: ['alpha'],
speedResults: {},
speedLoading: {},
@@ -227,7 +240,9 @@ test('runHealthCheck skips Claude speed tests when the primary health check alre
});
let claudeSpeedTestCalls = 0;
const context = {
+ ...createI18nMethods(),
...methods,
+ lang: 'zh',
providersList: ['alpha'],
speedResults: {},
speedLoading: {},
@@ -276,7 +291,9 @@ test('runHealthCheck preserves backend remote health result while appending spee
}
});
const context = {
+ ...createI18nMethods(),
...methods,
+ lang: 'zh',
providersList: ['alpha', 'beta'],
speedResults: {},
speedLoading: {},
@@ -324,7 +341,9 @@ test('applyCodexConfigDirect keeps the successful apply result when only the ref
}
});
const context = {
+ ...createI18nMethods(),
...methods,
+ lang: 'zh',
codexApplying: false,
_pendingCodexApplyOptions: null,
currentProvider: 'alpha',
diff --git a/tests/unit/claude-settings-sync.test.mjs b/tests/unit/claude-settings-sync.test.mjs
index 58f67631..e74de2f0 100644
--- a/tests/unit/claude-settings-sync.test.mjs
+++ b/tests/unit/claude-settings-sync.test.mjs
@@ -1,6 +1,15 @@
import assert from 'assert';
+import path from 'path';
+import { fileURLToPath, pathToFileURL } from 'url';
import { readBundledWebUiScript, readProjectFile } from './helpers/web-ui-source.mjs';
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+
+const { createI18nMethods } = await import(
+ pathToFileURL(path.join(__dirname, '..', '..', 'web-ui', 'modules', 'i18n.mjs'))
+);
+
const appSource = readBundledWebUiScript();
const claudeConfigModuleSource = readProjectFile('web-ui/modules/app.methods.claude-config.mjs');
@@ -224,7 +233,10 @@ test('addClaudeConfig requires a visible model value before saving', () => {
const addClaudeConfig = instantiateFunction(source, 'addClaudeConfig');
const messages = [];
let saveCount = 0;
+ const i18nMethods = createI18nMethods();
const context = {
+ ...i18nMethods,
+ lang: 'zh',
newClaudeConfig: {
name: 'Claude Test',
apiKey: 'sk-test',
@@ -256,7 +268,10 @@ ${extractMethodAsFunction(appSource, 'claudeConfigFieldError')}`;
${extractMethodAsFunction(appSource, 'canSubmitClaudeConfig')}`;
const claudeConfigFieldError = instantiateFunction(fieldErrorSource, 'claudeConfigFieldError');
const canSubmitClaudeConfig = instantiateFunction(canSubmitSource, 'canSubmitClaudeConfig');
+ const i18nMethods = createI18nMethods();
const context = {
+ ...i18nMethods,
+ lang: 'zh',
newClaudeConfig: {
name: 'Existing',
apiKey: '',
@@ -297,7 +312,10 @@ ${extractMethodAsFunction(appSource, 'claudeConfigFieldError')}`;
${extractMethodAsFunction(appSource, 'canSubmitClaudeConfig')}`;
const claudeConfigFieldError = instantiateFunction(fieldErrorSource, 'claudeConfigFieldError');
const canSubmitClaudeConfig = instantiateFunction(canSubmitSource, 'canSubmitClaudeConfig');
+ const i18nMethods = createI18nMethods();
const context = {
+ ...i18nMethods,
+ lang: 'zh',
newClaudeConfig: { name: '', apiKey: '', baseUrl: '', model: '' },
editingConfig: {
name: 'Imported Auth Token',
@@ -350,7 +368,10 @@ test('addClaudeConfig trims and persists the entered model', () => {
let saveCount = 0;
let closed = false;
let refreshed = false;
+ const i18nMethods = createI18nMethods();
const context = {
+ ...i18nMethods,
+ lang: 'zh',
newClaudeConfig: {
name: 'Claude Test',
apiKey: 'sk-test',
@@ -548,7 +569,10 @@ test('saveAndApplyConfig writes the edited Claude model through apply api', asyn
let saveCount = 0;
let closed = false;
let refreshCount = 0;
+ const i18nMethods = createI18nMethods();
const context = {
+ ...i18nMethods,
+ lang: 'zh',
editingConfig: {
name: 'UI Claude Use',
apiKey: 'sk-test',
@@ -607,7 +631,10 @@ test('saveAndApplyConfig saves external credential config without api key', asyn
let saveCount = 0;
let closed = false;
let refreshCount = 0;
+ const i18nMethods = createI18nMethods();
const context = {
+ ...i18nMethods,
+ lang: 'zh',
editingConfig: {
name: 'Imported Auth Token',
apiKey: '',
@@ -654,7 +681,10 @@ test('applyClaudeConfig reports informative message for external credential only
const messages = [];
let refreshCount = 0;
+ const i18nMethods = createI18nMethods();
const context = {
+ ...i18nMethods,
+ lang: 'zh',
claudeConfigs: {
imported: {
apiKey: '',
@@ -689,7 +719,10 @@ test('onClaudeModelChange applies external credential config without api key', (
let updateCount = 0;
const applyCalls = [];
const messages = [];
+ const i18nMethods = createI18nMethods();
const context = {
+ ...i18nMethods,
+ lang: 'zh',
currentClaudeConfig: 'imported',
currentClaudeModel: ' claude-opus-4-6 ',
claudeConfigs: {
@@ -735,7 +768,10 @@ test('onClaudeModelChange still requires api key when no external credential is
let updateCount = 0;
const applyCalls = [];
const messages = [];
+ const i18nMethods = createI18nMethods();
const context = {
+ ...i18nMethods,
+ lang: 'zh',
currentClaudeConfig: 'local',
currentClaudeModel: ' claude-opus-4-6 ',
claudeConfigs: {
diff --git a/tests/unit/openclaw-persist-regression.test.mjs b/tests/unit/openclaw-persist-regression.test.mjs
index b3724dfd..62ff7e90 100644
--- a/tests/unit/openclaw-persist-regression.test.mjs
+++ b/tests/unit/openclaw-persist-regression.test.mjs
@@ -8,6 +8,9 @@ const __dirname = path.dirname(__filename);
const { createOpenclawPersistMethods, DEFAULT_OPENCLAW_CONFIG_NAME } = await import(
pathToFileURL(path.join(__dirname, '..', '..', 'web-ui', 'modules', 'app.methods.openclaw-persist.mjs'))
);
+const { createI18nMethods } = await import(
+ pathToFileURL(path.join(__dirname, '..', '..', 'web-ui', 'modules', 'i18n.mjs'))
+);
function deferred() {
let resolve;
@@ -21,7 +24,9 @@ function deferred() {
function createContext(methods, overrides = {}) {
return {
+ ...createI18nMethods(),
...methods,
+ lang: 'zh',
openclawConfigs: {
saved: {
content: 'saved-local'
diff --git a/tests/unit/provider-switch-regression.test.mjs b/tests/unit/provider-switch-regression.test.mjs
index ef2b9ed3..19609c82 100644
--- a/tests/unit/provider-switch-regression.test.mjs
+++ b/tests/unit/provider-switch-regression.test.mjs
@@ -1,9 +1,18 @@
import assert from 'assert';
+import path from 'path';
+import { fileURLToPath, pathToFileURL } from 'url';
import {
captureCurrentBundledAppOptions,
withGlobalOverrides
} from './helpers/web-ui-app-options.mjs';
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+
+const { createI18nMethods } = await import(
+ pathToFileURL(path.join(__dirname, '..', '..', 'web-ui', 'modules', 'i18n.mjs'))
+);
+
const currentAppOptions = await captureCurrentBundledAppOptions();
const currentMethods = currentAppOptions.methods;
const currentComputed = currentAppOptions.computed;
@@ -13,6 +22,8 @@ function createProviderSwitchContext() {
const messages = [];
return {
+ ...createI18nMethods(),
+ lang: 'zh',
currentProvider: 'alpha',
currentModel: 'alpha-model',
models: ['alpha-model'],
@@ -58,6 +69,8 @@ function createProviderSwitchContext() {
function createProviderUpdateContext() {
const messages = [];
return {
+ ...createI18nMethods(),
+ lang: 'zh',
editingProvider: {
name: 'alpha',
url: ' https://api.example.com/v1 ',
diff --git a/tests/unit/providers-validation.test.mjs b/tests/unit/providers-validation.test.mjs
index da40ed0f..3d42dedf 100644
--- a/tests/unit/providers-validation.test.mjs
+++ b/tests/unit/providers-validation.test.mjs
@@ -1,11 +1,22 @@
import assert from 'assert';
+import path from 'path';
+import { fileURLToPath, pathToFileURL } from 'url';
import { createProvidersMethods } from '../../web-ui/modules/app.methods.providers.mjs';
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+
+const { createI18nMethods } = await import(
+ pathToFileURL(path.join(__dirname, '..', '..', 'web-ui', 'modules', 'i18n.mjs'))
+);
+
function createContext(overrides = {}, apiImpl = async () => ({ success: true })) {
const messages = [];
const loadAllCalls = [];
const methods = createProvidersMethods({ api: apiImpl });
const context = {
+ ...createI18nMethods(),
+ lang: 'zh',
providersList: [],
codexAuthProfiles: [],
showAddModal: true,
diff --git a/tests/unit/run.mjs b/tests/unit/run.mjs
index 7376712f..06085f8c 100644
--- a/tests/unit/run.mjs
+++ b/tests/unit/run.mjs
@@ -25,6 +25,7 @@ await import(pathToFileURL(path.join(__dirname, 'config-tabs-ui.test.mjs')));
await import(pathToFileURL(path.join(__dirname, 'compact-layout-ui.test.mjs')));
await import(pathToFileURL(path.join(__dirname, 'web-ui-source-bundle.test.mjs')));
await import(pathToFileURL(path.join(__dirname, 'startup-claude-star-prompt.test.mjs')));
+await import(pathToFileURL(path.join(__dirname, 'toast-message-translation.test.mjs')));
await import(pathToFileURL(path.join(__dirname, 'install-methods.test.mjs')));
await import(pathToFileURL(path.join(__dirname, 'cli-help.test.mjs')));
await import(pathToFileURL(path.join(__dirname, 'analytics-export-args.test.mjs')));
diff --git a/tests/unit/session-trash-state.test.mjs b/tests/unit/session-trash-state.test.mjs
index 187c48d9..88e5f54e 100644
--- a/tests/unit/session-trash-state.test.mjs
+++ b/tests/unit/session-trash-state.test.mjs
@@ -1,5 +1,6 @@
import assert from 'assert';
import path from 'path';
+import { fileURLToPath, pathToFileURL } from 'url';
import {
readBundledWebUiCss,
readBundledWebUiHtml,
@@ -7,6 +8,13 @@ import {
readProjectFile
} from './helpers/web-ui-source.mjs';
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+
+const { createI18nMethods } = await import(
+ pathToFileURL(path.join(__dirname, '..', '..', 'web-ui', 'modules', 'i18n.mjs'))
+);
+
const appSource = readBundledWebUiScript();
const cliSource = readProjectFile('cli.js');
const indexHtmlSource = readBundledWebUiHtml();
@@ -1904,7 +1912,10 @@ test('cloneSession keeps success message when refresh fails after clone succeeds
});
const messages = [];
+ const i18nMethods = createI18nMethods();
const context = {
+ ...i18nMethods,
+ lang: 'zh',
sessionCloning: {},
sessionsList: [],
isCloneAvailable: () => true,
@@ -1941,7 +1952,10 @@ test('cloneSession keeps success message when selecting the cloned session fails
});
const messages = [];
+ const i18nMethods = createI18nMethods();
const context = {
+ ...i18nMethods,
+ lang: 'zh',
sessionCloning: {},
sessionsList: [],
isCloneAvailable: () => true,
diff --git a/tests/unit/toast-message-translation.test.mjs b/tests/unit/toast-message-translation.test.mjs
new file mode 100644
index 00000000..6874ed2b
--- /dev/null
+++ b/tests/unit/toast-message-translation.test.mjs
@@ -0,0 +1,30 @@
+import assert from 'assert';
+import { translateUiMessage } from '../../web-ui/modules/app.methods.runtime.mjs';
+
+const zhContext = {
+ t(key) {
+ const table = {
+ 'toast.copy.ok': '已复制',
+ 'toast.operation.success': '操作成功',
+ 'toast.apply.fail': '应用配置失败',
+ 'toast.provider.addFail': '添加失败'
+ };
+ return table[key] || key;
+ }
+};
+
+test('translateUiMessage uses current toast i18n keys for copied-link prefixes', () => {
+ assert.strictEqual(translateUiMessage(zhContext, '已复制链接'), '已复制链接');
+ assert.strictEqual(translateUiMessage(zhContext, '已复制路径'), '已复制路径');
+});
+
+test('translateUiMessage uses current toast i18n keys for exact messages', () => {
+ assert.strictEqual(translateUiMessage(zhContext, '操作成功'), '操作成功');
+ assert.strictEqual(translateUiMessage(zhContext, '应用失败'), '应用配置失败');
+ assert.strictEqual(translateUiMessage(zhContext, '添加失败'), '添加失败');
+});
+
+test('translateUiMessage falls back to the original text when a mapped key is missing', () => {
+ assert.strictEqual(translateUiMessage(zhContext, '配置已加载'), '配置已加载');
+ assert.strictEqual(translateUiMessage(zhContext, '配置已加载,请刷新'), '配置已加载,请刷新');
+});
diff --git a/tests/unit/web-run-host.test.mjs b/tests/unit/web-run-host.test.mjs
index 5d831a6c..27e04fb9 100644
--- a/tests/unit/web-run-host.test.mjs
+++ b/tests/unit/web-run-host.test.mjs
@@ -135,10 +135,19 @@ const releaseRunPortIfNeededSource = extractFunctionBySignature(
'function releaseRunPortIfNeeded(port, host, deps = {}) {',
'releaseRunPortIfNeeded'
);
+const isProtectedWebSurfacePathSource = extractFunctionBySignature(
+ cliContent,
+ 'function isProtectedWebSurfacePath(requestPath) {',
+ 'isProtectedWebSurfacePath'
+);
const resolveWebHost = instantiateFunction(resolveWebHostSource, 'resolveWebHost', {
DEFAULT_WEB_HOST: defaultHostMatch[1],
process: { env: {} }
});
+const isProtectedWebSurfacePath = instantiateFunction(
+ isProtectedWebSurfacePathSource,
+ 'isProtectedWebSurfacePath'
+);
test('resolveWebHost defaults to LAN host', () => {
assert.strictEqual(resolveWebHost({}), '127.0.0.1');
@@ -891,6 +900,7 @@ function mockWriteJsonResponse(res, statusCode, payload, headers = {}) {
function mockIsProtectedWebSurfacePath(requestPath) {
return requestPath === '/'
+ || requestPath === '/session'
|| requestPath === '/web-ui/index.html'
|| requestPath.startsWith('/web-ui/')
|| requestPath.startsWith('/res/');
@@ -932,6 +942,11 @@ test('assertRequestAuthorized returns a basic auth challenge for unauthorized re
assert.strictEqual(response.body, '{\n "error": "Unauthorized"\n}');
});
+test('isProtectedWebSurfacePath protects standalone session links', () => {
+ assert.strictEqual(isProtectedWebSurfacePath('/session'), true);
+ assert.strictEqual(isProtectedWebSurfacePath('/session/extra'), false);
+});
+
test('resolveSkillTarget still falls back to default target when target is omitted', () => {
assert.deepStrictEqual(resolveSkillTarget({}), SKILL_TARGETS[0]);
assert.deepStrictEqual(resolveSkillTarget({ items: [] }), SKILL_TARGETS[0]);
@@ -984,6 +999,44 @@ test('createWebServer redirects bundled index URL to the canonical root URL', ()
assert.deepStrictEqual(errors, []);
});
+test('createWebServer serves the SPA shell for standalone session links', () => {
+ const { requestHandler, errors } = createWebServerHarness({
+ htmlReader() {
+ return '
standalone session';
+ }
+ });
+ const response = createMockResponse();
+
+ requestHandler({ url: '/session?source=codex&sessionId=session-1' }, response);
+
+ assert.strictEqual(response.statusCode, 200);
+ assert.strictEqual(response.body, 'standalone session');
+ assert.deepStrictEqual(errors, []);
+});
+
+test('createWebServer requires auth before serving standalone session links to remote clients', () => {
+ const calls = [];
+ const { requestHandler, errors } = createWebServerHarness({
+ htmlReader() {
+ throw new Error('html should not be read before auth');
+ },
+ authorizeRequest(req, res) {
+ calls.push(req.url);
+ res.writeHead(401, { 'Content-Type': 'application/json; charset=utf-8' });
+ res.end('{"error":"Unauthorized"}');
+ return { ok: false, mode: 'unauthorized' };
+ }
+ });
+ const response = createMockResponse();
+
+ requestHandler({ url: '/session?source=codex&sessionId=session-1', socket: { remoteAddress: '192.0.2.10' } }, response);
+
+ assert.strictEqual(response.statusCode, 401);
+ assert.deepStrictEqual(calls, ['/session?source=codex&sessionId=session-1']);
+ assert.strictEqual(response.body, '{"error":"Unauthorized"}');
+ assert.deepStrictEqual(errors, []);
+});
+
test('createWebServer requires auth before serving root page to remote clients', () => {
const calls = [];
const { requestHandler, errors } = createWebServerHarness({
diff --git a/tests/unit/web-ui-behavior-parity.test.mjs b/tests/unit/web-ui-behavior-parity.test.mjs
index 4e3b1598..a84ad213 100644
--- a/tests/unit/web-ui-behavior-parity.test.mjs
+++ b/tests/unit/web-ui-behavior-parity.test.mjs
@@ -1,10 +1,19 @@
import assert from 'assert';
+import path from 'path';
+import { fileURLToPath, pathToFileURL } from 'url';
import {
captureBehaviorParityBaselineAppOptions,
captureCurrentBundledAppOptions,
withGlobalOverrides
} from './helpers/web-ui-app-options.mjs';
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+
+const { createI18nMethods } = await import(
+ pathToFileURL(path.join(__dirname, '..', '..', 'web-ui', 'modules', 'i18n.mjs'))
+);
+
const currentAppOptions = await captureCurrentBundledAppOptions();
const parityBaseline = await captureBehaviorParityBaselineAppOptions();
const headAppOptions = parityBaseline.options;
@@ -289,6 +298,8 @@ function createDownloadEnvironment() {
function createCopyActionContext(methods) {
const messages = createMessagesRecorder();
return {
+ ...createI18nMethods(),
+ lang: 'zh',
...messages,
sessionResumeWithYolo: true,
shareCommandPrefix: 'npm start',
diff --git a/tests/unit/web-ui-startup-init.test.mjs b/tests/unit/web-ui-startup-init.test.mjs
index e3d0b6fd..cfead0c3 100644
--- a/tests/unit/web-ui-startup-init.test.mjs
+++ b/tests/unit/web-ui-startup-init.test.mjs
@@ -203,3 +203,148 @@ test('mounted skips auxiliary startup requests when loadAll fails', async () =>
assert.strictEqual(syncDefaultOpenclawCalls, 0);
});
});
+
+function createStartupMountContext(overrides = {}) {
+ return {
+ mainTab: 'dashboard',
+ configMode: 'codex',
+ settingsTab: 'general',
+ sessionResumeWithYolo: true,
+ claudeConfigs: {},
+ openclawConfigs: {
+ '默认配置': {
+ content: ''
+ }
+ },
+ currentOpenclawConfig: '',
+ switchedTabs: [],
+ initSessionStandaloneCalls: [],
+ initSessionStandalone() {
+ this.initSessionStandaloneCalls.push(window.location.href);
+ },
+ switchMainTab(tab) {
+ this.switchedTabs.push(tab);
+ this.mainTab = tab;
+ },
+ updateCompactLayoutMode() {},
+ restoreSessionFilterCache() {},
+ restoreSessionPinnedMap() {},
+ normalizeShareCommandPrefix(value) {
+ return value || 'npm start';
+ },
+ normalizeSessionTrashEnabled(value) {
+ return value !== '0' && value !== 'false';
+ },
+ normalizeSessionTrashRetentionDays(value) {
+ const numeric = Number(value);
+ if (!Number.isFinite(numeric) || numeric < 1) return 30;
+ return Math.min(365, Math.max(1, Math.floor(numeric)));
+ },
+ onWindowResize() {},
+ handleGlobalKeydown() {},
+ handleBeforeUnload() {},
+ refreshClaudeSelectionFromSettings() {
+ return Promise.resolve();
+ },
+ syncDefaultOpenclawConfigEntry() {
+ return Promise.resolve();
+ },
+ loadAll() {
+ return Promise.resolve(true);
+ },
+ ...overrides
+ };
+}
+
+function createMutableWindowLocation(initialHref) {
+ const location = {
+ href: '',
+ origin: '',
+ pathname: '',
+ search: '',
+ hash: '',
+ replace(nextHref) {
+ apply(nextHref);
+ }
+ };
+ function apply(nextHref) {
+ const url = new URL(nextHref, location.href || initialHref);
+ location.href = url.href;
+ location.origin = url.origin;
+ location.pathname = url.pathname;
+ location.search = url.search;
+ location.hash = url.hash;
+ }
+ apply(initialHref);
+ return { location, apply };
+}
+
+function createStartupMountGlobals(initialHref, replacements = []) {
+ const mutable = createMutableWindowLocation(initialHref);
+ return {
+ document: {
+ readyState: 'complete'
+ },
+ localStorage: {
+ getItem() {
+ return null;
+ },
+ setItem() {},
+ removeItem() {}
+ },
+ window: {
+ location: mutable.location,
+ history: {
+ replaceState(_state, _title, nextHref) {
+ replacements.push(String(nextHref));
+ mutable.apply(nextHref);
+ }
+ },
+ addEventListener() {},
+ removeEventListener() {}
+ },
+ requestAnimationFrame(callback) {
+ return callback;
+ },
+ cancelAnimationFrame() {},
+ setTimeout(callback) {
+ return callback;
+ },
+ clearTimeout() {}
+ };
+}
+
+test('mounted preserves standalone session query parameters before standalone initialization', async () => {
+ const appOptions = await captureCurrentBundledAppOptions();
+ const context = createStartupMountContext();
+ const replacements = [];
+
+ await withGlobalOverrides(
+ createStartupMountGlobals('http://127.0.0.1:3737/session?source=codex&sessionId=pr187-browser-link', replacements),
+ async () => {
+ appOptions.mounted.call(context);
+ }
+ );
+
+ assert.deepStrictEqual(replacements, []);
+ assert.deepStrictEqual(context.initSessionStandaloneCalls, [
+ 'http://127.0.0.1:3737/session?source=codex&sessionId=pr187-browser-link'
+ ]);
+});
+
+test('mounted consumes shareable sessions tab URLs before canonical cleanup', async () => {
+ const appOptions = await captureCurrentBundledAppOptions();
+ const context = createStartupMountContext();
+ const replacements = [];
+
+ await withGlobalOverrides(
+ createStartupMountGlobals('http://127.0.0.1:3737/?tab=sessions', replacements),
+ async () => {
+ appOptions.mounted.call(context);
+ }
+ );
+
+ assert.deepStrictEqual(replacements, []);
+ assert.deepStrictEqual(context.switchedTabs, ['sessions']);
+ assert.strictEqual(context.mainTab, 'sessions');
+});
diff --git a/web-ui/app.js b/web-ui/app.js
index 37593db4..7126f3ae 100644
--- a/web-ui/app.js
+++ b/web-ui/app.js
@@ -366,7 +366,21 @@ document.addEventListener('DOMContentLoaded', () => {
codexDownloadProgress: 0,
codexDownloadTimer: null,
settingsTab: 'general',
- toolConfigPermissions: { codex: false, claude: false },
+ toolConfigPermissions: (function() {
+ try {
+ const cached = localStorage.getItem('toolConfigPermissions');
+ if (cached) {
+ const parsed = JSON.parse(cached);
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
+ return {
+ codex: parsed.codex === true,
+ claude: parsed.claude === true
+ };
+ }
+ }
+ } catch (_) {}
+ return { codex: false, claude: false };
+ })(),
toolConfigPermissionSaving: { codex: false, claude: false },
sessionTrashEnabled: true,
sessionTrashItems: [],
@@ -445,13 +459,9 @@ document.addEventListener('DOMContentLoaded', () => {
window.location.replace(url.toString());
return;
}
- // 清理任何查询参数和 hash,保持 URL 为 /
- if (window.location.search || window.location.hash) {
- const url = new URL(window.location.href);
- url.search = '';
- url.hash = '';
- window.history.replaceState(null, '', url.toString());
- }
+ // Do not strip query/hash during startup: /session uses them to identify the
+ // standalone session, and shareable tab/filter URLs are consumed below before
+ // later runtime canonicalization can clean the address bar.
} catch (_) {}
if (typeof this.initI18n === 'function') {
diff --git a/web-ui/modules/app.methods.agents.mjs b/web-ui/modules/app.methods.agents.mjs
index f89e2a42..4374f28a 100644
--- a/web-ui/modules/app.methods.agents.mjs
+++ b/web-ui/modules/app.methods.agents.mjs
@@ -60,7 +60,7 @@ export function createAgentsMethods(options = {}) {
if (!isLatestRequestToken(this, '_agentsOpenRequestToken', requestToken)) {
return;
}
- this.showMessage('加载文件失败', 'error');
+ this.showMessage(this.t('toast.load.fail'), 'error');
} finally {
if (isLatestRequestToken(this, '_agentsOpenRequestToken', requestToken)) {
this.agentsLoading = false;
@@ -92,7 +92,7 @@ export function createAgentsMethods(options = {}) {
if (!isLatestRequestToken(this, '_agentsOpenRequestToken', requestToken)) {
return;
}
- this.showMessage('加载文件失败', 'error');
+ this.showMessage(this.t('toast.load.fail'), 'error');
} finally {
if (isLatestRequestToken(this, '_agentsOpenRequestToken', requestToken)) {
this.agentsLoading = false;
@@ -127,7 +127,7 @@ export function createAgentsMethods(options = {}) {
if (!isLatestRequestToken(this, '_agentsOpenRequestToken', requestToken)) {
return;
}
- this.showMessage('加载文件失败', 'error');
+ this.showMessage(this.t('toast.load.fail'), 'error');
} finally {
if (isLatestRequestToken(this, '_agentsOpenRequestToken', requestToken)) {
this.agentsLoading = false;
@@ -171,7 +171,7 @@ export function createAgentsMethods(options = {}) {
if (!isLatestRequestToken(this, '_agentsOpenRequestToken', requestToken)) {
return;
}
- this.showMessage('加载文件失败', 'error');
+ this.showMessage(this.t('toast.load.fail'), 'error');
} finally {
if (isLatestRequestToken(this, '_agentsOpenRequestToken', requestToken)) {
this.agentsLoading = false;
@@ -588,7 +588,7 @@ export function createAgentsMethods(options = {}) {
}
if (!this.agentsDiffVisible) {
if (!this.hasAgentsContentChanged()) {
- this.showMessage('未检测到改动', 'info');
+ this.showMessage(this.t('toast.noChanges'), 'info');
return;
}
await this.prepareAgentsDiff();
@@ -642,7 +642,7 @@ export function createAgentsMethods(options = {}) {
this.showMessage(successLabel, 'success');
this.closeAgentsModal({ force: true });
} catch (e) {
- this.showMessage('保存失败', 'error');
+ this.showMessage(this.t('toast.save.fail'), 'error');
} finally {
this.agentsSaving = false;
}
diff --git a/web-ui/modules/app.methods.claude-config.mjs b/web-ui/modules/app.methods.claude-config.mjs
index d1f0c4e2..7c25bbb9 100644
--- a/web-ui/modules/app.methods.claude-config.mjs
+++ b/web-ui/modules/app.methods.claude-config.mjs
@@ -31,23 +31,23 @@ function getClaudeConfigValidationForContext(vm, mode = 'add') {
};
if (!name) {
- errors.name = '配置名称不能为空';
+ errors.name = vm.t('validation.claude.nameRequired');
} else if (mode === 'add' && vm.claudeConfigs && vm.claudeConfigs[name]) {
- errors.name = '名称已存在';
+ errors.name = vm.t('validation.claude.nameExists');
}
if (!apiKey && !externalCredentialType) {
- errors.apiKey = 'API Key 必填';
+ errors.apiKey = vm.t('validation.claude.apiKeyRequired');
}
if (!baseUrl) {
- errors.baseUrl = 'Base URL 必填';
+ errors.baseUrl = vm.t('validation.claude.baseUrlRequired');
} else if (!isValidClaudeHttpUrl(baseUrl)) {
- errors.baseUrl = 'Base URL 仅支持 http/https';
+ errors.baseUrl = vm.t('validation.claude.baseUrlHttpOnly');
}
if (!model) {
- errors.model = '模型名称必填';
+ errors.model = vm.t('validation.claude.modelRequired');
}
return {
@@ -79,7 +79,7 @@ export function createClaudeConfigMethods(options = {}) {
}
const model = (this.currentClaudeModel || '').trim();
if (!model) {
- this.showMessage('请输入模型', 'error');
+ this.showMessage(this.t('toast.claude.modelRequired'), 'error');
return;
}
const existing = this.claudeConfigs[name] || {};
@@ -89,7 +89,7 @@ export function createClaudeConfigMethods(options = {}) {
this.saveClaudeConfigs();
this.updateClaudeModelsCurrent();
if (!this.claudeConfigs[name].apiKey && !this.claudeConfigs[name].externalCredentialType) {
- this.showMessage('请先配置 API Key', 'error');
+ this.showMessage(this.t('toast.claude.apiKeyRequired'), 'error');
return;
}
this.applyClaudeConfig(name);
@@ -153,7 +153,7 @@ export function createClaudeConfigMethods(options = {}) {
updateConfig() {
const validation = getClaudeConfigValidationForContext(this, 'edit');
if (!validation.ok) {
- return this.showMessage(validation.errors.name || validation.errors.apiKey || validation.errors.baseUrl || validation.errors.model || '请检查 Claude 配置', 'error');
+ return this.showMessage(validation.errors.name || validation.errors.apiKey || validation.errors.baseUrl || validation.errors.model || this.t('toast.claude.checkConfig'), 'error');
}
const name = validation.name;
this.editingConfig.apiKey = validation.apiKey;
@@ -161,7 +161,7 @@ export function createClaudeConfigMethods(options = {}) {
this.editingConfig.model = validation.model;
this.claudeConfigs[name] = this.mergeClaudeConfig(this.claudeConfigs[name], this.editingConfig);
this.saveClaudeConfigs();
- this.showMessage('操作成功', 'success');
+ this.showMessage(this.t('toast.operation.success'), 'success');
this.closeEditConfigModal();
if (name === this.currentClaudeConfig) {
this.refreshClaudeModelContext();
@@ -181,7 +181,7 @@ export function createClaudeConfigMethods(options = {}) {
async saveAndApplyConfig() {
const validation = getClaudeConfigValidationForContext(this, 'edit');
if (!validation.ok) {
- return this.showMessage(validation.errors.name || validation.errors.apiKey || validation.errors.baseUrl || validation.errors.model || '请检查 Claude 配置', 'error');
+ return this.showMessage(validation.errors.name || validation.errors.apiKey || validation.errors.baseUrl || validation.errors.model || this.t('toast.claude.checkConfig'), 'error');
}
const name = validation.name;
this.editingConfig.apiKey = validation.apiKey;
@@ -192,7 +192,7 @@ export function createClaudeConfigMethods(options = {}) {
const config = this.claudeConfigs[name];
if (!config.apiKey) {
- this.showMessage('已保存(未填写 API Key)', 'info');
+ this.showMessage(this.t('toast.claude.savedWithoutKey'), 'info');
this.closeEditConfigModal();
if (name === this.currentClaudeConfig) {
this.refreshClaudeModelContext();
@@ -204,25 +204,25 @@ export function createClaudeConfigMethods(options = {}) {
try {
const res = await api('apply-claude-config', { config });
if (res.error || res.success === false) {
- this.showMessage(res.error || '应用配置失败', 'error');
+ this.showMessage(res.error || this.t('toast.apply.fail'), 'error');
} else {
this.currentClaudeConfig = name;
if (this._lastAppliedClaudeKey !== _claudeKey) {
- this.showMessage('Claude 配置已生效', 'success');
+ this.showMessage(this.t('toast.claude.applied'), 'success');
this._lastAppliedClaudeKey = _claudeKey;
}
this.closeEditConfigModal();
this.refreshClaudeModelContext();
}
} catch (_) {
- this.showMessage('应用配置失败', 'error');
+ this.showMessage(this.t('toast.apply.fail'), 'error');
}
},
addClaudeConfig() {
const validation = getClaudeConfigValidationForContext(this, 'add');
if (!validation.ok) {
- return this.showMessage(validation.errors.name || validation.errors.apiKey || validation.errors.baseUrl || validation.errors.model || '请检查 Claude 配置', 'error');
+ return this.showMessage(validation.errors.name || validation.errors.apiKey || validation.errors.baseUrl || validation.errors.model || this.t('toast.claude.checkConfig'), 'error');
}
this.newClaudeConfig.name = validation.name;
this.newClaudeConfig.apiKey = validation.apiKey;
@@ -231,27 +231,27 @@ export function createClaudeConfigMethods(options = {}) {
const name = validation.name;
const duplicateName = this.findDuplicateClaudeConfigName(this.newClaudeConfig);
if (duplicateName) {
- return this.showMessage('配置已存在', 'info');
+ return this.showMessage(this.t('toast.claude.exists'), 'info');
}
this.claudeConfigs[name] = this.mergeClaudeConfig({}, this.newClaudeConfig);
this.currentClaudeConfig = name;
this.saveClaudeConfigs();
- this.showMessage('操作成功', 'success');
+ this.showMessage(this.t('toast.operation.success'), 'success');
this.closeClaudeConfigModal();
this.refreshClaudeModelContext();
},
async deleteClaudeConfig(name) {
if (Object.keys(this.claudeConfigs).length <= 1) {
- return this.showMessage('至少保留一项', 'error');
+ return this.showMessage(this.t('toast.claude.keepOne'), 'error');
}
const confirmed = await this.requestConfirmDialog({
- title: '删除 Claude 配置',
- message: `确定删除配置 "${name}"?`,
- confirmText: '删除',
- cancelText: '取消',
+ title: this.t('modal.claudeDelete.title'),
+ message: this.t('modal.claudeDelete.message', { name }),
+ confirmText: this.t('modal.claudeDelete.confirm'),
+ cancelText: this.t('modal.claudeDelete.cancel'),
danger: true
});
if (!confirmed) return;
@@ -261,7 +261,7 @@ export function createClaudeConfigMethods(options = {}) {
this.currentClaudeConfig = Object.keys(this.claudeConfigs)[0];
}
this.saveClaudeConfigs();
- this.showMessage('操作成功', 'success');
+ this.showMessage(this.t('toast.operation.success'), 'success');
this.refreshClaudeModelContext();
},
@@ -273,24 +273,24 @@ export function createClaudeConfigMethods(options = {}) {
if (!config.apiKey) {
if (config.externalCredentialType) {
- return this.showMessage('使用外部认证,无需 API Key', 'info');
+ return this.showMessage(this.t('toast.claude.externalAuth'), 'info');
}
- return this.showMessage('请先配置 API Key', 'error');
+ return this.showMessage(this.t('toast.claude.apiKeyRequired'), 'error');
}
const _claudeKey2 = `${name}|${config.apiKey || ""}|${config.baseUrl || ""}|${config.model || ""}`;
try {
const res = await api('apply-claude-config', { config });
if (res.error || res.success === false) {
- this.showMessage(res.error || '应用配置失败', 'error');
+ this.showMessage(res.error || this.t('toast.apply.fail'), 'error');
} else {
if (this._lastAppliedClaudeKey !== _claudeKey2) {
- this.showMessage('配置已应用', 'success');
+ this.showMessage(this.t('toast.apply.success'), 'success');
this._lastAppliedClaudeKey = _claudeKey2;
}
}
} catch (_) {
- this.showMessage('应用配置失败', 'error');
+ this.showMessage(this.t('toast.apply.fail'), 'error');
}
},
@@ -328,12 +328,12 @@ export function createClaudeConfigMethods(options = {}) {
return;
}
if (enable) {
- this.showMessage('Claude 本地负载均衡已启用', 'success');
+ this.showMessage(this.t('toast.claude.balanceEnabled'), 'success');
} else {
- this.showMessage('Claude 本地负载均衡已关闭', 'success');
+ this.showMessage(this.t('toast.claude.balanceDisabled'), 'success');
}
} catch (e) {
- this.showMessage('操作失败', 'error');
+ this.showMessage(this.t('toast.operation.fail'), 'error');
}
},
@@ -379,18 +379,18 @@ export function createClaudeConfigMethods(options = {}) {
const candidates = this.claudeLocalBridgeCandidateProviders();
if (candidates.length === 0) {
- return this.showMessage('请先添加并配置至少一个 Claude 提供商', 'error');
+ return this.showMessage(this.t('toast.claude.balanceRequireProvider'), 'error');
}
try {
const res = await api('claude-local-bridge-toggle', { enable: true });
if (res.error) {
- this.showMessage(res.error || '启用本地负载均衡失败', 'error');
+ this.showMessage(res.error || this.t('toast.claude.balanceEnableFail'), 'error');
return;
}
- this.showMessage('Claude 本地负载均衡已启用', 'success');
+ this.showMessage(this.t('toast.claude.balanceEnabled'), 'success');
} catch (e) {
- this.showMessage('启用本地负载均衡失败', 'error');
+ this.showMessage(this.t('toast.claude.balanceEnableFail'), 'error');
}
},
@@ -405,7 +405,7 @@ export function createClaudeConfigMethods(options = {}) {
this.configTemplateContext = 'claude';
this.showConfigTemplateModal = true;
} catch (e) {
- this.showMessage('加载 Claude settings 失败', 'error');
+ this.showMessage(this.t('toast.claude.loadSettingsFail'), 'error');
}
}
};
diff --git a/web-ui/modules/app.methods.codex-config.mjs b/web-ui/modules/app.methods.codex-config.mjs
index 3f0faded..f5fc87c7 100644
--- a/web-ui/modules/app.methods.codex-config.mjs
+++ b/web-ui/modules/app.methods.codex-config.mjs
@@ -66,7 +66,7 @@ export function createCodexConfigMethods(options = {}) {
const maxLabel = res.maxMessages === 'all' ? 'all' : res.maxMessages;
this.showMessage(`会话导出完成(已截断:最多 ${maxLabel} 条消息)`, 'info');
} else {
- this.showMessage('操作成功', 'success');
+ this.showMessage(this.t('toast.operation.success'), 'success');
}
} catch (e) {
this.showMessage('导出失败', 'error');
@@ -325,7 +325,7 @@ export function createCodexConfigMethods(options = {}) {
if (hasResponseError(res)) {
this.healthCheckResult = null;
if (!silent) {
- this.showMessage(getResponseMessage(res, '检查失败'), 'error');
+ this.showMessage(getResponseMessage(res, this.t('toast.check.fail')), 'error');
}
return;
}
@@ -342,13 +342,13 @@ export function createCodexConfigMethods(options = {}) {
this.healthCheckBatchDone = total;
this.healthCheckBatchFailed = errors + warns;
if (!silent && res.ok) {
- this.showMessage('检查通过', 'success');
+ this.showMessage(this.t('toast.check.success'), 'success');
}
return;
}
this.healthCheckResult = null;
if (!silent) {
- this.showMessage('检查失败', 'error');
+ this.showMessage(this.t('toast.check.fail'), 'error');
}
return;
}
@@ -473,7 +473,7 @@ export function createCodexConfigMethods(options = {}) {
} else {
this.healthCheckResult = null;
if (!silent) {
- this.showMessage('检查失败', 'error');
+ this.showMessage(this.t('toast.check.fail'), 'error');
}
}
} catch (e) {
@@ -561,7 +561,7 @@ export function createCodexConfigMethods(options = {}) {
this.configTemplateContext = 'codex';
this.showConfigTemplateModal = true;
} catch (e) {
- this.showMessage('加载模板失败', 'error');
+ this.showMessage(this.t('toast.template.loadFail'), 'error');
}
},
@@ -797,7 +797,7 @@ export function createCodexConfigMethods(options = {}) {
return;
}
if (!this.configTemplateContent || !this.configTemplateContent.trim()) {
- this.showMessage('模板不能为空', 'error');
+ this.showMessage(this.t('toast.template.empty'), 'error');
return;
}
@@ -822,15 +822,15 @@ export function createCodexConfigMethods(options = {}) {
this.showMessage(res.error, 'error');
return;
}
- this.showMessage('模板已应用', 'success');
+ this.showMessage(this.t('toast.template.applied'), 'success');
this.closeConfigTemplateModal({ force: true });
try {
await this.loadAll();
} catch (_) {
- this.showMessage('模板已应用,但界面刷新失败,请手动刷新', 'error');
+ this.showMessage(this.t('toast.template.appliedButRefreshFail'), 'error');
}
} catch (e) {
- this.showMessage('应用模板失败', 'error');
+ this.showMessage(this.t('toast.template.applyFail'), 'error');
} finally {
this.configTemplateApplying = false;
}
diff --git a/web-ui/modules/app.methods.openclaw-persist.mjs b/web-ui/modules/app.methods.openclaw-persist.mjs
index 929e1d8d..f9142ea4 100644
--- a/web-ui/modules/app.methods.openclaw-persist.mjs
+++ b/web-ui/modules/app.methods.openclaw-persist.mjs
@@ -274,7 +274,7 @@ export function createOpenclawPersistMethods(options = {}) {
try {
const name = this.persistOpenclawConfig();
if (!name) return;
- this.showMessage('操作成功', 'success');
+ this.showMessage(this.t('toast.operation.success'), 'success');
} finally {
this.openclawSaving = false;
}
diff --git a/web-ui/modules/app.methods.providers.mjs b/web-ui/modules/app.methods.providers.mjs
index b01e95d7..c2fb76ea 100644
--- a/web-ui/modules/app.methods.providers.mjs
+++ b/web-ui/modules/app.methods.providers.mjs
@@ -170,7 +170,7 @@ export function createProvidersMethods(options = {}) {
normalizeProviderDraftState(this.newProvider);
const validation = getProviderValidationForContext(this, 'add');
if (!validation.ok) {
- return this.showMessage(validation.errors.name || validation.errors.url || validation.errors.key || validation.errors.model || '名称、URL、API Key 和模型名称必填', 'error');
+ return this.showMessage(validation.errors.name || validation.errors.url || validation.errors.key || validation.errors.model || this.t('toast.provider.fieldsRequired'), 'error');
}
try {
@@ -206,7 +206,7 @@ export function createProvidersMethods(options = {}) {
};
this.providersList = [...this.providersList, newProvider];
- this.showMessage('操作成功', 'success');
+ this.showMessage(this.t('toast.operation.success'), 'success');
this.closeAddModal();
if (suggestedModel) {
@@ -214,7 +214,7 @@ export function createProvidersMethods(options = {}) {
this.currentModels[validation.name] = suggestedModel;
}
} catch (e) {
- this.showMessage('添加失败', 'error');
+ this.showMessage(this.t('toast.provider.addFail'), 'error');
}
},
@@ -275,7 +275,7 @@ export function createProvidersMethods(options = {}) {
async deleteProvider(name) {
if (this.isNonDeletableProvider(name)) {
- this.showMessage('该 provider 为保留项,不可删除', 'info');
+ this.showMessage(this.t('toast.provider.notDeletable'), 'info');
return;
}
try {
@@ -301,12 +301,13 @@ export function createProvidersMethods(options = {}) {
...p,
current: p.name === res.provider
}));
- this.showMessage(`已删除提供商,自动切换到 ${res.provider}${res.model ? ` / ${res.model}` : ''}`, 'success');
+ const modelSuffix = res.model ? ` / ${res.model}` : '';
+ this.showMessage(this.t('toast.provider.deletedAndSwitched', { provider: res.provider, model: modelSuffix }), 'success');
} else {
- this.showMessage('操作成功', 'success');
+ this.showMessage(this.t('toast.operation.success'), 'success');
}
} catch (_) {
- this.showMessage('删除失败', 'error');
+ this.showMessage(this.t('toast.delete.fail'), 'error');
}
},
@@ -330,7 +331,7 @@ export function createProvidersMethods(options = {}) {
const requestId = Symbol('openEditModal');
this._openEditModalRequestId = requestId;
if (!this.shouldShowProviderEdit(provider)) {
- this.showMessage('该 provider 为保留项,不可编辑', 'info');
+ this.showMessage(this.t('toast.provider.notEditable'), 'info');
return;
}
const isTransformProvider = (() => {
@@ -398,14 +399,14 @@ export function createProvidersMethods(options = {}) {
async updateProvider() {
if (this.editingProvider.readOnly || this.editingProvider.nonEditable) {
- this.showMessage('该 provider 为保留项,不可编辑', 'error');
+ this.showMessage(this.t('toast.provider.notEditable'), 'error');
this.closeEditModal();
return;
}
normalizeProviderDraftState(this.editingProvider);
const validation = getProviderValidationForContext(this, 'edit');
if (!validation.ok) {
- return this.showMessage(validation.errors.name || validation.errors.url || 'URL 必填', 'error');
+ return this.showMessage(validation.errors.name || validation.errors.url || this.t('toast.provider.urlRequired'), 'error');
}
const params = { name: validation.name, url: validation.url };
@@ -441,9 +442,9 @@ export function createProvidersMethods(options = {}) {
});
this.closeEditModal();
- this.showMessage('操作成功', 'success');
+ this.showMessage(this.t('toast.operation.success'), 'success');
} catch (e) {
- this.showMessage('更新失败', 'error');
+ this.showMessage(this.t('toast.provider.updateFail'), 'error');
}
},
@@ -469,10 +470,10 @@ export function createProvidersMethods(options = {}) {
return;
}
const backup = res.backupFile ? `(已备份: ${res.backupFile})` : '';
- this.showMessage(`配置已重装${backup}`, 'success');
+ this.showMessage(this.t('toast.provider.resetSuccess', { backup }), 'success');
await this.loadAll();
} catch (e) {
- this.showMessage('重装失败', 'error');
+ this.showMessage(this.t('toast.provider.resetFail'), 'error');
} finally {
this.resetConfigLoading = false;
}
@@ -501,7 +502,7 @@ export function createProvidersMethods(options = {}) {
}
return p;
});
- this.showMessage('操作成功', 'success');
+ this.showMessage(this.t('toast.operation.success'), 'success');
this.closeModelModal();
}
} catch (_) {
@@ -525,7 +526,7 @@ export function createProvidersMethods(options = {}) {
}
return p;
});
- this.showMessage('操作成功', 'success');
+ this.showMessage(this.t('toast.operation.success'), 'success');
}
} catch (_) {
this.showMessage('删除模型失败', 'error');
diff --git a/web-ui/modules/app.methods.runtime.mjs b/web-ui/modules/app.methods.runtime.mjs
index cc5e66c2..34bbb103 100644
--- a/web-ui/modules/app.methods.runtime.mjs
+++ b/web-ui/modules/app.methods.runtime.mjs
@@ -4,21 +4,21 @@ import {
} from '../logic.mjs';
const UI_MESSAGE_KEY_BY_TEXT = Object.freeze({
- '操作成功': 'toast.operationSuccess',
- '操作失败': 'toast.operationFailed',
- '添加失败': 'toast.addFailed',
- '更新失败': 'toast.updateFailed',
- '删除失败': 'toast.deleteFailed',
- '已删除': 'toast.deleted',
- '已复制': 'toast.copied',
- '复制失败': 'toast.copyFailed',
+ '操作成功': 'toast.operation.success',
+ '操作失败': 'toast.operation.fail',
+ '添加失败': 'toast.provider.addFail',
+ '更新失败': 'toast.provider.updateFail',
+ '删除失败': 'toast.delete.fail',
+ '已删除': 'toast.delete.ok',
+ '已复制': 'toast.copy.ok',
+ '复制失败': 'toast.copy.fail',
'剪贴板为空': 'toast.clipboardEmpty',
'无法读取剪贴板': 'toast.clipboardReadFailed',
'已粘贴': 'toast.pasted',
'未检测到改动': 'toast.noChanges',
- '配置已应用': 'toast.configApplied',
- '应用配置失败': 'toast.applyConfigFailed',
- '应用失败': 'toast.applyFailed',
+ '配置已应用': 'toast.apply.success',
+ '应用配置失败': 'toast.apply.fail',
+ '应用失败': 'toast.apply.fail',
'配置已加载': 'toast.configLoaded',
'配置就绪': 'toast.configReady',
'加载配置失败': 'toast.loadConfigFailed',
@@ -26,12 +26,12 @@ const UI_MESSAGE_KEY_BY_TEXT = Object.freeze({
'读取配置超时': 'toast.readConfigTimeout',
'备份失败': 'toast.backupFailed',
'备份成功,开始下载': 'toast.backupReadyDownload',
- '导入失败': 'toast.importFailed',
- '导入成功': 'toast.importSuccess',
+ '导入失败': 'toast.import.fail',
+ '导入成功': 'toast.import.ok',
'导入 skill 失败': 'toast.importSkillFailed',
- '导出失败': 'toast.exportFailed',
- '保存失败': 'toast.saveFailed',
- '加载文件失败': 'toast.loadFileFailed',
+ '导出失败': 'toast.export.fail',
+ '保存失败': 'toast.save.fail',
+ '加载文件失败': 'toast.load.fail',
'请填写名称': 'toast.nameRequired',
'请输入名称': 'toast.nameRequired',
'名称已存在': 'toast.nameExists',
@@ -45,8 +45,8 @@ const UI_MESSAGE_KEY_BY_TEXT = Object.freeze({
'不可分享': 'toast.notShareable',
'已移入回收站': 'toast.movedToTrash',
'生成命令失败': 'toast.commandGenerationFailed',
- '没有可复制内容': 'toast.nothingToCopy',
- '没有可导出内容': 'toast.nothingToExport',
+ '没有可复制内容': 'toast.copy.empty',
+ '没有可导出内容': 'toast.export.empty',
'会话已恢复': 'toast.sessionRestored',
'恢复失败': 'toast.restoreFailed',
'已彻底删除': 'toast.purged',
@@ -66,16 +66,22 @@ const UI_MESSAGE_PREFIX_ENTRIES = Object.freeze(
Object.entries(UI_MESSAGE_KEY_BY_TEXT).sort((a, b) => b[0].length - a[0].length)
);
-function translateUiMessage(context, text) {
+export function translateUiMessage(context, text) {
if (!context || typeof context.t !== 'function' || typeof text !== 'string') return text;
+ const translateKey = (key) => {
+ const translated = context.t(key);
+ return typeof translated === 'string' && translated && translated !== key ? translated : '';
+ };
const exactKey = UI_MESSAGE_KEY_BY_TEXT[text];
- if (exactKey) return context.t(exactKey);
+ if (exactKey) return translateKey(exactKey) || text;
const prefixEntry = UI_MESSAGE_PREFIX_ENTRIES.find(([sourceText]) => {
return text.length > sourceText.length && text.startsWith(sourceText);
});
if (!prefixEntry) return text;
const [sourceText, key] = prefixEntry;
- return `${context.t(key)}${text.slice(sourceText.length)}`;
+ const translatedPrefix = translateKey(key);
+ if (!translatedPrefix) return text;
+ return `${translatedPrefix}${text.slice(sourceText.length)}`;
}
function clearProgressResetTimer(context, timerKey) {
diff --git a/web-ui/modules/app.methods.session-actions.mjs b/web-ui/modules/app.methods.session-actions.mjs
index 31edaea7..9864e83d 100644
--- a/web-ui/modules/app.methods.session-actions.mjs
+++ b/web-ui/modules/app.methods.session-actions.mjs
@@ -126,7 +126,7 @@ export function createSessionActionMethods(options = {}) {
return;
}
} catch (_) {}
- this.showMessage('复制失败', 'error');
+ this.showMessage(this.t('toast.copy.fail'), 'error');
},
getSessionFilePath(session) {
@@ -152,7 +152,7 @@ export function createSessionActionMethods(options = {}) {
return;
}
} catch (_) {}
- this.showMessage('复制失败', 'error');
+ this.showMessage(this.t('toast.copy.fail'), 'error');
},
getSessionExportKey(session) {
@@ -312,15 +312,15 @@ export function createSessionActionMethods(options = {}) {
copyAgentsContent() {
const text = typeof this.agentsContent === 'string' ? this.agentsContent : '';
if (!text) {
- this.showMessage('没有可复制内容', 'info');
+ this.showMessage(this.t('toast.copy.empty'), 'info');
return;
}
const ok = this.fallbackCopyText(text);
if (ok) {
- this.showMessage('已复制', 'success');
+ this.showMessage(this.t('toast.copy.ok'), 'success');
return;
}
- this.showMessage('复制失败', 'error');
+ this.showMessage(this.t('toast.copy.fail'), 'error');
},
exportAgentsContent() {
@@ -344,7 +344,7 @@ export function createSessionActionMethods(options = {}) {
async copyInstallCommand(cmd) {
const text = typeof cmd === 'string' ? cmd.trim() : '';
if (!text) {
- this.showMessage('没有可复制内容', 'info');
+ this.showMessage(this.t('toast.copy.empty'), 'info');
return;
}
try {
@@ -359,7 +359,7 @@ export function createSessionActionMethods(options = {}) {
this.showMessage('已复制命令', 'success');
return;
}
- this.showMessage('复制失败', 'error');
+ this.showMessage(this.t('toast.copy.fail'), 'error');
},
async copyResumeCommand(session) {
@@ -370,17 +370,17 @@ export function createSessionActionMethods(options = {}) {
const command = this.buildResumeCommand(session);
const ok = this.fallbackCopyText(command);
if (ok) {
- this.showMessage('已复制', 'success');
+ this.showMessage(this.t('toast.copy.ok'), 'success');
return;
}
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(command);
- this.showMessage('已复制', 'success');
+ this.showMessage(this.t('toast.copy.ok'), 'success');
return;
}
} catch (_) {}
- this.showMessage('复制失败', 'error');
+ this.showMessage(this.t('toast.copy.fail'), 'error');
},
buildProviderShareCommand(payload) {
@@ -446,17 +446,17 @@ export function createSessionActionMethods(options = {}) {
}
const ok = this.fallbackCopyText(command);
if (ok) {
- this.showMessage('已复制', 'success');
+ this.showMessage(this.t('toast.copy.ok'), 'success');
return;
}
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(command);
- this.showMessage('已复制', 'success');
+ this.showMessage(this.t('toast.copy.ok'), 'success');
return;
}
} catch (_) {}
- this.showMessage('复制失败', 'error');
+ this.showMessage(this.t('toast.copy.fail'), 'error');
} catch (_) {
this.showMessage('生成命令失败', 'error');
} finally {
@@ -485,17 +485,17 @@ export function createSessionActionMethods(options = {}) {
}
const ok = this.fallbackCopyText(command);
if (ok) {
- this.showMessage('已复制', 'success');
+ this.showMessage(this.t('toast.copy.ok'), 'success');
return;
}
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(command);
- this.showMessage('已复制', 'success');
+ this.showMessage(this.t('toast.copy.ok'), 'success');
return;
}
} catch (_) {}
- this.showMessage('复制失败', 'error');
+ this.showMessage(this.t('toast.copy.fail'), 'error');
} catch (_) {
this.showMessage('生成命令失败', 'error');
} finally {
@@ -524,7 +524,7 @@ export function createSessionActionMethods(options = {}) {
return;
}
- this.showMessage('操作成功', 'success');
+ this.showMessage(this.t('toast.operation.success'), 'success');
if (typeof this.invalidateSessionsUsageData === 'function') {
this.invalidateSessionsUsageData({ preserveList: true });
}
@@ -608,7 +608,7 @@ export function createSessionActionMethods(options = {}) {
// The delete already succeeded remotely; keep the success result.
}
} catch (_) {
- this.showMessage('删除失败', 'error');
+ this.showMessage(this.t('toast.delete.fail'), 'error');
} finally {
this.sessionDeleting[key] = false;
}
diff --git a/web-ui/modules/app.methods.startup-claude.mjs b/web-ui/modules/app.methods.startup-claude.mjs
index 444feb44..4a89eed4 100644
--- a/web-ui/modules/app.methods.startup-claude.mjs
+++ b/web-ui/modules/app.methods.startup-claude.mjs
@@ -125,6 +125,9 @@ export function createStartupClaudeMethods(options = {}) {
codex: statusRes.toolConfigPermissions.codex === true,
claude: statusRes.toolConfigPermissions.claude === true
};
+ try {
+ localStorage.setItem('toolConfigPermissions', JSON.stringify(this.toolConfigPermissions));
+ } catch (_) {}
}
this.providersList = listRes.providers;
if (typeof this.loadLocalBridgeExcluded === 'function') { this.loadLocalBridgeExcluded(); }
diff --git a/web-ui/modules/app.methods.tool-config-permissions.mjs b/web-ui/modules/app.methods.tool-config-permissions.mjs
index 1dbc6d25..174ca4fd 100644
--- a/web-ui/modules/app.methods.tool-config-permissions.mjs
+++ b/web-ui/modules/app.methods.tool-config-permissions.mjs
@@ -64,6 +64,9 @@ export function createToolConfigPermissionMethods(options = {}) {
return;
}
this.toolConfigPermissions = normalizePermissions(res && res.permissions);
+ try {
+ localStorage.setItem('toolConfigPermissions', JSON.stringify(this.toolConfigPermissions));
+ } catch (_) {}
this.showMessage(
nextAllowWrite
? this.t('toolConfig.allowToast')
diff --git a/web-ui/modules/i18n/locales/en.mjs b/web-ui/modules/i18n/locales/en.mjs
index 86b9baab..b9081eb9 100644
--- a/web-ui/modules/i18n/locales/en.mjs
+++ b/web-ui/modules/i18n/locales/en.mjs
@@ -61,6 +61,8 @@ const en = Object.freeze({
'common.notExistsWillCreateOnApply': 'Not found. Will be created on apply.',
'common.notExistsWillCreateOnSave': 'Not found. Will be created on save.',
'common.none': 'None',
+ 'common.configured': 'Configured',
+ 'common.notConfigured': 'Not configured',
'cli.missing.title': '{name} CLI not installed',
'cli.missing.subtitle': 'Install {name} CLI before using this page.',
'cli.missing.openDocs': 'Open install guide',
@@ -358,7 +360,7 @@ const en = Object.freeze({
'plugins.builtin.commentPolish.desc': 'Polish the following code comments {{code}}',
'plugins.builtin.commentPolish.line1': 'Polish the following code comments',
'plugins.builtin.ruleAck.name': 'Rule acknowledgement',
- 'plugins.builtin.ruleAck.desc': 'Please follow 【{{rule}}】, reply when received',
+ 'plugins.builtin.ruleAck.desc': 'Generate rule acknowledgement reply',
'plugins.builtin.ruleAck.line1': 'Please follow 【{{rule}}】, reply when received',
// Toasts
@@ -385,6 +387,50 @@ const en = Object.freeze({
'toast.templates.nameRequired': 'Template name is required',
'toast.templates.builtinNotDuplicable': 'Built-in templates cannot be duplicated',
'toast.templates.builtinNotDeletable': 'Built-in templates cannot be deleted',
+ 'toast.operation.success': 'Operation successful',
+ 'toast.load.fail': 'Failed to load file',
+ 'toast.apply.success': 'Configuration applied',
+ 'toast.apply.fail': 'Failed to apply configuration',
+ 'toast.check.success': 'Check passed',
+ 'toast.check.fail': 'Check failed',
+ 'toast.noChanges': 'No changes detected',
+ 'toast.template.loadFail': 'Failed to load template',
+ 'toast.template.empty': 'Template cannot be empty',
+ 'toast.template.applied': 'Template applied',
+ 'toast.template.appliedButRefreshFail': 'Template applied, but UI refresh failed. Please refresh manually',
+ 'toast.template.applyFail': 'Failed to apply template',
+ 'toast.provider.addFail': 'Failed to add',
+ 'toast.provider.notDeletable': 'This provider is reserved and cannot be deleted',
+ 'toast.provider.deletedAndSwitched': 'Provider deleted, auto-switched to {provider}{model}',
+ 'toast.provider.notEditable': 'This provider is reserved and cannot be edited',
+ 'toast.provider.updateFail': 'Failed to update',
+ 'toast.provider.resetSuccess': 'Config reset{backup}',
+ 'toast.provider.resetFail': 'Reset failed',
+ 'toast.provider.fieldsRequired': 'Name, URL, API Key and model are required',
+ 'toast.provider.urlRequired': 'URL is required',
+ 'toast.claude.modelRequired': 'Please enter model',
+ 'toast.claude.apiKeyRequired': 'Please configure API Key first',
+ 'toast.claude.checkConfig': 'Please check Claude configuration',
+ 'toast.claude.savedWithoutKey': 'Saved (API Key not filled)',
+ 'toast.claude.applied': 'Claude config applied',
+ 'toast.claude.exists': 'Config already exists',
+ 'toast.claude.keepOne': 'Keep at least one',
+ 'toast.claude.externalAuth': 'Using external auth, no API Key needed',
+ 'toast.claude.balanceEnabled': 'Claude local load balancing enabled',
+ 'toast.claude.balanceDisabled': 'Claude local load balancing disabled',
+ 'toast.claude.balanceEnableFail': 'Failed to enable load balancing',
+ 'toast.claude.balanceRequireProvider': 'Please add and configure at least one Claude provider',
+ 'toast.claude.loadSettingsFail': 'Failed to load Claude settings',
+ 'validation.claude.nameRequired': 'Config name is required',
+ 'validation.claude.nameExists': 'Name already exists',
+ 'validation.claude.apiKeyRequired': 'API Key is required',
+ 'validation.claude.baseUrlRequired': 'Base URL is required',
+ 'validation.claude.baseUrlHttpOnly': 'Base URL only supports http/https',
+ 'validation.claude.modelRequired': 'Model name is required',
+ 'modal.claudeDelete.title': 'Delete Claude config',
+ 'modal.claudeDelete.message': 'Delete config "{name}"?',
+ 'modal.claudeDelete.confirm': 'Delete',
+ 'modal.claudeDelete.cancel': 'Cancel',
'toast.templates.deleteTitle': 'Delete template',
'toast.templates.deleteMessage': 'Delete “{name}”? This action cannot be undone.',
'toast.templates.deleteConfirm': 'Delete',
diff --git a/web-ui/modules/i18n/locales/ja.mjs b/web-ui/modules/i18n/locales/ja.mjs
index 376af5ed..91d9a89c 100644
--- a/web-ui/modules/i18n/locales/ja.mjs
+++ b/web-ui/modules/i18n/locales/ja.mjs
@@ -62,6 +62,8 @@ const ja = Object.freeze({
'common.notExistsWillCreateOnApply': '存在しません。適用時に作成されます',
'common.notExistsWillCreateOnSave': '存在しません。保存時に作成されます',
'common.none': 'なし',
+ 'common.configured': '設定済み',
+ 'common.notConfigured': '未設定',
'cli.missing.title': '{name} CLI がインストールされていません',
'cli.missing.subtitle': '{name} CLI をインストールしてからこのページをご利用ください。',
'cli.missing.openDocs': 'インストールガイドを開く',
@@ -360,7 +362,7 @@ const ja = Object.freeze({
'plugins.builtin.commentPolish.desc': '以下のコードコメントを軽く整えてください {{code}}',
'plugins.builtin.commentPolish.line1': '以下のコードコメントを軽く整えてください',
'plugins.builtin.ruleAck.name': 'ルール確認返信',
- 'plugins.builtin.ruleAck.desc': '【{{rule}}】に従って、受信確認を返してください',
+ 'plugins.builtin.ruleAck.desc': 'ルールに従うよう確認返信を指示',
'plugins.builtin.ruleAck.line1': '【{{rule}}】に従って、受信確認を返してください',
// Toasts
@@ -391,6 +393,50 @@ const ja = Object.freeze({
'toast.templates.deleteMessage': '「{name}」を削除しますか?この操作は取り消せません。',
'toast.templates.deleteConfirm': '削除',
'toast.templates.deleteCancel': 'キャンセル',
+ 'toast.operation.success': '操作が成功しました',
+ 'toast.load.fail': 'ファイルの読み込みに失敗しました',
+ 'toast.apply.success': '設定が適用されました',
+ 'toast.apply.fail': '設定の適用に失敗しました',
+ 'toast.check.success': 'チェック成功',
+ 'toast.check.fail': 'チェック失敗',
+ 'toast.noChanges': '変更が検出されませんでした',
+ 'toast.template.loadFail': 'テンプレートの読み込みに失敗しました',
+ 'toast.template.empty': 'テンプレートは空にできません',
+ 'toast.template.applied': 'テンプレートが適用されました',
+ 'toast.template.appliedButRefreshFail': 'テンプレートは適用されましたが、UI の更新に失敗しました。手動で更新してください',
+ 'toast.template.applyFail': 'テンプレートの適用に失敗しました',
+ 'toast.provider.addFail': '追加に失敗しました',
+ 'toast.provider.notDeletable': 'このプロバイダーは予約済みのため削除できません',
+ 'toast.provider.deletedAndSwitched': 'プロバイダーを削除し、{provider}{model}に自動切り替えしました',
+ 'toast.provider.notEditable': 'このプロバイダーは予約済みのため編集できません',
+ 'toast.provider.updateFail': '更新に失敗しました',
+ 'toast.provider.resetSuccess': '設定をリセットしました{backup}',
+ 'toast.provider.resetFail': 'リセットに失敗しました',
+ 'toast.provider.fieldsRequired': '名前、URL、API Key、モデル名は必須です',
+ 'toast.provider.urlRequired': 'URL は必須です',
+ 'toast.claude.modelRequired': 'モデルを入力してください',
+ 'toast.claude.apiKeyRequired': '先に API Key を設定してください',
+ 'toast.claude.checkConfig': 'Claude 設定を確認してください',
+ 'toast.claude.savedWithoutKey': '保存しました(API Key 未入力)',
+ 'toast.claude.applied': 'Claude 設定が適用されました',
+ 'toast.claude.exists': '設定が既に存在します',
+ 'toast.claude.keepOne': '最低1つは残してください',
+ 'toast.claude.externalAuth': '外部認証を使用するため、API Key は不要です',
+ 'toast.claude.balanceEnabled': 'Claude ローカル負荷分散が有効になりました',
+ 'toast.claude.balanceDisabled': 'Claude ローカル負荷分散が無効になりました',
+ 'toast.claude.balanceEnableFail': '負荷分散の有効化に失敗しました',
+ 'toast.claude.balanceRequireProvider': '最低1つの Claude プロバイダーを追加・設定してください',
+ 'toast.claude.loadSettingsFail': 'Claude settings の読み込みに失敗しました',
+ 'validation.claude.nameRequired': '設定名は必須です',
+ 'validation.claude.nameExists': '名前が既に存在します',
+ 'validation.claude.apiKeyRequired': 'API Key は必須です',
+ 'validation.claude.baseUrlRequired': 'Base URL は必須です',
+ 'validation.claude.baseUrlHttpOnly': 'Base URL は http/https のみサポートします',
+ 'validation.claude.modelRequired': 'モデル名は必須です',
+ 'modal.claudeDelete.title': 'Claude 設定の削除',
+ 'modal.claudeDelete.message': '設定 "{name}" を削除しますか?',
+ 'modal.claudeDelete.confirm': '削除',
+ 'modal.claudeDelete.cancel': 'キャンセル',
// Basic modals
'modal.providerAdd.title': 'プロバイダー追加',
diff --git a/web-ui/modules/i18n/locales/vi.mjs b/web-ui/modules/i18n/locales/vi.mjs
index cf6b2a8a..5a7422fa 100644
--- a/web-ui/modules/i18n/locales/vi.mjs
+++ b/web-ui/modules/i18n/locales/vi.mjs
@@ -6,7 +6,7 @@ const vi = Object.freeze({
'plugins.builtin.commentPolish.desc': 'Chỉnh nhẹ các chú thích mã sau {{code}}',
'plugins.builtin.commentPolish.line1': 'Chỉnh nhẹ các chú thích mã sau',
'plugins.builtin.ruleAck.name': 'Xác nhận quy tắc',
- 'plugins.builtin.ruleAck.desc': 'Hãy làm theo【{{rule}}】, nhận được thì phản hồi',
+ 'plugins.builtin.ruleAck.desc': 'Tạo phản hồi xác nhận quy tắc',
'plugins.builtin.ruleAck.line1': 'Hãy làm theo【{{rule}}】, nhận được thì phản hồi',
// Global
'lang.zh': 'Tiếng Trung',
@@ -71,6 +71,8 @@ const vi = Object.freeze({
'common.notExistsWillCreateOnApply': 'Không tồn tại. Sẽ được tạo khi áp dụng.',
'common.notExistsWillCreateOnSave': 'Không tồn tại. Sẽ được tạo khi lưu.',
'common.none': 'Không có',
+ 'common.configured': 'Đã cấu hình',
+ 'common.notConfigured': 'Chưa cấu hình',
'common.notSelected': 'Chưa chọn',
// Roles / labels
@@ -233,7 +235,60 @@ const vi = Object.freeze({
'dashboard.doctor.title': 'Doctor',
'dashboard.doctor.runChecks': 'Chạy kiểm tra',
'dashboard.doctor.checking': 'Đang kiểm tra...',
- 'dashboard.doctor.export': 'Xuất báo cáo'
+ 'dashboard.doctor.export': 'Xuất báo cáo',
+
+ // Toasts
+ 'toast.copy.empty': 'Không có gì để sao chép',
+ 'toast.copy.ok': 'Đã sao chép',
+ 'toast.copy.fail': 'Sao chép thất bại',
+ 'toast.save.ok': 'Đã lưu',
+ 'toast.save.fail': 'Lưu thất bại',
+ 'toast.delete.ok': 'Đã xóa',
+ 'toast.delete.fail': 'Xóa thất bại',
+ 'toast.operation.success': 'Thao tác thành công',
+ 'toast.load.fail': 'Tải tệp thất bại',
+ 'toast.apply.success': 'Đã áp dụng cấu hình',
+ 'toast.apply.fail': 'Áp dụng cấu hình thất bại',
+ 'toast.check.success': 'Kiểm tra thành công',
+ 'toast.check.fail': 'Kiểm tra thất bại',
+ 'toast.noChanges': 'Không phát hiện thay đổi',
+ 'toast.template.loadFail': 'Tải mẫu thất bại',
+ 'toast.template.empty': 'Mẫu không được để trống',
+ 'toast.template.applied': 'Đã áp dụng mẫu',
+ 'toast.template.appliedButRefreshFail': 'Đã áp dụng mẫu, nhưng làm mới giao diện thất bại. Vui lòng làm mới thủ công',
+ 'toast.template.applyFail': 'Áp dụng mẫu thất bại',
+ 'toast.provider.addFail': 'Thêm thất bại',
+ 'toast.provider.notDeletable': 'Provider này là mục dành riêng, không thể xóa',
+ 'toast.provider.deletedAndSwitched': 'Đã xóa provider, tự động chuyển sang {provider}{model}',
+ 'toast.provider.notEditable': 'Provider này là mục dành riêng, không thể chỉnh sửa',
+ 'toast.provider.updateFail': 'Cập nhật thất bại',
+ 'toast.provider.resetSuccess': 'Đã cài đặt lại cấu hình{backup}',
+ 'toast.provider.resetFail': 'Cài đặt lại thất bại',
+ 'toast.provider.fieldsRequired': 'Tên, URL, API Key và tên mô hình là bắt buộc',
+ 'toast.provider.urlRequired': 'URL là bắt buộc',
+ 'toast.claude.modelRequired': 'Vui lòng nhập mô hình',
+ 'toast.claude.apiKeyRequired': 'Vui lòng cấu hình API Key trước',
+ 'toast.claude.checkConfig': 'Vui lòng kiểm tra cấu hình Claude',
+ 'toast.claude.savedWithoutKey': 'Đã lưu (chưa điền API Key)',
+ 'toast.claude.applied': 'Cấu hình Claude đã được áp dụng',
+ 'toast.claude.exists': 'Cấu hình đã tồn tại',
+ 'toast.claude.keepOne': 'Giữ ít nhất một mục',
+ 'toast.claude.externalAuth': 'Sử dụng xác thực bên ngoài, không cần API Key',
+ 'toast.claude.balanceEnabled': 'Đã bật cân bằng tải cục bộ Claude',
+ 'toast.claude.balanceDisabled': 'Đã tắt cân bằng tải cục bộ Claude',
+ 'toast.claude.balanceEnableFail': 'Không thể bật cân bằng tải',
+ 'toast.claude.balanceRequireProvider': 'Vui lòng thêm và cấu hình ít nhất một nhà cung cấp Claude',
+ 'toast.claude.loadSettingsFail': 'Tải cài đặt Claude thất bại',
+ 'validation.claude.nameRequired': 'Tên cấu hình là bắt buộc',
+ 'validation.claude.nameExists': 'Tên đã tồn tại',
+ 'validation.claude.apiKeyRequired': 'API Key là bắt buộc',
+ 'validation.claude.baseUrlRequired': 'Base URL là bắt buộc',
+ 'validation.claude.baseUrlHttpOnly': 'Base URL chỉ hỗ trợ http/https',
+ 'validation.claude.modelRequired': 'Tên mô hình là bắt buộc',
+ 'modal.claudeDelete.title': 'Xóa cấu hình Claude',
+ 'modal.claudeDelete.message': 'Xóa cấu hình "{name}"?',
+ 'modal.claudeDelete.confirm': 'Xóa',
+ 'modal.claudeDelete.cancel': 'Hủy',
});
export { vi };
diff --git a/web-ui/modules/i18n/locales/zh.mjs b/web-ui/modules/i18n/locales/zh.mjs
index 7b17aca3..500cd1e1 100644
--- a/web-ui/modules/i18n/locales/zh.mjs
+++ b/web-ui/modules/i18n/locales/zh.mjs
@@ -61,6 +61,8 @@ const zh = Object.freeze({
'common.notExistsWillCreateOnApply': '不存在,将在应用时创建',
'common.notExistsWillCreateOnSave': '不存在,将在保存时创建',
'common.none': '暂无',
+ 'common.configured': '已配置',
+ 'common.notConfigured': '未配置',
'cli.missing.title': '{name} CLI 未安装',
'cli.missing.subtitle': '请先安装 {name} CLI 后再继续使用此页面。',
'cli.missing.openDocs': '打开安装指南',
@@ -358,7 +360,7 @@ const zh = Object.freeze({
'plugins.builtin.commentPolish.desc': '轻微收敛以下代码注释 {{code}}',
'plugins.builtin.commentPolish.line1': '轻微收敛以下代码注释',
'plugins.builtin.ruleAck.name': '规则确认回复',
- 'plugins.builtin.ruleAck.desc': '请根据【{{rule}}】,收到请回复',
+ 'plugins.builtin.ruleAck.desc': '生成规则确认回复',
'plugins.builtin.ruleAck.line1': '请根据【{{rule}}】,收到请回复',
// Toasts
@@ -386,9 +388,53 @@ const zh = Object.freeze({
'toast.templates.builtinNotDuplicable': '内置模板不可复制',
'toast.templates.builtinNotDeletable': '内置模板不可删除',
'toast.templates.deleteTitle': '删除模板',
- 'toast.templates.deleteMessage': '删除“{name}”?此操作无法撤销。',
+ 'toast.templates.deleteMessage': '删除”{name}”?此操作无法撤销。',
'toast.templates.deleteConfirm': '删除',
'toast.templates.deleteCancel': '取消',
+ 'toast.operation.success': '操作成功',
+ 'toast.load.fail': '加载文件失败',
+ 'toast.apply.success': '配置已应用',
+ 'toast.apply.fail': '应用配置失败',
+ 'toast.check.success': '检查通过',
+ 'toast.check.fail': '检查失败',
+ 'toast.noChanges': '未检测到改动',
+ 'toast.template.loadFail': '加载模板失败',
+ 'toast.template.empty': '模板不能为空',
+ 'toast.template.applied': '模板已应用',
+ 'toast.template.appliedButRefreshFail': '模板已应用,但界面刷新失败,请手动刷新',
+ 'toast.template.applyFail': '应用模板失败',
+ 'toast.provider.addFail': '添加失败',
+ 'toast.provider.notDeletable': '该 provider 为保留项,不可删除',
+ 'toast.provider.deletedAndSwitched': '已删除提供商,自动切换到 {provider}{model}',
+ 'toast.provider.notEditable': '该 provider 为保留项,不可编辑',
+ 'toast.provider.updateFail': '更新失败',
+ 'toast.provider.resetSuccess': '配置已重装{backup}',
+ 'toast.provider.resetFail': '重装失败',
+ 'toast.provider.fieldsRequired': '名称、URL、API Key 和模型名称必填',
+ 'toast.provider.urlRequired': 'URL 必填',
+ 'toast.claude.modelRequired': '请输入模型',
+ 'toast.claude.apiKeyRequired': '请先配置 API Key',
+ 'toast.claude.checkConfig': '请检查 Claude 配置',
+ 'toast.claude.savedWithoutKey': '已保存(未填写 API Key)',
+ 'toast.claude.applied': 'Claude 配置已生效',
+ 'toast.claude.exists': '配置已存在',
+ 'toast.claude.keepOne': '至少保留一项',
+ 'toast.claude.externalAuth': '使用外部认证,无需 API Key',
+ 'toast.claude.balanceEnabled': 'Claude 本地负载均衡已启用',
+ 'toast.claude.balanceDisabled': 'Claude 本地负载均衡已关闭',
+ 'toast.claude.balanceEnableFail': '启用本地负载均衡失败',
+ 'toast.claude.balanceRequireProvider': '请先添加并配置至少一个 Claude 提供商',
+ 'toast.claude.loadSettingsFail': '加载 Claude settings 失败',
+ 'validation.claude.nameRequired': '配置名称不能为空',
+ 'validation.claude.nameExists': '名称已存在',
+ 'validation.claude.apiKeyRequired': 'API Key 必填',
+ 'validation.claude.baseUrlRequired': 'Base URL 必填',
+ 'validation.claude.baseUrlHttpOnly': 'Base URL 仅支持 http/https',
+ 'validation.claude.modelRequired': '模型名称必填',
+ 'modal.claudeDelete.title': '删除 Claude 配置',
+ 'modal.claudeDelete.message': '确定删除配置 "{name}"?',
+ 'modal.claudeDelete.confirm': '删除',
+ 'modal.claudeDelete.cancel': '取消',
// Basic modals
'modal.providerAdd.title': '添加提供商',
diff --git a/web-ui/partials/index/panel-config-claude.html b/web-ui/partials/index/panel-config-claude.html
index 090f4424..5f1ebc07 100644
--- a/web-ui/partials/index/panel-config-claude.html
+++ b/web-ui/partials/index/panel-config-claude.html
@@ -154,7 +154,7 @@
{{ formatLatency(claudeSpeedResults[name]) }}
-
{{ config.hasKey ? t('claude.configured') : t('claude.notConfigured') }}
+
{{ config.hasKey ? t('common.configured') : t('common.notConfigured') }}