From 531e0beb96c877c7f89ac8e47e628aaa8093ac1d Mon Sep 17 00:00:00 2001 From: MoringstarsH <2671148074@qq.com> Date: Thu, 28 May 2026 19:17:08 +0800 Subject: [PATCH 1/8] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E4=B8=AD=E6=96=87?= =?UTF-8?q?=E7=95=8C=E9=9D=A2=E5=88=87=E6=8D=A2=E5=92=8C=E7=99=BD=E5=A4=A9?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- static/channels.js | 36 +- static/chat.js | 338 ++++++++-------- static/i18n.js | 877 ++++++++++++++++++++++++++++++++++++++++++ static/index.html | 173 +++++---- static/jobs.css | 19 + static/jobs.js | 82 ++-- static/rules-panel.js | 40 +- static/sessions.css | 4 + static/sessions.js | 193 ++++++---- static/style.css | 122 +++++- static/theme.js | 46 +++ 11 files changed, 1540 insertions(+), 390 deletions(-) create mode 100644 static/i18n.js create mode 100644 static/theme.js diff --git a/static/channels.js b/static/channels.js index a74b7332..86b5f15b 100644 --- a/static/channels.js +++ b/static/channels.js @@ -63,14 +63,14 @@ function renderChannelTabs() { const editBtn = document.createElement('button'); editBtn.className = 'ch-edit-btn'; - editBtn.title = 'Rename'; + editBtn.title = t('channel.rename.title'); editBtn.innerHTML = ''; editBtn.onclick = (e) => { e.stopPropagation(); showChannelRenameDialog(name); }; actions.appendChild(editBtn); const delBtn = document.createElement('button'); delBtn.className = 'ch-delete-btn'; - delBtn.title = 'Delete'; + delBtn.title = t('channel.delete.title'); delBtn.innerHTML = ''; delBtn.onclick = (e) => { e.stopPropagation(); deleteChannel(name); }; actions.appendChild(delBtn); @@ -142,13 +142,13 @@ function renderChannelSidebar() { actions.className = 'channel-sidebar-row-actions'; const editBtn = document.createElement('button'); - editBtn.title = 'Rename'; + editBtn.title = t('channel.rename.title'); editBtn.innerHTML = ''; editBtn.onclick = (e) => { e.stopPropagation(); _showSidebarRenameDialog(name); }; actions.appendChild(editBtn); const delBtn = document.createElement('button'); - delBtn.title = 'Delete'; + delBtn.title = t('channel.delete.title'); delBtn.innerHTML = ''; delBtn.onclick = (e) => { e.stopPropagation(); _sidebarConfirmDelete(name, row, label); }; actions.appendChild(delBtn); @@ -198,7 +198,7 @@ function _showSidebarRenameDialog(oldName) { const confirm = document.createElement('button'); confirm.className = 'confirm-btn'; confirm.innerHTML = '✓'; - confirm.title = 'Rename'; + confirm.title = t('channel.rename.title'); confirm.onclick = () => { const newName = input.value.trim().toLowerCase(); if (!newName || !/^[a-z0-9][a-z0-9\-]{0,19}$/.test(newName)) return; @@ -217,7 +217,7 @@ function _showSidebarRenameDialog(oldName) { const cancel = document.createElement('button'); cancel.className = 'cancel-btn'; cancel.innerHTML = '✕'; - cancel.title = 'Cancel'; + cancel.title = t('common.cancel'); cancel.onclick = cleanup; wrapper.appendChild(cancel); @@ -245,7 +245,7 @@ function _sidebarConfirmDelete(name, row, label) { const originalOnclick = row.onclick; row.classList.add('confirm-delete'); - label.textContent = `delete #${name}?`; + label.textContent = t('channel.deleteConfirm', { name }); if (actions) actions.style.display = 'none'; const confirmBar = document.createElement('span'); @@ -253,11 +253,11 @@ function _sidebarConfirmDelete(name, row, label) { confirmBar.style.display = 'flex'; const tickBtn = document.createElement('button'); - tickBtn.title = 'Confirm delete'; + tickBtn.title = t('common.confirm'); tickBtn.innerHTML = ''; const crossBtn = document.createElement('button'); - crossBtn.title = 'Cancel'; + crossBtn.title = t('common.cancel'); crossBtn.innerHTML = ''; confirmBar.appendChild(tickBtn); @@ -360,14 +360,14 @@ function showChannelCreateDialog() { const confirm = document.createElement('button'); confirm.className = 'confirm-btn'; confirm.innerHTML = '✓'; - confirm.title = 'Create'; + confirm.title = t('common.create'); confirm.onclick = () => { _submitInlineCreate(input, wrapper); if (addBtn) addBtn.style.display = ''; }; wrapper.appendChild(confirm); const cancel = document.createElement('button'); cancel.className = 'cancel-btn'; cancel.innerHTML = '✕'; - cancel.title = 'Cancel'; + cancel.title = t('common.cancel'); cancel.onclick = cleanup; wrapper.appendChild(cancel); @@ -425,7 +425,7 @@ function showChannelRenameDialog(oldName) { const confirm = document.createElement('button'); confirm.className = 'confirm-btn'; confirm.innerHTML = '✓'; - confirm.title = 'Rename'; + confirm.title = t('channel.rename.title'); confirm.onclick = () => { const newName = input.value.trim().toLowerCase(); if (!newName || !/^[a-z0-9][a-z0-9\-]{0,19}$/.test(newName)) return; @@ -444,7 +444,7 @@ function showChannelRenameDialog(oldName) { const cancel = document.createElement('button'); cancel.className = 'cancel-btn'; cancel.innerHTML = '✕'; - cancel.title = 'Cancel'; + cancel.title = t('common.cancel'); cancel.onclick = cleanup; wrapper.appendChild(cancel); @@ -482,7 +482,7 @@ function deleteChannel(name) { tab.classList.add('confirm-delete'); tab.classList.remove('editing'); - label.textContent = `delete #${name}?`; + label.textContent = t('channel.deleteConfirm', { name }); if (actions) actions.style.display = 'none'; const confirmBar = document.createElement('span'); @@ -490,12 +490,12 @@ function deleteChannel(name) { const tickBtn = document.createElement('button'); tickBtn.className = 'ch-confirm-yes'; - tickBtn.title = 'Confirm delete'; + tickBtn.title = t('common.confirm'); tickBtn.innerHTML = ''; const crossBtn = document.createElement('button'); crossBtn.className = 'ch-confirm-no'; - crossBtn.title = 'Cancel'; + crossBtn.title = t('common.cancel'); crossBtn.innerHTML = ''; confirmBar.appendChild(tickBtn); @@ -577,12 +577,12 @@ function _updateSupportLabel() { if (!label) return; const inSidebar = document.body.classList.contains('channels-in-sidebar'); if (!inSidebar) { - label.textContent = ' Support development'; + label.textContent = t('header.support'); return; } const panel = document.getElementById('channel-sidebar'); const w = panel ? panel.offsetWidth : 200; - label.textContent = w < 200 ? ' Support' : ' Support development'; + label.textContent = w < 200 ? t('header.support.short') : t('header.support'); } function setupChannelSidebarGrip() { diff --git a/static/chat.js b/static/chat.js index 94c5c373..a9a89ff1 100644 --- a/static/chat.js +++ b/static/chat.js @@ -66,14 +66,14 @@ function enableDragScroll(el) { // --- Notification sounds --- const SOUND_OPTIONS = [ - { value: 'soft-chime', label: 'Soft Chime' }, - { value: 'bright-ping', label: 'Bright Ping' }, - { value: 'gentle-pop', label: 'Gentle Pop' }, - { value: 'alert-tone', label: 'Alert Tone' }, - { value: 'pluck', label: 'Pluck' }, - { value: 'click', label: 'Click' }, - { value: 'warm-bell', label: 'Warm Bell' }, - { value: 'none', label: 'None' }, + { value: 'soft-chime', labelKey: 'sound.softChime' }, + { value: 'bright-ping', labelKey: 'sound.brightPing' }, + { value: 'gentle-pop', labelKey: 'sound.gentlePop' }, + { value: 'alert-tone', labelKey: 'sound.alertTone' }, + { value: 'pluck', labelKey: 'sound.pluck' }, + { value: 'click', labelKey: 'sound.click' }, + { value: 'warm-bell', labelKey: 'sound.warmBell' }, + { value: 'none', labelKey: 'sound.none' }, ]; const DEFAULT_SOUND = 'soft-chime'; const CROSS_CHANNEL_SOUND = 'pluck'; @@ -116,8 +116,8 @@ function buildSoundSettings() { row.className = 'sound-row'; const label = document.createElement('span'); label.className = 'sound-label'; - label.textContent = name === 'default' ? 'Default sound' - : name === 'cross-channel' ? 'Background alerts' + label.textContent = name === 'default' ? t('sound.default') + : name === 'cross-channel' ? t('sound.background') : (agentConfig[name]?.label || name); const select = document.createElement('select'); select.className = 'sound-select'; @@ -127,7 +127,7 @@ function buildSoundSettings() { for (const opt of SOUND_OPTIONS) { const o = document.createElement('option'); o.value = opt.value; - o.textContent = opt.label; + o.textContent = t(opt.labelKey); if (currentVal === opt.value) o.selected = true; select.appendChild(o); } @@ -135,7 +135,7 @@ function buildSoundSettings() { if (name !== 'default' && name !== 'cross-channel') { const o = document.createElement('option'); o.value = ''; - o.textContent = 'Use default'; + o.textContent = t('sound.useDefault'); if (!soundPrefs[name]) o.selected = true; select.insertBefore(o, select.firstChild); } @@ -207,9 +207,9 @@ async function checkForUpdate() { return; } - const label = data.state === 'upstream_update' ? 'Upstream update available' : 'Update available'; + const label = data.state === 'upstream_update' ? t('update.upstream') : t('update.available'); pill.href = data.url || 'https://github.com/bcurts/agentchattr/releases'; - pill.innerHTML = `${label}`; + pill.innerHTML = `${label}`; pill.classList.remove('hidden'); } catch { // Silent fail -- version check should never block the UI @@ -347,17 +347,17 @@ function addCodeCopyButtons(container) { if (pre.querySelector('.code-copy-btn')) continue; const btn = document.createElement('button'); btn.className = 'code-copy-btn'; - btn.textContent = 'copy'; + btn.textContent = t('timeline.copyButton'); btn.onclick = async (e) => { e.stopPropagation(); const code = pre.querySelector('code')?.textContent || pre.textContent; try { await navigator.clipboard.writeText(code); - btn.textContent = 'copied!'; - setTimeout(() => { btn.textContent = 'copy'; }, 1500); + btn.textContent = t('timeline.copied'); + setTimeout(() => { btn.textContent = t('timeline.copyButton'); }, 1500); } catch (err) { - btn.textContent = 'failed'; - setTimeout(() => { btn.textContent = 'copy'; }, 1500); + btn.textContent = t('timeline.failed'); + setTimeout(() => { btn.textContent = t('timeline.copyButton'); }, 1500); } }; pre.style.position = 'relative'; @@ -393,7 +393,7 @@ function connectWebSocket() { const choicesEl = existing.querySelector('.decision-choices'); const meta = updated.metadata || {}; if (choicesEl && meta.resolved) { - choicesEl.innerHTML = `
You chose: ${escapeHtml(meta.chosen || '')}
`; + choicesEl.innerHTML = `
${escapeHtml(t('timeline.youChose', { choice: meta.chosen || '' }))}
`; } } } @@ -633,8 +633,8 @@ function formatDateDivider(dateStr) { const yesterday = new Date(today); yesterday.setDate(yesterday.getDate() - 1); - if (date.toDateString() === today.toDateString()) return 'Today'; - if (date.toDateString() === yesterday.toDateString()) return 'Yesterday'; + if (date.toDateString() === today.toDateString()) return t('timeline.today'); + if (date.toDateString() === yesterday.toDateString()) return t('timeline.yesterday'); return date.toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' @@ -676,11 +676,11 @@ function appendMessage(msg) { if (msg.type === 'join' || msg.type === 'leave') { el.classList.add('join-msg'); const color = getColor(msg.sender); - el.innerHTML = `${escapeHtml(msg.sender)} ${msg.type === 'join' ? 'joined' : 'left'}`; + el.innerHTML = `${escapeHtml(msg.sender)} ${msg.type === 'join' ? t('timeline.joined') : t('timeline.left')}`; } else if (msg.type === 'summary') { el.classList.add('summary-msg'); const color = getColor(msg.sender); - el.innerHTML = `
Summary${escapeHtml(msg.sender)}
${escapeHtml(msg.text)}
`; + el.innerHTML = `
${t('timeline.summary')}${escapeHtml(msg.sender)}
${escapeHtml(msg.text)}
`; } else if (msg.type === 'job_proposal') { el.classList.add('proposal-msg'); const meta = msg.metadata || {}; @@ -695,22 +695,22 @@ function appendMessage(msg) { el.innerHTML = `
- Job Proposal + ${t('jobs.proposal')} ${escapeHtml(msg.sender)}
${title}
${body ? `
${body}
` : ''} ${isPending ? `
- - - + + +
` : ` -
${status === 'accepted' ? 'Accepted' : 'Dismissed'}
+
${status === 'accepted' ? t('jobs.accepted') : t('jobs.dismissed')}
`}
- ${!isPending ? `
` : ''}`; + ${!isPending ? `
` : ''}`; } else if (msg.type === 'rule_proposal') { el.classList.add('proposal-msg'); const meta = msg.metadata || {}; @@ -721,21 +721,21 @@ function appendMessage(msg) { el.innerHTML = `
- Rule Proposal + ${t('rules.proposal')} ${escapeHtml(msg.sender)}
${ruleText}
${isPending ? `
- - - + + +
` : ` -
${status === 'activated' ? 'Activated' : status === 'drafted' ? 'Added to drafts' : 'Dismissed'}
+
${status === 'activated' ? t('rules.activated') : status === 'drafted' ? t('rules.addedDrafts') : t('jobs.dismissed')}
`}
- ${!isPending ? `
` : ''}`; + ${!isPending ? `
` : ''}`; } else if (window._messageRenderers && window._messageRenderers[msg.type]) { window._messageRenderers[msg.type](el, msg); } else if (msg.type === 'system' || msg.sender === 'system') { @@ -796,21 +796,21 @@ function appendMessage(msg) { el.dataset.rawText = msg.text; const senderRole = _agentRoles[msg.sender] || ''; const roleClass = senderRole ? 'bubble-role has-role' : 'bubble-role'; - const rolePillHtml = !isSelf ? `` : ''; + const rolePillHtml = !isSelf ? `` : ''; // Inline decision choices (if present) let choicesHtml = ''; const meta = msg.metadata || {}; const choicesList = meta.choices || []; if (msg.type === 'decision' && choicesList.length > 0) { if (meta.resolved) { - choicesHtml = `
You chose: ${escapeHtml(meta.chosen || '')}
`; + choicesHtml = `
${escapeHtml(t('timeline.youChose', { choice: meta.chosen || '' }))}
`; } else { choicesHtml = '
' + choicesList.map(c => `` ).join('') + '
'; } } - el.innerHTML = `
${isSelf ? '' : avatarHtml}
${replyHtml}
${escapeHtml(msg.sender)}${rolePillHtml}${msg.time || ''}
${textHtml}
${choicesHtml}${attachmentsHtml}
`; + el.innerHTML = `
${isSelf ? '' : avatarHtml}
${replyHtml}
${escapeHtml(msg.sender)}${rolePillHtml}${msg.time || ''}
${textHtml}
${choicesHtml}${attachmentsHtml}
`; if (todoStatus) el.classList.add('msg-todo', `msg-todo-${todoStatus}`); if (msg.metadata?.session_output) el.classList.add('session-output'); @@ -1173,6 +1173,22 @@ const ROLE_PRESETS = [ { label: 'Hype', emoji: '🎉' }, ]; +function roleDisplayLabel(label) { + const keyByLabel = { + 'Planner': 'role.planner', + 'Designer': 'role.designer', + 'Architect': 'role.architect', + 'Builder': 'role.builder', + 'Reviewer': 'role.reviewer', + 'Researcher': 'role.researcher', + 'Red Team': 'role.redTeam', + 'Wry': 'role.wry', + 'Unhinged': 'role.unhinged', + 'Hype': 'role.hype', + }; + return keyByLabel[label] ? t(keyByLabel[label]) : label; +} + // --- Agent naming lightbox --- const _pendingNameQueue = []; @@ -1208,8 +1224,8 @@ function showAgentNameModal(opts) {

- - + +
`; // Close on backdrop click @@ -1236,11 +1252,11 @@ function showAgentNameModal(opts) { if (opts.mode === 'pending') { const familyLabel = (baseColors[opts.base] || {}).label || opts.base || 'agent'; - titleEl.textContent = 'Name this agent'; - subtitleEl.textContent = `A new ${familyLabel} instance connected`; + titleEl.textContent = t('agent.nameThis'); + subtitleEl.textContent = t('agent.newInstance', { family: familyLabel }); } else { - titleEl.textContent = 'Rename agent'; - subtitleEl.textContent = `Current ID: @${opts.name}`; + titleEl.textContent = t('agent.rename'); + subtitleEl.textContent = t('agent.currentId', { name: opts.name }); } inputEl.value = opts.label; @@ -1299,7 +1315,7 @@ function showPillPopover(pillEl, opts) { const currentRole = (_agentRoles[opts.name] || '').toLowerCase(); const roleChipsHtml = ROLE_PRESETS.map(p => - `` + `` ).join(''); const customChipsHtml = (window.customRoles || []) .filter(r => r && !ROLE_PRESETS.some(p => p.label.toLowerCase() === r.toLowerCase())) @@ -1309,21 +1325,21 @@ function showPillPopover(pillEl, opts) { popover.innerHTML = `
- +
- +
- +
- + ${roleChipsHtml} ${customChipsHtml}
- +
${(() => { @@ -1339,15 +1355,15 @@ function showPillPopover(pillEl, opts) { const matchesSwatch = swatches.some(c => current === c.toLowerCase()); const colorInputVal = (current && !current.startsWith('var(')) ? current : (opts.color || '#888888'); return `
- +
${swatches.map(c => `` ).join('')}
- - + +
`; })()} @@ -1531,14 +1547,14 @@ function showBubbleRolePicker(btn, agentName) { // None chip const noneChip = document.createElement('button'); noneChip.className = 'role-preset-chip' + (!currentRole ? ' active' : ''); - noneChip.textContent = 'None'; + noneChip.textContent = t('common.none'); noneChip.addEventListener('click', () => { _setRole(agentName, ''); closePicker(); }); picker.appendChild(noneChip); for (const preset of ROLE_PRESETS) { const chip = document.createElement('button'); chip.className = 'role-preset-chip' + (currentRole === preset.label.toLowerCase() ? ' active' : ''); - chip.textContent = `${preset.emoji} ${preset.label}`; + chip.textContent = `${preset.emoji} ${roleDisplayLabel(preset.label)}`; chip.addEventListener('click', () => { _setRole(agentName, preset.label); closePicker(); }); picker.appendChild(chip); } @@ -1559,7 +1575,7 @@ function showBubbleRolePicker(btn, agentName) { const customInput = document.createElement('input'); customInput.type = 'text'; customInput.className = 'bubble-role-input'; - customInput.placeholder = 'Custom...'; + customInput.placeholder = t('agent.custom.placeholder'); customInput.maxLength = 20; customInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') { @@ -1793,7 +1809,7 @@ function _clearClearChatConfirm() { const confirmEl = document.getElementById('clear-chat-confirm'); if (confirmEl) confirmEl.remove(); if (btn) { - btn.textContent = 'Clear Chat'; + btn.textContent = t('timeline.clearChat'); btn.classList.remove('confirming'); } document.removeEventListener('click', _clearChatOutsideClick, true); @@ -1823,17 +1839,17 @@ function clearChat() { return; } - btn.textContent = 'Clear Chat?'; + btn.textContent = t('timeline.clearChatConfirm'); btn.classList.add('confirming'); const confirmWrap = document.createElement('span'); confirmWrap.id = 'clear-chat-confirm'; confirmWrap.className = 'session-inline-confirm'; confirmWrap.innerHTML = ` - - `; @@ -1936,7 +1952,7 @@ async function exportHistory() { }); if (!resp.ok) { const err = await resp.json().catch(() => ({})); - showToast(err.error || 'Export failed', 'error'); + showToast(err.error || t('settings.exportFailed'), 'error'); return; } const blob = await resp.blob(); @@ -1950,9 +1966,9 @@ async function exportHistory() { URL.revokeObjectURL(a.href); const counts = []; // Parse counts from manifest if possible, or just show success - showToast('History exported', 'success'); + showToast(t('settings.exportSuccess'), 'success'); } catch (e) { - showToast('Export failed: ' + e.message, 'error'); + showToast(t('settings.exportFailed') + ': ' + e.message, 'error'); } } @@ -1962,7 +1978,7 @@ async function importHistory(input) { input.value = ''; // Reset so same file can be picked again const btn = document.getElementById('import-history-btn'); const origText = btn.textContent; - btn.textContent = 'Importing...'; + btn.textContent = t('settings.importing'); btn.disabled = true; try { const formData = new FormData(); @@ -1974,25 +1990,25 @@ async function importHistory(input) { }); const data = await resp.json(); if (!resp.ok || !data.ok) { - showToast(data.error || 'Import failed', 'error'); + showToast(data.error || t('settings.importFailed'), 'error'); return; } // Build result message const parts = []; const s = data.sections || {}; - if (s.messages) parts.push(`${s.messages.created} messages`); + if (s.messages) parts.push(`${s.messages.created} ${t('settings.messages')}`); if (s.jobs) parts.push(`${s.jobs.created} jobs`); if (s.rules) parts.push(`${s.rules.created} rules`); if (s.summaries) parts.push(`${s.summaries.created + (s.summaries.updated || 0)} summaries`); const dupes = (s.messages?.duplicates || 0) + (s.jobs?.duplicates || 0) + (s.rules?.duplicates || 0); - let msg = 'Imported ' + parts.join(', '); - if (dupes > 0) msg += ` (${dupes} duplicates skipped)`; + let msg = t('settings.imported', { items: parts.join(', ') }); + if (dupes > 0) msg += ` (${t('settings.duplicateSkipped', { count: dupes })})`; if (data.warnings && data.warnings.length > 0) { - msg += `. ${data.warnings.length} warning(s)`; + msg += `. ${t('settings.warnings', { count: data.warnings.length })}`; } showToast(msg, 'success'); } catch (e) { - showToast('Import failed: ' + e.message, 'error'); + showToast(t('settings.importFailed') + ': ' + e.message, 'error'); } finally { btn.textContent = origText; btn.disabled = false; @@ -2049,6 +2065,22 @@ const SLASH_COMMANDS = [ { cmd: '/clear', desc: 'Clear messages in current channel', broadcast: false }, ]; +function slashDescription(item) { + const keyByCmd = { + '/artchallenge': 'slash.artchallenge', + '/hatmaking': 'slash.hatmaking', + '/roastreview': 'slash.roastreview', + '/poetry haiku': 'slash.haiku', + '/poetry limerick': 'slash.limerick', + '/poetry sonnet': 'slash.sonnet', + '/summary': 'slash.summary', + '/summarise': 'slash.summarise', + '/continue': 'slash.continue', + '/clear': 'slash.clear', + }; + return keyByCmd[item.cmd] ? t(keyByCmd[item.cmd]) : item.desc; +} + let slashMenuIndex = 0; let slashMenuVisible = false; let mentionMenuIndex = 0; @@ -2078,7 +2110,7 @@ function updateSlashMenu(text) { matches.forEach((item, i) => { const row = document.createElement('div'); row.className = 'slash-item' + (i === slashMenuIndex ? ' active' : ''); - row.innerHTML = `${escapeHtml(item.cmd)}${escapeHtml(item.desc)}`; + row.innerHTML = `${escapeHtml(item.cmd)}${escapeHtml(slashDescription(item))}`; row.addEventListener('mousedown', (e) => { e.preventDefault(); selectSlashCommand(item.cmd); @@ -2581,7 +2613,7 @@ function renderReplyPreview() { } const truncated = replyingTo.text.length > 100 ? replyingTo.text.slice(0, 100) + '...' : replyingTo.text; const color = getColor(replyingTo.sender); - container.innerHTML = `replying to ${escapeHtml(replyingTo.sender)}: ${escapeHtml(truncated)} `; + container.innerHTML = `${t('timeline.replyingTo')} ${escapeHtml(replyingTo.sender)}: ${escapeHtml(truncated)} `; } function cancelReply() { @@ -2601,9 +2633,9 @@ function scrollToMessage(msgId) { // --- Todos --- function todoStatusLabel(status) { - if (!status) return 'pin'; - if (status === 'todo') return 'done?'; - return 'unpin'; + if (!status) return t('pins.pin'); + if (status === 'todo') return t('pins.done?'); + return t('pins.unpin'); } function todoCycle(msgId) { @@ -2755,7 +2787,7 @@ function showDeleteBar() { if (!bar) { bar = document.createElement('div'); bar.id = 'delete-bar'; - bar.innerHTML = ``; + bar.innerHTML = ``; const footer = document.querySelector('footer'); footer.parentNode.insertBefore(bar, footer); } @@ -2765,10 +2797,10 @@ function showDeleteBar() { function updateDeleteBar() { const count = deleteSelected.size; const span = document.querySelector('.delete-bar-count'); - if (span) span.textContent = count > 0 ? `${count} selected` : 'Select messages'; + if (span) span.textContent = count > 0 ? t('timeline.selected', { count }) : t('timeline.selectMessages'); const btn = document.querySelector('.delete-bar-confirm'); if (btn) { - btn.textContent = count > 0 ? `Delete (${count})` : 'Delete'; + btn.textContent = count > 0 ? t('timeline.deleteCount', { count }) : t('common.delete'); btn.disabled = count === 0; } } @@ -2868,7 +2900,7 @@ function renderTodosPanel() { const todoIds = Object.keys(todos); if (todoIds.length === 0) { - list.innerHTML = '
No pinned messages
'; + list.innerHTML = `
${t('pins.empty')}
`; return; } @@ -2892,7 +2924,7 @@ function renderTodosPanel() { const checkClass = status === 'done' ? 'todo-check done' : 'todo-check'; const msgChannel = el.dataset.channel || 'general'; - item.innerHTML = `#${msgChannel} ${escapeHtml(time)} ${escapeHtml(sender)} ${escapeHtml(text)}`; + item.innerHTML = `#${msgChannel} ${escapeHtml(time)} ${escapeHtml(sender)} ${escapeHtml(text)}`; item.addEventListener('click', (e) => { if (e.target.closest('button')) return; // Cross-channel pin: switch channel if needed @@ -3239,22 +3271,22 @@ function renderSchedulesBar() { const s = schedulesList[0]; const isPaused = s.active === false; const targetStr = (s.targets || []).map(t => '@' + t).join(', '); - countEl.textContent = `${targetStr} "${s.prompt}"` + (isPaused ? ' (paused)' : ''); + countEl.textContent = `${targetStr} "${s.prompt}"` + (isPaused ? t('schedule.pausedSuffix') : ''); const nextStr = (!isPaused && s.next_run) ? formatScheduleTime(s.next_run) : ''; nextEl.textContent = nextStr - ? formatScheduleInterval(s) + ' -- next in ' + nextStr + ? formatScheduleInterval(s) + ' -- ' + t('schedule.nextIn', { time: nextStr }) : formatScheduleInterval(s); } else if (active.length > 0) { const paused = schedulesList.length - active.length; - const parts = [`${active.length} active`]; - if (paused > 0) parts.push(`${paused} paused`); + const parts = [t('schedule.activeCount', { count: active.length })]; + if (paused > 0) parts.push(t('schedule.pausedCount', { count: paused })); countEl.textContent = parts.join(', '); const futureRuns = active.filter(s => s.next_run && s.next_run * 1000 > Date.now()).map(s => s.next_run); const nextTimeStr = futureRuns.length > 0 ? formatScheduleTime(Math.min(...futureRuns)) : ''; - nextEl.textContent = nextTimeStr ? 'next in ' + nextTimeStr : ''; + nextEl.textContent = nextTimeStr ? t('schedule.nextIn', { time: nextTimeStr }) : ''; } else { const paused = schedulesList.length; - countEl.textContent = `${paused} paused schedule${paused !== 1 ? 's' : ''}`; + countEl.textContent = t('schedule.paused', { count: paused, plural: paused !== 1 ? 's' : '' }); nextEl.textContent = ''; } @@ -3269,13 +3301,13 @@ function renderSchedulesBar() { pauseBtn.innerHTML = isPaused ? '' : ''; - pauseBtn.title = isPaused ? 'Resume' : 'Pause'; + pauseBtn.title = isPaused ? t('schedule.resume') : t('schedule.pause'); pauseBtn.onclick = () => toggleSchedule(s.id); const trashBtn = document.createElement('button'); trashBtn.className = 'schedule-delete-inline'; trashBtn.innerHTML = ''; - trashBtn.title = 'Delete'; + trashBtn.title = t('common.delete'); trashBtn.onclick = () => deleteSchedule(s.id); summaryDiv.append(pauseBtn, trashBtn); @@ -3283,7 +3315,7 @@ function renderSchedulesBar() { // Collapse expanded list — summary has everything bar.classList.remove('expanded'); document.getElementById('schedules-list')?.classList.add('hidden'); - if (seeAllLink) seeAllLink.textContent = 'See all'; + if (seeAllLink) seeAllLink.textContent = t('schedule.seeAll'); } else { if (seeAllLink) seeAllLink.style.display = ''; } @@ -3314,13 +3346,13 @@ function renderSchedulesBar() { const toggleBtn = document.createElement('button'); toggleBtn.className = 'schedule-toggle'; toggleBtn.textContent = s.active === false ? '▶' : '⏸'; - toggleBtn.title = s.active === false ? 'Resume' : 'Pause'; + toggleBtn.title = s.active === false ? t('schedule.resume') : t('schedule.pause'); toggleBtn.onclick = () => toggleSchedule(s.id); const deleteBtn = document.createElement('button'); deleteBtn.className = 'schedule-delete'; deleteBtn.innerHTML = ''; - deleteBtn.title = 'Delete'; + deleteBtn.title = t('common.delete'); deleteBtn.onclick = () => deleteSchedule(s.id); row.append(targets, prompt, interval, toggleBtn, deleteBtn); @@ -3344,11 +3376,11 @@ function formatScheduleTime(ts) { } function formatScheduleInterval(s) { - if (s.daily_at) return 'daily at ' + s.daily_at; + if (s.daily_at) return t('schedule.dailyAt', { time: s.daily_at }); const sec = s.interval_seconds || 0; - if (sec < 3600) return 'every ' + Math.round(sec / 60) + 'm'; - if (sec < 86400) return 'every ' + Math.round(sec / 3600) + 'h'; - return 'every ' + Math.round(sec / 86400) + 'd'; + if (sec < 3600) return t('schedule.everyShort', { value: Math.round(sec / 60), unit: 'm' }); + if (sec < 86400) return t('schedule.everyShort', { value: Math.round(sec / 3600), unit: 'h' }); + return t('schedule.everyShort', { value: Math.round(sec / 86400), unit: 'd' }); } function toggleSchedulesExpand() { @@ -3358,7 +3390,7 @@ function toggleSchedulesExpand() { const expanded = bar.classList.toggle('expanded'); list.classList.toggle('hidden', !expanded); const link = bar.querySelector('.schedules-see-all'); - if (link) link.textContent = expanded ? 'Hide' : 'See all'; + if (link) link.textContent = expanded ? t('schedule.hide') : t('schedule.seeAll'); } async function toggleSchedule(id) { @@ -3446,14 +3478,14 @@ function populateScheduleDropdowns() { // Date options: Today, Tomorrow, then next 5 weekdays dateEl.innerHTML = ''; - const days = ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday']; + const days = [t('schedule.sunday'), t('schedule.monday'), t('schedule.tuesday'), t('schedule.wednesday'), t('schedule.thursday'), t('schedule.friday'), t('schedule.saturday')]; const now = new Date(); for (let i = 0; i < 7; i++) { const d = new Date(now); d.setDate(d.getDate() + i); const opt = document.createElement('option'); opt.value = d.toISOString().slice(0, 10); - opt.textContent = i === 0 ? 'Today' : i === 1 ? 'Tomorrow' : days[d.getDay()]; + opt.textContent = i === 0 ? t('schedule.today') : i === 1 ? t('schedule.tomorrow') : days[d.getDay()]; dateEl.appendChild(opt); } @@ -3515,7 +3547,7 @@ function updateSchedulePopoverState() { const targets = new Set(mentionMatches.map(m => m.slice(1))); for (const name of activeMentions) targets.add(name); if (targets.size === 0) { - if (errEl) { errEl.textContent = 'Toggle an agent to set a target'; errEl.classList.remove('hidden'); } + if (errEl) { errEl.textContent = t('schedule.targetError'); errEl.classList.remove('hidden'); } if (submitBtn) { submitBtn.disabled = true; } } else { if (errEl) { errEl.classList.add('hidden'); errEl.textContent = ''; } @@ -3537,7 +3569,7 @@ async function submitSchedulePopover() { if (targets.size === 0) return; // button should be disabled anyway if (!prompt) { - if (errEl) { errEl.textContent = 'Type a message first'; errEl.classList.remove('hidden'); } + if (errEl) { errEl.textContent = t('schedule.messageError'); errEl.classList.remove('hidden'); } return; } @@ -3587,11 +3619,11 @@ async function submitSchedulePopover() { showScheduleConfirmation(); } else { const err = await resp.json().catch(() => ({})); - showSlashHint(err.error || 'Failed to schedule'); + showSlashHint(err.error || t('schedule.failed')); } } catch (e) { console.error('Failed to create schedule:', e); - showSlashHint('Failed to create schedule'); + showSlashHint(t('schedule.createFailed')); } } @@ -3651,90 +3683,88 @@ function _helpCardDefs() { anchor: '#agent-status', row: 'top', html: - '
Agents header pills
' + - '

The status pills in the header show who is here and what they\'re doing.

' + + `
${t('help.agents.title')} ${t('help.agents.loc')}
` + + `

${t('help.agents.desc')}

` + '
' + '
' + '' + 'claude' + '
' + - 'Online' + + `${t('help.agents.online')}` + '
' + '
' + '
' + '' + 'codex' + '
' + - 'Working — spinning border' + + `${t('help.agents.working')}` + '
' + '
' + '
' + '' + 'gemini' + '
' + - 'Offline' + + `${t('help.agents.offline')}` + '
' + - '

Click any pill to rename it, assign a role, or change its colour.

' + `

${t('help.agents.tip')}

` }, { id: 'hg-jobs', anchor: '#jobs-toggle', row: 'top', html: - '
Jobs hover any message + sidebar
' + - '

Jobs turn conversation into tracked work. Any message can become a job.

' + + `
${t('help.jobs.title')} ${t('help.jobs.loc')}
` + + `

${t('help.jobs.desc')}

` + '
' + '
' + '
' + 'claude' + - 'The auth module needs refactoring before we can add SSO support.' + + `${t('help.jobs.sample')}` + '
' + - 'convert to job' + + `${t('timeline.convertJob')}` + '
' + - '

Hover any message to see convert to job. This opens the Jobs sidebar.

' + + `

${t('help.jobs.tip1')}

` + '
' + '
' + - 'Jobs sidebar' + + `${t('help.jobs.sidebar')}` + '+' + '
' + '
' + '' + - 'Auth refactor' + + `${t('help.jobs.jobTitle')}` + '
' + - 'TO DO' + - 'ACTIVE' + - 'CLOSED' + + `${t('common.todo')}` + + `${t('common.active')}` + + `${t('common.closed')}` + '
' + '
' + '
' + - '

Jobs live in the sidebar. Each one opens its own thread, and you can track it from TO DO to ACTIVE to CLOSED.

' + `

${t('help.jobs.tip2')}

` }, { id: 'hg-rules', anchor: '#rules-toggle', row: 'top', html: - '
Rules top-right panel
' + - '

Rules tell agents how to work in this room. Active rules are followed automatically.

' + + `
${t('help.rules.title')} ${t('help.rules.loc')}
` + + `

${t('help.rules.desc')}

` + '
' + '
' + - 'Rules' + + `${t('rules.title')}` + '2' + '+' + '
' + '
' + '' + - 'Run tests before committing' + - 'draft' + + `${t('help.rules.sampleDraft')}` + + `${t('help.rules.draft')}` + '
' + '
' + '' + - 'Always explain your reasoning before making changes' + + `${t('help.rules.sampleActive')}` + '
' + '
' + - '

Active = agents follow this. ' + - ' Draft = not active yet. ' + - 'You or agents can add rules.

' + `

${t('help.rules.tip')}

` }, { id: 'hg-channels', @@ -3743,15 +3773,15 @@ function _helpCardDefs() { light: true, wide: true, html: - '
Channels top bar
' + - '

Split conversations by topic. Each channel has its own message history.

' + + `
${t('help.channels.title')} ${t('help.channels.loc')}
` + + `

${t('help.channels.desc')}

` + '
' + '# general' + '# design' + '# backend' + '+' + '
' + - '

Click + to create a new channel. Agents can be mentioned in any channel.

' + `

${t('help.channels.tip')}

` }, { id: 'hg-mentions', @@ -3759,62 +3789,62 @@ function _helpCardDefs() { row: 'bottom', light: true, html: - '
Mentions pills above composer
' + - '

Type @ in the composer to mention an agent. The pills above the input let you pre-select who to address. Selected agents are mentioned automatically when you send.

' + - '

Mentioned agents receive a trigger and will respond in the channel.

' + `
${t('help.mentions.title')} ${t('help.mentions.loc')}
` + + `

${t('help.mentions.desc')}

` + + `

${t('help.mentions.tip')}

` }, { id: 'hg-sessions', anchor: '#session-launch-btn', row: 'bottom', html: - '
Sessions play button
' + - '

Sessions are structured multi-agent workflows with phases and roles. Launch one to coordinate agents on a shared goal.

' + + `
${t('help.sessions.title')} ${t('help.sessions.loc')}
` + + `

${t('help.sessions.desc')}

` + '
' + '
' + '' + '
' + - 'Brainstorm' + - 'Free-form idea generation with all agents' + + `${t('help.sessions.brainstorm')}` + + `${t('help.sessions.brainstormDesc')}` + '
' + '
' + '
' + '' + '
' + - 'Code Review' + - 'Structured review with phases and roles' + + `${t('help.sessions.codeReview')}` + + `${t('help.sessions.codeReviewDesc')}` + '
' + '
' + '
' + '' + '
' + - 'Design Review' + - 'Critique and iterate on design decisions' + + `${t('help.sessions.designReview')}` + + `${t('help.sessions.designReviewDesc')}` + '
' + '
' + '
' + '
' + - 'Custom sessions — describe a goal and agents will organise themselves into a structured workflow.' + + `${t('help.sessions.custom')} -- ${t('help.sessions.customDesc')}` + '
' + - '

Click the ▶ play button next to the composer to start.

' + `

${t('help.sessions.tip')}

` }, { id: 'hg-scheduling', anchor: '#schedule-btn', row: 'bottom', html: - '
Scheduling clock button
' + - '

Schedule any message for later delivery, or set up recurring prompts on a timer.

' + + `
${t('help.scheduling.title')} ${t('help.scheduling.loc')}
` + + `

${t('help.scheduling.desc')}

` + '
' + '3:00 PM' + - '@codex check deployment status' + - 'every 1h' + + `${t('help.scheduling.sample1')}` + + `${t('help.scheduling.recur')}` + '
' + '
' + - 'Tomorrow 9 AM' + - '@claude summarise overnight changes' + + `${t('help.scheduling.tomorrow9')}` + + `${t('help.scheduling.sample2')}` + '
' + - '

Click the ⏱ clock button next to Send to schedule a one-time or recurring message.

' + `

${t('help.scheduling.tip')}

` } ]; } @@ -3924,7 +3954,7 @@ function _openHelpAnchored(cardDefs) { // Dismiss hint var hint = document.createElement('div'); hint.className = 'hg-dismiss-hint'; - hint.textContent = 'Press Esc or click outside to close'; + hint.textContent = t('help.dismissHint'); overlay.appendChild(hint); document.body.appendChild(overlay); @@ -4046,7 +4076,7 @@ function _openHelpStacked(cardDefs) { // Sticky header var header = document.createElement('div'); header.className = 'hg-modal-header'; - header.innerHTML = 'Guide'; + header.innerHTML = `${t('help.guide')}`; var closeBtn = document.createElement('button'); closeBtn.className = 'hg-modal-close'; closeBtn.innerHTML = '×'; diff --git a/static/i18n.js b/static/i18n.js new file mode 100644 index 00000000..fd09633c --- /dev/null +++ b/static/i18n.js @@ -0,0 +1,877 @@ +/** + * i18n.js -- lightweight runtime localization for agentchattr. + * + * The app is static JS without a build step, so translations live here and are + * applied directly to DOM nodes or requested by dynamic renderers via t(). + */ + +(function () { + 'use strict'; + + const STORAGE_KEY = 'agentchattr-language'; + + const I18N_STRINGS = { + en: { + 'lang.auto': 'Auto', + 'lang.en': 'English', + 'lang.zhCN': 'Simplified Chinese', + + 'settings.language': 'Language', + 'settings.theme': 'Theme', + 'settings.theme.light': 'Light', + 'settings.theme.dark': 'Dark', + 'settings.name': 'Name', + 'settings.name.placeholder': 'your name', + 'settings.font': 'Font', + 'settings.font.mono': 'Monospace', + 'settings.font.sans': 'Sans', + 'settings.loopGuard': 'Loop guard', + 'settings.loopGuard.title': 'Max agent-to-agent hops before pausing', + 'settings.history': 'History', + 'settings.history.all': 'All', + 'settings.contrast': 'Contrast', + 'settings.contrast.normal': 'Normal', + 'settings.contrast.high': 'High', + 'settings.channels': 'Channels', + 'settings.channels.title': 'Show channels in a sidebar (Discord/Slack-style) instead of a top bar', + 'settings.channels.top': 'Top bar', + 'settings.channels.sidebar': 'Sidebar', + 'settings.rulesRefresh': 'Rule refresh', + 'settings.rulesRefresh.title': 'How often to re-send active rules to agents', + 'settings.rulesRefresh.change': 'On rule changes', + 'settings.rulesRefresh.every': 'Every {count} triggers', + 'settings.rulesRefresh.every5': 'Every 5 triggers', + 'settings.rulesRefresh.every10': 'Every 10 triggers', + 'settings.rulesRefresh.every20': 'Every 20 triggers', + 'settings.rulesRefresh.every50': 'Every 50 triggers', + 'settings.projectHistory': 'Project History', + 'settings.exportHistory': 'Export History', + 'settings.importHistory': 'Import History', + 'settings.importing': 'Importing...', + 'settings.exportFailed': 'Export failed', + 'settings.exportSuccess': 'History exported', + 'settings.importFailed': 'Import failed', + 'settings.imported': 'Imported {items}', + 'settings.messages': 'messages', + 'settings.duplicateSkipped': '{count} duplicates skipped', + 'settings.warnings': '{count} warning(s)', + + 'header.discord.title': 'Join the Discord', + 'header.jobs.title': 'Jobs', + 'header.rules.title': 'Rules', + 'header.pins.title': 'Pinned messages', + 'header.settings.title': 'Settings', + 'header.help.title': 'Help', + 'header.support.title': 'Support development', + 'header.support': ' Support development', + 'header.support.short': ' Support', + + 'common.send': 'Send', + 'common.cancel': 'Cancel', + 'common.create': 'Create', + 'common.save': 'Save', + 'common.saved': 'Saved', + 'common.delete': 'Delete', + 'common.delete?': 'Delete?', + 'common.confirm': 'Confirm', + 'common.rename': 'Rename', + 'common.dismiss': 'Dismiss', + 'common.back': 'Back', + 'common.none': 'None', + 'common.reset': 'Reset', + 'common.active': 'ACTIVE', + 'common.closed': 'CLOSED', + 'common.todo': 'TO DO', + 'common.archive': 'ARCHIVE', + 'common.drafts': 'DRAFTS', + 'common.open': 'Open', + 'common.yes': 'Yes', + 'common.no': 'No', + 'common.error': 'Error: {message}', + + 'pins.title': 'Pinned', + 'pins.empty': 'No pinned messages', + 'pins.remove': 'Remove from todos', + 'pins.pin': 'pin', + 'pins.done?': 'done?', + 'pins.unpin': 'unpin', + + 'channel.add.title': 'Add channel', + 'channel.rename.title': 'Rename', + 'channel.delete.title': 'Delete', + 'channel.deleteConfirm': 'delete #{name}?', + + 'timeline.loading': 'Loading messages...', + 'timeline.thinking': 'is thinking...', + 'timeline.joined': 'joined', + 'timeline.left': 'left', + 'timeline.summary': 'Summary', + 'timeline.today': 'Today', + 'timeline.yesterday': 'Yesterday', + 'timeline.reply': 'reply', + 'timeline.deleteShort': 'del', + 'timeline.copy': 'Copy message', + 'timeline.copyButton': 'copy', + 'timeline.copied': 'copied!', + 'timeline.failed': 'failed', + 'timeline.openPath': 'Open in file manager', + 'timeline.convertJob': 'convert to job', + 'timeline.setRole': 'Set role', + 'timeline.chooseRole': 'choose a role', + 'timeline.youChose': 'You chose: {choice}', + 'timeline.replyingTo': 'replying to', + 'timeline.selected': '{count} selected', + 'timeline.selectMessages': 'Select messages', + 'timeline.deleteCount': 'Delete ({count})', + 'timeline.clearChat': 'Clear Chat', + 'timeline.clearChat.title': 'Clear chat in this channel', + 'timeline.clearChatConfirm': 'Clear Chat?', + 'timeline.confirmClearChat.title': 'Confirm clear chat', + + 'composer.startSession.title': 'Start a session', + 'composer.placeholder': 'Type a message... (use @name to mention agents)', + 'composer.voice.title': 'Voice typing (local speech-to-text)', + 'composer.voice.label': 'Voice typing', + 'composer.schedule.title': 'Schedule message', + 'composer.hint': 'Enter to send · Shift+Enter for newline · Paste/drop images · Mic for voice · Type / or @ for autocomplete', + 'composer.dropImage': 'Drop image here', + 'composer.speechUnsupported': 'Speech recognition not supported -- use Chrome or Edge.', + 'composer.micBlocked': 'Microphone access was blocked. Allow microphone access in Chrome and try again.', + + 'sound.default': 'Default sound', + 'sound.background': 'Background alerts', + 'sound.useDefault': 'Use default', + 'sound.softChime': 'Soft Chime', + 'sound.brightPing': 'Bright Ping', + 'sound.gentlePop': 'Gentle Pop', + 'sound.alertTone': 'Alert Tone', + 'sound.pluck': 'Pluck', + 'sound.click': 'Click', + 'sound.warmBell': 'Warm Bell', + 'sound.none': 'None', + + 'update.upstream': 'Upstream update available', + 'update.available': 'Update available', + 'update.dismiss': 'Dismiss', + + 'agent.nameThis': 'Name this agent', + 'agent.newInstance': 'A new {family} instance connected', + 'agent.rename': 'Rename agent', + 'agent.currentId': 'Current ID: @{name}', + 'agent.role': 'Role', + 'agent.customRole.placeholder': 'Custom role...', + 'agent.color': 'Color', + 'agent.pickColor.title': 'Pick custom color', + 'agent.resetColor.title': 'Reset to default', + 'agent.custom.placeholder': 'Custom...', + + 'role.planner': 'Planner', + 'role.designer': 'Designer', + 'role.architect': 'Architect', + 'role.builder': 'Builder', + 'role.reviewer': 'Reviewer', + 'role.researcher': 'Researcher', + 'role.redTeam': 'Red Team', + 'role.wry': 'Wry', + 'role.unhinged': 'Unhinged', + 'role.hype': 'Hype', + + 'jobs.title': 'Jobs', + 'jobs.create.title': 'Create Job', + 'jobs.openJob.title': 'Open job', + 'jobs.newJob': 'New job:', + 'jobs.proposal': 'Job Proposal', + 'jobs.accept': 'Accept', + 'jobs.requestChanges': 'Request Changes', + 'jobs.dismissed': 'Dismissed', + 'jobs.started': '{count} jobs were started', + 'jobs.none': 'none', + 'jobs.unread.title': 'Unread messages', + 'jobs.empty.title': 'No jobs yet', + 'jobs.empty.meta': 'Create one or convert a chat message', + 'jobs.dropDeleteOne': 'Drop to delete job', + 'jobs.dropDeleteMany': 'Drop to delete {count} jobs', + 'jobs.assign.placeholder': 'Send to assign · click to edit', + 'jobs.message.placeholder': 'Message...', + 'jobs.emptyMessages': 'No messages yet. Start the conversation!', + 'jobs.loadFailed': 'Failed to load messages.', + 'jobs.deleteShort': 'DEL', + 'jobs.title.placeholder': 'Job title', + 'jobs.body.placeholder': 'Description (optional)', + 'jobs.accepted': 'Accepted', + 'jobs.deleteSelected': 'Delete {count} selected', + 'jobs.dragDelete': 'Drag here to delete', + 'jobs.convertTitle': 'Convert to Job', + 'jobs.convertSubtitle': 'An agent will write a job proposal for you to accept', + 'jobs.convertLabel': 'Ask agent to write proposal', + 'jobs.convert': 'Convert', + 'jobs.askingAgent': 'Asking @{agent} to create a job card...', + 'jobs.deletePermanentTitle': 'Delete Job Permanently?', + 'jobs.deletePermanentSubtitle': 'This removes the job and its messages permanently. This cannot be undone.', + 'jobs.replyTarget.title': 'Reply target', + 'jobs.clearReplyTarget.title': 'Clear reply target', + 'jobs.replyTargetPrefix': 'To', + + 'rules.title': 'Rules', + 'rules.proposal': 'Rule Proposal', + 'rules.activate': 'Activate', + 'rules.addDrafts': 'Add to drafts', + 'rules.activated': 'Activated', + 'rules.addedDrafts': 'Added to drafts', + 'rules.add.title': 'Add Rule', + 'rules.remind': 'Remind agents', + 'rules.queued': 'Queued -- next trigger', + 'rules.empty.title': 'No rules yet', + 'rules.empty.meta': 'Tell your agents how to work', + 'rules.nextTrigger': 'New rules are sent on the next agent trigger', + 'rules.softWarning': 'Less than seven active rules tends to work better', + 'rules.dragDelete': 'Drag here to delete', + 'rules.create.placeholder': 'Write a short rule agents should follow', + 'rules.deleteConfirm': 'Delete?', + 'rules.rejectedPrefix': 'Rule rejected', + + 'sessions.goToChannel': 'Go to channel', + 'sessions.end': 'End Session', + 'sessions.endConfirm': 'End Session?', + 'sessions.confirmEnd.title': 'Confirm end session', + 'sessions.activeIn': 'Session active in #{channel}', + 'sessions.activeElsewhere': '{count} sessions active elsewhere', + 'sessions.session': 'Session', + 'sessions.customSession': 'Custom session', + 'sessions.waitingFor': 'Waiting for {agent}', + 'sessions.paused': 'Paused', + 'sessions.viewOutput': 'View output', + 'sessions.proposal': 'Session Proposal', + 'sessions.superseded': 'Superseded draft', + 'sessions.replacedByRevision': 'Replaced by revision {revision}.', + 'sessions.invalidDraft': 'Invalid session draft', + 'sessions.invalidDraftHelp': 'This draft needs changes before it can be run or saved.', + 'sessions.requestChanges': 'Request Changes', + 'sessions.run': 'Run', + 'sessions.saveTemplate': 'Save Template', + 'sessions.deleteTemplate.title': 'Delete custom template', + 'sessions.cast': 'Cast', + 'sessions.phaseNumber': 'Phase {number}', + 'sessions.goToChannelNamed': 'Go to #{channel}', + 'sessions.goToChannelExtra': 'Go to #{channel} (+{count})', + 'sessions.template.codeReview.name': 'Code Review', + 'sessions.template.codeReview.description': 'Structured code review with builder, reviewer, and red team.', + 'sessions.template.debate.name': 'Debate', + 'sessions.template.debate.description': 'Two agents argue opposing positions. A third moderates and delivers a verdict.', + 'sessions.template.designCritique.name': 'Design Critique', + 'sessions.template.designCritique.description': 'One agent presents work, one critiques it, one synthesises the outcome.', + 'sessions.template.planning.name': 'Planning', + 'sessions.template.planning.description': 'Turn a problem into an execution plan through framing, challenge, and synthesis.', + 'sessions.role.builder': 'builder', + 'sessions.role.reviewer': 'reviewer', + 'sessions.role.red_team': 'red_team', + 'sessions.role.synthesiser': 'synthesiser', + 'sessions.role.proposer': 'proposer', + 'sessions.role.for': 'for', + 'sessions.role.against': 'against', + 'sessions.role.moderator': 'moderator', + 'sessions.role.presenter': 'presenter', + 'sessions.role.critic': 'critic', + 'sessions.role.planner': 'planner', + 'sessions.role.challenger': 'challenger', + 'sessions.phase.submit': 'Submit', + 'sessions.phase.review': 'Review', + 'sessions.phase.respond': 'Respond', + 'sessions.phase.summary': 'Summary', + 'sessions.phase.frame': 'Frame', + 'sessions.phase.opening_arguments': 'Opening Arguments', + 'sessions.phase.rebuttals': 'Rebuttals', + 'sessions.phase.verdict': 'Verdict', + 'sessions.phase.present': 'Present', + 'sessions.phase.critique': 'Critique', + 'sessions.phase.synthesis': 'Synthesis', + 'sessions.phase.challenge': 'Challenge', + 'sessions.phase.plan': 'Plan', + 'sessions.design.placeholder': 'Describe the session you want...', + 'sessions.goal.placeholder': 'Goal (optional) -- what should this session achieve?', + 'sessions.goalShort.placeholder': 'Goal (optional)', + 'sessions.changes.placeholder': 'What changes do you want?', + 'sessions.sendDesignFailed': 'Failed to send design request (HTTP {status})', + 'sessions.startFailed': 'Failed to start session', + 'sessions.startError': 'Error starting session: {message}', + 'sessions.endFailed': 'Failed to end session', + 'sessions.endError': 'Error ending session: {message}', + 'sessions.startDraftFailed': 'Failed to start session from draft', + 'sessions.saveFailed': 'Failed to save template', + 'sessions.deleteFailed': 'Failed to delete template', + 'sessions.connectionLost': 'Connection lost. Reconnect and try again.', + 'sessions.dismissFailed': 'Failed to dismiss session proposal', + + 'schedule.seeAll': 'See all', + 'schedule.hide': 'Hide', + 'schedule.message': 'Schedule message', + 'schedule.when': 'When', + 'schedule.at': 'At', + 'schedule.recurring': 'Recurring', + 'schedule.every': 'Every', + 'schedule.minutes': 'minutes', + 'schedule.hours': 'hours', + 'schedule.days': 'days', + 'schedule.submit': 'Schedule', + 'schedule.today': 'Today', + 'schedule.tomorrow': 'Tomorrow', + 'schedule.sunday': 'Sunday', + 'schedule.monday': 'Monday', + 'schedule.tuesday': 'Tuesday', + 'schedule.wednesday': 'Wednesday', + 'schedule.thursday': 'Thursday', + 'schedule.friday': 'Friday', + 'schedule.saturday': 'Saturday', + 'schedule.nextIn': 'next in {time}', + 'schedule.paused': '{count} paused schedule{plural}', + 'schedule.activeCount': '{count} active', + 'schedule.pausedCount': '{count} paused', + 'schedule.pausedSuffix': ' (paused)', + 'schedule.dailyAt': 'daily at {time}', + 'schedule.everyShort': 'every {value}{unit}', + 'schedule.pause': 'Pause', + 'schedule.resume': 'Resume', + 'schedule.failed': 'Failed to schedule', + 'schedule.createFailed': 'Failed to create schedule', + 'schedule.targetError': 'Toggle an agent to set a target', + 'schedule.messageError': 'Type a message first', + + 'slash.artchallenge': 'SVG art challenge -- all agents create artwork (optional theme)', + 'slash.hatmaking': 'All agents design a hat to wear on their avatar', + 'slash.roastreview': 'Get all agents to review and roast each other\'s work', + 'slash.haiku': 'Agents write a haiku about the codebase', + 'slash.limerick': 'Agents write a limerick about the codebase', + 'slash.sonnet': 'Agents write a sonnet about the codebase', + 'slash.summary': 'Summarize recent messages -- tag an agent (e.g. /summary @claude)', + 'slash.summarise': 'Summarize recent messages -- tag an agent (e.g. /summarise @claude)', + 'slash.continue': 'Resume after loop guard pauses', + 'slash.clear': 'Clear messages in current channel', + + 'help.guide': 'Guide', + 'help.dismissHint': 'Press Esc or click outside to close', + 'help.agents.title': 'Agents', + 'help.agents.loc': 'header pills', + 'help.agents.desc': 'The status pills in the header show who is here and what they\'re doing.', + 'help.agents.online': 'Online', + 'help.agents.working': 'Working -- spinning border', + 'help.agents.offline': 'Offline', + 'help.agents.tip': 'Click any pill to rename it, assign a role, or change its color.', + 'help.jobs.title': 'Jobs', + 'help.jobs.loc': 'hover any message + sidebar', + 'help.jobs.desc': 'Jobs turn conversation into tracked work. Any message can become a job.', + 'help.jobs.sample': 'The auth module needs refactoring before we can add SSO support.', + 'help.jobs.tip1': 'Hover any message to see convert to job. This opens the Jobs sidebar.', + 'help.jobs.sidebar': 'Jobs sidebar', + 'help.jobs.jobTitle': 'Auth refactor', + 'help.jobs.tip2': 'Jobs live in the sidebar. Each one opens its own thread, and you can track it from TO DO to ACTIVE to CLOSED.', + 'help.rules.title': 'Rules', + 'help.rules.loc': 'top-right panel', + 'help.rules.desc': 'Rules tell agents how to work in this room. Active rules are followed automatically.', + 'help.rules.sampleDraft': 'Run tests before committing', + 'help.rules.sampleActive': 'Always explain your reasoning before making changes', + 'help.rules.draft': 'draft', + 'help.rules.tip': 'Active = agents follow this. Draft = not active yet. You or agents can add rules.', + 'help.channels.title': 'Channels', + 'help.channels.loc': 'top bar', + 'help.channels.desc': 'Split conversations by topic. Each channel has its own message history.', + 'help.channels.tip': 'Click + to create a new channel. Agents can be mentioned in any channel.', + 'help.mentions.title': 'Mentions', + 'help.mentions.loc': 'pills above composer', + 'help.mentions.desc': 'Type @ in the composer to mention an agent. The pills above the input let you pre-select who to address. Selected agents are mentioned automatically when you send.', + 'help.mentions.tip': 'Mentioned agents receive a trigger and will respond in the channel.', + 'help.sessions.title': 'Sessions', + 'help.sessions.loc': 'play button', + 'help.sessions.desc': 'Sessions are structured multi-agent workflows with phases and roles. Launch one to coordinate agents on a shared goal.', + 'help.sessions.brainstorm': 'Brainstorm', + 'help.sessions.brainstormDesc': 'Free-form idea generation with all agents', + 'help.sessions.codeReview': 'Code Review', + 'help.sessions.codeReviewDesc': 'Structured review with phases and roles', + 'help.sessions.designReview': 'Design Review', + 'help.sessions.designReviewDesc': 'Critique and iterate on design decisions', + 'help.sessions.custom': 'Custom sessions', + 'help.sessions.customDesc': 'describe a goal and agents will organize themselves into a structured workflow.', + 'help.sessions.tip': 'Click the play button next to the composer to start.', + 'help.scheduling.title': 'Scheduling', + 'help.scheduling.loc': 'clock button', + 'help.scheduling.desc': 'Schedule any message for later delivery, or set up recurring prompts on a timer.', + 'help.scheduling.sample1': '@codex check deployment status', + 'help.scheduling.sample2': '@claude summarize overnight changes', + 'help.scheduling.recur': 'every 1h', + 'help.scheduling.tomorrow9': 'Tomorrow 9 AM', + 'help.scheduling.tip': 'Click the clock button next to Send to schedule a one-time or recurring message.', + }, + zhCN: { + 'lang.auto': '自动', + 'lang.en': 'English', + 'lang.zhCN': '简体中文', + + 'settings.language': '语言', + 'settings.theme': '主题', + 'settings.theme.light': '白天', + 'settings.theme.dark': '黑夜', + 'settings.name': '名称', + 'settings.name.placeholder': '你的名字', + 'settings.font': '字体', + 'settings.font.mono': '等宽', + 'settings.font.sans': '无衬线', + 'settings.loopGuard': '循环保护', + 'settings.loopGuard.title': '代理连续互相触发多少次后暂停', + 'settings.history': '历史记录', + 'settings.history.all': '全部', + 'settings.contrast': '对比度', + 'settings.contrast.normal': '普通', + 'settings.contrast.high': '高', + 'settings.channels': '频道', + 'settings.channels.title': '用侧边栏显示频道,而不是顶部栏', + 'settings.channels.top': '顶部栏', + 'settings.channels.sidebar': '侧边栏', + 'settings.rulesRefresh': '规则刷新', + 'settings.rulesRefresh.title': '多久重新把启用规则发送给代理', + 'settings.rulesRefresh.change': '规则变更时', + 'settings.rulesRefresh.every': '每 {count} 次触发', + 'settings.rulesRefresh.every5': '每 5 次触发', + 'settings.rulesRefresh.every10': '每 10 次触发', + 'settings.rulesRefresh.every20': '每 20 次触发', + 'settings.rulesRefresh.every50': '每 50 次触发', + 'settings.projectHistory': '项目历史', + 'settings.exportHistory': '导出历史', + 'settings.importHistory': '导入历史', + 'settings.importing': '正在导入...', + 'settings.exportFailed': '导出失败', + 'settings.exportSuccess': '历史已导出', + 'settings.importFailed': '导入失败', + 'settings.imported': '已导入 {items}', + 'settings.messages': '条消息', + 'settings.duplicateSkipped': '跳过 {count} 个重复项', + 'settings.warnings': '{count} 个警告', + + 'header.discord.title': '加入 Discord', + 'header.jobs.title': '任务', + 'header.rules.title': '规则', + 'header.pins.title': '已置顶消息', + 'header.settings.title': '设置', + 'header.help.title': '帮助', + 'header.support.title': '支持开发', + 'header.support': ' 支持开发', + 'header.support.short': ' 支持', + + 'common.send': '发送', + 'common.cancel': '取消', + 'common.create': '创建', + 'common.save': '保存', + 'common.saved': '已保存', + 'common.delete': '删除', + 'common.delete?': '删除?', + 'common.confirm': '确认', + 'common.rename': '重命名', + 'common.dismiss': '忽略', + 'common.back': '返回', + 'common.none': '无', + 'common.reset': '重置', + 'common.active': '进行中', + 'common.closed': '已关闭', + 'common.todo': '待办', + 'common.archive': '归档', + 'common.drafts': '草稿', + 'common.open': '打开', + 'common.yes': '是', + 'common.no': '否', + 'common.error': '错误:{message}', + + 'pins.title': '已置顶', + 'pins.empty': '没有已置顶消息', + 'pins.remove': '从待办中移除', + 'pins.pin': '置顶', + 'pins.done?': '完成?', + 'pins.unpin': '取消置顶', + + 'channel.add.title': '添加频道', + 'channel.rename.title': '重命名', + 'channel.delete.title': '删除', + 'channel.deleteConfirm': '删除 #{name}?', + + 'timeline.loading': '正在加载消息...', + 'timeline.thinking': '正在思考...', + 'timeline.joined': '加入了', + 'timeline.left': '离开了', + 'timeline.summary': '摘要', + 'timeline.today': '今天', + 'timeline.yesterday': '昨天', + 'timeline.reply': '回复', + 'timeline.deleteShort': '删', + 'timeline.copy': '复制消息', + 'timeline.copyButton': '复制', + 'timeline.copied': '已复制', + 'timeline.failed': '失败', + 'timeline.openPath': '在文件管理器中打开', + 'timeline.convertJob': '转为任务', + 'timeline.setRole': '设置角色', + 'timeline.chooseRole': '选择角色', + 'timeline.youChose': '你选择了:{choice}', + 'timeline.replyingTo': '正在回复', + 'timeline.selected': '已选择 {count} 条', + 'timeline.selectMessages': '选择消息', + 'timeline.deleteCount': '删除 ({count})', + 'timeline.clearChat': '清空聊天', + 'timeline.clearChat.title': '清空当前频道聊天', + 'timeline.clearChatConfirm': '确认清空?', + 'timeline.confirmClearChat.title': '确认清空聊天', + + 'composer.startSession.title': '启动会话', + 'composer.placeholder': '输入消息...(用 @name 提及代理)', + 'composer.voice.title': '语音输入(本地语音转文字)', + 'composer.voice.label': '语音输入', + 'composer.schedule.title': '定时消息', + 'composer.hint': 'Enter 发送 · Shift+Enter 换行 · 粘贴/拖放图片 · 麦克风语音输入 · 输入 / 或 @ 自动补全', + 'composer.dropImage': '把图片拖到这里', + 'composer.speechUnsupported': '当前浏览器不支持语音识别,请使用 Chrome 或 Edge。', + 'composer.micBlocked': '麦克风权限被阻止。请在 Chrome 中允许麦克风权限后重试。', + + 'sound.default': '默认提示音', + 'sound.background': '后台提醒', + 'sound.useDefault': '使用默认', + 'sound.softChime': '柔和铃声', + 'sound.brightPing': '明亮提示', + 'sound.gentlePop': '轻柔弹音', + 'sound.alertTone': '警示音', + 'sound.pluck': '拨弦', + 'sound.click': '点击声', + 'sound.warmBell': '温暖铃声', + 'sound.none': '无', + + 'update.upstream': '上游有可用更新', + 'update.available': '有可用更新', + 'update.dismiss': '忽略', + + 'agent.nameThis': '给这个代理命名', + 'agent.newInstance': '新的 {family} 实例已连接', + 'agent.rename': '重命名代理', + 'agent.currentId': '当前 ID:@{name}', + 'agent.role': '角色', + 'agent.customRole.placeholder': '自定义角色...', + 'agent.color': '颜色', + 'agent.pickColor.title': '选择自定义颜色', + 'agent.resetColor.title': '恢复默认颜色', + 'agent.custom.placeholder': '自定义...', + + 'role.planner': '规划者', + 'role.designer': '设计师', + 'role.architect': '架构师', + 'role.builder': '构建者', + 'role.reviewer': '审查者', + 'role.researcher': '研究员', + 'role.redTeam': '红队', + 'role.wry': '吐槽担当', + 'role.unhinged': '放飞型', + 'role.hype': '气氛组', + + 'jobs.title': '任务', + 'jobs.create.title': '创建任务', + 'jobs.openJob.title': '打开任务', + 'jobs.newJob': '新任务:', + 'jobs.proposal': '任务提案', + 'jobs.accept': '接受', + 'jobs.requestChanges': '请求修改', + 'jobs.dismissed': '已忽略', + 'jobs.started': '已创建 {count} 个任务', + 'jobs.none': '无', + 'jobs.unread.title': '未读消息', + 'jobs.empty.title': '还没有任务', + 'jobs.empty.meta': '创建一个任务,或把聊天消息转成任务', + 'jobs.dropDeleteOne': '松手删除任务', + 'jobs.dropDeleteMany': '松手删除 {count} 个任务', + 'jobs.assign.placeholder': '发送以分配 · 点击编辑', + 'jobs.message.placeholder': '消息...', + 'jobs.emptyMessages': '还没有消息。开始对话吧!', + 'jobs.loadFailed': '加载消息失败。', + 'jobs.deleteShort': '删', + 'jobs.title.placeholder': '任务标题', + 'jobs.body.placeholder': '描述(可选)', + 'jobs.accepted': '已接受', + 'jobs.deleteSelected': '删除已选择的 {count} 项', + 'jobs.dragDelete': '拖到这里删除', + 'jobs.convertTitle': '转为任务', + 'jobs.convertSubtitle': '代理会写一个任务提案,供你接受', + 'jobs.convertLabel': '让代理编写提案', + 'jobs.convert': '转换', + 'jobs.askingAgent': '正在请求 @{agent} 创建任务卡片...', + 'jobs.deletePermanentTitle': '永久删除任务?', + 'jobs.deletePermanentSubtitle': '这会永久删除该任务及其消息,无法撤销。', + 'jobs.replyTarget.title': '回复对象', + 'jobs.clearReplyTarget.title': '清除回复对象', + 'jobs.replyTargetPrefix': '发给', + + 'rules.title': '规则', + 'rules.proposal': '规则提案', + 'rules.activate': '启用', + 'rules.addDrafts': '加入草稿', + 'rules.activated': '已启用', + 'rules.addedDrafts': '已加入草稿', + 'rules.add.title': '添加规则', + 'rules.remind': '提醒代理', + 'rules.queued': '已排队,下次触发时发送', + 'rules.empty.title': '还没有规则', + 'rules.empty.meta': '告诉代理应该怎么工作', + 'rules.nextTrigger': '新规则会在下一次代理触发时发送', + 'rules.softWarning': '启用规则少于 7 条通常效果更好', + 'rules.dragDelete': '拖到这里删除', + 'rules.create.placeholder': '写一条代理应该遵守的简短规则', + 'rules.deleteConfirm': '删除?', + 'rules.rejectedPrefix': '规则已拒绝', + + 'sessions.goToChannel': '前往频道', + 'sessions.end': '结束会话', + 'sessions.endConfirm': '结束会话?', + 'sessions.confirmEnd.title': '确认结束会话', + 'sessions.activeIn': '会话正在 #{channel} 中进行', + 'sessions.activeElsewhere': '其他频道有 {count} 个会话正在进行', + 'sessions.session': '会话', + 'sessions.customSession': '自定义会话', + 'sessions.waitingFor': '正在等待 {agent}', + 'sessions.paused': '已暂停', + 'sessions.viewOutput': '查看输出', + 'sessions.proposal': '会话提案', + 'sessions.superseded': '已被替换的草稿', + 'sessions.replacedByRevision': '已由修订版 {revision} 替换。', + 'sessions.invalidDraft': '无效的会话草稿', + 'sessions.invalidDraftHelp': '这个草稿需要修改后才能运行或保存。', + 'sessions.requestChanges': '请求修改', + 'sessions.run': '运行', + 'sessions.saveTemplate': '保存模板', + 'sessions.deleteTemplate.title': '删除自定义模板', + 'sessions.cast': '成员分配', + 'sessions.phaseNumber': '阶段 {number}', + 'sessions.goToChannelNamed': '前往 #{channel}', + 'sessions.goToChannelExtra': '前往 #{channel}(+{count})', + 'sessions.template.codeReview.name': '代码审查', + 'sessions.template.codeReview.description': '由构建者、审查者和红队进行结构化代码审查。', + 'sessions.template.debate.name': '辩论', + 'sessions.template.debate.description': '两个代理分别支持相反立场,第三个代理主持并给出结论。', + 'sessions.template.designCritique.name': '设计评审', + 'sessions.template.designCritique.description': '一个代理展示方案,一个代理提出评审意见,另一个代理汇总结论。', + 'sessions.template.planning.name': '规划', + 'sessions.template.planning.description': '通过界定问题、挑战假设和综合结论,把问题转成执行计划。', + 'sessions.role.builder': '构建者', + 'sessions.role.reviewer': '审查者', + 'sessions.role.red_team': '红队', + 'sessions.role.synthesiser': '总结者', + 'sessions.role.proposer': '提议者', + 'sessions.role.for': '正方', + 'sessions.role.against': '反方', + 'sessions.role.moderator': '主持人', + 'sessions.role.presenter': '展示者', + 'sessions.role.critic': '评审者', + 'sessions.role.planner': '规划者', + 'sessions.role.challenger': '挑战者', + 'sessions.phase.submit': '提交', + 'sessions.phase.review': '审查', + 'sessions.phase.respond': '回应', + 'sessions.phase.summary': '总结', + 'sessions.phase.frame': '界定问题', + 'sessions.phase.opening_arguments': '开场陈述', + 'sessions.phase.rebuttals': '反驳', + 'sessions.phase.verdict': '结论', + 'sessions.phase.present': '展示', + 'sessions.phase.critique': '评审', + 'sessions.phase.synthesis': '综合', + 'sessions.phase.challenge': '挑战', + 'sessions.phase.plan': '计划', + 'sessions.design.placeholder': '描述你想要的会话...', + 'sessions.goal.placeholder': '目标(可选)-- 这个会话应该达成什么?', + 'sessions.goalShort.placeholder': '目标(可选)', + 'sessions.changes.placeholder': '你希望修改什么?', + 'sessions.sendDesignFailed': '发送设计请求失败(HTTP {status})', + 'sessions.startFailed': '启动会话失败', + 'sessions.startError': '启动会话出错:{message}', + 'sessions.endFailed': '结束会话失败', + 'sessions.endError': '结束会话出错:{message}', + 'sessions.startDraftFailed': '从草稿启动会话失败', + 'sessions.saveFailed': '保存模板失败', + 'sessions.deleteFailed': '删除模板失败', + 'sessions.connectionLost': '连接已断开。请重新连接后重试。', + 'sessions.dismissFailed': '忽略会话提案失败', + + 'schedule.seeAll': '查看全部', + 'schedule.hide': '收起', + 'schedule.message': '定时消息', + 'schedule.when': '日期', + 'schedule.at': '时间', + 'schedule.recurring': '重复', + 'schedule.every': '每', + 'schedule.minutes': '分钟', + 'schedule.hours': '小时', + 'schedule.days': '天', + 'schedule.submit': '定时发送', + 'schedule.today': '今天', + 'schedule.tomorrow': '明天', + 'schedule.sunday': '星期日', + 'schedule.monday': '星期一', + 'schedule.tuesday': '星期二', + 'schedule.wednesday': '星期三', + 'schedule.thursday': '星期四', + 'schedule.friday': '星期五', + 'schedule.saturday': '星期六', + 'schedule.nextIn': '{time} 后执行', + 'schedule.paused': '{count} 个定时已暂停', + 'schedule.activeCount': '{count} 个启用', + 'schedule.pausedCount': '{count} 个暂停', + 'schedule.pausedSuffix': '(已暂停)', + 'schedule.dailyAt': '每天 {time}', + 'schedule.everyShort': '每 {value}{unit}', + 'schedule.pause': '暂停', + 'schedule.resume': '继续', + 'schedule.failed': '定时失败', + 'schedule.createFailed': '创建定时失败', + 'schedule.targetError': '先打开一个代理开关作为目标', + 'schedule.messageError': '请先输入消息', + + 'slash.artchallenge': 'SVG 艺术挑战:所有代理创作作品(可选主题)', + 'slash.hatmaking': '所有代理为自己的头像设计一顶帽子', + 'slash.roastreview': '让所有代理互相审查并犀利点评工作', + 'slash.haiku': '让代理写一首关于代码库的俳句', + 'slash.limerick': '让代理写一首关于代码库的五行打油诗', + 'slash.sonnet': '让代理写一首关于代码库的十四行诗', + 'slash.summary': '总结最近消息:请提及一个代理(例如 /summary @claude)', + 'slash.summarise': '总结最近消息:请提及一个代理(例如 /summarise @claude)', + 'slash.continue': '循环保护暂停后继续', + 'slash.clear': '清空当前频道消息', + + 'help.guide': '指南', + 'help.dismissHint': '按 Esc 或点击外部关闭', + 'help.agents.title': '代理', + 'help.agents.loc': '顶部状态胶囊', + 'help.agents.desc': '顶部状态胶囊显示谁在线,以及他们正在做什么。', + 'help.agents.online': '在线', + 'help.agents.working': '工作中:边框会旋转', + 'help.agents.offline': '离线', + 'help.agents.tip': '点击任意胶囊可以重命名、分配角色或修改颜色。', + 'help.jobs.title': '任务', + 'help.jobs.loc': '悬停消息 + 侧边栏', + 'help.jobs.desc': '任务可以把聊天内容变成可追踪的工作。任何消息都可以转成任务。', + 'help.jobs.sample': '认证模块需要先重构,才能加入 SSO 支持。', + 'help.jobs.tip1': '悬停任意消息可看到“转为任务”,并打开任务侧边栏。', + 'help.jobs.sidebar': '任务侧边栏', + 'help.jobs.jobTitle': '认证重构', + 'help.jobs.tip2': '任务位于侧边栏。每个任务都有自己的线程,并可从待办追踪到进行中、已关闭。', + 'help.rules.title': '规则', + 'help.rules.loc': '右上角面板', + 'help.rules.desc': '规则告诉代理在这个房间里应该如何工作。启用的规则会自动生效。', + 'help.rules.sampleDraft': '提交前运行测试', + 'help.rules.sampleActive': '改动前总是先说明你的推理', + 'help.rules.draft': '草稿', + 'help.rules.tip': '启用 = 代理会遵守。草稿 = 暂未启用。你和代理都可以添加规则。', + 'help.channels.title': '频道', + 'help.channels.loc': '顶部栏', + 'help.channels.desc': '按主题拆分对话。每个频道都有自己的消息历史。', + 'help.channels.tip': '点击 + 创建新频道。任何频道里都可以提及代理。', + 'help.mentions.title': '提及', + 'help.mentions.loc': '输入框上方胶囊', + 'help.mentions.desc': '在输入框里输入 @ 可以提及代理。输入框上方的胶囊可预先选择要发送给谁,发送时会自动提及。', + 'help.mentions.tip': '被提及的代理会收到触发,并在频道中回复。', + 'help.sessions.title': '会话', + 'help.sessions.loc': '播放按钮', + 'help.sessions.desc': '会话是带阶段和角色的多代理结构化工作流。启动一个会话即可让代理围绕共同目标协作。', + 'help.sessions.brainstorm': '头脑风暴', + 'help.sessions.brainstormDesc': '所有代理参与的自由发散', + 'help.sessions.codeReview': '代码审查', + 'help.sessions.codeReviewDesc': '带阶段和角色的结构化审查', + 'help.sessions.designReview': '设计审查', + 'help.sessions.designReviewDesc': '评审并迭代设计决策', + 'help.sessions.custom': '自定义会话', + 'help.sessions.customDesc': '描述一个目标,代理会自行组织成结构化工作流。', + 'help.sessions.tip': '点击输入框旁边的播放按钮开始。', + 'help.scheduling.title': '定时', + 'help.scheduling.loc': '时钟按钮', + 'help.scheduling.desc': '把任意消息安排到稍后发送,或设置按时间重复的提示。', + 'help.scheduling.sample1': '@codex 检查部署状态', + 'help.scheduling.sample2': '@claude 总结夜间变更', + 'help.scheduling.recur': '每 1 小时', + 'help.scheduling.tomorrow9': '明天上午 9 点', + 'help.scheduling.tip': '点击发送按钮旁边的时钟,可以设置一次性或重复定时消息。', + }, + }; + + function normalizeLang(value) { + if (value === 'zh-CN' || value === 'zhCN' || value === 'zh') return 'zh-CN'; + if (value === 'en') return 'en'; + return 'auto'; + } + + function resolveLanguage(pref) { + const normalized = normalizeLang(pref); + if (normalized === 'zh-CN' || normalized === 'en') return normalized; + const nav = (navigator.languages && navigator.languages[0]) || navigator.language || ''; + return /^zh\b/i.test(nav) ? 'zh-CN' : 'en'; + } + + function getLanguagePreference() { + return normalizeLang(localStorage.getItem(STORAGE_KEY) || 'auto'); + } + + function getLanguage() { + return resolveLanguage(getLanguagePreference()); + } + + function interpolate(str, vars) { + if (!vars) return str; + return str.replace(/\{(\w+)\}/g, (m, key) => + Object.prototype.hasOwnProperty.call(vars, key) ? String(vars[key]) : m + ); + } + + function t(key, vars) { + const lang = getLanguage(); + const table = lang === 'zh-CN' ? I18N_STRINGS.zhCN : I18N_STRINGS.en; + const fallback = I18N_STRINGS.en; + return interpolate(table[key] || fallback[key] || key, vars); + } + + function applyTranslations(root) { + const scope = root || document; + document.documentElement.lang = getLanguage(); + + scope.querySelectorAll('[data-i18n]').forEach(el => { + el.textContent = t(el.dataset.i18n); + }); + scope.querySelectorAll('[data-i18n-html]').forEach(el => { + el.innerHTML = t(el.dataset.i18nHtml); + }); + scope.querySelectorAll('[data-i18n-title]').forEach(el => { + el.title = t(el.dataset.i18nTitle); + }); + scope.querySelectorAll('[data-i18n-placeholder]').forEach(el => { + el.placeholder = t(el.dataset.i18nPlaceholder); + }); + scope.querySelectorAll('[data-i18n-aria-label]').forEach(el => { + el.setAttribute('aria-label', t(el.dataset.i18nAriaLabel)); + }); + + const selector = document.getElementById('setting-language'); + if (selector) selector.value = getLanguagePreference(); + } + + function setLanguage(lang) { + const pref = normalizeLang(lang); + localStorage.setItem(STORAGE_KEY, pref); + applyTranslations(document); + document.dispatchEvent(new CustomEvent('i18n:change', { + detail: { language: getLanguage(), preference: pref }, + })); + // Existing messages and sidebar cards are rendered HTML. Reloading keeps + // the switch reliable without introducing a client-side virtual DOM. + setTimeout(() => window.location.reload(), 50); + } + + window.I18N_STRINGS = I18N_STRINGS; + window.t = t; + window.setLanguage = setLanguage; + window.getLanguage = getLanguage; + window.getLanguagePreference = getLanguagePreference; + window.applyTranslations = applyTranslations; + + document.addEventListener('DOMContentLoaded', () => { + applyTranslations(document); + const selector = document.getElementById('setting-language'); + if (selector) selector.addEventListener('change', () => setLanguage(selector.value)); + }); +})(); diff --git a/static/index.html b/static/index.html index 955feb87..a8d9856a 100644 --- a/static/index.html +++ b/static/index.html @@ -4,9 +4,12 @@ agentchattr - - - + + + + @@ -15,7 +18,7 @@

agentchattr

- + Discord @@ -23,33 +26,33 @@

agentchattr

- - - - - @@ -81,9 +99,9 @@

agentchattr

- +
- +
- - + +
- - + + + + +
@@ -121,10 +139,10 @@

agentchattr

- +
- - + +
@@ -132,7 +150,7 @@

agentchattr

@@ -141,11 +159,11 @@

agentchattr

- +
- Support development + Support development
@@ -156,8 +174,8 @@

agentchattr

- - + +
@@ -165,8 +183,8 @@

agentchattr