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 <[email protected]>
This commit is contained in:
jinxin
2026-03-11 22:34:13 +08:00
committed by GitHub
co-authored by zwb1982
parent 8f513428f1
commit 6b841bb817
9 changed files with 1174 additions and 184 deletions
+1 -1
View File
@@ -229,7 +229,7 @@ WASM ──► 白名单 ──► 泄露扫描 ──► 凭据 ──► 执
│ │ │ │
│ ┌──────────▼────┐ ┌──▼───────────────┐ │
│ │ 调度器 │ │ 定时任务引擎 │ │
│ │ (并行任务) │ │(cron, 事件, wh) │
│ │ (并行任务) │ │(cron, 事件, Webhook)│
│ └──────┬────────┘ └────────┬─────────┘ │
│ │ │ │
│ ┌─────────────┼────────────────────┘ │
+45 -1
View File
@@ -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<HealthResponse> {
+99 -96
View File
@@ -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 = '<div class="empty-state">No extensions installed</div>';
extList.innerHTML = '<div class="empty-state">' + I18n.t('extensions.noInstalled') + '</div>';
} else {
extList.innerHTML = '';
for (const ext of extData.extensions) {
@@ -2053,7 +2055,7 @@ function loadExtensions() {
// Available WASM extensions
if (wasmEntries.length === 0) {
wasmList.innerHTML = '<div class="empty-state">No additional WASM extensions available</div>';
wasmList.innerHTML = '<div class="empty-state">' + I18n.t('extensions.noAvailable') + '</div>';
} 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 = '<div class="empty-state">No MCP servers available</div>';
mcpList.innerHTML = '<div class="empty-state">' + I18n.t('mcp.noServers') + '</div>';
} 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 += '<div class="gw-section-label">Connections</div>';
html += '<div class="gw-stat"><span>SSE</span><span>' + (data.sse_connections || 0) + '</span></div>';
html += '<div class="gw-stat"><span>WebSocket</span><span>' + (data.ws_connections || 0) + '</span></div>';
html += '<div class="gw-stat"><span>Uptime</span><span>' + formatDuration(data.uptime_secs) + '</span></div>';
html += '<div class="gw-section-label">' + I18n.t('dashboard.connections') + '</div>';
html += '<div class="gw-stat"><span>' + I18n.t('dashboard.sse') + '</span><span>' + (data.sse_connections || 0) + '</span></div>';
html += '<div class="gw-stat"><span>' + I18n.t('dashboard.websocket') + '</span><span>' + (data.ws_connections || 0) + '</span></div>';
html += '<div class="gw-stat"><span>' + I18n.t('dashboard.uptime') + '</span><span>' + formatDuration(data.uptime_secs) + '</span></div>';
// Cost tracker
if (data.daily_cost != null) {
html += '<div class="gw-divider"></div>';
html += '<div class="gw-section-label">Cost Today</div>';
html += '<div class="gw-stat"><span>Spent</span><span>' + formatCost(data.daily_cost) + '</span></div>';
html += '<div class="gw-section-label">' + I18n.t('dashboard.costToday') + '</div>';
html += '<div class="gw-stat"><span>' + I18n.t('dashboard.spent') + '</span><span>' + formatCost(data.daily_cost) + '</span></div>';
if (data.actions_this_hour != null) {
html += '<div class="gw-stat"><span>Actions/hr</span><span>' + data.actions_this_hour + '</span></div>';
html += '<div class="gw-stat"><span>' + I18n.t('dashboard.actionsPerHour') + '</span><span>' + data.actions_this_hour + '</span></div>';
}
}
@@ -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 = '<div class="empty-state">No skills installed</div>';
skillsList.innerHTML = '<div class="empty-state">' + I18n.t('skills.noInstalled') + '</div>';
return;
}
skillsList.innerHTML = '';
@@ -3759,7 +3762,7 @@ function loadSkills() {
skillsList.appendChild(renderSkillCard(data.skills[i]));
}
}).catch(function(err) {
skillsList.innerHTML = '<div class="empty-state">Failed to load skills: ' + escapeHtml(err.message) + '</div>';
skillsList.innerHTML = '<div class="empty-state">' + I18n.t('skills.loadFailed', {message: escapeHtml(err.message)}) + '</div>';
});
}
@@ -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 = '<div class="empty-state">Searching...</div>';
resultsDiv.innerHTML = '<div class="empty-state">' + I18n.t('skills.searching') + '</div>';
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 = '<div class="empty-state">No skills found for "' + escapeHtml(query) + '"</div>';
resultsDiv.innerHTML = '<div class="empty-state">' + I18n.t('skills.noResults', {query: escapeHtml(query)}) + '</div>';
}
}).catch(function(err) {
resultsDiv.innerHTML = '<div class="empty-state">Search failed: ' + escapeHtml(err.message) + '</div>';
resultsDiv.innerHTML = '<div class="empty-state">' + I18n.t('skills.searchFailed', {message: escapeHtml(err.message)}) + '</div>';
});
}
@@ -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');
});
}
+74
View File
@@ -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';
}
}
});
+351
View File
@@ -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 <id> for a specific job',
'cmd.list.desc': 'List all jobs',
'cmd.cancel.desc': '/cancel <job-id> — 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',
});
+89
View File
@@ -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;
+351
View File
@@ -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 <id> 查看特定任务',
'cmd.list.desc': '列出所有任务',
'cmd.cancel.desc': '/cancel <job-id> — 取消正在运行的任务',
'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': '取消',
});
+109 -86
View File
@@ -9,6 +9,12 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/style.css">
<!-- i18n Modules -->
<script src="/i18n/index.js"></script>
<script src="/i18n/en.js"></script>
<script src="/i18n/zh-CN.js"></script>
<script
src="https://cdn.jsdelivr.net/npm/[email protected]/lib/marked.umd.min.js"
integrity="sha384-pN9zSKOnTZwXRtYZAu0PBPEgR2B7DOC1aeLxQ33oJ0oy5iN1we6gm57xldM2irDG"
@@ -20,16 +26,16 @@
<div id="auth-screen">
<div class="auth-card-login">
<div class="auth-brand">
<h1>IronClaw</h1>
<p class="auth-tagline">Secure AI Assistant</p>
<h1 data-i18n="auth.title">IronClaw</h1>
<p class="auth-tagline" data-i18n="auth.tagline">Secure AI Assistant</p>
</div>
<div class="auth-form">
<label for="token-input">Gateway Token</label>
<input type="password" id="token-input" placeholder="Paste your auth token" autofocus>
<button onclick="authenticate()">Connect</button>
<label for="token-input" data-i18n="auth.tokenLabel">Gateway Token</label>
<input type="password" id="token-input" data-i18n="auth.tokenPlaceholder" data-i18n-attr="placeholder" placeholder="Paste your auth token" autofocus>
<button onclick="authenticate()" data-i18n="auth.connect">Connect</button>
</div>
<div id="auth-error"></div>
<p class="auth-hint">Enter the GATEWAY_AUTH_TOKEN from your .env configuration.</p>
<p class="auth-hint" data-i18n="auth.hint">Enter the GATEWAY_AUTH_TOKEN from your .env configuration.</p>
</div>
</div>
@@ -38,21 +44,22 @@
<div class="restart-modal-overlay" onclick="cancelRestart()"></div>
<div class="restart-modal-content">
<div class="restart-modal-header">
<h2>Restart IronClaw Instance</h2>
<button class="restart-modal-close" onclick="cancelRestart()" title="Close">×</button>
<h2 data-i18n="restart.title">Restart IronClaw Instance</h2>
<button class="restart-modal-close" onclick="cancelRestart()" data-i18n="restart.closeTooltip" data-i18n-attr="title"
title="Close">×</button>
</div>
<div class="restart-modal-body">
<p class="restart-modal-description">
<p class="restart-modal-description" data-i18n="restart.description">
Are you sure you want to restart the IronClaw instance? This will gracefully restart the process.
</p>
<div class="restart-modal-warning">
<span class="restart-modal-warning-icon">⚠️</span>
<p>Any in-progress jobs may be interrupted. The restart will complete within a few seconds.</p>
<p data-i18n="restart.warning">Any in-progress jobs may be interrupted. The restart will complete within a few seconds.</p>
</div>
</div>
<div class="restart-modal-footer">
<button class="restart-modal-btn cancel" onclick="cancelRestart()">Cancel</button>
<button class="restart-modal-btn confirm" onclick="confirmRestart()">Confirm Restart</button>
<button class="restart-modal-btn cancel" onclick="cancelRestart()" data-i18n="restart.cancel">Cancel</button>
<button class="restart-modal-btn confirm" onclick="confirmRestart()" data-i18n="restart.confirm">Confirm Restart</button>
</div>
</div>
</div>
@@ -63,13 +70,13 @@
<div class="restart-loader-content">
<div class="restart-spinner"></div>
<div class="restart-loader-text">
<p class="restart-title">Restarting IronClaw</p>
<p class="restart-subtitle">Please wait while the process restarts...</p>
<p class="restart-title" data-i18n="restart.progressTitle">Restarting IronClaw</p>
<p class="restart-subtitle" data-i18n="restart.progressSubtitle">Please wait while the process restarts...</p>
</div>
<div class="restart-progress-bar">
<div class="restart-progress-fill"></div>
</div>
<p class="restart-modal-info">
<p class="restart-modal-info" data-i18n="restart.checkLogs">
Check the Logs tab for details after the restart completes.
</p>
</div>
@@ -79,33 +86,45 @@
<div id="app">
<!-- Tab Bar -->
<div class="tab-bar">
<button class="active" data-tab="chat">Chat</button>
<button data-tab="memory">Memory</button>
<button data-tab="jobs">Jobs</button>
<button data-tab="routines">Routines</button>
<button data-tab="extensions">Extensions</button>
<button data-tab="skills">Skills</button>
<button class="active" data-tab="chat" data-i18n="tab.chat">Chat</button>
<button data-tab="memory" data-i18n="tab.memory">Memory</button>
<button data-tab="jobs" data-i18n="tab.jobs">Jobs</button>
<button data-tab="routines" data-i18n="tab.routines">Routines</button>
<button data-tab="extensions" data-i18n="tab.extensions">Extensions</button>
<button data-tab="skills" data-i18n="tab.skills">Skills</button>
<div class="spacer"></div>
<button class="status-logs-btn" data-tab="logs" title="Logs">Logs</button>
<!-- Language Switcher -->
<div class="language-switcher">
<button class="language-btn" id="language-btn" type="button" onclick="toggleLanguageMenu()" title="Switch Language"
aria-label="Switch language" aria-haspopup="true" aria-expanded="false" aria-controls="language-menu">🌐</button>
<div class="language-menu" id="language-menu" style="display: none;">
<button type="button" class="language-option" onclick="switchLanguage('en')" data-lang="en">English</button>
<button type="button" class="language-option" onclick="switchLanguage('zh-CN')" data-lang="zh-CN">简体中文</button>
</div>
</div>
<button class="status-logs-btn" data-tab="logs" data-i18n="tab.logs" title="Logs">Logs</button>
<div class="tee-shield" id="tee-shield" style="display:none" title="Running in a Trusted Execution Environment">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
</svg>
<span id="tee-shield-label">TEE Verified</span>
<span id="tee-shield-label" data-i18n="status.teeVerified">TEE Verified</span>
<div class="tee-popover" id="tee-popover"></div>
</div>
<div class="status" id="gateway-status-trigger">
<div class="dot" id="sse-dot"></div>
<span id="sse-status">Connected</span>
<span id="sse-status" data-i18n="status.connected">Connected</span>
<div class="gateway-popover" id="gateway-popover"></div>
</div>
<button class="restart-btn" id="restart-btn" onclick="triggerRestart()" title="Gracefully restart the process" style="display: none;">
<button class="restart-btn" id="restart-btn" onclick="triggerRestart()" data-i18n="status.restartTooltip"
data-i18n-attr="title" title="Gracefully restart the process" style="display: none;">
<svg id="restart-icon" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M23 4v6h-6"></path>
<path d="M1 20v-6h6"></path>
<path d="M3.51 9a9 9 0 0114.85-3.36M20.49 15a9 9 0 01-14.85 3.36"></path>
</svg>
<span>Restart</span>
<span data-i18n="status.restart">Restart</span>
</button>
</div>
@@ -113,16 +132,18 @@
<div class="tab-panel active" id="tab-chat">
<div class="thread-sidebar" id="thread-sidebar">
<div class="thread-sidebar-header">
<button class="thread-new-btn" onclick="createNewThread()" title="New thread (Ctrl/Cmd+N)">+</button>
<button class="thread-new-btn" onclick="createNewThread()" data-i18n="chat.newThread" data-i18n-attr="title"
title="New thread (Ctrl/Cmd+N)">+</button>
<div class="spacer"></div>
<button class="thread-toggle-btn" id="thread-toggle-btn" onclick="toggleThreadSidebar()" title="Toggle sidebar">&laquo;</button>
<button class="thread-toggle-btn" id="thread-toggle-btn" onclick="toggleThreadSidebar()" data-i18n="chat.toggleSidebar"
data-i18n-attr="title" title="Toggle sidebar">&laquo;</button>
</div>
<div class="assistant-item" id="assistant-thread" onclick="switchToAssistant()">
<span class="assistant-label" id="assistant-label">Assistant</span>
<span class="assistant-label" id="assistant-label" data-i18n="chat.assistant">Assistant</span>
<span class="assistant-meta" id="assistant-meta"></span>
</div>
<div class="threads-section-header">
<span>Conversations</span>
<span data-i18n="chat.conversations">Conversations</span>
</div>
<div class="thread-list" id="thread-list"></div>
</div>
@@ -131,10 +152,11 @@
<div id="slash-autocomplete" class="slash-autocomplete" style="display:none"></div>
<div class="chat-input">
<div id="image-preview-strip" class="image-preview-strip"></div>
<textarea id="chat-input" placeholder="Message or / for commands..." rows="1"></textarea>
<textarea id="chat-input" data-i18n="chat.inputPlaceholder" data-i18n-attr="placeholder" placeholder="Message or / for commands..." rows="1"></textarea>
<input type="file" id="image-file-input" accept="image/*" multiple style="display:none">
<button id="attach-btn" class="attach-btn" title="Attach images" aria-label="Attach images">&#x1F4CE;</button>
<button id="send-btn" onclick="sendMessage()">Send</button>
<button id="attach-btn" class="attach-btn" data-i18n="chat.attachImages" data-i18n-attr="title" title="Attach images"
aria-label="Attach images">&#x1F4CE;</button>
<button id="send-btn" onclick="sendMessage()" data-i18n="chat.send">Send</button>
</div>
</div>
</div>
@@ -144,23 +166,23 @@
<div class="memory-container">
<div class="memory-sidebar">
<div class="search-box">
<input type="text" id="memory-search" placeholder="Search memory...">
<input type="text" id="memory-search" data-i18n="memory.searchPlaceholder" data-i18n-attr="placeholder" placeholder="Search memory...">
</div>
<div class="memory-tree" id="memory-tree"></div>
</div>
<div class="memory-content">
<div class="memory-breadcrumb" id="memory-breadcrumb">
<span id="memory-breadcrumb-path">workspace /</span>
<button class="memory-edit-btn" id="memory-edit-btn" style="display:none" onclick="startMemoryEdit()">Edit</button>
<button class="memory-edit-btn" id="memory-edit-btn" style="display:none" onclick="startMemoryEdit()" data-i18n="memory.edit">Edit</button>
</div>
<div class="memory-viewer" id="memory-viewer">
<div class="empty">Select a file to view its contents</div>
<div class="empty" data-i18n="memory.selectFile">Select a file to view its contents</div>
</div>
<div class="memory-editor" id="memory-editor" style="display:none">
<textarea id="memory-edit-textarea"></textarea>
<div class="memory-editor-actions">
<button class="btn-save" onclick="saveMemoryEdit()">Save</button>
<button class="btn-cancel-edit" onclick="cancelMemoryEdit()">Cancel</button>
<button class="btn-save" onclick="saveMemoryEdit()" data-i18n="memory.save">Save</button>
<button class="btn-cancel-edit" onclick="cancelMemoryEdit()" data-i18n="memory.cancel">Cancel</button>
</div>
</div>
</div>
@@ -174,17 +196,17 @@
<table class="jobs-table" id="jobs-table">
<thead>
<tr>
<th>ID</th>
<th>Title</th>
<th>Source</th>
<th>Status</th>
<th>Created</th>
<th>Actions</th>
<th data-i18n="jobs.id">ID</th>
<th data-i18n="jobs.title">Title</th>
<th data-i18n="jobs.source">Source</th>
<th data-i18n="jobs.status">Status</th>
<th data-i18n="jobs.created">Created</th>
<th data-i18n="jobs.actions">Actions</th>
</tr>
</thead>
<tbody id="jobs-tbody"></tbody>
</table>
<div class="empty-state" id="jobs-empty" style="display:none">No jobs found</div>
<div class="empty-state" id="jobs-empty" style="display:none" data-i18n="jobs.empty">No jobs found</div>
</div>
</div>
@@ -199,16 +221,16 @@
<option value="debug">Server: DEBUG</option>
</select>
<select id="logs-level-filter">
<option value="all">All Levels</option>
<option value="ERROR">Error</option>
<option value="WARN">Warn</option>
<option value="INFO">Info</option>
<option value="DEBUG">Debug</option>
<option value="all" data-i18n="logs.allLevels">All Levels</option>
<option value="ERROR" data-i18n="logs.error">Error</option>
<option value="WARN" data-i18n="logs.warn">Warn</option>
<option value="INFO" data-i18n="logs.info">Info</option>
<option value="DEBUG" data-i18n="logs.debug">Debug</option>
</select>
<input type="text" id="logs-target-filter" placeholder="Filter by target...">
<label class="logs-checkbox"><input type="checkbox" id="logs-autoscroll" checked> Auto-scroll</label>
<button id="logs-pause-btn" onclick="toggleLogsPause()">Pause</button>
<button onclick="clearLogs()">Clear</button>
<label class="logs-checkbox"><input type="checkbox" id="logs-autoscroll" checked> <span data-i18n="logs.autoScroll">Auto-scroll</span></label>
<button id="logs-pause-btn" onclick="toggleLogsPause()" data-i18n="logs.pause">Pause</button>
<button onclick="clearLogs()" data-i18n="logs.clear">Clear</button>
</div>
<div class="logs-output" id="logs-output"></div>
</div>
@@ -221,20 +243,20 @@
<table class="routines-table" id="routines-table">
<thead>
<tr>
<th>Name</th>
<th>Trigger</th>
<th>Action</th>
<th>Last Run</th>
<th>Next Run</th>
<th>Runs</th>
<th>Status</th>
<th>Actions</th>
<th data-i18n="routines.name">Name</th>
<th data-i18n="routines.trigger">Trigger</th>
<th data-i18n="routines.action">Action</th>
<th data-i18n="routines.lastRun">Last Run</th>
<th data-i18n="routines.nextRun">Next Run</th>
<th data-i18n="routines.runs">Runs</th>
<th data-i18n="routines.status">Status</th>
<th data-i18n="routines.actions">Actions</th>
</tr>
</thead>
<tbody id="routines-tbody"></tbody>
</table>
<div class="empty-state" id="routines-empty" style="display:none">
No routines configured. Ask the assistant to create one.
<span data-i18n="routines.noConfigured">No routines configured. Ask the assistant to create one.</span>
</div>
<div class="routine-detail" id="routine-detail" style="display:none"></div>
</div>
@@ -244,44 +266,44 @@
<div class="tab-panel" id="tab-extensions">
<div class="extensions-container">
<div class="extensions-section">
<h3>Installed Extensions</h3>
<h3 data-i18n="extensions.installed">Installed Extensions</h3>
<div class="extensions-list" id="extensions-list">
<div class="empty-state">Loading extensions...</div>
<div class="empty-state" data-i18n="common.loading">Loading...</div>
</div>
</div>
<div class="extensions-section" id="available-wasm-section">
<h3>Available WASM Extensions</h3>
<h3 data-i18n="extensions.available">Available WASM Extensions</h3>
<div class="extensions-list" id="available-wasm-list">
<div class="empty-state">Loading...</div>
<div class="empty-state" data-i18n="common.loading">Loading...</div>
</div>
</div>
<div class="extensions-section">
<h3>Install WASM Extension</h3>
<h3 data-i18n="extensions.installWasm">Install WASM Extension</h3>
<div class="ext-install-form">
<input type="text" id="wasm-install-name" placeholder="Extension name">
<input type="text" id="wasm-install-name" data-i18n-placeholder="common.name" placeholder="Extension name">
<input type="text" id="wasm-install-url" placeholder="URL to .tar.gz bundle">
<button onclick="installWasmExtension()">Install</button>
<button onclick="installWasmExtension()" data-i18n="extensions.install">Install</button>
</div>
</div>
<div class="extensions-section">
<h3>MCP Servers</h3>
<h3 data-i18n="mcp.servers">MCP Servers</h3>
<div class="extensions-list" id="mcp-servers-list">
<div class="empty-state">Loading...</div>
<div class="empty-state" data-i18n="common.loading">Loading...</div>
</div>
<h4>Add Custom MCP Server</h4>
<h4 data-i18n="mcp.addCustom">Add Custom MCP Server</h4>
<div class="ext-install-form">
<input type="text" id="mcp-install-name" placeholder="Server name">
<input type="text" id="mcp-install-name" data-i18n-placeholder="common.name" placeholder="Server name">
<input type="text" id="mcp-install-url" placeholder="MCP server URL (https://...)">
<button onclick="addMcpServer()">Add</button>
<button onclick="addMcpServer()" data-i18n="mcp.add">Add</button>
</div>
</div>
<div class="extensions-section">
<h3>Registered Tools</h3>
<h3 data-i18n="tools.registered">Registered Tools</h3>
<table class="tools-table" id="tools-table">
<thead><tr><th>Name</th><th>Description</th></tr></thead>
<thead><tr><th data-i18n="tools.name">Name</th><th data-i18n="tools.description">Description</th></tr></thead>
<tbody id="tools-tbody"></tbody>
</table>
<div class="empty-state" id="tools-empty" style="display:none">No tools registered</div>
<div class="empty-state" id="tools-empty" style="display:none" data-i18n="tools.empty">No tools registered</div>
</div>
</div>
</div>
@@ -290,25 +312,25 @@
<div class="tab-panel" id="tab-skills">
<div class="extensions-container">
<div class="extensions-section">
<h3>Search ClawHub</h3>
<h3 data-i18n="skills.searchClawHub">Search ClawHub</h3>
<div class="skill-search-box">
<input type="text" id="skill-search-input" placeholder="Search for skills...">
<button onclick="searchClawHub()">Search</button>
<input type="text" id="skill-search-input" data-i18n-placeholder="skills.searchPlaceholder" placeholder="Search...">
<button onclick="searchClawHub()" data-i18n="skills.search">Search</button>
</div>
<div class="extensions-list" id="skill-search-results"></div>
</div>
<div class="extensions-section">
<h3>Installed Skills</h3>
<h3 data-i18n="skills.installed">Installed Skills</h3>
<div class="extensions-list" id="skills-list">
<div class="empty-state">Loading skills...</div>
<div class="empty-state" data-i18n="skills.loading">Loading skills...</div>
</div>
</div>
<div class="extensions-section">
<h3>Install Skill by URL</h3>
<h3 data-i18n="skills.installByUrl">Install Skill by URL</h3>
<div class="ext-install-form">
<input type="text" id="skill-install-name" placeholder="Skill name or slug">
<input type="text" id="skill-install-url" placeholder="HTTPS URL to SKILL.md (optional)">
<button onclick="installSkillFromForm()">Install</button>
<input type="text" id="skill-install-name" data-i18n-placeholder="skills.namePlaceholder" placeholder="Skill name or slug">
<input type="text" id="skill-install-url" data-i18n-placeholder="skills.urlPlaceholder" placeholder="HTTPS URL to SKILL.md (optional)">
<button onclick="installSkillFromForm()" data-i18n="extensions.install">Install</button>
</div>
</div>
</div>
@@ -317,5 +339,6 @@
<div id="toasts"></div>
<script src="/app.js"></script>
<script src="/i18n-app.js"></script>
</body>
</html>
+55
View File
@@ -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);