mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
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:
+1
-1
@@ -229,7 +229,7 @@ WASM ──► 白名单 ──► 泄露扫描 ──► 凭据 ──► 执
|
||||
│ │ │ │
|
||||
│ ┌──────────▼────┐ ┌──▼───────────────┐ │
|
||||
│ │ 调度器 │ │ 定时任务引擎 │ │
|
||||
│ │ (并行任务) │ │(cron, 事件, wh) │ │
|
||||
│ │ (并行任务) │ │(cron, 事件, Webhook)│ │
|
||||
│ └──────┬────────┘ └────────┬─────────┘ │
|
||||
│ │ │ │
|
||||
│ ┌─────────────┼────────────────────┘ │
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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': '取消',
|
||||
});
|
||||
@@ -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">«</button>
|
||||
<button class="thread-toggle-btn" id="thread-toggle-btn" onclick="toggleThreadSidebar()" data-i18n="chat.toggleSidebar"
|
||||
data-i18n-attr="title" title="Toggle sidebar">«</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">📎</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">📎</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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user