From 6b841bb8170ff52f576c4a707160d1a834e98f12 Mon Sep 17 00:00:00 2001 From: jinxin <106428113+italic-jinxin@users.noreply.github.com> Date: Wed, 11 Mar 2026 22:34:13 +0800 Subject: [PATCH] feat(i18n): Add internationalization support with Chinese and English translations (#929) * feat(i18n): Add internationalization support with Chinese and English translations * fix(i18n): fix duplicate keys, broken placeholders, and dead overrides --------- Co-authored-by: zwb1982 <133180666+zwb1982@users.noreply.github.com> --- README.zh-CN.md | 2 +- src/channels/web/server.rs | 46 +++- src/channels/web/static/app.js | 195 +++++++------- src/channels/web/static/i18n-app.js | 74 ++++++ src/channels/web/static/i18n/en.js | 351 ++++++++++++++++++++++++++ src/channels/web/static/i18n/index.js | 89 +++++++ src/channels/web/static/i18n/zh-CN.js | 351 ++++++++++++++++++++++++++ src/channels/web/static/index.html | 195 +++++++------- src/channels/web/static/style.css | 55 ++++ 9 files changed, 1174 insertions(+), 184 deletions(-) create mode 100644 src/channels/web/static/i18n-app.js create mode 100644 src/channels/web/static/i18n/en.js create mode 100644 src/channels/web/static/i18n/index.js create mode 100644 src/channels/web/static/i18n/zh-CN.js diff --git a/README.zh-CN.md b/README.zh-CN.md index 97bbf097..179614ac 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -229,7 +229,7 @@ WASM ──► 白名单 ──► 泄露扫描 ──► 凭据 ──► 执 │ │ │ │ │ ┌──────────▼────┐ ┌──▼───────────────┐ │ │ │ 调度器 │ │ 定时任务引擎 │ │ -│ │ (并行任务) │ │(cron, 事件, wh) │ │ +│ │ (并行任务) │ │(cron, 事件, Webhook)│ │ │ └──────┬────────┘ └────────┬─────────┘ │ │ │ │ │ │ ┌─────────────┼────────────────────┘ │ diff --git a/src/channels/web/server.rs b/src/channels/web/server.rs index 48d3407c..825685b5 100644 --- a/src/channels/web/server.rs +++ b/src/channels/web/server.rs @@ -318,7 +318,11 @@ pub async fn start_server( .route("/", get(index_handler)) .route("/style.css", get(css_handler)) .route("/app.js", get(js_handler)) - .route("/favicon.ico", get(favicon_handler)); + .route("/favicon.ico", get(favicon_handler)) + .route("/i18n/index.js", get(i18n_index_handler)) + .route("/i18n/en.js", get(i18n_en_handler)) + .route("/i18n/zh-CN.js", get(i18n_zh_handler)) + .route("/i18n-app.js", get(i18n_app_handler)); // Project file serving (behind auth to prevent unauthorized file access). let projects = Router::new() @@ -430,6 +434,46 @@ async fn favicon_handler() -> impl IntoResponse { ) } +async fn i18n_index_handler() -> impl IntoResponse { + ( + [ + (header::CONTENT_TYPE, "application/javascript"), + (header::CACHE_CONTROL, "no-cache"), + ], + include_str!("static/i18n/index.js"), + ) +} + +async fn i18n_en_handler() -> impl IntoResponse { + ( + [ + (header::CONTENT_TYPE, "application/javascript"), + (header::CACHE_CONTROL, "no-cache"), + ], + include_str!("static/i18n/en.js"), + ) +} + +async fn i18n_zh_handler() -> impl IntoResponse { + ( + [ + (header::CONTENT_TYPE, "application/javascript"), + (header::CACHE_CONTROL, "no-cache"), + ], + include_str!("static/i18n/zh-CN.js"), + ) +} + +async fn i18n_app_handler() -> impl IntoResponse { + ( + [ + (header::CONTENT_TYPE, "application/javascript"), + (header::CACHE_CONTROL, "no-cache"), + ], + include_str!("static/i18n-app.js"), + ) +} + // --- Health --- async fn health_handler() -> Json { diff --git a/src/channels/web/static/app.js b/src/channels/web/static/app.js index 42ce6ba2..7ca9a25b 100644 --- a/src/channels/web/static/app.js +++ b/src/channels/web/static/app.js @@ -55,7 +55,7 @@ let _activityThinking = null; function authenticate() { token = document.getElementById('token-input').value.trim(); if (!token) { - document.getElementById('auth-error').textContent = 'Token required'; + document.getElementById('auth-error').textContent = I18n.t('auth.errorRequired'); return; } @@ -89,7 +89,7 @@ function authenticate() { sessionStorage.removeItem('ironclaw_token'); document.getElementById('auth-screen').style.display = ''; document.getElementById('app').style.display = 'none'; - document.getElementById('auth-error').textContent = 'Invalid token'; + document.getElementById('auth-error').textContent = I18n.t('auth.errorInvalid'); }); } @@ -144,7 +144,7 @@ let restartEnabled = false; // Track if restart is available in this deployment function triggerRestart() { if (!currentThreadId) { - alert('Please start a conversation first'); + alert(I18n.t('error.startConversation')); return; } @@ -155,7 +155,7 @@ function triggerRestart() { function confirmRestart() { if (!currentThreadId) { - alert('Please start a conversation first'); + alert(I18n.t('error.startConversation')); return; } @@ -190,7 +190,7 @@ function confirmRestart() { }) .catch((err) => { console.error('[confirmRestart] Restart request failed:', err); - addMessage('system', 'Restart failed: ' + err.message); + addMessage('system', I18n.t('error.restartFailed', { message: err.message })); isRestarting = false; restartBtn.disabled = false; if (restartIcon) restartIcon.classList.remove('spinning'); @@ -234,7 +234,7 @@ function connectSSE() { eventSource.onopen = () => { document.getElementById('sse-dot').classList.remove('disconnected'); - document.getElementById('sse-status').textContent = 'Connected'; + document.getElementById('sse-status').textContent = I18n.t('status.connected'); // If we were restarting, close the modal and reset button now that server is back if (isRestarting) { @@ -256,7 +256,7 @@ function connectSSE() { eventSource.onerror = () => { document.getElementById('sse-dot').classList.add('disconnected'); - document.getElementById('sse-status').textContent = 'Reconnecting...'; + document.getElementById('sse-status').textContent = I18n.t('status.reconnecting'); }; eventSource.addEventListener('response', (e) => { @@ -464,7 +464,7 @@ function enableChatInput() { const btn = document.getElementById('send-btn'); if (input) { input.disabled = false; - input.placeholder = 'Message or / for commands...'; + input.placeholder = I18n.t('chat.inputPlaceholder'); } if (btn) btn.disabled = false; } @@ -703,8 +703,8 @@ function copyCodeBlock(btn) { const code = pre.querySelector('code'); const text = code ? code.textContent : pre.textContent; navigator.clipboard.writeText(text).then(() => { - btn.textContent = 'Copied!'; - setTimeout(() => { btn.textContent = 'Copy'; }, 1500); + btn.textContent = I18n.t('btn.copied'); + setTimeout(() => { btn.textContent = I18n.t('btn.copy'); }, 1500); }); } @@ -991,7 +991,7 @@ function showApproval(data) { const header = document.createElement('div'); header.className = 'approval-header'; - header.textContent = 'Tool requires approval'; + header.textContent = I18n.t('approval.title'); card.appendChild(header); const toolName = document.createElement('div'); @@ -1009,7 +1009,7 @@ function showApproval(data) { if (data.parameters) { const paramsToggle = document.createElement('button'); paramsToggle.className = 'approval-params-toggle'; - paramsToggle.textContent = 'Show parameters'; + paramsToggle.textContent = I18n.t('approval.showParams'); const paramsBlock = document.createElement('pre'); paramsBlock.className = 'approval-params'; paramsBlock.textContent = data.parameters; @@ -1017,7 +1017,7 @@ function showApproval(data) { paramsToggle.addEventListener('click', () => { const visible = paramsBlock.style.display !== 'none'; paramsBlock.style.display = visible ? 'none' : 'block'; - paramsToggle.textContent = visible ? 'Show parameters' : 'Hide parameters'; + paramsToggle.textContent = visible ? I18n.t('approval.showParams') : I18n.t('approval.hideParams'); }); card.appendChild(paramsToggle); card.appendChild(paramsBlock); @@ -1028,17 +1028,17 @@ function showApproval(data) { const approveBtn = document.createElement('button'); approveBtn.className = 'approve'; - approveBtn.textContent = 'Approve'; + approveBtn.textContent = I18n.t('approval.approve'); approveBtn.addEventListener('click', () => sendApprovalAction(data.request_id, 'approve')); const alwaysBtn = document.createElement('button'); alwaysBtn.className = 'always'; - alwaysBtn.textContent = 'Always'; + alwaysBtn.textContent = I18n.t('approval.always'); alwaysBtn.addEventListener('click', () => sendApprovalAction(data.request_id, 'always')); const denyBtn = document.createElement('button'); denyBtn.className = 'deny'; - denyBtn.textContent = 'Deny'; + denyBtn.textContent = I18n.t('approval.deny'); denyBtn.addEventListener('click', () => sendApprovalAction(data.request_id, 'deny')); actions.appendChild(approveBtn); @@ -1065,7 +1065,7 @@ function showJobCard(data) { const title = document.createElement('div'); title.className = 'job-card-title'; - title.textContent = data.title || 'Sandbox Job'; + title.textContent = data.title || I18n.t('sandbox.job'); info.appendChild(title); const id = document.createElement('div'); @@ -1077,7 +1077,7 @@ function showJobCard(data) { const viewBtn = document.createElement('button'); viewBtn.className = 'job-card-view'; - viewBtn.textContent = 'View Job'; + viewBtn.textContent = I18n.t('jobs.viewJob'); viewBtn.addEventListener('click', () => { switchTab('jobs'); openJobDetail(data.job_id); @@ -1089,7 +1089,7 @@ function showJobCard(data) { browseBtn.className = 'job-card-browse'; browseBtn.href = data.browse_url; browseBtn.target = '_blank'; - browseBtn.textContent = 'Browse'; + browseBtn.textContent = I18n.t('jobs.browse'); card.appendChild(browseBtn); } @@ -1110,7 +1110,7 @@ function showAuthCard(data) { const header = document.createElement('div'); header.className = 'auth-header'; - header.textContent = 'Authentication required for ' + data.extension_name; + header.textContent = I18n.t('authRequired.title', {name: data.extension_name}); card.appendChild(header); if (data.instructions) { @@ -1126,7 +1126,7 @@ function showAuthCard(data) { if (data.auth_url) { const oauthBtn = document.createElement('button'); oauthBtn.className = 'auth-oauth'; - oauthBtn.textContent = 'Authenticate with ' + data.extension_name; + oauthBtn.textContent = I18n.t('authRequired.authenticateWith', {name: data.extension_name}); oauthBtn.addEventListener('click', () => { openOAuthUrl(data.auth_url); }); @@ -1137,7 +1137,7 @@ function showAuthCard(data) { const setupLink = document.createElement('a'); setupLink.href = data.setup_url; setupLink.target = '_blank'; - setupLink.textContent = 'Get your token'; + setupLink.textContent = I18n.t('authRequired.getToken'); links.appendChild(setupLink); } @@ -1151,7 +1151,9 @@ function showAuthCard(data) { const tokenInput = document.createElement('input'); tokenInput.type = 'password'; - tokenInput.placeholder = data.instructions || 'Paste your API key or token'; + tokenInput.placeholder = data.instructions + || I18n.t('auth.extensionTokenPlaceholder') + || I18n.t('auth.tokenPlaceholder'); tokenInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') submitAuthToken(data.extension_name, tokenInput.value); }); @@ -1170,12 +1172,12 @@ function showAuthCard(data) { const submitBtn = document.createElement('button'); submitBtn.className = 'auth-submit'; - submitBtn.textContent = 'Submit'; + submitBtn.textContent = I18n.t('btn.submit'); submitBtn.addEventListener('click', () => submitAuthToken(data.extension_name, tokenInput.value)); const cancelBtn = document.createElement('button'); cancelBtn.className = 'auth-cancel'; - cancelBtn.textContent = 'Cancel'; + cancelBtn.textContent = I18n.t('btn.cancel'); cancelBtn.addEventListener('click', () => cancelAuth(data.extension_name)); actions.appendChild(submitBtn); @@ -1967,7 +1969,7 @@ function prependLogEntry(entry) { function toggleLogsPause() { logsPaused = !logsPaused; const btn = document.getElementById('logs-pause-btn'); - btn.textContent = logsPaused ? 'Resume' : 'Pause'; + btn.textContent = logsPaused ? I18n.t('logs.resume') : I18n.t('logs.pause'); if (!logsPaused) { // Flush buffer: oldest-first + prepend naturally puts newest at top @@ -2039,7 +2041,7 @@ function loadExtensions() { ]).then(([extData, toolData, registryData]) => { // Render installed extensions if (extData.extensions.length === 0) { - extList.innerHTML = '
No extensions installed
'; + extList.innerHTML = '
' + I18n.t('extensions.noInstalled') + '
'; } else { extList.innerHTML = ''; for (const ext of extData.extensions) { @@ -2053,7 +2055,7 @@ function loadExtensions() { // Available WASM extensions if (wasmEntries.length === 0) { - wasmList.innerHTML = '
No additional WASM extensions available
'; + wasmList.innerHTML = '
' + I18n.t('extensions.noAvailable') + '
'; } else { wasmList.innerHTML = ''; for (const entry of wasmEntries) { @@ -2063,7 +2065,7 @@ function loadExtensions() { // MCP servers (show both installed and uninstalled) if (mcpEntries.length === 0) { - mcpList.innerHTML = '
No MCP servers available
'; + mcpList.innerHTML = '
' + I18n.t('mcp.noServers') + '
'; } else { mcpList.innerHTML = ''; for (const entry of mcpEntries) { @@ -2128,16 +2130,16 @@ function renderAvailableExtensionCard(entry) { const installBtn = document.createElement('button'); installBtn.className = 'btn-ext install'; - installBtn.textContent = 'Install'; + installBtn.textContent = I18n.t('extensions.install'); installBtn.addEventListener('click', function() { installBtn.disabled = true; - installBtn.textContent = 'Installing...'; + installBtn.textContent = I18n.t('extensions.installing'); apiFetch('/api/extensions/install', { method: 'POST', body: { name: entry.name, kind: entry.kind }, }).then(function(res) { if (res.success) { - showToast('Installed ' + entry.display_name, 'success'); + showToast(I18n.t('extensions.installedSuccess', {name: entry.display_name}), 'success'); // OAuth popup if auth started during install (builtin creds) if (res.auth_url) { showToast('Opening authentication for ' + entry.display_name, 'info'); @@ -2201,39 +2203,39 @@ function renderMcpServerCard(entry, installedExt) { if (!installedExt.active) { var activateBtn = document.createElement('button'); activateBtn.className = 'btn-ext activate'; - activateBtn.textContent = 'Activate'; + activateBtn.textContent = I18n.t('common.activate'); activateBtn.addEventListener('click', function() { activateExtension(installedExt.name); }); actions.appendChild(activateBtn); } else { var activeLabel = document.createElement('span'); activeLabel.className = 'ext-active-label'; - activeLabel.textContent = 'Active'; + activeLabel.textContent = I18n.t('ext.active'); actions.appendChild(activeLabel); } var removeBtn = document.createElement('button'); removeBtn.className = 'btn-ext remove'; - removeBtn.textContent = 'Remove'; + removeBtn.textContent = I18n.t('ext.remove'); removeBtn.addEventListener('click', function() { removeExtension(installedExt.name); }); actions.appendChild(removeBtn); } else { var installBtn = document.createElement('button'); installBtn.className = 'btn-ext install'; - installBtn.textContent = 'Install'; + installBtn.textContent = I18n.t('ext.install'); installBtn.addEventListener('click', function() { installBtn.disabled = true; - installBtn.textContent = 'Installing...'; + installBtn.textContent = I18n.t('ext.installing'); apiFetch('/api/extensions/install', { method: 'POST', body: { name: entry.name, kind: entry.kind }, }).then(function(res) { if (res.success) { - showToast('Installed ' + entry.display_name, 'success'); + showToast(I18n.t('extensions.installedSuccess', { name: entry.display_name }), 'success'); } else { - showToast('Install: ' + (res.message || 'unknown error'), 'error'); + showToast(I18n.t('ext.install') + ': ' + (res.message || 'unknown error'), 'error'); } loadExtensions(); }).catch(function(err) { - showToast('Install failed: ' + err.message, 'error'); + showToast(I18n.t('ext.installFailed', { message: err.message }), 'error'); loadExtensions(); }); }); @@ -2247,7 +2249,7 @@ function renderMcpServerCard(entry, installedExt) { function createReconfigureButton(extName) { var btn = document.createElement('button'); btn.className = 'btn-ext configure'; - btn.textContent = 'Reconfigure'; + btn.textContent = I18n.t('ext.reconfigure'); btn.addEventListener('click', function() { showConfigureModal(extName); }); return btn; } @@ -2331,13 +2333,13 @@ function renderExtensionCard(ext) { if (status === 'active') { var activeLabel = document.createElement('span'); activeLabel.className = 'ext-active-label'; - activeLabel.textContent = 'Active'; + activeLabel.textContent = I18n.t('ext.active'); actions.appendChild(activeLabel); actions.appendChild(createReconfigureButton(ext.name)); } else if (status === 'pairing') { var pairingLabel = document.createElement('span'); pairingLabel.className = 'ext-pairing-label'; - pairingLabel.textContent = 'Awaiting Pairing'; + pairingLabel.textContent = I18n.t('status.awaitingPairing'); actions.appendChild(pairingLabel); actions.appendChild(createReconfigureButton(ext.name)); } else if (status === 'failed') { @@ -2346,7 +2348,7 @@ function renderExtensionCard(ext) { // installed or configured: show Setup button var setupBtn = document.createElement('button'); setupBtn.className = 'btn-ext configure'; - setupBtn.textContent = 'Setup'; + setupBtn.textContent = I18n.t('ext.setup'); setupBtn.addEventListener('click', function() { showConfigureModal(ext.name); }); actions.appendChild(setupBtn); } @@ -2354,14 +2356,14 @@ function renderExtensionCard(ext) { // WASM tools / MCP servers const activeLabel = document.createElement('span'); activeLabel.className = 'ext-active-label'; - activeLabel.textContent = ext.active ? 'Active' : 'Installed'; + activeLabel.textContent = ext.active ? I18n.t('ext.active') : I18n.t('status.installed'); actions.appendChild(activeLabel); // MCP servers and channel-relay extensions may be installed but inactive — show Activate button if ((ext.kind === 'mcp_server' || ext.kind === 'channel_relay') && !ext.active) { const activateBtn = document.createElement('button'); activateBtn.className = 'btn-ext activate'; - activateBtn.textContent = 'Activate'; + activateBtn.textContent = I18n.t('common.activate'); activateBtn.addEventListener('click', () => activateExtension(ext.name)); actions.appendChild(activateBtn); } @@ -2373,7 +2375,7 @@ function renderExtensionCard(ext) { if (ext.needs_setup || (ext.has_auth && ext.authenticated)) { const configBtn = document.createElement('button'); configBtn.className = 'btn-ext configure'; - configBtn.textContent = ext.authenticated ? 'Reconfigure' : 'Configure'; + configBtn.textContent = ext.authenticated ? I18n.t('ext.reconfigure') : I18n.t('ext.configure'); configBtn.addEventListener('click', () => showConfigureModal(ext.name)); actions.appendChild(configBtn); } @@ -2381,7 +2383,7 @@ function renderExtensionCard(ext) { const removeBtn = document.createElement('button'); removeBtn.className = 'btn-ext remove'; - removeBtn.textContent = 'Remove'; + removeBtn.textContent = I18n.t('ext.remove'); removeBtn.addEventListener('click', () => removeExtension(ext.name)); actions.appendChild(removeBtn); @@ -2426,17 +2428,17 @@ function activateExtension(name) { } function removeExtension(name) { - if (!confirm('Remove extension "' + name + '"?')) return; + if (!confirm(I18n.t('ext.confirmRemove', { name: name }))) return; apiFetch('/api/extensions/' + encodeURIComponent(name) + '/remove', { method: 'POST' }) .then((res) => { if (!res.success) { - showToast('Remove failed: ' + res.message, 'error'); + showToast(I18n.t('ext.removeFailed', { message: res.message }), 'error'); } else { - showToast('Removed ' + name, 'success'); + showToast(I18n.t('ext.removed', { name: name }), 'success'); } loadExtensions(); }) - .catch((err) => showToast('Remove failed: ' + err.message, 'error')); + .catch((err) => showToast(I18n.t('ext.removeFailed', { message: err.message }), 'error')); } function showConfigureModal(name) { @@ -2463,7 +2465,7 @@ function renderConfigureModal(name, secrets) { modal.className = 'configure-modal'; const header = document.createElement('h3'); - header.textContent = 'Configure ' + name; + header.textContent = I18n.t('config.title', { name: name }); modal.appendChild(header); const form = document.createElement('div'); @@ -2479,7 +2481,7 @@ function renderConfigureModal(name, secrets) { if (secret.optional) { const opt = document.createElement('span'); opt.className = 'field-optional'; - opt.textContent = ' (optional)'; + opt.textContent = I18n.t('config.optional'); label.appendChild(opt); } field.appendChild(label); @@ -2490,7 +2492,7 @@ function renderConfigureModal(name, secrets) { const input = document.createElement('input'); input.type = 'password'; input.name = secret.name; - input.placeholder = secret.provided ? '(already set — leave empty to keep)' : ''; + input.placeholder = secret.provided ? I18n.t('config.alreadySet') : ''; input.addEventListener('keydown', (e) => { if (e.key === 'Enter') submitConfigureModal(name, fields); }); @@ -2500,13 +2502,13 @@ function renderConfigureModal(name, secrets) { const badge = document.createElement('span'); badge.className = 'field-provided'; badge.textContent = '\u2713'; - badge.title = 'Already configured'; + badge.title = I18n.t('config.alreadyConfigured'); inputRow.appendChild(badge); } if (secret.auto_generate && !secret.provided) { const hint = document.createElement('span'); hint.className = 'field-autogen'; - hint.textContent = 'Auto-generated if empty'; + hint.textContent = I18n.t('config.autoGenerate'); inputRow.appendChild(hint); } @@ -2522,13 +2524,13 @@ function renderConfigureModal(name, secrets) { const submitBtn = document.createElement('button'); submitBtn.className = 'btn-ext activate'; - submitBtn.textContent = 'Save'; + submitBtn.textContent = I18n.t('config.save'); submitBtn.addEventListener('click', () => submitConfigureModal(name, fields)); actions.appendChild(submitBtn); const cancelBtn = document.createElement('button'); cancelBtn.className = 'btn-ext remove'; - cancelBtn.textContent = 'Cancel'; + cancelBtn.textContent = I18n.t('config.cancel'); cancelBtn.addEventListener('click', closeConfigureModal); actions.appendChild(cancelBtn); @@ -2768,11 +2770,11 @@ function loadJobs() { function renderJobsSummary(s) { document.getElementById('jobs-summary').innerHTML = '' - + summaryCard('Total', s.total, '') - + summaryCard('In Progress', s.in_progress, 'active') - + summaryCard('Completed', s.completed, 'completed') - + summaryCard('Failed', s.failed, 'failed') - + summaryCard('Stuck', s.stuck, 'stuck'); + + summaryCard(I18n.t('jobs.summary.total'), s.total, '') + + summaryCard(I18n.t('jobs.summary.inProgress'), s.in_progress, 'active') + + summaryCard(I18n.t('jobs.summary.completed'), s.completed, 'completed') + + summaryCard(I18n.t('jobs.summary.failed'), s.failed, 'failed') + + summaryCard(I18n.t('jobs.summary.stuck'), s.stuck, 'stuck'); } function summaryCard(label, count, cls) { @@ -3302,11 +3304,11 @@ function loadRoutines() { function renderRoutinesSummary(s) { document.getElementById('routines-summary').innerHTML = '' - + summaryCard('Total', s.total, '') - + summaryCard('Enabled', s.enabled, 'active') - + summaryCard('Disabled', s.disabled, '') - + summaryCard('Failing', s.failing, 'failed') - + summaryCard('Runs Today', s.runs_today, 'completed'); + + summaryCard(I18n.t('routines.summary.total'), s.total, '') + + summaryCard(I18n.t('routines.summary.enabled'), s.enabled, 'active') + + summaryCard(I18n.t('routines.summary.disabled'), s.disabled, '') + + summaryCard(I18n.t('routines.summary.failing'), s.failing, 'failed') + + summaryCard(I18n.t('routines.summary.runsToday'), s.runs_today, 'completed'); } function renderRoutinesList(routines) { @@ -3472,17 +3474,18 @@ function formatRelativeTime(isoString) { const absDiff = Math.abs(diffMs); const future = diffMs < 0; - if (absDiff < 60000) return future ? 'in <1m' : '<1m ago'; + if (absDiff < 60000) + return future ? I18n.t('time.lessThan1MinuteFromNow') : I18n.t('time.lessThan1MinuteAgo'); if (absDiff < 3600000) { const m = Math.floor(absDiff / 60000); - return future ? 'in ' + m + 'm' : m + 'm ago'; + return future ? I18n.t('time.minutesFromNow', { n: m }) : I18n.t('time.minutesAgo', { n: m }); } if (absDiff < 86400000) { const h = Math.floor(absDiff / 3600000); - return future ? 'in ' + h + 'h' : h + 'h ago'; + return future ? I18n.t('time.hoursFromNow', { n: h }) : I18n.t('time.hoursAgo', { n: h }); } const days = Math.floor(absDiff / 86400000); - return future ? 'in ' + days + 'd' : days + 'd ago'; + return future ? I18n.t('time.daysFromNow', { n: days }) : I18n.t('time.daysAgo', { n: days }); } // --- Gateway status widget --- @@ -3532,18 +3535,18 @@ function fetchGatewayStatus() { } // Connection info - html += '
Connections
'; - html += '
SSE' + (data.sse_connections || 0) + '
'; - html += '
WebSocket' + (data.ws_connections || 0) + '
'; - html += '
Uptime' + formatDuration(data.uptime_secs) + '
'; + html += '
' + I18n.t('dashboard.connections') + '
'; + html += '
' + I18n.t('dashboard.sse') + '' + (data.sse_connections || 0) + '
'; + html += '
' + I18n.t('dashboard.websocket') + '' + (data.ws_connections || 0) + '
'; + html += '
' + I18n.t('dashboard.uptime') + '' + formatDuration(data.uptime_secs) + '
'; // Cost tracker if (data.daily_cost != null) { html += '
'; - html += '
Cost Today
'; - html += '
Spent' + formatCost(data.daily_cost) + '
'; + html += '
' + I18n.t('dashboard.costToday') + '
'; + html += '
' + I18n.t('dashboard.spent') + '' + formatCost(data.daily_cost) + '
'; if (data.actions_this_hour != null) { - html += '
Actions/hr' + data.actions_this_hour + '
'; + html += '
' + I18n.t('dashboard.actionsPerHour') + '' + data.actions_this_hour + '
'; } } @@ -3751,7 +3754,7 @@ function loadSkills() { var skillsList = document.getElementById('skills-list'); apiFetch('/api/skills').then(function(data) { if (!data.skills || data.skills.length === 0) { - skillsList.innerHTML = '
No skills installed
'; + skillsList.innerHTML = '
' + I18n.t('skills.noInstalled') + '
'; return; } skillsList.innerHTML = ''; @@ -3759,7 +3762,7 @@ function loadSkills() { skillsList.appendChild(renderSkillCard(data.skills[i])); } }).catch(function(err) { - skillsList.innerHTML = '
Failed to load skills: ' + escapeHtml(err.message) + '
'; + skillsList.innerHTML = '
' + I18n.t('skills.loadFailed', {message: escapeHtml(err.message)}) + '
'; }); } @@ -3796,7 +3799,7 @@ function renderSkillCard(skill) { if (skill.keywords && skill.keywords.length > 0) { var kw = document.createElement('div'); kw.className = 'ext-keywords'; - kw.textContent = 'Activates on: ' + skill.keywords.join(', '); + kw.textContent = I18n.t('skills.activatesOn') + ': ' + skill.keywords.join(', '); card.appendChild(kw); } @@ -3807,7 +3810,7 @@ function renderSkillCard(skill) { if (skill.trust.toLowerCase() !== 'trusted') { var removeBtn = document.createElement('button'); removeBtn.className = 'btn-ext remove'; - removeBtn.textContent = 'Remove'; + removeBtn.textContent = I18n.t('skills.remove'); removeBtn.addEventListener('click', function() { removeSkill(skill.name); }); actions.appendChild(removeBtn); } @@ -3822,7 +3825,7 @@ function searchClawHub() { if (!query) return; var resultsDiv = document.getElementById('skill-search-results'); - resultsDiv.innerHTML = '
Searching...
'; + resultsDiv.innerHTML = '
' + I18n.t('skills.searching') + '
'; apiFetch('/api/skills/search', { method: 'POST', @@ -3838,7 +3841,7 @@ function searchClawHub() { warning.style.borderLeft = '3px solid #f0ad4e'; warning.style.paddingLeft = '12px'; warning.style.marginBottom = '16px'; - warning.textContent = 'Could not reach ClawHub registry: ' + data.catalog_error; + warning.textContent = I18n.t('skills.registryError', {message: data.catalog_error}); resultsDiv.appendChild(warning); } @@ -3870,10 +3873,10 @@ function searchClawHub() { } if (resultsDiv.children.length === 0) { - resultsDiv.innerHTML = '
No skills found for "' + escapeHtml(query) + '"
'; + resultsDiv.innerHTML = '
' + I18n.t('skills.noResults', {query: escapeHtml(query)}) + '
'; } }).catch(function(err) { - resultsDiv.innerHTML = '
Search failed: ' + escapeHtml(err.message) + '
'; + resultsDiv.innerHTML = '
' + I18n.t('skills.searchFailed', {message: escapeHtml(err.message)}) + '
'; }); } @@ -3967,17 +3970,17 @@ function renderCatalogSkillCard(entry, installedNames) { if (isInstalled) { var label = document.createElement('span'); label.className = 'ext-active-label'; - label.textContent = 'Installed'; + label.textContent = I18n.t('status.installed'); actions.appendChild(label); } else { var installBtn = document.createElement('button'); installBtn.className = 'btn-ext install'; - installBtn.textContent = 'Install'; + installBtn.textContent = I18n.t('extensions.install'); installBtn.addEventListener('click', (function(s, btn) { return function() { if (!confirm('Install skill "' + s + '" from ClawHub?')) return; btn.disabled = true; - btn.textContent = 'Installing...'; + btn.textContent = I18n.t('extensions.installing'); installSkill(s, null, btn); }; })(slug, installBtn)); @@ -4019,7 +4022,7 @@ function installSkill(nameOrSlug, url, btn) { body: body, }).then(function(res) { if (res.success) { - showToast('Installed skill "' + nameOrSlug + '"', 'success'); + showToast(I18n.t('skills.installedSuccess', {name: nameOrSlug}), 'success'); } else { showToast('Install failed: ' + (res.message || 'unknown error'), 'error'); } @@ -4032,19 +4035,19 @@ function installSkill(nameOrSlug, url, btn) { } function removeSkill(name) { - if (!confirm('Remove skill "' + name + '"?')) return; + if (!confirm(I18n.t('skills.confirmRemove', { name: name }))) return; apiFetch('/api/skills/' + encodeURIComponent(name), { method: 'DELETE', headers: { 'X-Confirm-Action': 'true' }, }).then(function(res) { if (res.success) { - showToast('Removed skill "' + name + '"', 'success'); + showToast(I18n.t('skills.removed', { name: name }), 'success'); } else { - showToast('Remove failed: ' + (res.message || 'unknown error'), 'error'); + showToast(I18n.t('skills.removeFailed', { message: res.message || 'unknown error' }), 'error'); } loadSkills(); }).catch(function(err) { - showToast('Remove failed: ' + err.message, 'error'); + showToast(I18n.t('skills.removeFailed', { message: err.message }), 'error'); }); } diff --git a/src/channels/web/static/i18n-app.js b/src/channels/web/static/i18n-app.js new file mode 100644 index 00000000..87624b96 --- /dev/null +++ b/src/channels/web/static/i18n-app.js @@ -0,0 +1,74 @@ +// i18n Integration for IronClaw App +// This file contains i18n-related functions that extend app.js + +// Initialize i18n when DOM is ready +document.addEventListener('DOMContentLoaded', () => { + // Initialize i18n + I18n.init(); + I18n.updatePageContent(); + updateSlashCommands(); + updateLanguageMenu(); +}); + +// Update slash commands with current language +function updateSlashCommands() { + // Update SLASH_COMMANDS descriptions + SLASH_COMMANDS.forEach(cmd => { + const key = 'cmd.' + cmd.cmd.replace(/\s+/g, '').replace(/\//g, '') + '.desc'; + const translated = I18n.t(key); + if (translated !== key) { + cmd.desc = translated; + } + }); +} + +// Toggle language menu +function toggleLanguageMenu() { + const menu = document.getElementById('language-menu'); + if (menu) { + menu.style.display = menu.style.display === 'none' ? 'block' : 'none'; + } +} + +// Switch language +function switchLanguage(lang) { + if (I18n.setLanguage(lang)) { + // Update slash commands + updateSlashCommands(); + + // Update language menu active state + updateLanguageMenu(); + + // Close menu + const menu = document.getElementById('language-menu'); + if (menu) { + menu.style.display = 'none'; + } + + // Show toast notification + showToast(I18n.t('language.switch') + ': ' + (lang === 'zh-CN' ? '简体中文' : 'English')); + } +} + +// Update language menu active state +function updateLanguageMenu() { + const currentLang = I18n.getCurrentLang(); + document.querySelectorAll('.language-option').forEach(option => { + if (option.getAttribute('data-lang') === currentLang) { + option.classList.add('active'); + } else { + option.classList.remove('active'); + } + }); +} + +// Close language menu when clicking outside +document.addEventListener('click', (e) => { + if (!e.target.closest('.language-switcher')) { + const menu = document.getElementById('language-menu'); + if (menu) { + menu.style.display = 'none'; + } + } +}); + diff --git a/src/channels/web/static/i18n/en.js b/src/channels/web/static/i18n/en.js new file mode 100644 index 00000000..b637f144 --- /dev/null +++ b/src/channels/web/static/i18n/en.js @@ -0,0 +1,351 @@ +// English Language Pack for IronClaw + +I18n.register('en', { + // Auth Page + 'auth.title': 'IronClaw', + 'auth.tagline': 'Secure AI Assistant', + 'auth.tokenLabel': 'Gateway Token', + 'auth.tokenPlaceholder': 'Paste your token', + 'auth.connect': 'Connect', + 'auth.errorRequired': 'Token required', + 'auth.errorInvalid': 'Invalid token', + 'auth.hint': 'Enter the GATEWAY_AUTH_TOKEN from your .env file', + + // Chat + 'chat.inputPlaceholder': 'Message or / for commands...', + + // Restart Modal + 'restart.title': 'Restart IronClaw Instance', + 'restart.description': 'Are you sure you want to restart IronClaw? This will gracefully restart the process.', + 'restart.warning': 'Running tasks may be interrupted. Restart will complete in a few seconds.', + 'restart.cancel': 'Cancel', + 'restart.confirm': 'Confirm Restart', + 'restart.progressTitle': 'Restarting IronClaw', + 'restart.progressSubtitle': 'Please wait for the process to restart...', + 'restart.checkLogs': 'Check the Logs tab for details after restart completes.', + + // Tabs + 'tab.chat': 'Chat', + 'tab.memory': 'Memory', + 'tab.jobs': 'Jobs', + 'tab.routines': 'Routines', + 'tab.extensions': 'Extensions', + 'tab.skills': 'Skills', + 'tab.logs': 'Logs', + + // Status + 'status.connected': 'Connected', + 'status.disconnected': 'Disconnected', + 'status.connecting': 'Connecting...', + 'status.reconnecting': 'Reconnecting...', + 'status.teeVerified': 'TEE Verified', + 'status.restart': 'Restart', + 'status.active': 'Active', + 'status.installed': 'Installed', + 'status.awaitingPairing': 'Awaiting Pairing', + + // Dashboard + 'dashboard.connections': 'Connections', + 'dashboard.uptime': 'Uptime', + 'dashboard.costToday': 'Cost Today', + 'dashboard.spent': 'Spent', + 'dashboard.actionsPerHour': 'Actions/hr', + 'dashboard.sse': 'SSE', + 'dashboard.websocket': 'WebSocket', + + // Chat Tab + 'chat.newThread': 'New Thread', + 'chat.toggleSidebar': 'Toggle Sidebar', + 'chat.assistant': 'Assistant', + 'chat.conversations': 'Conversations', + 'chat.send': 'Send', + 'chat.attachImages': 'Attach Images', + 'chat.empty': 'Select a file to view content', + 'chat.loading': 'Loading...', + 'chat.loadingOlder': 'Loading older messages...', + 'chat.noFiles': 'No files in workspace', + 'chat.noResults': 'No results', + + // Thread Sidebar + 'thread.assistant': 'Assistant', + 'thread.new': 'New Thread', + + // Memory Tab + 'memory.searchPlaceholder': 'Search memory...', + 'memory.workspace': 'workspace', + 'memory.edit': 'Edit', + 'memory.save': 'Save', + 'memory.cancel': 'Cancel', + 'memory.selectFile': 'Select a file to view content', + + // Jobs Tab + 'jobs.summary': 'Jobs Summary', + 'jobs.id': 'ID', + 'jobs.title': 'Title', + 'jobs.source': 'Source', + 'jobs.status': 'Status', + 'jobs.created': 'Created', + 'jobs.actions': 'Actions', + 'jobs.empty': 'No jobs', + 'jobs.statusRunning': 'Running', + 'jobs.statusCompleted': 'Completed', + 'jobs.statusFailed': 'Failed', + 'jobs.statusPending': 'Pending', + 'jobs.jobId': 'Job ID', + 'jobs.description': 'Description', + 'jobs.stateTransitions': 'State Transitions', + 'jobs.projectFiles': 'Project Files', + 'jobs.noProjectFiles': 'No project files', + 'jobs.viewJob': 'View Job', + 'jobs.browse': 'Browse', + + // Routines Tab + 'routines.summary': 'Routines Summary', + 'routines.name': 'Name', + 'routines.trigger': 'Trigger', + 'routines.action': 'Action', + 'routines.lastRun': 'Last Run', + 'routines.nextRun': 'Next Run', + 'routines.runs': 'Runs', + 'routines.status': 'Status', + 'routines.actions': 'Actions', + 'routines.runsToday': 'Runs Today', + 'routines.empty': 'No routines', + 'routines.noConfigured': 'No routines configured. Ask the assistant to create one.', + 'routines.triggerFailed': 'Trigger failed: {message}', + + // Logs Tab + 'logs.serverLevel': 'Server: ERROR', + 'logs.clientLevel': 'Client Log Level', + 'logs.pause': 'Pause', + 'logs.resume': 'Resume', + 'logs.clear': 'Clear', + 'logs.autoScroll': 'Auto-scroll', + 'logs.filter': 'Filter logs...', + 'logs.empty': 'No logs', + 'logs.allLevels': 'All Levels', + 'logs.error': 'Error', + 'logs.warn': 'Warn', + 'logs.info': 'Info', + 'logs.debug': 'Debug', + + // Extensions Tab + 'extensions.installed': 'Installed Extensions', + 'extensions.available': 'Available WASM Extensions', + 'extensions.installWasm': 'Install WASM Extension', + 'extensions.noInstalled': 'No extensions installed', + 'extensions.noAvailable': 'No additional WASM extensions available', + 'extensions.loading': 'Loading...', + 'extensions.install': 'Install', + 'extensions.installing': 'Installing...', + 'extensions.installedSuccess': 'Installed {name}', + 'extensions.remove': 'Remove', + 'extensions.activate': 'Activate', + 'extensions.reconfigure': 'Reconfigure', + 'extensions.tools': 'Tools', + 'extensions.noConfigNeeded': 'No configuration needed for {name}', + 'extensions.configure': 'Configure {name}', + 'extensions.optional': ' (optional)', + 'extensions.autoGenerated': 'Auto-generated if empty', + 'extensions.pendingPairing': 'Pending pairing requests', + 'extensions.from': 'from', + + // MCP Servers + 'mcp.servers': 'MCP Servers', + 'mcp.noServers': 'No MCP servers available', + 'mcp.addCustom': 'Add Custom MCP Server', + 'mcp.add': 'Add', + 'mcp.addedSuccess': 'Added MCP server {name}', + + // Registered Tools + 'tools.registered': 'Registered Tools', + 'tools.name': 'Name', + 'tools.description': 'Description', + 'tools.empty': 'No tools registered', + + // Skills Tab + 'skills.installed': 'Installed Skills', + 'skills.noInstalled': 'No skills installed', + 'skills.searchClawHub': 'Search ClawHub', + 'skills.searchPlaceholder': 'Search...', + 'skills.installByUrl': 'Install Skill by URL', + 'skills.namePlaceholder': 'Skill name or slug', + 'skills.urlPlaceholder': 'HTTPS URL to SKILL.md (optional)', + 'skills.search': 'Search', + 'skills.searching': 'Searching...', + 'skills.noResults': 'No skills found for "{query}"', + 'skills.searchFailed': 'Search failed: {message}', + 'skills.install': 'Install', + 'skills.installing': 'Installing...', + 'skills.installedSuccess': 'Installed skill "{name}"', + 'skills.remove': 'Remove', + 'skills.activatesOn': 'Activates on', + 'skills.registryError': 'Could not reach ClawHub registry: {message}', + 'skills.by': 'by', + 'skills.updated': 'updated', + 'skills.loading': 'Loading skills...', + 'skills.loadFailed': 'Failed to load skills: {message}', + 'skills.confirmRemove': 'Remove skill "{name}"?', + 'skills.removeFailed': 'Remove failed: {message}', + 'skills.removed': 'Removed skill "{name}"', + + // Jobs Summary + 'jobs.summary.total': 'Total', + 'jobs.summary.inProgress': 'In Progress', + 'jobs.summary.completed': 'Completed', + 'jobs.summary.failed': 'Failed', + 'jobs.summary.stuck': 'Stuck', + + // Routines Summary + 'routines.summary.total': 'Total', + 'routines.summary.enabled': 'Enabled', + 'routines.summary.disabled': 'Disabled', + 'routines.summary.failing': 'Failing', + 'routines.summary.runsToday': 'Runs Today', + + // Buttons + 'btn.close': 'Close', + 'btn.cancel': 'Cancel', + 'btn.save': 'Save', + 'btn.edit': 'Edit', + 'btn.confirm': 'Confirm', + 'btn.send': 'Send', + 'btn.refresh': 'Refresh', + 'btn.loadMore': 'Load More', + 'btn.copy': 'Copy', + 'btn.copied': 'Copied!', + 'btn.submit': 'Submit', + 'btn.setup': 'Setup', + + // Time + 'time.lessThan1MinuteAgo': '<1m ago', + 'time.lessThan1MinuteFromNow': 'in <1m', + 'time.minutesAgo': '{n}m ago', + 'time.minutesFromNow': 'in {n}m', + 'time.hoursAgo': '{n}h ago', + 'time.hoursFromNow': 'in {n}h', + 'time.daysAgo': '{n}d ago', + 'time.daysFromNow': 'in {n}d', + + // Tool Approval + 'approval.title': 'Tool requires approval', + 'approval.description': 'A tool is requesting permission to run.', + 'approval.approve': 'Approve', + 'approval.deny': 'Deny', + 'approval.always': 'Always', + 'approval.approved': 'Approved', + 'approval.alwaysApproved': 'Always approved', + 'approval.denied': 'Denied', + 'approval.showParams': 'Show parameters', + 'approval.hideParams': 'Hide parameters', + + // Authentication Required + 'authRequired.title': 'Authentication required for {name}', + 'authRequired.authenticateWith': 'Authenticate with {name}', + 'authRequired.getToken': 'Get your token', + 'authRequired.instructions': 'Instructions', + + // Sandbox Jobs + 'sandbox.job': 'Sandbox Job', + 'sandbox.doneSignal': 'Done signal sent', + + // Error Messages + 'error.startConversation': 'Please start a conversation first', + 'error.restartFailed': 'Restart failed: {message}', + 'error.tokenRequired': 'Token required', + 'error.tokenInvalid': 'Invalid token', + 'error.connectionFailed': 'Connection failed', + 'error.unknown': 'Unknown error', + 'error.loadFailed': 'Failed to load: {message}', + + // Success Messages + 'success.restartInitiated': 'Restart initiated', + 'success.saved': 'Saved successfully', + + // Slash Commands + 'cmd.status.desc': 'Show all jobs, or /status for a specific job', + 'cmd.list.desc': 'List all jobs', + 'cmd.cancel.desc': '/cancel — Cancel a running job', + 'cmd.undo.desc': 'Undo last action', + 'cmd.redo.desc': 'Redo undone action', + 'cmd.compact.desc': 'Compact context window', + 'cmd.clear.desc': 'Clear conversation and start fresh', + 'cmd.interrupt.desc': 'Stop current operation', + 'cmd.heartbeat.desc': 'Trigger manual heartbeat check', + 'cmd.summarize.desc': 'Summarize current conversation', + 'cmd.suggest.desc': 'Suggest next actions', + 'cmd.help.desc': 'Show help', + 'cmd.version.desc': 'Show version info', + 'cmd.tools.desc': 'List available tools', + 'cmd.skills.desc': 'List installed skills', + 'cmd.model.desc': 'Show or switch LLM model', + 'cmd.threadNew.desc': 'Create new conversation thread', + + // Language Switcher + 'language.title': 'Language', + 'language.en': 'English', + 'language.zhCN': '简体中文', + 'language.switch': 'Switch Language', + + // Tool Activity + 'tool.thinking': 'Thinking...', + 'tool.completed': 'Completed', + 'tool.failed': 'Failed', + 'tool.running': 'Running', + 'tool.used': '{count} tool(s) used', + 'tool.requiresApproval': 'Tool requires approval', + + + // TEE + 'tee.loadingReport': 'Loading attestation report...', + 'tee.loadFailed': 'Could not load attestation report', + + // Common + 'common.loading': 'Loading...', + 'common.noData': 'No data', + 'common.search': 'Search', + 'common.add': 'Add', + 'common.remove': 'Remove', + 'common.install': 'Install', + 'common.activate': 'Activate', + 'common.deactivate': 'Deactivate', + 'common.configure': 'Configure', + 'common.save': 'Save', + 'common.cancel': 'Cancel', + 'common.confirm': 'Confirm', + 'common.close': 'Close', + 'common.edit': 'Edit', + 'common.delete': 'Delete', + 'common.refresh': 'Refresh', + 'common.searchPlaceholder': 'Search...', + 'common.name': 'Name', + 'common.description': 'Description', + 'common.status': 'Status', + 'common.actions': 'Actions', + 'common.version': 'Version', + 'common.owner': 'Owner', + 'common.tags': 'Tags', + + // Extensions + 'ext.active': 'Active', + 'ext.remove': 'Remove', + 'ext.install': 'Install', + 'ext.installing': 'Installing...', + 'ext.installed': 'Installed', + 'ext.setup': 'Setup', + 'ext.reconfigure': 'Reconfigure', + 'ext.configure': 'Configure', + 'ext.confirmRemove': 'Remove extension "{name}"?', + 'ext.removeFailed': 'Remove failed: {message}', + 'ext.removed': 'Removed {name}', + 'ext.installFailed': 'Install failed: {message}', + + // Configure + 'config.title': 'Configure {name}', + 'config.optional': ' (optional)', + 'config.alreadySet': '(already set — leave empty to keep)', + 'config.alreadyConfigured': 'Already configured', + 'config.autoGenerate': 'Auto-generated if empty', + 'config.save': 'Save', + 'config.cancel': 'Cancel', +}); diff --git a/src/channels/web/static/i18n/index.js b/src/channels/web/static/i18n/index.js new file mode 100644 index 00000000..4c92bcc5 --- /dev/null +++ b/src/channels/web/static/i18n/index.js @@ -0,0 +1,89 @@ +// Lightweight internationalization implementation with dynamic language switching + +const I18n = { + currentLang: 'en', + fallbackLang: 'en', + translations: {}, + + // Initialize i18n + init() { + // Read user preference from localStorage + const savedLang = localStorage.getItem('ironclaw_language'); + if (savedLang && this.translations[savedLang]) { + this.currentLang = savedLang; + } else { + // Detect browser language + const browserLang = navigator.language || navigator.userLanguage; + this.currentLang = browserLang.startsWith('zh') ? 'zh-CN' : 'en'; + } + this.updateHtmlLang(); + }, + + // Register language pack + register(lang, translations) { + this.translations[lang] = translations; + }, + + // Switch language + setLanguage(lang) { + if (this.translations[lang]) { + this.currentLang = lang; + localStorage.setItem('ironclaw_language', lang); + this.updateHtmlLang(); + this.updatePageContent(); + return true; + } + return false; + }, + + // Get current language + getCurrentLang() { + return this.currentLang; + }, + + // Translate function + t(key, params = {}) { + const translation = this.translations[this.currentLang]?.[key] + || this.translations[this.fallbackLang]?.[key] + || key; + + // Support placeholder replacement: {name} + return translation.replace(/\{(\w+)\}/g, (match, key) => { + return params[key] !== undefined ? params[key] : match; + }); + }, + + // Update HTML lang attribute + updateHtmlLang() { + document.documentElement.lang = this.currentLang; + }, + + // Update page content (traverse all data-i18n elements) + updatePageContent() { + // Update text content + document.querySelectorAll('[data-i18n]').forEach(el => { + const key = el.getAttribute('data-i18n'); + const attr = el.getAttribute('data-i18n-attr'); + if (attr) { + el.setAttribute(attr, this.t(key)); + } else { + el.textContent = this.t(key); + } + }); + + // Update placeholder attributes + document.querySelectorAll('[data-i18n-placeholder]').forEach(el => { + const key = el.getAttribute('data-i18n-placeholder'); + el.placeholder = this.t(key); + }); + + // Update title attributes + document.querySelectorAll('[data-i18n-title]').forEach(el => { + const key = el.getAttribute('data-i18n-title'); + el.title = this.t(key); + }); + } +}; + +// Global access +window.I18n = I18n; diff --git a/src/channels/web/static/i18n/zh-CN.js b/src/channels/web/static/i18n/zh-CN.js new file mode 100644 index 00000000..8a7fd520 --- /dev/null +++ b/src/channels/web/static/i18n/zh-CN.js @@ -0,0 +1,351 @@ +// 中文语言包 for IronClaw + +I18n.register('zh-CN', { + // 认证页面 + 'auth.title': 'IronClaw', + 'auth.tagline': '安全可靠的 AI 助手', + 'auth.tokenLabel': '网关令牌', + 'auth.tokenPlaceholder': '粘贴你的网关令牌', + 'auth.connect': '连接', + 'auth.errorRequired': '请输入令牌', + 'auth.errorInvalid': '令牌无效', + 'auth.hint': '输入 .env 配置文件中的 GATEWAY_AUTH_TOKEN', + + // 聊天 + 'chat.inputPlaceholder': '输入消息或 / 以使用命令...', + + // 重启弹窗 + 'restart.title': '重启 IronClaw 实例', + 'restart.description': '确定要重启 IronClaw 实例吗?这将优雅地重启进程。', + 'restart.warning': '正在运行的任务可能会中断。重启将在几秒钟内完成。', + 'restart.cancel': '取消', + 'restart.confirm': '确认重启', + 'restart.progressTitle': '正在重启 IronClaw', + 'restart.progressSubtitle': '请等待进程重启...', + 'restart.checkLogs': '重启完成后,请查看日志标签页了解详情。', + + // 标签页 + 'tab.chat': '聊天', + 'tab.memory': '记忆', + 'tab.jobs': '任务', + 'tab.routines': '定时任务', + 'tab.extensions': '扩展', + 'tab.skills': '技能', + 'tab.logs': '日志', + + // 状态 + 'status.connected': '已连接', + 'status.disconnected': '已断开', + 'status.connecting': '连接中...', + 'status.reconnecting': '重新连接中...', + 'status.teeVerified': 'TEE 已验证', + 'status.restart': '重启', + 'status.active': '已激活', + 'status.installed': '已安装', + 'status.awaitingPairing': '等待配对', + + // 仪表盘 + 'dashboard.connections': '连接数', + 'dashboard.uptime': '运行时间', + 'dashboard.costToday': '今日费用', + 'dashboard.spent': '已花费', + 'dashboard.actionsPerHour': '每小时操作', + 'dashboard.sse': 'SSE', + 'dashboard.websocket': 'WebSocket', + + // 聊天标签页 + 'chat.newThread': '新对话', + 'chat.toggleSidebar': '切换侧边栏', + 'chat.assistant': '助手', + 'chat.conversations': '对话列表', + 'chat.send': '发送', + 'chat.attachImages': '附加图片', + 'chat.empty': '选择文件查看内容', + 'chat.loading': '加载中...', + 'chat.loadingOlder': '加载更早的消息...', + 'chat.noFiles': '工作区没有文件', + 'chat.noResults': '没有结果', + + // 对话侧边栏 + 'thread.assistant': '助手', + 'thread.new': '新对话', + + // 记忆标签页 + 'memory.searchPlaceholder': '搜索记忆...', + 'memory.workspace': '工作区', + 'memory.edit': '编辑', + 'memory.save': '保存', + 'memory.cancel': '取消', + 'memory.selectFile': '选择文件查看内容', + + // 任务标签页 + 'jobs.summary': '任务摘要', + 'jobs.id': 'ID', + 'jobs.title': '标题', + 'jobs.source': '来源', + 'jobs.status': '状态', + 'jobs.created': '创建时间', + 'jobs.actions': '操作', + 'jobs.empty': '暂无任务', + 'jobs.statusRunning': '运行中', + 'jobs.statusCompleted': '已完成', + 'jobs.statusFailed': '失败', + 'jobs.statusPending': '等待中', + 'jobs.jobId': '任务 ID', + 'jobs.description': '描述', + 'jobs.stateTransitions': '状态转换', + 'jobs.projectFiles': '项目文件', + 'jobs.noProjectFiles': '没有项目文件', + 'jobs.viewJob': '查看任务', + 'jobs.browse': '浏览', + + // 定时任务标签页 + 'routines.summary': '定时任务摘要', + 'routines.name': '名称', + 'routines.trigger': '触发器', + 'routines.action': '操作', + 'routines.lastRun': '上次运行', + 'routines.nextRun': '下次运行', + 'routines.runs': '运行次数', + 'routines.status': '状态', + 'routines.actions': '操作', + 'routines.runsToday': '今日运行', + 'routines.empty': '暂无定时任务', + 'routines.noConfigured': '暂无配置的定时任务。请让助手创建一个。', + 'routines.triggerFailed': '触发失败: {message}', + + // 日志标签页 + 'logs.serverLevel': '服务端日志级别', + 'logs.clientLevel': '客户端日志级别', + 'logs.pause': '暂停', + 'logs.resume': '继续', + 'logs.clear': '清空', + 'logs.autoScroll': '自动滚动', + 'logs.filter': '筛选日志...', + 'logs.empty': '暂无日志', + 'logs.allLevels': '所有级别', + 'logs.error': '错误', + 'logs.warn': '警告', + 'logs.info': '信息', + 'logs.debug': '调试', + + // 扩展标签页 + 'extensions.installed': '已安装扩展', + 'extensions.available': '可用 WASM 扩展', + 'extensions.installWasm': '安装 WASM 扩展', + 'extensions.noInstalled': '没有安装扩展', + 'extensions.noAvailable': '没有其他可用的 WASM 扩展', + 'extensions.loading': '加载中...', + 'extensions.install': '安装', + 'extensions.installing': '安装中...', + 'extensions.installedSuccess': '已安装 {name}', + 'extensions.remove': '移除', + 'extensions.activate': '激活', + 'extensions.reconfigure': '重新配置', + 'extensions.tools': '工具', + 'extensions.noConfigNeeded': '{name} 不需要配置', + 'extensions.configure': '配置 {name}', + 'extensions.optional': ' (可选)', + 'extensions.autoGenerated': '留空则自动生成', + 'extensions.pendingPairing': '等待配对请求', + 'extensions.from': '来自', + + // MCP 服务器 + 'mcp.servers': 'MCP 服务器', + 'mcp.noServers': '没有可用的 MCP 服务器', + 'mcp.addCustom': '添加自定义 MCP 服务器', + 'mcp.add': '添加', + 'mcp.addedSuccess': '已添加 MCP 服务器 {name}', + + // 注册工具 + 'tools.registered': '注册工具', + 'tools.name': '名称', + 'tools.description': '描述', + 'tools.empty': '没有注册工具', + + // 技能标签页 + 'skills.installed': '已安装技能', + 'skills.noInstalled': '没有安装技能', + 'skills.searchClawHub': '搜索 ClawHub', + 'skills.searchPlaceholder': '搜索...', + 'skills.installByUrl': '通过 URL 安装技能', + 'skills.namePlaceholder': '技能名称或标识', + 'skills.urlPlaceholder': 'SKILL.md 的 HTTPS URL(可选)', + 'skills.search': '搜索', + 'skills.searching': '搜索中...', + 'skills.noResults': '没有找到 "{query}" 相关技能', + 'skills.searchFailed': '搜索失败: {message}', + 'skills.install': '安装', + 'skills.installing': '安装中...', + 'skills.installedSuccess': '已安装技能 "{name}"', + 'skills.remove': '移除', + 'skills.activatesOn': '激活关键词', + 'skills.registryError': '无法连接 ClawHub 注册表: {message}', + 'skills.by': '作者', + 'skills.updated': '更新于', + 'skills.loading': '加载技能中...', + 'skills.loadFailed': '加载技能失败: {message}', + 'skills.confirmRemove': '确定要移除技能 "{name}" 吗?', + 'skills.removeFailed': '移除失败: {message}', + 'skills.removed': '已移除技能 "{name}"', + + // 任务摘要 + 'jobs.summary.total': '总计', + 'jobs.summary.inProgress': '进行中', + 'jobs.summary.completed': '已完成', + 'jobs.summary.failed': '失败', + 'jobs.summary.stuck': '卡住', + + // 定时任务摘要 + 'routines.summary.total': '总计', + 'routines.summary.enabled': '已启用', + 'routines.summary.disabled': '已禁用', + 'routines.summary.failing': '失败', + 'routines.summary.runsToday': '今日运行', + + // 按钮 + 'btn.close': '关闭', + 'btn.cancel': '取消', + 'btn.save': '保存', + 'btn.edit': '编辑', + 'btn.confirm': '确认', + 'btn.send': '发送', + 'btn.refresh': '刷新', + 'btn.loadMore': '加载更多', + 'btn.copy': '复制', + 'btn.copied': '已复制!', + 'btn.submit': '提交', + 'btn.setup': '设置', + + // 时间 + 'time.lessThan1MinuteAgo': '刚刚', + 'time.lessThan1MinuteFromNow': '1分钟内', + 'time.minutesAgo': '{n}分钟前', + 'time.minutesFromNow': '{n}分钟后', + 'time.hoursAgo': '{n}小时前', + 'time.hoursFromNow': '{n}小时后', + 'time.daysAgo': '{n}天前', + 'time.daysFromNow': '{n}天后', + + // 工具审批 + 'approval.title': '工具需要审批', + 'approval.description': '一个工具请求运行权限。', + 'approval.approve': '批准', + 'approval.deny': '拒绝', + 'approval.always': '始终允许', + 'approval.approved': '已批准', + 'approval.alwaysApproved': '始终批准', + 'approval.denied': '已拒绝', + 'approval.showParams': '显示参数', + 'approval.hideParams': '隐藏参数', + + // 认证 + 'authRequired.title': '{name} 需要认证', + 'authRequired.authenticateWith': '使用 {name} 认证', + 'authRequired.getToken': '获取令牌', + 'authRequired.instructions': '说明', + + // 沙盒任务 + 'sandbox.job': '沙盒任务', + 'sandbox.doneSignal': '完成信号已发送', + + // 错误消息 + 'error.startConversation': '请先开始一个对话', + 'error.restartFailed': '重启失败: {message}', + 'error.tokenRequired': '请输入令牌', + 'error.tokenInvalid': '令牌无效', + 'error.connectionFailed': '连接失败', + 'error.unknown': '未知错误', + 'error.loadFailed': '加载失败: {message}', + + // 成功消息 + 'success.restartInitiated': '已开始重启', + 'success.saved': '保存成功', + + // 斜杠命令 + 'cmd.status.desc': '显示所有任务,或使用 /status 查看特定任务', + 'cmd.list.desc': '列出所有任务', + 'cmd.cancel.desc': '/cancel — 取消正在运行的任务', + 'cmd.undo.desc': '撤销上一步', + 'cmd.redo.desc': '重做已撤销的操作', + 'cmd.compact.desc': '压缩上下文窗口', + 'cmd.clear.desc': '清空对话并重新开始', + 'cmd.interrupt.desc': '停止当前操作', + 'cmd.heartbeat.desc': '触发手动心跳检查', + 'cmd.summarize.desc': '总结当前对话', + 'cmd.suggest.desc': '建议下一步操作', + 'cmd.help.desc': '显示帮助', + 'cmd.version.desc': '显示版本信息', + 'cmd.tools.desc': '列出可用工具', + 'cmd.skills.desc': '列出已安装的 AI 技能', + 'cmd.model.desc': '显示或切换 LLM 模型', + 'cmd.threadNew.desc': '创建新对话线程', + + // 语言切换 + 'language.title': '语言', + 'language.en': 'English', + 'language.zhCN': '简体中文', + 'language.switch': '切换语言', + + // 工具活动 + 'tool.thinking': '思考中...', + 'tool.completed': '已完成', + 'tool.failed': '失败', + 'tool.running': '运行中', + 'tool.used': '{count} 个工具已使用', + 'tool.requiresApproval': '工具需要审批', + + + // TEE + 'tee.loadingReport': '正在加载证明报告...', + 'tee.loadFailed': '无法加载证明报告', + + // 通用 + 'common.loading': '加载中...', + 'common.noData': '暂无数据', + 'common.search': '搜索', + 'common.add': '添加', + 'common.remove': '移除', + 'common.install': '安装', + 'common.activate': '激活', + 'common.deactivate': '停用', + 'common.configure': '配置', + 'common.save': '保存', + 'common.cancel': '取消', + 'common.confirm': '确认', + 'common.close': '关闭', + 'common.edit': '编辑', + 'common.delete': '删除', + 'common.refresh': '刷新', + 'common.searchPlaceholder': '搜索...', + 'common.name': '名称', + 'common.description': '描述', + 'common.status': '状态', + 'common.actions': '操作', + 'common.version': '版本', + 'common.owner': '作者', + 'common.tags': '标签', + + // 扩展 + 'ext.active': '已激活', + 'ext.remove': '移除', + 'ext.install': '安装', + 'ext.installing': '安装中...', + 'ext.installed': '已安装', + 'ext.setup': '设置', + 'ext.reconfigure': '重新配置', + 'ext.configure': '配置', + 'ext.confirmRemove': '确定要移除扩展 "{name}" 吗?', + 'ext.removeFailed': '移除失败: {message}', + 'ext.removed': '已移除 {name}', + 'ext.installFailed': '安装失败: {message}', + + // 配置 + 'config.title': '配置 {name}', + 'config.optional': '(可选)', + 'config.alreadySet': '(已设置 — 留空以保持不变)', + 'config.alreadyConfigured': '已配置', + 'config.autoGenerate': '如果为空则自动生成', + 'config.save': '保存', + 'config.cancel': '取消', +}); diff --git a/src/channels/web/static/index.html b/src/channels/web/static/index.html index b4a78a12..6f21b428 100644 --- a/src/channels/web/static/index.html +++ b/src/channels/web/static/index.html @@ -9,6 +9,12 @@ + + + + + + + diff --git a/src/channels/web/static/style.css b/src/channels/web/static/style.css index 1536f9e9..a0985ce3 100644 --- a/src/channels/web/static/style.css +++ b/src/channels/web/static/style.css @@ -3885,6 +3885,61 @@ mark { display: block; } +/* Language Switcher */ +.language-switcher { + position: relative; + display: flex; + align-items: center; +} + +.language-btn { + background: transparent; + border: none; + color: var(--text-secondary); + cursor: pointer; + padding: 8px; + font-size: 16px; + border-radius: var(--radius); + transition: all 0.2s; +} + +.language-btn:hover { + color: var(--text); + background: var(--bg-tertiary); +} + +.language-menu { + position: absolute; + top: 100%; + right: 0; + margin-top: 4px; + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 4px; + min-width: 120px; + z-index: 1000; + box-shadow: var(--shadow); +} + +.language-option { + padding: 8px 12px; + cursor: pointer; + border-radius: var(--radius); + color: var(--text); + font-size: 13px; + transition: all 0.2s; +} + +.language-option:hover { + background: var(--bg-tertiary); +} + +.language-option.active { + background: var(--accent); + color: var(--bg); +} + .generated-image-path { font-size: 12px; color: var(--text-secondary);