/** * 聊天页面 - 完整版,对接 OpenClaw Gateway * 支持:流式响应、Markdown 渲染、会话管理、Agent 选择、快捷指令 */ import { api, invalidate, isTauriRuntime } from '../lib/tauri-api.js' import { navigate } from '../router.js' import { wsClient, uuid } from '../lib/ws-client.js' import { renderMarkdown } from '../lib/markdown.js' import { saveMessage, saveMessages, getLocalMessages, isStorageAvailable } from '../lib/message-db.js' import { toast } from '../components/toast.js' import { showModal, showConfirm } from '../components/modal.js' import { icon as svgIcon } from '../lib/icons.js' import { t } from '../lib/i18n.js' const RENDER_THROTTLE = 30 const STORAGE_SESSION_KEY = 'clawpanel-last-session' const STORAGE_MODEL_KEY = 'clawpanel-chat-selected-model' const STORAGE_SIDEBAR_KEY = 'clawpanel-chat-sidebar-open' const STORAGE_SESSION_NAMES_KEY = 'clawpanel-chat-session-names' const STORAGE_WORKSPACE_PANEL_KEY = 'clawpanel-chat-workspace-open' const COMMANDS = [ { title: 'chat.cmdSession', commands: [ { cmd: '/new', desc: 'chat.cmdNewSession', action: 'exec' }, { cmd: '/reset', desc: 'chat.cmdResetSession', action: 'exec' }, { cmd: '/stop', desc: 'chat.cmdStopGen', action: 'exec' }, ]}, { title: 'chat.cmdModel', commands: [ { cmd: '/model ', desc: 'chat.cmdSwitchModel', action: 'fill' }, { cmd: '/model list', desc: 'chat.cmdListModels', action: 'exec' }, { cmd: '/model status', desc: 'chat.cmdModelStatus', action: 'exec' }, ]}, { title: 'chat.cmdThinkMode', commands: [ { cmd: '/think off', desc: 'chat.cmdThinkOff', action: 'exec' }, { cmd: '/think low', desc: 'chat.cmdThinkLow', action: 'exec' }, { cmd: '/think medium', desc: 'chat.cmdThinkMedium', action: 'exec' }, { cmd: '/think high', desc: 'chat.cmdThinkHigh', action: 'exec' }, ]}, { title: 'chat.cmdFastMode', commands: [ { cmd: '/fast', desc: 'chat.cmdFastToggle', action: 'exec' }, { cmd: '/fast on', desc: 'chat.cmdFastOn', action: 'exec' }, { cmd: '/fast off', desc: 'chat.cmdFastOff', action: 'exec' }, ]}, { title: 'chat.cmdVerbose', commands: [ { cmd: '/verbose off', desc: 'chat.cmdVerboseOff', action: 'exec' }, { cmd: '/verbose low', desc: 'chat.cmdVerboseLow', action: 'exec' }, { cmd: '/verbose high', desc: 'chat.cmdVerboseHigh', action: 'exec' }, { cmd: '/reasoning off', desc: 'chat.cmdReasoningOff', action: 'exec' }, { cmd: '/reasoning low', desc: 'chat.cmdReasoningLow', action: 'exec' }, { cmd: '/reasoning medium', desc: 'chat.cmdReasoningMedium', action: 'exec' }, { cmd: '/reasoning high', desc: 'chat.cmdReasoningHigh', action: 'exec' }, ]}, { title: 'chat.cmdInfo', commands: [ { cmd: '/help', desc: 'chat.cmdHelp', action: 'exec' }, { cmd: '/status', desc: 'chat.cmdStatus', action: 'exec' }, { cmd: '/context', desc: 'chat.cmdContext', action: 'exec' }, ]}, ] let _sessionKey = null, _page = null, _messagesEl = null, _textarea = null let _sendBtn = null, _statusDot = null, _typingEl = null, _scrollBtn = null let _sessionListEl = null, _cmdPanelEl = null, _attachPreviewEl = null, _fileInputEl = null let _modelSelectEl = null let _currentAiBubble = null, _currentAiText = '', _currentAiImages = [], _currentAiVideos = [], _currentAiAudios = [], _currentAiFiles = [], _currentAiTools = [], _currentRunId = null let _isStreaming = false, _isSending = false, _messageQueue = [], _streamStartTime = 0 let _lastRenderTime = 0, _renderPending = false, _lastHistoryHash = '' let _autoScrollEnabled = true, _lastScrollTop = 0, _touchStartY = 0 let _isLoadingHistory = false let _streamSafetyTimer = null, _unsubEvent = null, _unsubReady = null, _unsubStatus = null let _seenRunIds = new Set() let _pageActive = false const _toolEventTimes = new Map() const _toolEventData = new Map() const _toolRunIndex = new Map() const _toolEventSeen = new Set() let _errorTimer = null, _lastErrorMsg = null let _responseWatchdog = null, _postFinalCheck = null let _ultimateTimer = null, _sendTimestamp = 0 let _attachments = [] let _hasEverConnected = false let _availableModels = [] let _primaryModel = '' let _selectedModel = '' let _isApplyingModel = false // ── 托管 Agent ── const HOSTED_STATUS = { IDLE: 'idle', RUNNING: 'running', WAITING: 'waiting_reply', PAUSED: 'paused', ERROR: 'error' } const HOSTED_SESSIONS_KEY = 'clawpanel-hosted-agent-sessions' const HOSTED_SYSTEM_PROMPT = `你是一个托管调度 Agent。你的职责是:根据用户设定的目标,持续引导 OpenClaw AI Agent 完成任务。 规则: 1. 你每一轮只输出一条简洁的指令(1-3 句话),发给 OpenClaw 执行 2. 根据 OpenClaw 的回复评估进展,决定下一步指令 3. 如果任务已完成或无法继续,回复包含"完成"或"停止"来结束循环 4. 不要重复相同的指令,不要输出解释性文字,只输出下一步要执行的指令` const HOSTED_DEFAULTS = { enabled: false, prompt: '', autoRunAfterTarget: true, stopPolicy: 'self', maxSteps: 50, stepDelayMs: 1200, retryLimit: 2, autoStopMinutes: 0 } const HOSTED_RUNTIME_DEFAULT = { status: HOSTED_STATUS.IDLE, stepCount: 0, lastRunAt: 0, lastRunId: '', lastError: '', pending: false, errorCount: 0 } const HOSTED_CONTEXT_MAX = 30 const HOSTED_COMPRESS_THRESHOLD = 20 let _hostedBtn = null, _hostedPanelEl = null, _hostedBadgeEl = null let _hostedPromptEl = null, _hostedMaxStepsEl = null, _hostedStepDelayEl = null, _hostedRetryLimitEl = null let _hostedAutoStopEl = null let _hostedSaveBtn = null, _hostedStopBtn = null, _hostedCloseBtn = null let _hostedDefaults = null let _hostedSessionConfig = null let _hostedBoundSessionKey = null let _hostedRuntime = { ...HOSTED_RUNTIME_DEFAULT } let _hostedBusy = false let _hostedAbort = null let _hostedLastTargetTs = 0 let _hostedAutoStopTimer = null let _hostedStartTime = 0 let _workspaceBtn = null, _workspacePanelEl = null, _workspaceAgentBadgeEl = null, _workspaceAgentTitleEl = null let _workspacePathEl = null, _workspaceCoreListEl = null, _workspaceTreeEl = null, _workspaceCurrentFileEl = null let _workspaceMetaEl = null, _workspaceEditorEl = null, _workspacePreviewEl = null, _workspaceEmptyEl = null let _workspaceSaveBtn = null, _workspaceReloadBtn = null, _workspacePreviewBtn = null let _workspaceInfo = null, _workspaceCoreFiles = [], _workspaceTreeCache = new Map(), _workspaceExpandedDirs = new Set() let _workspaceCurrentAgentId = 'main', _workspaceCurrentFile = null, _workspacePreviewMode = false, _workspaceDirty = false let _workspaceLoadedContent = '', _workspaceLoading = false let _workspaceLoadSeq = 0, _workspaceOpenSeq = 0 export async function render() { const page = document.createElement('div') page.className = 'page chat-page' _pageActive = true _page = page page.innerHTML = `
${t('chat.sessionList')}
${t('chat.chatTitle')}
` _messagesEl = page.querySelector('#chat-messages') _textarea = page.querySelector('#chat-input') _sendBtn = page.querySelector('#chat-send-btn') _statusDot = page.querySelector('#chat-status-dot') _typingEl = page.querySelector('#typing-indicator') _scrollBtn = page.querySelector('#chat-scroll-btn') _sessionListEl = page.querySelector('#chat-session-list') _cmdPanelEl = page.querySelector('#chat-cmd-panel') _attachPreviewEl = page.querySelector('#chat-attachments-preview') _fileInputEl = page.querySelector('#chat-file-input') _modelSelectEl = page.querySelector('#chat-model-select') _hostedBtn = page.querySelector('#chat-hosted-btn') _hostedBadgeEl = page.querySelector('#chat-hosted-badge') _hostedPanelEl = page.querySelector('#hosted-agent-panel') _hostedPromptEl = page.querySelector('#hosted-agent-prompt') _hostedMaxStepsEl = page.querySelector('#hosted-agent-max-steps') _hostedStepDelayEl = page.querySelector('#hosted-agent-step-delay') _hostedRetryLimitEl = page.querySelector('#hosted-agent-retry') _hostedAutoStopEl = page.querySelector('#hosted-agent-auto-stop') _hostedSaveBtn = page.querySelector('#hosted-agent-save') _hostedCloseBtn = page.querySelector('#hosted-agent-close') _workspaceBtn = page.querySelector('#btn-chat-workspace') _workspacePanelEl = page.querySelector('#chat-workspace-panel') _workspaceAgentBadgeEl = page.querySelector('#chat-workspace-agent-badge') _workspaceAgentTitleEl = page.querySelector('#chat-workspace-agent-title') _workspacePathEl = page.querySelector('#chat-workspace-path') _workspaceCoreListEl = page.querySelector('#chat-workspace-core-list') _workspaceTreeEl = page.querySelector('#chat-workspace-tree') _workspaceCurrentFileEl = page.querySelector('#chat-workspace-current-file') _workspaceMetaEl = page.querySelector('#chat-workspace-editor-meta') _workspaceEditorEl = page.querySelector('#chat-workspace-editor') _workspacePreviewEl = page.querySelector('#chat-workspace-preview') _workspaceEmptyEl = page.querySelector('#chat-workspace-empty') _workspaceSaveBtn = page.querySelector('#chat-workspace-save') _workspaceReloadBtn = page.querySelector('#chat-workspace-reload') _workspacePreviewBtn = page.querySelector('#chat-workspace-preview-toggle') page.querySelector('#chat-sidebar')?.classList.toggle('open', getSidebarOpen()) bindEvents(page) bindConnectOverlay(page) const workspaceOpen = getWorkspacePanelOpen() applyWorkspacePanelVisibility(workspaceOpen) if (!workspaceOpen) syncWorkspaceContext(false) // 首次使用引导提示 showPageGuide(_messagesEl) loadHostedDefaults().then(() => { loadHostedSessionConfig(); renderHostedPanel(); updateHostedBadge() }) loadModelOptions() // 非阻塞:先返回 DOM,后台连接 Gateway connectGateway() return page } const GUIDE_KEY = 'clawpanel-guide-chat-dismissed' function showPageGuide(container) { if (localStorage.getItem(GUIDE_KEY)) return if (!container || container.querySelector('.chat-page-guide')) return const guide = document.createElement('div') guide.className = 'chat-page-guide' guide.innerHTML = `
${t('chat.guideTitle')}

${t('chat.guideDesc')}

${t('chat.guideHint')}

` guide.querySelector('.chat-guide-close').onclick = () => { localStorage.setItem(GUIDE_KEY, '1') guide.remove() } container.insertBefore(guide, container.firstChild) } // ── 事件绑定 ── function bindEvents(page) { if (_modelSelectEl) { _modelSelectEl.addEventListener('change', () => { _selectedModel = _modelSelectEl.value if (_selectedModel) localStorage.setItem(STORAGE_MODEL_KEY, _selectedModel) else localStorage.removeItem(STORAGE_MODEL_KEY) applySelectedModel() }) } _textarea.addEventListener('input', () => { _textarea.style.height = 'auto' _textarea.style.height = Math.min(_textarea.scrollHeight, 150) + 'px' updateSendState() // 输入 / 时显示指令面板 if (_textarea.value === '/') showCmdPanel() else if (!_textarea.value.startsWith('/')) hideCmdPanel() }) _textarea.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey && !e.isComposing && e.keyCode !== 229) { e.preventDefault(); sendMessage() } if (e.key === 'Escape') hideCmdPanel() }) _sendBtn.addEventListener('click', () => { if (_isStreaming) stopGeneration() else sendMessage() }) if (_hostedBtn) _hostedBtn.addEventListener('click', (e) => { e.stopPropagation(); toggleHostedPanel() }) if (_hostedCloseBtn) _hostedCloseBtn.addEventListener('click', () => hideHostedPanel()) if (_hostedSaveBtn) _hostedSaveBtn.addEventListener('click', () => toggleHostedRun()) // 滑块实时值显示 if (_hostedMaxStepsEl) _hostedMaxStepsEl.addEventListener('input', () => { const valEl = page.querySelector('#ha-steps-val') if (valEl) valEl.textContent = parseInt(_hostedMaxStepsEl.value) >= 205 ? '∞' : _hostedMaxStepsEl.value }) // 定时器开关 const timerToggle = page.querySelector('#hosted-agent-timer-on') const timerBody = page.querySelector('#ha-timer-body') if (timerToggle && timerBody) { timerToggle.addEventListener('change', () => { timerBody.style.display = timerToggle.checked ? '' : 'none' }) } const toggleSidebar = () => { const sidebar = page.querySelector('#chat-sidebar') if (!sidebar) return const nextOpen = !sidebar.classList.contains('open') sidebar.classList.toggle('open', nextOpen) setSidebarOpen(nextOpen) } page.querySelector('#btn-toggle-sidebar')?.addEventListener('click', toggleSidebar) page.querySelector('#btn-toggle-sidebar-main')?.addEventListener('click', toggleSidebar) page.querySelector('#btn-new-session').addEventListener('click', () => showNewSessionDialog()) page.querySelector('#btn-cmd').addEventListener('click', () => toggleCmdPanel()) page.querySelector('#btn-reset-session').addEventListener('click', () => resetCurrentSession()) page.querySelector('#btn-refresh-models')?.addEventListener('click', () => loadModelOptions(true)) _workspaceBtn?.addEventListener('click', async (e) => { e.stopPropagation() if (getWorkspacePanelOpen() && _workspaceDirty) { const yes = await confirmWorkspaceDiscardIfNeeded() if (!yes) return discardWorkspaceChanges() } toggleWorkspacePanel() }) page.querySelector('#chat-workspace-close')?.addEventListener('click', async () => { if (_workspaceDirty) { const yes = await confirmWorkspaceDiscardIfNeeded() if (!yes) return discardWorkspaceChanges() } toggleWorkspacePanel(false) }) page.querySelector('#chat-workspace-refresh')?.addEventListener('click', async () => { if (_workspaceDirty) { const yes = await confirmWorkspaceDiscardIfNeeded() if (!yes) return discardWorkspaceChanges() } loadWorkspacePanelData(true) }) _workspaceCoreListEl?.addEventListener('click', async (e) => { const item = e.target.closest('[data-core-path]') if (!item) return const relativePath = item.dataset.corePath || '' if (!relativePath) return if (item.dataset.coreExists === '1') await openWorkspaceFile(relativePath, { kind: 'core' }) else { const yes = await confirmWorkspaceDiscardIfNeeded() if (!yes) return discardWorkspaceChanges() prepareWorkspaceDraftFile(relativePath, { kind: 'core' }) } }) _workspaceTreeEl?.addEventListener('click', async (e) => { const toggle = e.target.closest('[data-tree-toggle]') if (toggle) { try { await toggleWorkspaceDirectory(toggle.dataset.treeToggle || '') } catch (err) { toast(`${t('chat.workspaceLoadFailed')}: ${err?.message || err}`, 'error') } return } const link = e.target.closest('[data-tree-path]') if (!link) return const relativePath = link.dataset.treePath || '' if (!relativePath) return if (link.dataset.treeType === 'dir') { try { await toggleWorkspaceDirectory(relativePath) } catch (err) { toast(`${t('chat.workspaceLoadFailed')}: ${err?.message || err}`, 'error') } return } await openWorkspaceFile(relativePath, { kind: 'tree' }) }) _workspaceEditorEl?.addEventListener('input', () => { if (!_workspaceCurrentFile || !_workspaceEditorEl) return _workspaceDirty = _workspaceEditorEl.value !== _workspaceLoadedContent if (_workspacePreviewMode) renderWorkspacePreview() updateWorkspaceEditorState() }) _workspaceEditorEl?.addEventListener('keydown', (e) => { if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') { e.preventDefault() saveWorkspaceCurrentFile() } }) _workspaceReloadBtn?.addEventListener('click', () => reloadWorkspaceCurrentFile()) _workspacePreviewBtn?.addEventListener('click', () => toggleWorkspacePreview()) _workspaceSaveBtn?.addEventListener('click', () => saveWorkspaceCurrentFile()) // 文件上传 page.querySelector('#chat-attach-btn').addEventListener('click', () => _fileInputEl.click()) _fileInputEl.addEventListener('change', handleFileSelect) // 粘贴图片(Ctrl+V) _textarea.addEventListener('paste', handlePaste) _messagesEl.addEventListener('scroll', () => { const { scrollTop, scrollHeight, clientHeight } = _messagesEl _scrollBtn.style.display = (scrollHeight - scrollTop - clientHeight < 80) ? 'none' : 'flex' if (scrollTop < _lastScrollTop - 2) _autoScrollEnabled = false if (isAtBottom()) _autoScrollEnabled = true _lastScrollTop = scrollTop }) _messagesEl.addEventListener('wheel', (e) => { if (e.deltaY < 0) _autoScrollEnabled = false }, { passive: true }) _messagesEl.addEventListener('touchstart', (e) => { _touchStartY = e.touches?.[0]?.clientY || 0 }, { passive: true }) _messagesEl.addEventListener('touchmove', (e) => { const y = e.touches?.[0]?.clientY || 0 if (y > _touchStartY + 2) _autoScrollEnabled = false }, { passive: true }) _scrollBtn.addEventListener('click', () => { _autoScrollEnabled = true scrollToBottom(true) }) _messagesEl.addEventListener('click', (e) => { const copyBtn = e.target.closest('.msg-copy-btn') if (copyBtn) { e.stopPropagation() const msgWrap = copyBtn.closest('.msg') const bubble = msgWrap?.querySelector('.msg-bubble') if (bubble) { const text = bubble.innerText || bubble.textContent || '' navigator.clipboard.writeText(text.trim()).then(() => { copyBtn.classList.add('copied') copyBtn.innerHTML = svgIcon('check', 12) setTimeout(() => { copyBtn.classList.remove('copied'); copyBtn.innerHTML = svgIcon('copy', 12) }, 1500) }).catch(() => {}) } return } hideCmdPanel() }) } async function loadModelOptions(showToast = false) { if (!_modelSelectEl) return // 显示加载状态 _modelSelectEl.innerHTML = `` _modelSelectEl.disabled = true try { invalidate('read_openclaw_config') const configPromise = api.readOpenclawConfig() const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('timeout(8s)')), 8000)) const config = await Promise.race([configPromise, timeoutPromise]) const providers = config?.models?.providers || {} _primaryModel = config?.agents?.defaults?.model?.primary || '' const models = [] const seen = new Set() if (_primaryModel) { seen.add(_primaryModel) models.push(_primaryModel) } for (const [providerKey, provider] of Object.entries(providers)) { for (const item of (provider?.models || [])) { const modelId = typeof item === 'string' ? item : item?.id if (!modelId) continue const full = `${providerKey}/${modelId}` if (seen.has(full)) continue seen.add(full) models.push(full) } } _availableModels = models const saved = localStorage.getItem(STORAGE_MODEL_KEY) || '' _selectedModel = models.includes(saved) ? saved : (_primaryModel || models[0] || '') renderModelSelect() if (showToast) toast(`${t('chat.refreshModels')} (${models.length})`, 'success') } catch (e) { _availableModels = [] _primaryModel = '' _selectedModel = '' renderModelSelect(`${t('common.loadFailed')}: ${e.message || e}`) if (showToast) toast(`${t('common.loadFailed')}: ${e.message || e}`, 'error') } } function renderModelSelect(errorText = '') { if (!_modelSelectEl) return if (!_availableModels.length) { _modelSelectEl.innerHTML = `` _modelSelectEl.disabled = true _modelSelectEl.title = errorText || '' return } _modelSelectEl.disabled = _isApplyingModel _modelSelectEl.innerHTML = _availableModels.map(full => { const suffix = full === _primaryModel ? ` ${t('chat.defaultSuffix')}` : '' return `` }).join('') _modelSelectEl.title = _selectedModel || '' } function escapeAttr(str) { return (str || '').replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>') } /** 本地会话别名缓存 */ function getSessionNames() { try { return JSON.parse(localStorage.getItem(STORAGE_SESSION_NAMES_KEY) || '{}') } catch { return {} } } function setSessionName(key, name) { const names = getSessionNames() if (name) names[key] = name else delete names[key] localStorage.setItem(STORAGE_SESSION_NAMES_KEY, JSON.stringify(names)) } function getDisplayLabel(key) { const custom = getSessionNames()[key] return custom || parseSessionLabel(key) } function getSidebarOpen() { return localStorage.getItem(STORAGE_SIDEBAR_KEY) === '1' } function setSidebarOpen(open) { localStorage.setItem(STORAGE_SIDEBAR_KEY, open ? '1' : '0') } function getWorkspacePanelOpen() { return localStorage.getItem(STORAGE_WORKSPACE_PANEL_KEY) === '1' } function setWorkspacePanelOpen(open) { localStorage.setItem(STORAGE_WORKSPACE_PANEL_KEY, open ? '1' : '0') } function formatWorkspaceFileSize(bytes) { const size = Number(bytes) || 0 if (size < 1024) return `${size} B` if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB` return `${(size / (1024 * 1024)).toFixed(1)} MB` } function formatWorkspaceFileTime(value) { if (!value) return '' const date = new Date(value) if (Number.isNaN(date.getTime())) return '' return date.toLocaleString() } function isMarkdownWorkspaceFile(relativePath) { return /\.(md|markdown|mdx)$/i.test(relativePath || '') } async function confirmWorkspaceDiscardIfNeeded() { if (!_workspaceDirty) return true return showConfirm(t('chat.confirmDiscardWorkspaceChanges')) } function discardWorkspaceChanges() { if (!_workspaceCurrentFile) { _workspaceDirty = false updateWorkspaceEditorState() return } if (_workspaceEditorEl) _workspaceEditorEl.value = _workspaceLoadedContent _workspaceDirty = false if (_workspacePreviewMode) renderWorkspacePreview() updateWorkspaceEditorState() } function getCurrentWorkspaceAgentId() { return parseSessionAgent(_sessionKey) || wsClient.snapshot?.sessionDefaults?.defaultAgentId || 'main' } function getWorkspaceAgentTitle() { if (_sessionKey) return getDisplayLabel(_sessionKey) if (_workspaceCurrentAgentId === 'main') return t('chat.mainSession') return _workspaceCurrentAgentId || t('chat.workspace') } async function syncWorkspaceContext(reload = true) { const nextAgentId = getCurrentWorkspaceAgentId() const prevAgentId = _workspaceCurrentAgentId _workspaceCurrentAgentId = nextAgentId || 'main' const triggerAgentEl = _page?.querySelector('#chat-workspace-trigger-agent') if (triggerAgentEl) triggerAgentEl.textContent = _workspaceCurrentAgentId if (_workspaceAgentBadgeEl) _workspaceAgentBadgeEl.textContent = _workspaceCurrentAgentId if (_workspaceAgentTitleEl) { _workspaceAgentTitleEl.textContent = getWorkspaceAgentTitle() } if (!_workspacePanelEl || !getWorkspacePanelOpen()) return if (!reload && prevAgentId === _workspaceCurrentAgentId && _workspaceInfo) return if (prevAgentId !== _workspaceCurrentAgentId) { _workspaceDirty = false _workspaceCurrentFile = null } await loadWorkspacePanelData(prevAgentId === _workspaceCurrentAgentId) } function applyWorkspacePanelVisibility(open) { if (!_workspacePanelEl) return _workspacePanelEl.style.display = open ? '' : 'none' _workspaceBtn?.classList.toggle('is-active', open) if (open) syncWorkspaceContext(true) } function toggleWorkspacePanel(force) { const nextOpen = typeof force === 'boolean' ? force : !getWorkspacePanelOpen() setWorkspacePanelOpen(nextOpen) applyWorkspacePanelVisibility(nextOpen) } function renderWorkspacePanelMeta() { if (_workspaceAgentBadgeEl) _workspaceAgentBadgeEl.textContent = _workspaceCurrentAgentId if (_workspaceAgentTitleEl) { _workspaceAgentTitleEl.textContent = getWorkspaceAgentTitle() } if (_workspacePathEl) { const path = _workspaceInfo?.workspacePath || '' _workspacePathEl.textContent = path || t('chat.workspaceUnavailable') _workspacePathEl.title = path || '' } } function renderWorkspaceCoreFiles() { if (!_workspaceCoreListEl) return if (!_workspaceCoreFiles.length) { _workspaceCoreListEl.innerHTML = `
${t('chat.workspaceNoCoreFiles')}
` return } _workspaceCoreListEl.innerHTML = _workspaceCoreFiles.map(file => { const active = _workspaceCurrentFile?.relativePath === file.name ? ' active' : '' const status = file.exists ? t('common.edit') : t('common.add') return ` ` }).join('') } function renderWorkspaceTreeNode(entry, depth) { const isDir = entry.type === 'dir' const expanded = isDir && _workspaceExpandedDirs.has(entry.relativePath) const active = _workspaceCurrentFile?.relativePath === entry.relativePath ? ' active' : '' const children = expanded ? (_workspaceTreeCache.get(entry.relativePath) || []).map(child => renderWorkspaceTreeNode(child, depth + 1)).join('') : '' return `
${isDir ? `` : ''}
${children}
` } function renderWorkspaceTree() { if (!_workspaceTreeEl) return const rootEntries = _workspaceTreeCache.get('') || [] if (!rootEntries.length) { _workspaceTreeEl.innerHTML = `
${t('chat.workspaceTreeEmpty')}
` return } _workspaceTreeEl.innerHTML = rootEntries.map(entry => renderWorkspaceTreeNode(entry, 0)).join('') } function renderWorkspacePreview() { if (!_workspacePreviewEl || !_workspaceEditorEl) return _workspacePreviewEl.innerHTML = renderMarkdown(_workspaceEditorEl.value || '') } function updateWorkspaceEditorState() { const hasFile = !!_workspaceCurrentFile const canSaveDraft = hasFile && _workspaceCurrentFile?.exists === false if (_workspaceCurrentFileEl) { _workspaceCurrentFileEl.textContent = hasFile ? `${_workspaceCurrentFile.relativePath}${_workspaceDirty ? ' *' : ''}` : t('chat.selectWorkspaceFile') } if (_workspaceSaveBtn) _workspaceSaveBtn.disabled = !hasFile || (!canSaveDraft && !_workspaceDirty) || _workspaceLoading if (_workspaceReloadBtn) _workspaceReloadBtn.disabled = !hasFile || _workspaceLoading if (_workspacePreviewBtn) _workspacePreviewBtn.disabled = !hasFile || !_workspaceCurrentFile?.previewable || _workspaceLoading const previewLabelEl = _page?.querySelector('#chat-workspace-preview-label') if (previewLabelEl) previewLabelEl.textContent = _workspacePreviewMode ? t('chat.editWorkspaceFile') : t('chat.previewWorkspaceFile') if (_workspaceEditorEl) { _workspaceEditorEl.disabled = !hasFile || _workspaceLoading _workspaceEditorEl.style.display = hasFile && !_workspacePreviewMode ? '' : 'none' } if (_workspacePreviewEl) { _workspacePreviewEl.style.display = hasFile && _workspacePreviewMode ? '' : 'none' } if (_workspaceEmptyEl) { _workspaceEmptyEl.style.display = hasFile ? 'none' : '' } if (hasFile && _workspacePreviewMode) renderWorkspacePreview() } function resetWorkspaceEditor(emptyText = t('chat.workspaceEmptyState')) { _workspaceCurrentFile = null _workspacePreviewMode = false _workspaceDirty = false _workspaceLoadedContent = '' if (_workspaceMetaEl) _workspaceMetaEl.textContent = '' if (_workspaceEditorEl) { _workspaceEditorEl.value = '' _workspaceEditorEl.placeholder = t('chat.selectWorkspaceFile') } if (_workspacePreviewEl) { _workspacePreviewEl.innerHTML = '' _workspacePreviewEl.style.display = 'none' } if (_workspaceEmptyEl) _workspaceEmptyEl.textContent = emptyText renderWorkspaceCoreFiles() renderWorkspaceTree() updateWorkspaceEditorState() } function prepareWorkspaceDraftFile(relativePath, options = {}) { const { kind = 'core', previewable = isMarkdownWorkspaceFile(relativePath) } = options _workspaceCurrentFile = { agentId: _workspaceCurrentAgentId, relativePath, kind, previewable, exists: false } _workspacePreviewMode = false _workspaceDirty = false _workspaceLoadedContent = '' if (_workspaceEditorEl) { _workspaceEditorEl.value = '' _workspaceEditorEl.placeholder = t('chat.workspaceDraftHint') } if (_workspaceMetaEl) _workspaceMetaEl.textContent = t('chat.workspaceDraftHint') renderWorkspaceCoreFiles() renderWorkspaceTree() updateWorkspaceEditorState() } async function loadWorkspacePanelData(preserveCurrentFile = false) { if (!_workspaceCoreListEl || !_workspaceTreeEl) return const loadSeq = ++_workspaceLoadSeq const agentId = _workspaceCurrentAgentId || 'main' _workspaceLoading = true renderWorkspacePanelMeta() _workspaceCoreListEl.innerHTML = `
${t('common.loading')}
` _workspaceTreeEl.innerHTML = `
${t('common.loading')}
` updateWorkspaceEditorState() try { const previousFile = preserveCurrentFile ? _workspaceCurrentFile : null const [info, coreFiles, rootEntries] = await Promise.all([ api.getAgentWorkspaceInfo(agentId), api.listAgentFiles(agentId), api.listAgentWorkspaceEntries(agentId, ''), ]) if (loadSeq !== _workspaceLoadSeq || agentId !== _workspaceCurrentAgentId) return _workspaceInfo = info || null _workspaceCoreFiles = Array.isArray(coreFiles) ? coreFiles : [] _workspaceTreeCache = new Map([['', Array.isArray(rootEntries) ? rootEntries : []]]) _workspaceExpandedDirs = new Set() renderWorkspacePanelMeta() renderWorkspaceCoreFiles() renderWorkspaceTree() if (previousFile && previousFile.agentId === agentId) { if (previousFile.kind === 'core' && previousFile.exists === false) { prepareWorkspaceDraftFile(previousFile.relativePath, previousFile) } else { await openWorkspaceFile(previousFile.relativePath, { kind: previousFile.kind, force: true, silent: true }) } } else { resetWorkspaceEditor(t('chat.workspaceEmptyState')) } } catch (e) { if (loadSeq !== _workspaceLoadSeq || agentId !== _workspaceCurrentAgentId) return _workspaceInfo = null _workspaceCoreFiles = [] _workspaceTreeCache = new Map([['', []]]) _workspaceExpandedDirs = new Set() resetWorkspaceEditor(t('chat.workspaceUnavailable')) renderWorkspacePanelMeta() const message = e?.message || String(e) _workspaceCoreListEl.innerHTML = `
${escapeAttr(message)}
` _workspaceTreeEl.innerHTML = `
${escapeAttr(message)}
` toast(`${t('chat.workspaceLoadFailed')}: ${message}`, 'error') } finally { if (loadSeq !== _workspaceLoadSeq) return _workspaceLoading = false updateWorkspaceEditorState() } } async function toggleWorkspaceDirectory(relativePath) { if (!relativePath) return if (_workspaceExpandedDirs.has(relativePath)) { _workspaceExpandedDirs.delete(relativePath) renderWorkspaceTree() return } try { if (!_workspaceTreeCache.has(relativePath)) { const entries = await api.listAgentWorkspaceEntries(_workspaceCurrentAgentId, relativePath) _workspaceTreeCache.set(relativePath, Array.isArray(entries) ? entries : []) } _workspaceExpandedDirs.add(relativePath) renderWorkspaceTree() } catch (e) { toast(`${t('common.loadFailed')}: ${e?.message || e}`, 'error') } } async function openWorkspaceFile(relativePath, options = {}) { const { kind = 'tree', force = false, silent = false } = options if (!force && !(await confirmWorkspaceDiscardIfNeeded())) return const openSeq = ++_workspaceOpenSeq const agentId = _workspaceCurrentAgentId try { const file = await api.readAgentWorkspaceFile(agentId, relativePath) if (openSeq !== _workspaceOpenSeq || agentId !== _workspaceCurrentAgentId) return _workspaceCurrentFile = { agentId, relativePath, kind, previewable: !!file.previewable, exists: true, } _workspaceLoadedContent = file.content || '' _workspacePreviewMode = false _workspaceDirty = false if (_workspaceEditorEl) { _workspaceEditorEl.value = _workspaceLoadedContent _workspaceEditorEl.placeholder = t('chat.selectWorkspaceFile') } const metaParts = [] if (typeof file.size === 'number') metaParts.push(formatWorkspaceFileSize(file.size)) const timeText = formatWorkspaceFileTime(file.mtime) if (timeText) metaParts.push(timeText) if (_workspaceMetaEl) _workspaceMetaEl.textContent = metaParts.join(' · ') renderWorkspaceCoreFiles() renderWorkspaceTree() updateWorkspaceEditorState() } catch (e) { if (openSeq !== _workspaceOpenSeq || agentId !== _workspaceCurrentAgentId) return if (!silent) toast(`${t('chat.workspaceOpenFailed')}: ${e?.message || e}`, 'error') } } async function reloadWorkspaceCurrentFile(force = false) { if (!_workspaceCurrentFile) return if (!force && !(await confirmWorkspaceDiscardIfNeeded())) return if (_workspaceCurrentFile.kind === 'core' && _workspaceCurrentFile.exists === false) { prepareWorkspaceDraftFile(_workspaceCurrentFile.relativePath, _workspaceCurrentFile) return } await openWorkspaceFile(_workspaceCurrentFile.relativePath, { kind: _workspaceCurrentFile.kind, force: true }) } function toggleWorkspacePreview() { if (!_workspaceCurrentFile?.previewable) return _workspacePreviewMode = !_workspacePreviewMode updateWorkspaceEditorState() } async function saveWorkspaceCurrentFile() { if (!_workspaceCurrentFile || !_workspaceEditorEl) return const text = _workspaceEditorEl.value const wasExisting = _workspaceCurrentFile.exists !== false try { await api.writeAgentWorkspaceFile(_workspaceCurrentAgentId, _workspaceCurrentFile.relativePath, text) _workspaceCurrentFile = { ..._workspaceCurrentFile, exists: true } _workspaceLoadedContent = text _workspaceDirty = false try { await loadWorkspacePanelData(true) } catch (refreshError) { console.warn('[chat] workspace refresh after save failed:', refreshError) } toast(wasExisting ? t('common.saveSuccess') : t('chat.workspaceFileCreated'), 'success') } catch (e) { toast(`${t('common.saveFailed')}: ${e?.message || e}`, 'error') } } async function applySelectedModel() { if (!_selectedModel) { toast(t('chat.loadingModels'), 'warning') return } if (!wsClient.gatewayReady || !_sessionKey) { toast(t('chat.gatewayNotReadySend'), 'warning') return } _isApplyingModel = true renderModelSelect() try { await wsClient.chatSend(_sessionKey, `/model ${_selectedModel}`) toast(`${_selectedModel}`, 'success') } catch (e) { toast(`${t('chat.sendFailed')}${e.message || e}`, 'error') } finally { _isApplyingModel = false renderModelSelect() } } // ── 连接引导遮罩 ── function bindConnectOverlay(page) { const fixBtn = page.querySelector('#btn-fix-connect') const gwBtn = page.querySelector('#btn-goto-gateway') if (fixBtn) { fixBtn.addEventListener('click', async () => { fixBtn.disabled = true fixBtn.textContent = t('chat.fixing') const desc = document.getElementById('chat-connect-desc') try { if (desc) desc.textContent = t('chat.writingConfig') await api.autoPairDevice() await api.reloadGateway() if (desc) desc.textContent = t('chat.fixDoneReconnecting') // 断开旧连接,重新发起 wsClient.disconnect() setTimeout(() => connectGateway(), 3000) } catch (e) { if (desc) desc.textContent = `${t('chat.fixFailed')}${e.message || e}` } finally { fixBtn.disabled = false fixBtn.textContent = t('chat.fixAndReconnect') } }) } if (gwBtn) { gwBtn.addEventListener('click', () => navigate('/gateway')) } } // ── 文件上传 ── async function handleFileSelect(e) { const files = Array.from(e.target.files || []) if (!files.length) return for (const file of files) { if (!file.type.startsWith('image/')) { toast(t('chat.imageOnly'), 'warning') continue } if (file.size > 5 * 1024 * 1024) { toast(`${file.name} > 5MB`, 'warning') continue } try { const base64 = await fileToBase64(file) _attachments.push({ type: 'image', mimeType: file.type, fileName: file.name, content: base64, }) renderAttachments() } catch (e) { toast(`${t('chat.readFileFailed')} ${file.name}`, 'error') } } _fileInputEl.value = '' } async function handlePaste(e) { const items = Array.from(e.clipboardData?.items || []) const imageItems = items.filter(item => item.type.startsWith('image/')) if (!imageItems.length) return e.preventDefault() for (const item of imageItems) { const file = item.getAsFile() if (!file) continue if (file.size > 5 * 1024 * 1024) { toast(t('chat.imageSizeLimit'), 'warning'); continue } try { const base64 = await fileToBase64(file) _attachments.push({ type: 'image', mimeType: file.type || 'image/png', fileName: `paste-${Date.now()}.png`, content: base64 }) renderAttachments() } catch (_) { toast(t('chat.readFileFailed'), 'error') } } } function fileToBase64(file) { return new Promise((resolve, reject) => { const reader = new FileReader() reader.onload = () => { const dataUrl = reader.result const match = /^data:[^;]+;base64,(.+)$/.exec(dataUrl) if (!match) { reject(new Error('invalid data URL')); return } resolve(match[1]) } reader.onerror = reject reader.readAsDataURL(file) }) } function renderAttachments() { if (!_attachPreviewEl) return if (!_attachments.length) { _attachPreviewEl.style.display = 'none' return } _attachPreviewEl.style.display = 'flex' _attachPreviewEl.innerHTML = _attachments.map((att, idx) => `
${att.fileName}
`).join('') _attachPreviewEl.querySelectorAll('.chat-attachment-del').forEach(btn => { btn.addEventListener('click', () => { const idx = parseInt(btn.dataset.idx) _attachments.splice(idx, 1) renderAttachments() }) }) updateSendState() } // ── Gateway 连接 ── async function connectGateway() { try { // 清理旧的订阅,避免重复监听 if (_unsubStatus) { _unsubStatus(); _unsubStatus = null } if (_unsubReady) { _unsubReady(); _unsubReady = null } if (_unsubEvent) { _unsubEvent(); _unsubEvent = null } // 订阅状态变化(订阅式,返回 unsub) _unsubStatus = wsClient.onStatusChange((status, errorMsg) => { if (!_pageActive) return updateStatusDot(status) const bar = document.getElementById('chat-disconnect-bar') const overlay = document.getElementById('chat-connect-overlay') const desc = document.getElementById('chat-connect-desc') if (status === 'ready' || status === 'connected') { _hasEverConnected = true if (bar) bar.style.display = 'none' if (overlay) overlay.style.display = 'none' // WS 已连接,主动刷新 Gateway 状态以消除顶部横条延迟 import('../lib/app-state.js').then(m => m.refreshGatewayStatus()).catch(() => {}) } else if (status === 'error') { // 连接错误:显示引导遮罩而非底部条 if (bar) bar.style.display = 'none' if (overlay) { overlay.style.display = 'flex' if (desc) desc.textContent = errorMsg || t('chat.connectFailed') } } else if (status === 'reconnecting' || status === 'disconnected') { // 首次连接或多次重连失败时,显示引导遮罩而非底部小条 if (!_hasEverConnected) { if (overlay) { overlay.style.display = 'flex'; if (desc) desc.textContent = t('chat.connectingGateway') } } else { if (bar) { bar.textContent = t('chat.disconnected'); bar.style.display = 'flex' } } } else { if (bar) bar.style.display = 'none' } }) _unsubReady = wsClient.onReady((hello, sessionKey, err) => { if (!_pageActive) return const overlay = document.getElementById('chat-connect-overlay') if (err?.error) { if (overlay) { overlay.style.display = 'flex' const desc = document.getElementById('chat-connect-desc') if (desc) desc.textContent = err.message || t('chat.connectFailed') } return } if (overlay) overlay.style.display = 'none' showTyping(false) // Gateway 就绪后关闭加载动画 // 重连后恢复:保留当前 sessionKey,不重复加载历史 if (!_sessionKey) { const saved = localStorage.getItem(STORAGE_SESSION_KEY) _sessionKey = saved || sessionKey updateSessionTitle() loadHistory() } else { syncWorkspaceContext(false) } // 始终刷新会话列表(无论是否有 sessionKey) refreshSessionList() }) _unsubEvent = wsClient.onEvent((msg) => { if (!_pageActive) return handleEvent(msg) }) // 如果已连接且 Gateway 就绪,直接复用 if (wsClient.connected && wsClient.gatewayReady) { const saved = localStorage.getItem(STORAGE_SESSION_KEY) _sessionKey = saved || wsClient.sessionKey updateStatusDot('ready') showTyping(false) // 确保关闭加载动画 updateSessionTitle() loadHistory() refreshSessionList() return } // 如果正在连接中(重连等),等待 onReady 回调即可 if (wsClient.connected || wsClient.connecting || wsClient.gatewayReady) return // 未连接,发起新连接 const config = await api.readOpenclawConfig() const gw = config?.gateway || {} const host = isTauriRuntime() ? `127.0.0.1:${gw.port || 18789}` : location.host const token = gw.auth?.token || gw.authToken || '' wsClient.connect(host, token) } catch (e) { toast(`${t('common.loadFailed')}: ${e.message}`, 'error') } } // ── 会话管理 ── async function refreshSessionList() { if (!_sessionListEl || !wsClient.gatewayReady) return try { const result = await wsClient.sessionsList(50) const sessions = result?.sessions || result || [] renderSessionList(sessions) } catch (e) { console.error('[chat] refreshSessionList error:', e) } } function renderSessionList(sessions) { if (!_sessionListEl) return if (!sessions.length) { _sessionListEl.innerHTML = `
${t('chat.noSessions')}
` return } sessions.sort((a, b) => (b.updatedAt || b.lastActivity || 0) - (a.updatedAt || a.lastActivity || 0)) _sessionListEl.innerHTML = sessions.map(s => { const key = s.sessionKey || s.key || '' const active = key === _sessionKey ? ' active' : '' const label = parseSessionLabel(key) const ts = s.updatedAt || s.lastActivity || s.createdAt || 0 const timeStr = ts ? formatSessionTime(ts) : '' const msgCount = s.messageCount || s.messages || 0 const agentId = parseSessionAgent(key) const displayLabel = getDisplayLabel(key) || label return `
${escapeAttr(displayLabel)}
${agentId && agentId !== 'main' ? `${escapeAttr(agentId)}` : ''} ${msgCount > 0 ? `${msgCount} msgs` : ''} ${timeStr ? `${timeStr}` : ''}
` }).join('') _sessionListEl.onclick = (e) => { const delBtn = e.target.closest('[data-del]') if (delBtn) { e.stopPropagation(); deleteSession(delBtn.dataset.del); return } const item = e.target.closest('[data-key]') if (item) void switchSession(item.dataset.key) } _sessionListEl.ondblclick = (e) => { const labelEl = e.target.closest('.chat-session-label') if (!labelEl) return const card = labelEl.closest('[data-key]') if (!card) return e.stopPropagation() renameSession(card.dataset.key, labelEl) } } function formatSessionTime(ts) { const d = new Date(typeof ts === 'number' && ts < 1e12 ? ts * 1000 : ts) if (isNaN(d.getTime())) return '' const now = new Date() const diffMs = now - d if (diffMs < 60000) return t('chat.justNow') if (diffMs < 3600000) return t('chat.minutesAgo', { n: Math.floor(diffMs / 60000) }) if (diffMs < 86400000) return t('chat.hoursAgo', { n: Math.floor(diffMs / 3600000) }) if (diffMs < 604800000) return t('chat.daysAgo', { n: Math.floor(diffMs / 86400000) }) return `${(d.getMonth() + 1).toString().padStart(2, '0')}-${d.getDate().toString().padStart(2, '0')}` } function parseSessionAgent(key) { const parts = (key || '').split(':') return parts.length >= 2 ? parts[1] : '' } function parseSessionLabel(key) { const parts = (key || '').split(':') if (parts.length < 3) return key || t('common.unknown') const agent = parts[1] || 'main' const channel = parts.slice(2).join(':') if (agent === 'main' && channel === 'main') return t('chat.mainSession') if (agent === 'main') return channel return `${agent} / ${channel}` } async function switchSession(newKey, options = {}) { const { forceWorkspace = false } = options if (newKey === _sessionKey) return false const nextAgentId = parseSessionAgent(newKey) || 'main' if (!forceWorkspace && _workspaceDirty && nextAgentId !== _workspaceCurrentAgentId) { const yes = await confirmWorkspaceDiscardIfNeeded() if (!yes) return false discardWorkspaceChanges() } _sessionKey = newKey localStorage.setItem(STORAGE_SESSION_KEY, newKey) _lastHistoryHash = '' resetStreamState() updateSessionTitle() clearMessages() loadHistory() refreshSessionList() return true } async function showNewSessionDialog() { const defaultAgent = wsClient.snapshot?.sessionDefaults?.defaultAgentId || 'main' // 先用默认选项立即显示弹窗 const initialOptions = [ { value: 'main', label: `main ${t('chat.defaultSuffix')}` }, { value: '__new__', label: `+ ${t('chat.newAgent')}` } ] showModal({ title: t('chat.newSession'), fields: [ { name: 'name', label: t('chat.sessionName'), value: '', placeholder: t('chat.sessionNamePlaceholder') }, { name: 'agent', label: 'Agent', type: 'select', value: defaultAgent, options: initialOptions }, ], onConfirm: (result) => { const name = (result.name || '').trim() if (!name) { toast(t('chat.enterSessionName'), 'warning'); return } const agent = result.agent || defaultAgent if (agent === '__new__') { navigate('/agents') toast(t('chat.createAgentHint'), 'info') return } switchSession(`agent:${agent}:${name}`).then((switched) => { if (switched) toast(t('chat.sessionCreated'), 'success') }) } }) // 异步加载完整 Agent 列表并更新下拉框 try { const agents = await api.listAgents() const agentOptions = agents.map(a => ({ value: a.id, label: `${a.id}${a.isDefault ? ` ${t('chat.defaultSuffix')}` : ''}${a.identityName ? ' — ' + a.identityName.split(',')[0] : ''}` })) agentOptions.push({ value: '__new__', label: `+ ${t('chat.newAgent')}` }) // 更新弹窗中的下拉框选项 const selectEl = document.querySelector('.modal-overlay [data-name="agent"]') if (selectEl) { const currentValue = selectEl.value selectEl.innerHTML = agentOptions.map(o => `` ).join('') } } catch (e) { console.warn('[chat] 加载 Agent 列表失败:', e) } } async function deleteSession(key) { const mainKey = wsClient.snapshot?.sessionDefaults?.mainSessionKey || 'agent:main:main' if (key === mainKey) { toast(t('chat.cannotDeleteMain'), 'warning'); return } const label = parseSessionLabel(key) const yes = await showConfirm(t('chat.confirmDeleteSession', { label })) if (!yes) return try { await wsClient.sessionsDelete(key) toast(t('chat.sessionDeleted'), 'success') if (key === _sessionKey) void switchSession(mainKey, { forceWorkspace: true }) else refreshSessionList() } catch (e) { toast(`${t('common.operationFailed')}: ${e.message}`, 'error') } } async function resetCurrentSession() { if (!_sessionKey) return const label = getDisplayLabel(_sessionKey) const yes = await showConfirm(t('chat.confirmResetSession', { label })) if (!yes) return try { await wsClient.sessionsReset(_sessionKey) clearMessages() _lastHistoryHash = '' appendSystemMessage(t('chat.sessionResetDone')) toast(t('chat.sessionResetDone'), 'success') } catch (e) { toast(`${t('common.operationFailed')}: ${e.message}`, 'error') } } function updateSessionTitle() { const el = _page?.querySelector('#chat-title') if (el) el.textContent = getDisplayLabel(_sessionKey) syncWorkspaceContext(false) } function renameSession(key, labelEl) { const current = getDisplayLabel(key) const input = document.createElement('input') input.type = 'text' input.value = current input.className = 'chat-session-rename-input' input.style.cssText = 'width:100%;padding:2px 6px;border:1px solid var(--accent);border-radius:4px;background:var(--bg-secondary);color:var(--text-primary);font-size:12px;outline:none' const originalText = labelEl.textContent labelEl.textContent = '' labelEl.appendChild(input) input.focus() input.select() let done = false const finish = () => { if (done) return done = true const newName = input.value.trim() if (newName && newName !== parseSessionLabel(key)) { setSessionName(key, newName) toast(t('chat.sessionRenamed'), 'success') } else if (!newName || newName === parseSessionLabel(key)) { setSessionName(key, '') // clear custom name } labelEl.textContent = getDisplayLabel(key) // 如果是当前会话,同步更新顶部标题 if (key === _sessionKey) updateSessionTitle() } input.addEventListener('blur', finish) input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); input.blur() } if (e.key === 'Escape') { input.value = originalText; input.blur() } }) } // ── 快捷指令面板 ── function showCmdPanel() { if (!_cmdPanelEl) return let html = '' for (const group of COMMANDS) { html += `
${t(group.title)}
` for (const c of group.commands) { html += `
${c.cmd} ${t(c.desc)}
` } } _cmdPanelEl.innerHTML = html _cmdPanelEl.style.display = 'block' _cmdPanelEl.onclick = (e) => { const item = e.target.closest('.cmd-item') if (!item) return hideCmdPanel() if (item.dataset.action === 'fill') { _textarea.value = item.dataset.cmd _textarea.focus() updateSendState() } else { _textarea.value = item.dataset.cmd sendMessage() } } } function hideCmdPanel() { if (_cmdPanelEl) _cmdPanelEl.style.display = 'none' } function toggleCmdPanel() { if (_cmdPanelEl?.style.display === 'block') hideCmdPanel() else { _textarea.value = '/'; showCmdPanel(); _textarea.focus() } } // ── 消息发送 ── function sendMessage() { const text = _textarea.value.trim() if (!text && !_attachments.length) return if (!wsClient.gatewayReady || !_sessionKey) { toast(t('chat.gatewayNotReadySend'), 'warning') return } hideCmdPanel() _textarea.value = '' _textarea.style.height = 'auto' updateSendState() const attachments = [..._attachments] _attachments = [] renderAttachments() if (_isSending || _isStreaming) { _messageQueue.push({ text, attachments }); return } doSend(text, attachments) } async function doSend(text, attachments = []) { if (!wsClient.gatewayReady || !_sessionKey) { toast(t('chat.gatewayNotReadySend'), 'warning') return } appendUserMessage(text, attachments) saveMessage({ id: uuid(), sessionKey: _sessionKey, role: 'user', content: text, timestamp: Date.now(), attachments: attachments?.length ? attachments.map(a => ({ category: a.category || 'image', mimeType: a.mimeType || '', content: a.content || '', url: a.url || '' })) : undefined }) showTyping(true) _isSending = true _startResponseWatchdog() try { await wsClient.chatSend(_sessionKey, text, attachments.length ? attachments : undefined) } catch (err) { showTyping(false) _cancelResponseWatchdog() _sendTimestamp = 0 appendSystemMessage(`${t('chat.sendFailed')}${err.message}`) } finally { _isSending = false updateSendState() } } function processMessageQueue() { if (_messageQueue.length === 0 || _isSending || _isStreaming) return const msg = _messageQueue.shift() if (typeof msg === 'string') doSend(msg, []) else doSend(msg.text, msg.attachments || []) } function stopGeneration() { if (_currentRunId) wsClient.chatAbort(_sessionKey, _currentRunId).catch(() => {}) } // ── 事件处理(参照 clawapp 实现) ── function handleEvent(msg) { const { event, payload } = msg if (!payload) return // ── 处理所有 agent 事件(OpenClaw 4.5+ 结构化进度) ── if (event === 'agent') { // 任何 agent 事件都说明 OpenClaw 在活跃处理,重置看门狗 _resetWatchdogOnActivity() const stream = payload?.stream const data = payload?.data || {} // tool 事件(已有逻辑) if (stream === 'tool' && data.toolCallId) { const ts = payload.ts const toolCallId = data.toolCallId const runKey = `${payload.runId}:${toolCallId}` if (_toolEventSeen.has(runKey)) return _toolEventSeen.add(runKey) if (ts) _toolEventTimes.set(toolCallId, ts) const current = _toolEventData.get(toolCallId) || {} if (data.args && current.input == null) current.input = data.args if (data.meta && current.output == null) current.output = data.meta if (typeof data.isError === 'boolean' && current.status == null) current.status = data.isError ? 'error' : 'ok' if (current.time == null) current.time = ts || null _toolEventData.set(toolCallId, current) if (payload.runId) { const list = _toolRunIndex.get(payload.runId) || [] if (!list.includes(toolCallId)) list.push(toolCallId) _toolRunIndex.set(payload.runId, list) } const toolName = data.name || data.toolName || '' if (toolName && !_isStreaming) { showTyping(true, t('chat.usingTool', { name: toolName })) } } // lifecycle 事件:处理开始/结束 if (stream === 'lifecycle') { const phase = data.phase if (phase === 'start' && !_isStreaming) { showTyping(true, t('chat.aiProcessing')) } } // item 事件(4.5+ 结构化执行步骤:tool/command/patch/search/analysis) if (stream === 'item') { const title = data.title || data.name || '' const kind = data.kind || '' if ((data.phase === 'start' || data.phase === 'update') && !_isStreaming) { const hint = kind === 'command' ? t('chat.commandRunning') : kind === 'search' ? t('chat.aiSearching') : kind === 'analysis' ? t('chat.aiAnalyzing') : title ? t('chat.aiExecuting', { title }) : t('chat.aiProcessing') showTyping(true, hint) } } // plan 事件(4.5+ 计划更新) if (stream === 'plan' && !_isStreaming) { showTyping(true, t('chat.aiPlanning')) } // approval 事件(操作审批) if (stream === 'approval' && !_isStreaming) { showTyping(true, t('chat.waitingApproval')) } // thinking 事件(推理/思考) if (stream === 'thinking' && !_isStreaming) { showTyping(true, t('chat.aiThinking')) } // command_output 事件(命令输出增量) if (stream === 'command_output' && !_isStreaming) { showTyping(true, t('chat.commandRunning')) } // compaction 事件 if (stream === 'compaction') { showCompactionHint(true) } // error 事件 if (stream === 'error' && data.message && !_isStreaming) { showTyping(true, `⚠ ${data.message}`) } } if (event === 'chat') handleChatEvent(payload) // Compaction 状态指示:上游 2026.3.12 新增 status_reaction 事件 if (event === 'chat.status_reaction' || event === 'status_reaction') { const reaction = payload.reaction || payload.emoji || '' if (reaction.includes('compact') || reaction === '🗜️' || reaction === '📦') { showCompactionHint(true) } else if (!reaction || reaction === 'thinking' || reaction === '💭') { showCompactionHint(false) } } } function handleChatEvent(payload) { const hostedSessionKey = getHostedBoundSessionKey() const isCurrentSession = !payload.sessionKey || !_sessionKey || payload.sessionKey === _sessionKey const isHostedSession = !!payload.sessionKey && !!hostedSessionKey && payload.sessionKey === hostedSessionKey // sessionKey 过滤:当前会话照常渲染;托管绑定会话在后台继续驱动循环 if (!isCurrentSession && !isHostedSession) return if (!isCurrentSession && isHostedSession) { if (payload.state === 'final' && shouldCaptureHostedTarget(payload)) { const c = extractChatContent(payload.message) const capturedText = c?.text || '' if (capturedText) { appendHostedTarget(capturedText) if (detectStopFromText(capturedText)) { stopHostedAgent() } else { maybeTriggerHostedRun() } } } if (payload.state === 'error' && _hostedSessionConfig?.enabled) { _hostedRuntime.errorCount = (_hostedRuntime.errorCount || 0) + 1 _hostedRuntime.lastError = payload.errorMessage || payload.error?.message || t('common.error') _hostedRuntime.pending = false if (_hostedRuntime.errorCount >= _hostedSessionConfig.retryLimit) { _hostedRuntime.status = HOSTED_STATUS.ERROR } persistHostedRuntime() updateHostedBadge() } return } const { state } = payload const runId = payload.runId // 重复 run 过滤:跳过已完成的 runId 的后续事件(Gateway 可能对同一消息触发多个 run) if (runId && state === 'final' && _seenRunIds.has(runId)) { console.log('[chat] 跳过重复 final, runId:', runId) return } if (runId && state === 'delta' && _seenRunIds.has(runId) && !_isStreaming) { console.log('[chat] 跳过已完成 run 的 delta, runId:', runId) return } if (state === 'delta') { _cancelResponseWatchdog() const c = extractChatContent(payload.message) if (c?.images?.length) _currentAiImages = c.images if (c?.videos?.length) _currentAiVideos = c.videos if (c?.audios?.length) _currentAiAudios = c.audios if (c?.files?.length) _currentAiFiles = c.files if (c?.tools?.length) _currentAiTools = c.tools if (c?.text && c.text.length > _currentAiText.length) { showTyping(false) if (!_currentAiBubble) { _currentAiBubble = createStreamBubble() _currentRunId = payload.runId _isStreaming = true _streamStartTime = Date.now() updateSendState() } _currentAiText = c.text // 每次收到 delta 重置安全超时(90s 无新 delta 则强制结束) clearTimeout(_streamSafetyTimer) _streamSafetyTimer = setTimeout(() => { if (_isStreaming) { console.warn('[chat] 流式输出超时(90s 无新数据),强制结束') if (_currentAiBubble && _currentAiText) { _currentAiBubble.innerHTML = renderMarkdown(_currentAiText) } appendSystemMessage(t('chat.streamTimeout')) resetStreamState() processMessageQueue() } }, 90000) throttledRender() } return } if (state === 'final') { _cancelResponseWatchdog() const c = extractChatContent(payload.message) const finalText = c?.text || '' const finalImages = c?.images || [] const finalVideos = c?.videos || [] const finalAudios = c?.audios || [] const finalFiles = c?.files || [] let finalTools = c?.tools || [] if (!finalTools.length && runId) { const ids = _toolRunIndex.get(runId) || [] finalTools = ids.map(id => mergeToolEventData({ id, name: 'tool' })).filter(Boolean) } if (finalImages.length) _currentAiImages = finalImages if (finalVideos.length) _currentAiVideos = finalVideos if (finalAudios.length) _currentAiAudios = finalAudios if (finalFiles.length) _currentAiFiles = finalFiles if (finalTools.length) _currentAiTools = finalTools const hasContent = finalText || _currentAiImages.length || _currentAiVideos.length || _currentAiAudios.length || _currentAiFiles.length || _currentAiTools.length // 忽略空 final(Gateway 会为一条消息触发多个 run,部分是空 final) if (!_currentAiBubble && !hasContent) return // 标记 runId 为已处理,防止重复 if (runId) { _seenRunIds.add(runId) if (_seenRunIds.size > 200) { const first = _seenRunIds.values().next().value _seenRunIds.delete(first) } } showTyping(false) // 如果流式阶段没有创建 bubble,从 final message 中提取 if (!_currentAiBubble && hasContent) { _currentAiBubble = createStreamBubble() _currentAiText = finalText } if (_currentAiBubble) { if (_currentAiText) _currentAiBubble.innerHTML = renderMarkdown(_currentAiText) appendImagesToEl(_currentAiBubble, _currentAiImages) appendVideosToEl(_currentAiBubble, _currentAiVideos) appendAudiosToEl(_currentAiBubble, _currentAiAudios) appendFilesToEl(_currentAiBubble, _currentAiFiles) appendToolsToEl(_currentAiBubble, finalTools.length ? finalTools : _currentAiTools) } // 添加时间戳 + 耗时 + token 消耗 const wrapper = _currentAiBubble?.parentElement if (wrapper) { const meta = document.createElement('div') meta.className = 'msg-meta' let parts = [`${formatTime(new Date())}`] // 计算响应耗时 let durStr = '' if (payload.durationMs) { durStr = (payload.durationMs / 1000).toFixed(1) + 's' } else if (_streamStartTime) { durStr = ((Date.now() - _streamStartTime) / 1000).toFixed(1) + 's' } if (durStr) parts.push(`·⏱ ${durStr}`) // token 消耗(从 payload.usage 或 payload.message.usage 提取) const usage = payload.usage || payload.message?.usage || null if (usage) { const inp = usage.input_tokens || usage.prompt_tokens || 0 const out = usage.output_tokens || usage.completion_tokens || 0 const total = usage.total_tokens || (inp + out) if (total > 0) { let tokenStr = `${total} tokens` if (inp && out) tokenStr = `↑${inp} ↓${out}` parts.push(`·${tokenStr}`) } } parts.push(``) meta.innerHTML = parts.join('') wrapper.appendChild(meta) } if (_currentAiText || _currentAiImages.length) { saveMessage({ id: payload.runId || uuid(), sessionKey: _sessionKey, role: 'assistant', content: _currentAiText, timestamp: Date.now(), attachments: _currentAiImages.map(i => ({ category: 'image', mimeType: i.mediaType || 'image/png', url: i.url, content: i.data })).filter(a => a.url || a.content) }) } // 托管 Agent:捕获 AI 回复,检测停止信号,决定是否继续 if (shouldCaptureHostedTarget(payload)) { const capturedText = finalText || _currentAiText || '' if (capturedText) { appendHostedTarget(capturedText) if (detectStopFromText(capturedText)) { appendHostedOutput(t('chat.hostedAutoStopSignal')) stopHostedAgent() } else { maybeTriggerHostedRun() } } } resetStreamState() _schedulePostFinalCheck() processMessageQueue() return } if (state === 'aborted') { showTyping(false) if (_currentAiBubble && _currentAiText) { _currentAiBubble.innerHTML = renderMarkdown(_currentAiText) } appendSystemMessage(t('chat.generationStopped')) resetStreamState() processMessageQueue() return } if (state === 'error') { const errMsg = payload.errorMessage || payload.error?.message || t('common.error') // 连接级错误(origin/pairing/auth)拦截,不作为聊天消息显示 if (/origin not allowed|NOT_PAIRED|PAIRING_REQUIRED|auth.*fail/i.test(errMsg)) { console.warn('[chat] 拦截连接级错误,不显示为聊天消息:', errMsg) const overlay = document.getElementById('chat-connect-overlay') if (overlay) { overlay.style.display = 'flex' const desc = document.getElementById('chat-connect-desc') if (desc) desc.textContent = t('chat.connectionRejected') } return } // 防抖:如果是相同错误且在 2 秒内,忽略(避免重复显示) const now = Date.now() if (_lastErrorMsg === errMsg && _errorTimer && (now - _errorTimer < 2000)) { console.warn('[chat] 忽略重复错误:', errMsg) return } _lastErrorMsg = errMsg _errorTimer = now // 如果正在流式输出,说明消息已经部分成功,不显示错误 if (_isStreaming || _currentAiBubble) { console.warn('[chat] 流式中收到错误,但消息已部分成功,忽略错误提示:', errMsg) return } showTyping(false) appendSystemMessage(`${t('chat.errorPrefix')}${errMsg}`) resetStreamState() processMessageQueue() return } } /** 从 Gateway message 对象提取文本和所有媒体(参照 clawapp extractContent) */ function extractChatContent(message) { if (!message || typeof message !== 'object') return null const tools = [] collectToolsFromMessage(message, tools) if (message.role === 'tool' || message.role === 'toolResult') { const output = typeof message.content === 'string' ? message.content : null if (!tools.length) { tools.push({ name: message.name || message.tool || message.tool_name || 'tool', input: message.input || message.args || message.parameters || null, output: output || message.output || message.result || null, status: message.status || 'ok', }) } else if (output && !tools[0].output) { tools[0].output = output } return { text: '', images: [], videos: [], audios: [], files: [], tools } } const content = message.content if (typeof content === 'string') return { text: stripThinkingTags(content), images: [], videos: [], audios: [], files: [], tools } if (Array.isArray(content)) { const texts = [], images = [], videos = [], audios = [], files = [] for (const block of content) { if (block.type === 'text' && typeof block.text === 'string') texts.push(block.text) else if (block.type === 'image' && !block.omitted) { if (block.data) images.push({ mediaType: block.mimeType || 'image/png', data: block.data }) else if (block.source?.type === 'base64' && block.source.data) images.push({ mediaType: block.source.media_type || 'image/png', data: block.source.data }) else if (block.url || block.source?.url) images.push({ url: block.url || block.source.url, mediaType: block.mimeType || 'image/png' }) } else if (block.type === 'image_url' && block.image_url?.url) images.push({ url: block.image_url.url, mediaType: 'image/png' }) else if (block.type === 'video') { if (block.data) videos.push({ mediaType: block.mimeType || 'video/mp4', data: block.data }) else if (block.url) videos.push({ url: block.url, mediaType: block.mimeType || 'video/mp4' }) } else if (block.type === 'audio' || block.type === 'voice') { if (block.data) audios.push({ mediaType: block.mimeType || 'audio/mpeg', data: block.data, duration: block.duration }) else if (block.url) audios.push({ url: block.url, mediaType: block.mimeType || 'audio/mpeg', duration: block.duration }) } else if (block.type === 'file' || block.type === 'document') { files.push({ url: block.url || '', name: block.fileName || block.name || 'file', mimeType: block.mimeType || '', size: block.size, data: block.data }) } else if (block.type === 'tool' || block.type === 'tool_use' || block.type === 'tool_call' || block.type === 'toolCall') { const callId = block.id || block.tool_call_id || block.toolCallId upsertTool(tools, { id: callId, name: block.name || block.tool || block.tool_name || block.toolName || 'tool', input: block.input || block.args || block.parameters || block.arguments || null, output: null, status: block.status || 'ok', time: resolveToolTime(callId, message.timestamp), }) } else if (block.type === 'tool_result' || block.type === 'toolResult') { const resId = block.id || block.tool_call_id || block.toolCallId upsertTool(tools, { id: resId, name: block.name || block.tool || block.tool_name || block.toolName || 'tool', input: block.input || block.args || null, output: block.output || block.result || block.content || null, status: block.status || 'ok', time: resolveToolTime(resId, message.timestamp), }) } } if (tools.length) { tools.forEach(t => { if (typeof t.input === 'string') t.input = stripAnsi(t.input) if (typeof t.output === 'string') t.output = stripAnsi(t.output) }) } // 从 mediaUrl/mediaUrls 提取 const mediaUrls = message.mediaUrls || (message.mediaUrl ? [message.mediaUrl] : []) for (const url of mediaUrls) { if (!url) continue if (/\.(mp4|webm|mov|mkv)(\?|$)/i.test(url)) videos.push({ url, mediaType: 'video/mp4' }) else if (/\.(mp3|wav|ogg|m4a|aac|flac)(\?|$)/i.test(url)) audios.push({ url, mediaType: 'audio/mpeg' }) else if (/\.(jpe?g|png|gif|webp|heic|svg)(\?|$)/i.test(url)) images.push({ url, mediaType: 'image/png' }) else files.push({ url, name: url.split('/').pop().split('?')[0] || 'file', mimeType: '' }) } const text = texts.length ? stripThinkingTags(texts.join('\n')) : '' return { text, images, videos, audios, files, tools } } if (typeof message.text === 'string') return { text: stripThinkingTags(message.text), images: [], videos: [], audios: [], files: [], tools: [] } return null } function stripAnsi(text) { if (!text) return '' return text.replace(/\u001b\[[0-9;]*[A-Za-z]/g, '') } function escapeHtml(text) { return (text || '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, ''') } function stripThinkingTags(text) { const safe = stripAnsi(text) return safe .replace(/<\s*think(?:ing)?\s*>[\s\S]*?<\s*\/\s*think(?:ing)?\s*>/gi, '') .replace(/Conversation info \(untrusted metadata\):\s*```json[\s\S]*?```\s*/gi, '') .replace(/\[Queued messages while agent was busy\]\s*---\s*Queued #\d+\s*/gi, '') .trim() } function normalizeTime(raw) { if (!raw) return null if (raw instanceof Date) return raw.getTime() if (typeof raw === 'string') { const num = Number(raw) if (!Number.isNaN(num)) raw = num else { const parsed = Date.parse(raw) return Number.isNaN(parsed) ? null : parsed } } if (typeof raw === 'number' && raw < 1e12) return raw * 1000 return raw } function resolveToolTime(toolId, messageTimestamp) { const eventTs = toolId ? _toolEventTimes.get(toolId) : null return normalizeTime(eventTs) || normalizeTime(messageTimestamp) || null } function getToolTime(tool) { const raw = tool?.end_time || tool?.endTime || tool?.timestamp || tool?.time || tool?.started_at || tool?.startedAt || null return normalizeTime(raw) } function safeStringify(value) { if (value == null) return '' const seen = new WeakSet() try { return JSON.stringify(value, (key, val) => { if (typeof val === 'bigint') return val.toString() if (typeof val === 'object' && val !== null) { if (seen.has(val)) return '[Circular]' seen.add(val) } return val }, 2) } catch { try { return String(value) } catch { return '' } } } function formatTime(date) { const now = new Date() const h = date.getHours().toString().padStart(2, '0') const m = date.getMinutes().toString().padStart(2, '0') const isToday = date.getFullYear() === now.getFullYear() && date.getMonth() === now.getMonth() && date.getDate() === now.getDate() if (isToday) return `${h}:${m}` const mon = (date.getMonth() + 1).toString().padStart(2, '0') const day = date.getDate().toString().padStart(2, '0') return `${mon}-${day} ${h}:${m}` } function formatFileSize(bytes) { if (!bytes || bytes <= 0) return '' if (bytes < 1024) return bytes + ' B' if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB' return (bytes / (1024 * 1024)).toFixed(1) + ' MB' } /** 创建流式 AI 气泡 */ function createStreamBubble() { if (!_messagesEl || !_typingEl) return null showTyping(false) const wrap = document.createElement('div') wrap.className = 'msg msg-ai' const bubble = document.createElement('div') bubble.className = 'msg-bubble' bubble.innerHTML = '' wrap.appendChild(bubble) _messagesEl.insertBefore(wrap, _typingEl) scrollToBottom() return bubble } // ── 流式渲染(节流) ── function throttledRender() { if (_renderPending) return const now = performance.now() if (now - _lastRenderTime >= RENDER_THROTTLE) { doRender() } else { _renderPending = true requestAnimationFrame(() => { _renderPending = false; doRender() }) } } function doRender() { _lastRenderTime = performance.now() if (_currentAiBubble && _currentAiText) { _currentAiBubble.innerHTML = renderMarkdown(_currentAiText) scrollToBottom() } } // ── 响应看门狗:防止页面卡在等待状态 ── const WATCHDOG_INTERVAL = 15000 // 15s 轮询间隔 const ULTIMATE_TIMEOUT = 180000 // 3 分钟终极超时 function _startResponseWatchdog() { // 只清除轮询定时器,不清除终极超时(终极超时应持续到收到响应) clearTimeout(_responseWatchdog) _responseWatchdog = null _sendTimestamp = _sendTimestamp || Date.now() // 启动终极超时(3分钟内如果没有收到任何 chat 事件则放弃) if (!_ultimateTimer) { _ultimateTimer = setTimeout(() => { _ultimateTimer = null if (!_isStreaming && _sessionKey && _pageActive) { console.warn('[chat] 终极超时: 3分钟无 chat 回复') showTyping(false) appendSystemMessage(t('chat.responseTimeout', { seconds: Math.round(ULTIMATE_TIMEOUT / 1000) })) _cancelResponseWatchdog() resetStreamState() processMessageQueue() } }, ULTIMATE_TIMEOUT) } _responseWatchdog = setTimeout(async () => { _responseWatchdog = null // 如果还在等待(未开始流式),强制刷新历史 if (!_isStreaming && _sessionKey && _messagesEl && _pageActive) { const elapsed = Math.round((Date.now() - _sendTimestamp) / 1000) console.log(`[chat] 响应看门狗触发:${elapsed}s 无 delta,刷新历史`) const oldHash = _lastHistoryHash _lastHistoryHash = '' await loadHistory() // 如果历史有更新,关闭 typing 指示器 if (_lastHistoryHash && _lastHistoryHash !== oldHash) { showTyping(false) _cancelUltimateTimer() } else { // 历史没更新,更新 typing 提示显示已等待时间 if (elapsed >= 30) { showTyping(true, `${t('chat.stillWaiting')} (${t('chat.elapsedTime', { seconds: elapsed })})`) } // 继续等待,再设一轮看门狗 _startResponseWatchdog() } } }, WATCHDOG_INTERVAL) } function _resetWatchdogOnActivity() { // agent 事件说明 OpenClaw 在活跃处理,重置轮询看门狗(但不重置终极超时) if (_responseWatchdog) { clearTimeout(_responseWatchdog) _responseWatchdog = setTimeout(async () => { _responseWatchdog = null if (!_isStreaming && _sessionKey && _messagesEl && _pageActive) { const elapsed = _sendTimestamp ? Math.round((Date.now() - _sendTimestamp) / 1000) : 0 console.log(`[chat] agent 活跃后看门狗触发:${elapsed}s`) const oldHash = _lastHistoryHash _lastHistoryHash = '' await loadHistory() if (_lastHistoryHash && _lastHistoryHash !== oldHash) { showTyping(false) _cancelUltimateTimer() } else { _startResponseWatchdog() } } }, WATCHDOG_INTERVAL) } } function _cancelResponseWatchdog() { clearTimeout(_responseWatchdog) _responseWatchdog = null _cancelUltimateTimer() } function _cancelUltimateTimer() { clearTimeout(_ultimateTimer) _ultimateTimer = null } function _schedulePostFinalCheck() { clearTimeout(_postFinalCheck) _postFinalCheck = setTimeout(async () => { _postFinalCheck = null if (_sessionKey && _messagesEl && _pageActive && !_isStreaming && !_isSending) { _lastHistoryHash = '' await loadHistory() } }, 2000) } // ensureAiBubble 已被 createStreamBubble 替代 function resetStreamState() { clearTimeout(_streamSafetyTimer) clearInterval(_typingElapsedInterval) _typingElapsedInterval = null if (_currentAiBubble && (_currentAiText || _currentAiImages.length || _currentAiVideos.length || _currentAiAudios.length || _currentAiFiles.length || _currentAiTools.length)) { _currentAiBubble.innerHTML = renderMarkdown(_currentAiText) appendImagesToEl(_currentAiBubble, _currentAiImages) appendVideosToEl(_currentAiBubble, _currentAiVideos) appendAudiosToEl(_currentAiBubble, _currentAiAudios) appendFilesToEl(_currentAiBubble, _currentAiFiles) appendToolsToEl(_currentAiBubble, _currentAiTools) } _renderPending = false _lastRenderTime = 0 _currentAiBubble = null _currentAiText = '' _currentAiImages = [] _currentAiVideos = [] _currentAiAudios = [] _currentAiFiles = [] _currentAiTools = [] _currentRunId = null _isStreaming = false _streamStartTime = 0 _lastErrorMsg = null _errorTimer = null _sendTimestamp = 0 showTyping(false) updateSendState() } // ── 历史消息加载 ── async function loadHistory() { if (!_sessionKey || !_messagesEl) return _isLoadingHistory = true const hasExisting = _messagesEl.querySelector('.msg') if (!hasExisting && isStorageAvailable()) { const local = await getLocalMessages(_sessionKey, 200) if (local.length) { clearMessages() local.forEach(msg => { if (!msg.content && !msg.attachments?.length) return const msgTime = msg.timestamp ? new Date(msg.timestamp) : new Date() if (msg.role === 'user') appendUserMessage(msg.content || '', msg.attachments || null, msgTime) else if (msg.role === 'assistant') { const images = (msg.attachments || []).filter(a => a.category === 'image').map(a => ({ mediaType: a.mimeType, data: a.content, url: a.url })) appendAiMessage(msg.content || '', msgTime, images, [], [], [], []) } }) scrollToBottom() } } if (!wsClient.gatewayReady) { _isLoadingHistory = false; return } try { const result = await wsClient.chatHistory(_sessionKey, 200) if (!result?.messages?.length) { if (_messagesEl && !_messagesEl.querySelector('.msg')) appendSystemMessage(t('chat.noMessages')) return } const deduped = dedupeHistory(result.messages) const hash = deduped.map(m => `${m.role}:${(m.text || '').length}`).join('|') if (hash === _lastHistoryHash && hasExisting) return _lastHistoryHash = hash // 正在发送/流式输出时不全量重绘,避免覆盖本地乐观渲染 if (hasExisting && (_isSending || _isStreaming || _messageQueue.length > 0)) { saveMessages(result.messages.map(m => { const c = extractContent(m) const role = (m.role === 'tool' || m.role === 'toolResult') ? 'assistant' : m.role return { id: m.id || uuid(), sessionKey: _sessionKey, role, content: c?.text || '', timestamp: m.timestamp || Date.now() } })) _isLoadingHistory = false return } clearMessages() let hasOmittedImages = false deduped.forEach(msg => { if (!msg.text && !msg.images?.length && !msg.videos?.length && !msg.audios?.length && !msg.files?.length && !msg.tools?.length) return const msgTime = msg.timestamp ? new Date(msg.timestamp) : new Date() if (msg.role === 'user') { const userAtts = msg.images?.length ? msg.images.map(i => ({ mimeType: i.mediaType || i.media_type || 'image/png', content: i.data || i.source?.data || '', category: 'image', })).filter(a => a.content) : [] if (msg.images?.length && !userAtts.length) hasOmittedImages = true appendUserMessage(msg.text, userAtts, msgTime) } else if (msg.role === 'assistant') { appendAiMessage(msg.text, msgTime, msg.images, msg.videos, msg.audios, msg.files, msg.tools) } }) if (hasOmittedImages) { appendSystemMessage(t('chat.imageHistoryHint')) } saveMessages(result.messages.map(m => { const c = extractContent(m) const role = (m.role === 'tool' || m.role === 'toolResult') ? 'assistant' : m.role return { id: m.id || uuid(), sessionKey: _sessionKey, role, content: c?.text || '', timestamp: m.timestamp || Date.now() } })) scrollToBottom() } catch (e) { console.error('[chat] loadHistory error:', e) if (_messagesEl && !_messagesEl.querySelector('.msg')) appendSystemMessage(`${t('common.loadFailed')}: ${e.message}`) } finally { _isLoadingHistory = false } } function dedupeHistory(messages) { const deduped = [] for (const msg of messages) { const role = (msg.role === 'tool' || msg.role === 'toolResult') ? 'assistant' : msg.role const c = extractContent(msg) if (!c.text && !c.images.length && !c.videos.length && !c.audios.length && !c.files.length && !c.tools.length) continue const tools = (c.tools || []).map(t => { const id = t.id || t.tool_call_id const time = t.time || resolveToolTime(id, msg.timestamp) return { ...t, time, messageTimestamp: msg.timestamp } }) const last = deduped[deduped.length - 1] if (last && last.role === role) { if (role === 'user' && last.text === c.text) continue if (role === 'assistant') { // 同文本去重(Gateway 重试产生的重复回复) if (c.text && last.text === c.text) continue // 不同文本则合并 last.text = [last.text, c.text].filter(Boolean).join('\n') last.images = [...(last.images || []), ...c.images] last.videos = [...(last.videos || []), ...c.videos] last.audios = [...(last.audios || []), ...c.audios] last.files = [...(last.files || []), ...c.files] tools.forEach(t => upsertTool(last.tools, t)) continue } } deduped.push({ role, text: c.text, images: c.images, videos: c.videos, audios: c.audios, files: c.files, tools, timestamp: msg.timestamp }) } return deduped } function extractContent(msg) { const tools = [] collectToolsFromMessage(msg, tools) if (msg.role === 'tool' || msg.role === 'toolResult') { const output = typeof msg.content === 'string' ? msg.content : null if (!tools.length) { upsertTool(tools, { id: msg.id || msg.tool_call_id || msg.toolCallId, name: msg.name || msg.tool || msg.tool_name || 'tool', input: msg.input || msg.args || msg.parameters || null, output: output || msg.output || msg.result || null, status: msg.status || 'ok', time: resolveToolTime(msg.tool_call_id || msg.toolCallId || msg.id, msg.timestamp), }) } else if (output && !tools[0].output) { tools[0].output = output } return { text: '', images: [], videos: [], audios: [], files: [], tools } } if (Array.isArray(msg.content)) { const texts = [], images = [], videos = [], audios = [], files = [] for (const block of msg.content) { if (block.type === 'text' && typeof block.text === 'string') texts.push(block.text) else if (block.type === 'image' && !block.omitted) { if (block.data) images.push({ mediaType: block.mimeType || 'image/png', data: block.data }) else if (block.source?.type === 'base64' && block.source.data) images.push({ mediaType: block.source.media_type || 'image/png', data: block.source.data }) else if (block.url || block.source?.url) images.push({ url: block.url || block.source.url, mediaType: block.mimeType || 'image/png' }) } else if (block.type === 'image_url' && block.image_url?.url) images.push({ url: block.image_url.url, mediaType: 'image/png' }) else if (block.type === 'video') { if (block.data) videos.push({ mediaType: block.mimeType || 'video/mp4', data: block.data }) else if (block.url) videos.push({ url: block.url, mediaType: block.mimeType || 'video/mp4' }) } else if (block.type === 'audio' || block.type === 'voice') { if (block.data) audios.push({ mediaType: block.mimeType || 'audio/mpeg', data: block.data, duration: block.duration }) else if (block.url) audios.push({ url: block.url, mediaType: block.mimeType || 'audio/mpeg', duration: block.duration }) } else if (block.type === 'file' || block.type === 'document') { files.push({ url: block.url || '', name: block.fileName || block.name || 'file', mimeType: block.mimeType || '', size: block.size, data: block.data }) } else if (block.type === 'tool' || block.type === 'tool_use' || block.type === 'tool_call' || block.type === 'toolCall') { const callId = block.id || block.tool_call_id || block.toolCallId upsertTool(tools, { id: callId, name: block.name || block.tool || block.tool_name || block.toolName || 'tool', input: block.input || block.args || block.parameters || block.arguments || null, output: null, status: block.status || 'ok', time: resolveToolTime(callId, msg.timestamp), }) } else if (block.type === 'tool_result' || block.type === 'toolResult') { const resId = block.id || block.tool_call_id || block.toolCallId upsertTool(tools, { id: resId, name: block.name || block.tool || block.tool_name || block.toolName || 'tool', input: block.input || block.args || null, output: block.output || block.result || block.content || null, status: block.status || 'ok', time: resolveToolTime(resId, msg.timestamp), }) } } if (tools.length) { tools.forEach(t => { if (typeof t.input === 'string') t.input = stripAnsi(t.input) if (typeof t.output === 'string') t.output = stripAnsi(t.output) }) } const mediaUrls = msg.mediaUrls || (msg.mediaUrl ? [msg.mediaUrl] : []) for (const url of mediaUrls) { if (!url) continue if (/\.(mp4|webm|mov|mkv)(\?|$)/i.test(url)) videos.push({ url, mediaType: 'video/mp4' }) else if (/\.(mp3|wav|ogg|m4a|aac|flac)(\?|$)/i.test(url)) audios.push({ url, mediaType: 'audio/mpeg' }) else if (/\.(jpe?g|png|gif|webp|heic|svg)(\?|$)/i.test(url)) images.push({ url, mediaType: 'image/png' }) else files.push({ url, name: url.split('/').pop().split('?')[0] || 'file', mimeType: '' }) } return { text: stripThinkingTags(texts.join('\n')), images, videos, audios, files, tools } } const text = typeof msg.text === 'string' ? msg.text : (typeof msg.content === 'string' ? msg.content : '') return { text: stripThinkingTags(text), images: [], videos: [], audios: [], files: [], tools } } // ── DOM 操作 ── function appendUserMessage(text, attachments = [], msgTime) { const wrap = document.createElement('div') wrap.className = 'msg msg-user' const bubble = document.createElement('div') bubble.className = 'msg-bubble' if (attachments && attachments.length > 0) { const mediaContainer = document.createElement('div') mediaContainer.style.cssText = 'display:flex;gap:4px;margin-bottom:8px;flex-wrap:wrap' attachments.forEach(att => { const cat = att.category || att.type || 'image' const src = att.data ? `data:${att.mimeType || att.mediaType || 'image/png'};base64,${att.data}` : att.content ? `data:${att.mimeType || 'image/png'};base64,${att.content}` : att.url || '' if (cat === 'image' && src) { const img = document.createElement('img') img.src = src img.className = 'msg-img' img.onclick = () => showLightbox(img.src) mediaContainer.appendChild(img) } else if (cat === 'video' && src) { const video = document.createElement('video') video.src = src video.className = 'msg-video' video.controls = true video.preload = 'metadata' video.playsInline = true mediaContainer.appendChild(video) } else if (cat === 'audio' && src) { const audio = document.createElement('audio') audio.src = src audio.className = 'msg-audio' audio.controls = true audio.preload = 'metadata' mediaContainer.appendChild(audio) } else if (att.fileName || att.name) { const card = document.createElement('div') card.className = 'msg-file-card' card.innerHTML = `${svgIcon('paperclip', 16)}${att.fileName || att.name}` mediaContainer.appendChild(card) } }) if (mediaContainer.children.length) bubble.appendChild(mediaContainer) } if (text) { const textNode = document.createElement('div') textNode.textContent = text bubble.appendChild(textNode) } const meta = document.createElement('div') meta.className = 'msg-meta' meta.innerHTML = `${formatTime(msgTime || new Date())}` wrap.appendChild(bubble) wrap.appendChild(meta) _messagesEl.insertBefore(wrap, _typingEl) scrollToBottom() } function appendAiMessage(text, msgTime, images, videos, audios, files, tools) { const wrap = document.createElement('div') wrap.className = 'msg msg-ai' const bubble = document.createElement('div') bubble.className = 'msg-bubble' appendToolsToEl(bubble, tools) const textEl = document.createElement('div') textEl.className = 'msg-text' textEl.innerHTML = renderMarkdown(text || '') bubble.appendChild(textEl) appendImagesToEl(bubble, images) appendVideosToEl(bubble, videos) appendAudiosToEl(bubble, audios) appendFilesToEl(bubble, files) // 图片点击灯箱 bubble.querySelectorAll('img').forEach(img => { if (!img.onclick) img.onclick = () => showLightbox(img.src) }) const meta = document.createElement('div') meta.className = 'msg-meta' meta.innerHTML = `${formatTime(msgTime || new Date())}` wrap.appendChild(bubble) wrap.appendChild(meta) _messagesEl.insertBefore(wrap, _typingEl) scrollToBottom() } /** 渲染图片到消息气泡(支持 Anthropic/OpenAI/直接格式) */ function appendImagesToEl(el, images) { if (!images?.length) return const container = document.createElement('div') container.style.cssText = 'display:flex;gap:6px;margin-top:8px;flex-wrap:wrap' images.forEach(img => { const imgEl = document.createElement('img') // Anthropic 格式: { type: 'image', source: { data, media_type } } if (img.source?.data) { imgEl.src = `data:${img.source.media_type || 'image/png'};base64,${img.source.data}` // 直接格式: { data, mediaType } } else if (img.data) { imgEl.src = `data:${img.mediaType || img.media_type || 'image/png'};base64,${img.data}` // OpenAI 格式: { type: 'image_url', image_url: { url } } } else if (img.image_url?.url) { imgEl.src = img.image_url.url // URL 格式 } else if (img.url) { imgEl.src = img.url } else { return } imgEl.style.cssText = 'max-width:300px;max-height:300px;border-radius:6px;cursor:pointer' imgEl.onclick = () => showLightbox(imgEl.src) container.appendChild(imgEl) }) if (container.children.length) el.appendChild(container) } /** 渲染视频到消息气泡 */ function appendVideosToEl(el, videos) { if (!videos?.length) return videos.forEach(vid => { const videoEl = document.createElement('video') videoEl.className = 'msg-video' videoEl.controls = true videoEl.preload = 'metadata' videoEl.playsInline = true if (vid.data) videoEl.src = `data:${vid.mediaType};base64,${vid.data}` else if (vid.url) videoEl.src = vid.url el.appendChild(videoEl) }) } /** 渲染音频到消息气泡 */ function appendAudiosToEl(el, audios) { if (!audios?.length) return audios.forEach(aud => { const audioEl = document.createElement('audio') audioEl.className = 'msg-audio' audioEl.controls = true audioEl.preload = 'metadata' if (aud.data) audioEl.src = `data:${aud.mediaType};base64,${aud.data}` else if (aud.url) audioEl.src = aud.url el.appendChild(audioEl) }) } /** 渲染文件卡片到消息气泡 */ function appendFilesToEl(el, files) { if (!files?.length) return files.forEach(f => { const card = document.createElement('div') card.className = 'msg-file-card' const ext = (f.name || '').split('.').pop().toLowerCase() const fileIconMap = { pdf: 'file', doc: 'file-text', docx: 'file-text', txt: 'file-plain', md: 'file-plain', json: 'clipboard', csv: 'bar-chart', zip: 'package', rar: 'package' } const fileIcon = svgIcon(fileIconMap[ext] || 'paperclip', 16) const size = f.size ? formatFileSize(f.size) : '' card.innerHTML = `${fileIcon}
${f.name || 'file'}${size ? `${size}` : ''}
` if (f.url) { card.style.cursor = 'pointer' card.onclick = () => window.open(f.url, '_blank') } else if (f.data) { card.style.cursor = 'pointer' card.onclick = () => { const a = document.createElement('a') a.href = `data:${f.mimeType || 'application/octet-stream'};base64,${f.data}` a.download = f.name || 'file' a.click() } } el.appendChild(card) }) } function mergeToolEventData(entry) { const id = entry?.id || entry?.tool_call_id if (!id) return entry const extra = _toolEventData.get(id) if (!extra) return entry if (entry.input == null && extra.input != null) entry.input = extra.input if (entry.output == null && extra.output != null) entry.output = extra.output if (entry.status == null && extra.status != null) entry.status = extra.status if (entry.time == null) entry.time = extra.time || _toolEventTimes.get(id) || null return entry } function upsertTool(tools, entry) { if (!entry) return const id = entry.id || entry.tool_call_id let target = null if (id) target = tools.find(t => t.id === id || t.tool_call_id === id) if (!target && entry.name) target = tools.find(t => t.name === entry.name && !t.output) if (target) { if (entry.input != null && target.input == null) target.input = entry.input if (entry.output != null && target.output == null) target.output = entry.output if (entry.status && target.status == null) target.status = entry.status if (entry.time && target.time == null) target.time = entry.time return } tools.push(mergeToolEventData(entry)) } function collectToolsFromMessage(message, tools) { if (!message || !tools) return const toolCalls = message.tool_calls || message.toolCalls || message.tools if (Array.isArray(toolCalls)) { toolCalls.forEach(call => { const fn = call.function || null const name = call.name || call.tool || call.tool_name || fn?.name const input = call.input || call.args || call.parameters || call.arguments || fn?.arguments || null const callId = call.id || call.tool_call_id upsertTool(tools, { id: callId, name: name || 'tool', input, output: null, status: call.status || 'ok', time: resolveToolTime(callId, message?.timestamp), }) }) } const toolResults = message.tool_results || message.toolResults if (Array.isArray(toolResults)) { toolResults.forEach(res => { const resId = res.id || res.tool_call_id upsertTool(tools, { id: resId, name: res.name || res.tool || res.tool_name || 'tool', input: res.input || res.args || null, output: res.output || res.result || res.content || null, status: res.status || 'ok', time: resolveToolTime(resId, message?.timestamp), }) }) } } /** 渲染工具调用到消息气泡 */ function appendToolsToEl(el, tools) { if (!el) return const existing = el.querySelector?.('.msg-tool') if (!tools?.length) { if (existing) existing.remove() return } const container = document.createElement('div') container.className = 'msg-tool' tools.forEach(tool => { const details = document.createElement('details') details.className = 'msg-tool-item' const summary = document.createElement('summary') const status = tool.status === 'error' ? t('chat.toolFailed') : t('chat.toolSuccess') const timeValue = getToolTime(tool) || resolveToolTime(tool.id || tool.tool_call_id, tool.messageTimestamp) const timeText = timeValue ? formatTime(new Date(timeValue)) : '' summary.innerHTML = `${escapeHtml(tool.name || 'tool')} · ${status}${timeText ? ' · ' + timeText : ''}` const body = document.createElement('div') body.className = 'msg-tool-body' const inputJson = stripAnsi(safeStringify(tool.input)) const outputJson = stripAnsi(safeStringify(tool.output)) body.innerHTML = `
${t('chat.toolParams')}
${escapeHtml(inputJson || '-')}
` + `
${t('chat.toolResult')}
${escapeHtml(outputJson || '-')}
` details.appendChild(summary) details.appendChild(body) container.appendChild(details) }) if (existing) existing.remove() el.insertBefore(container, el.firstChild) } /** 图片灯箱查看 */ function showLightbox(src) { const existing = document.querySelector('.chat-lightbox') if (existing) existing.remove() const lb = document.createElement('div') lb.className = 'chat-lightbox' lb.innerHTML = `` lb.onclick = (e) => { if (e.target === lb || e.target.tagName !== 'IMG') lb.remove() } document.body.appendChild(lb) // ESC 关闭 const onKey = (e) => { if (e.key === 'Escape') { lb.remove(); document.removeEventListener('keydown', onKey) } } document.addEventListener('keydown', onKey) } function appendSystemMessage(text) { const wrap = document.createElement('div') wrap.className = 'msg msg-system' wrap.textContent = text _messagesEl.insertBefore(wrap, _typingEl) scrollToBottom() } function clearMessages() { _messagesEl.querySelectorAll('.msg').forEach(m => m.remove()) _autoScrollEnabled = true _lastScrollTop = 0 } let _typingElapsedInterval = null function showTyping(show, hint) { if (_typingEl) { _typingEl.style.display = show ? 'flex' : 'none' // 更新提示文字(如工具调用状态) const hintEl = _typingEl.querySelector('.typing-hint') if (hintEl) hintEl.textContent = hint || '' // 管理已用时间显示 let elapsedEl = _typingEl.querySelector('.typing-elapsed') if (show && _sendTimestamp) { if (!elapsedEl) { elapsedEl = document.createElement('span') elapsedEl.className = 'typing-elapsed' _typingEl.appendChild(elapsedEl) } const updateElapsed = () => { if (!_sendTimestamp || !_typingEl) return const sec = Math.round((Date.now() - _sendTimestamp) / 1000) if (sec >= 5 && elapsedEl) elapsedEl.textContent = t('chat.elapsedTime', { seconds: sec }) } updateElapsed() clearInterval(_typingElapsedInterval) _typingElapsedInterval = setInterval(updateElapsed, 5000) } else { clearInterval(_typingElapsedInterval) _typingElapsedInterval = null if (elapsedEl) elapsedEl.textContent = '' } } if (show) scrollToBottom() } function showCompactionHint(show) { let hint = _page?.querySelector('#compaction-hint') if (show && !hint && _messagesEl) { hint = document.createElement('div') hint.id = 'compaction-hint' hint.className = 'msg msg-system compaction-hint' hint.innerHTML = `🗜️ ${t('chat.compacting')}` _messagesEl.insertBefore(hint, _typingEl) scrollToBottom() } else if (!show && hint) { hint.remove() } } function scrollToBottom(force = false) { if (!_messagesEl) return if (!force && !_autoScrollEnabled) return requestAnimationFrame(() => { _messagesEl.scrollTop = _messagesEl.scrollHeight }) } function isAtBottom() { if (!_messagesEl) return true return _messagesEl.scrollHeight - _messagesEl.scrollTop - _messagesEl.clientHeight < 80 } function updateSendState() { if (!_sendBtn || !_textarea) return if (_isStreaming) { _sendBtn.disabled = false _sendBtn.innerHTML = '' _sendBtn.title = t('chat.cmdStopGen') } else { _sendBtn.disabled = !_textarea.value.trim() && !_attachments.length _sendBtn.innerHTML = '' _sendBtn.title = t('chat.send') } } function updateStatusDot(status) { if (!_statusDot) return _statusDot.className = 'status-dot' if (status === 'ready' || status === 'connected') _statusDot.classList.add('online') else if (status === 'connecting' || status === 'reconnecting') _statusDot.classList.add('connecting') else _statusDot.classList.add('offline') } // ── 托管 Agent 核心逻辑 ── function toggleHostedPanel() { if (!_hostedPanelEl) return const next = _hostedPanelEl.style.display !== 'block' _hostedPanelEl.style.display = next ? 'block' : 'none' if (next) renderHostedPanel() } function hideHostedPanel() { if (_hostedPanelEl) _hostedPanelEl.style.display = 'none' } function getHostedSessionKey() { return _sessionKey || localStorage.getItem(STORAGE_SESSION_KEY) || 'agent:main:main' } function getHostedBoundSessionKey() { return _hostedSessionConfig?.boundSessionKey || _hostedBoundSessionKey || '' } async function loadHostedDefaults() { try { const panel = await api.readPanelConfig() _hostedDefaults = panel?.hostedAgent?.default || null } catch { _hostedDefaults = null } } function loadHostedSessionConfig() { let data = {} try { data = JSON.parse(localStorage.getItem(HOSTED_SESSIONS_KEY) || '{}') } catch { data = {} } const key = getHostedSessionKey() const current = data[key] || {} _hostedSessionConfig = { ...HOSTED_DEFAULTS, ..._hostedDefaults, ...current } if (_hostedSessionConfig.enabled && !_hostedSessionConfig.boundSessionKey) { _hostedSessionConfig.boundSessionKey = key } _hostedBoundSessionKey = _hostedSessionConfig.boundSessionKey || null if (!_hostedSessionConfig.state) _hostedSessionConfig.state = { ...HOSTED_RUNTIME_DEFAULT } if (!_hostedSessionConfig.history) _hostedSessionConfig.history = [] _hostedRuntime = { ...HOSTED_RUNTIME_DEFAULT, ..._hostedSessionConfig.state } updateHostedBadge() } function saveHostedSessionConfig(nextConfig, key = null) { let data = {} try { data = JSON.parse(localStorage.getItem(HOSTED_SESSIONS_KEY) || '{}') } catch { data = {} } data[key || getHostedSessionKey()] = nextConfig localStorage.setItem(HOSTED_SESSIONS_KEY, JSON.stringify(data)) } function persistHostedRuntime(persistKey = null) { if (!_hostedSessionConfig) return _hostedSessionConfig.state = { ..._hostedRuntime } const key = persistKey || getHostedBoundSessionKey() || getHostedSessionKey() saveHostedSessionConfig(_hostedSessionConfig, key) } function updateHostedBadge() { if (!_hostedBadgeEl || !_hostedSessionConfig) return const status = _hostedRuntime.status || HOSTED_STATUS.IDLE const enabled = _hostedSessionConfig.enabled let text = t('chat.hostedNotEnabled'), cls = 'chat-hosted-badge' if (!enabled) { text = t('chat.hostedNotEnabled'); cls += ' idle' } else if (status === HOSTED_STATUS.RUNNING) { text = t('chat.hostedRunning'); cls += ' running' } else if (status === HOSTED_STATUS.WAITING) { text = t('chat.hostedWaiting'); cls += ' waiting' } else if (status === HOSTED_STATUS.PAUSED) { text = t('chat.hostedPaused'); cls += ' paused' } else if (status === HOSTED_STATUS.ERROR) { text = t('chat.hostedErrorStatus'); cls += ' error' } else { text = t('chat.hostedStandby'); cls += ' idle' } _hostedBadgeEl.className = cls _hostedBadgeEl.textContent = text } let _countdownInterval = null function renderHostedPanel() { if (!_hostedPanelEl || !_hostedSessionConfig) return const isRunning = _hostedSessionConfig.enabled && _hostedRuntime.status !== HOSTED_STATUS.IDLE if (_hostedPromptEl) { _hostedPromptEl.value = _hostedSessionConfig.prompt || ''; _hostedPromptEl.disabled = isRunning } if (_hostedMaxStepsEl) { _hostedMaxStepsEl.value = _hostedSessionConfig.maxSteps || HOSTED_DEFAULTS.maxSteps _hostedMaxStepsEl.disabled = isRunning const valEl = _hostedPanelEl.querySelector('#ha-steps-val') if (valEl) valEl.textContent = _hostedMaxStepsEl.value } if (_hostedAutoStopEl) { _hostedAutoStopEl.value = _hostedSessionConfig.autoStopMinutes || 30; _hostedAutoStopEl.disabled = isRunning } const timerToggle = _hostedPanelEl.querySelector('#hosted-agent-timer-on') const timerBody = _hostedPanelEl.querySelector('#ha-timer-body') if (timerToggle) { timerToggle.checked = (_hostedSessionConfig.autoStopMinutes || 0) > 0; timerToggle.disabled = isRunning } if (timerBody) timerBody.style.display = timerToggle?.checked ? '' : 'none' if (_hostedSaveBtn) { _hostedSaveBtn.textContent = isRunning ? `⏹ ${t('chat.stopHosted')}` : `▶ ${t('chat.startHosted')}` _hostedSaveBtn.className = isRunning ? 'btn btn-ghost' : 'btn btn-primary' _hostedSaveBtn.style.flex = '1' } // 主按钮同时作为停止按钮,无需额外 stop btn // 状态栏 const statusEl = _hostedPanelEl.querySelector('#hosted-agent-status') if (statusEl) { let msg = t('chat.ready') if (_hostedRuntime.lastError) msg = `${t('chat.errorPrefix')}${_hostedRuntime.lastError}` else if (isRunning) { const remaining = Math.max(0, _hostedSessionConfig.maxSteps - _hostedRuntime.stepCount) msg = `${t('chat.hostedRunning')} · ${t('chat.remaining')} ${remaining}` } statusEl.textContent = msg } // 倒计时 updateCountdown() } function updateCountdown() { const cdEl = _hostedPanelEl?.querySelector('#ha-countdown') const fillEl = _hostedPanelEl?.querySelector('#ha-countdown-fill') const textEl = _hostedPanelEl?.querySelector('#ha-countdown-text') if (!cdEl || !fillEl || !textEl) return if (!_hostedAutoStopTimer || !_hostedStartTime || !_hostedSessionConfig?.autoStopMinutes) { cdEl.style.display = 'none' clearInterval(_countdownInterval); _countdownInterval = null return } cdEl.style.display = '' const totalMs = _hostedSessionConfig.autoStopMinutes * 60000 const elapsed = Date.now() - _hostedStartTime const remaining = Math.max(0, totalMs - elapsed) const pct = Math.max(0, Math.min(100, (remaining / totalMs) * 100)) fillEl.style.width = pct + '%' const mins = Math.floor(remaining / 60000) const secs = Math.floor((remaining % 60000) / 1000) textEl.textContent = `${t('chat.remaining')} ${mins}:${secs.toString().padStart(2, '0')}` if (!_countdownInterval) { _countdownInterval = setInterval(() => updateCountdown(), 1000) } if (remaining <= 0) { clearInterval(_countdownInterval); _countdownInterval = null } } function toggleHostedRun() { if (!_hostedSessionConfig) return if (_hostedSessionConfig.enabled && _hostedRuntime.status !== HOSTED_STATUS.IDLE) { stopHostedAgent() } else { startHostedAgent() } } async function startHostedAgent() { if (!_hostedSessionConfig) return const prompt = (_hostedPromptEl?.value || '').trim() if (!prompt) { toast(t('chat.enterTaskGoal'), 'warning'); return } const rawSteps = parseInt(_hostedMaxStepsEl?.value || HOSTED_DEFAULTS.maxSteps, 10) const maxSteps = rawSteps >= 205 ? 999999 : Math.max(1, rawSteps) const stepDelayMs = Math.max(200, parseInt(_hostedStepDelayEl?.value || HOSTED_DEFAULTS.stepDelayMs, 10)) const retryLimit = Math.max(0, parseInt(_hostedRetryLimitEl?.value || HOSTED_DEFAULTS.retryLimit, 10)) const timerOn = _page?.querySelector('#hosted-agent-timer-on')?.checked const autoStopMinutes = timerOn ? Math.max(0, parseInt(_hostedAutoStopEl?.value || 0, 10)) : 0 const boundSessionKey = getHostedSessionKey() _hostedBoundSessionKey = boundSessionKey _hostedSessionConfig = { ..._hostedSessionConfig, prompt, enabled: true, maxSteps, stepDelayMs, retryLimit, autoStopMinutes, boundSessionKey } const sysContent = HOSTED_SYSTEM_PROMPT + '\n\nUser goal: ' + prompt if (!_hostedSessionConfig.history?.length) _hostedSessionConfig.history = [{ role: 'system', content: sysContent }] else if (_hostedSessionConfig.history[0]?.role === 'system') _hostedSessionConfig.history[0].content = sysContent else _hostedSessionConfig.history.unshift({ role: 'system', content: sysContent }) _hostedRuntime = { ...HOSTED_RUNTIME_DEFAULT, status: HOSTED_STATUS.RUNNING } _hostedStartTime = Date.now() persistHostedRuntime() renderHostedPanel() updateHostedBadge() // 启动定时停止 clearTimeout(_hostedAutoStopTimer) if (autoStopMinutes > 0) { _hostedAutoStopTimer = setTimeout(() => { appendHostedOutput(t('chat.hostedTimerExpired', { min: autoStopMinutes })) stopHostedAgent() }, autoStopMinutes * 60000) } if (!wsClient.gatewayReady || !_sessionKey) { toast(t('chat.gatewayNotReadySend'), 'warning'); return } toast(t('chat.hostedStarted'), 'success') runHostedAgentStep() } function stopHostedAgent() { if (!_hostedSessionConfig) return const boundSessionKey = getHostedBoundSessionKey() || getHostedSessionKey() if (_hostedAbort) { _hostedAbort.abort(); _hostedAbort = null } clearTimeout(_hostedAutoStopTimer); _hostedAutoStopTimer = null clearInterval(_countdownInterval); _countdownInterval = null _hostedBusy = false _hostedSessionConfig.enabled = false _hostedRuntime.status = HOSTED_STATUS.IDLE _hostedRuntime.pending = false _hostedRuntime.stepCount = 0 _hostedRuntime.lastError = '' _hostedRuntime.errorCount = 0 _hostedStartTime = 0 persistHostedRuntime(boundSessionKey) _hostedBoundSessionKey = null renderHostedPanel() updateHostedBadge() toast(t('chat.hostedStopped'), 'info') } function shouldCaptureHostedTarget(payload) { if (!_hostedSessionConfig?.enabled) return false const hostedSessionKey = getHostedBoundSessionKey() if (payload?.sessionKey && hostedSessionKey && payload.sessionKey !== hostedSessionKey) return false if (_hostedRuntime.status === HOSTED_STATUS.PAUSED || _hostedRuntime.status === HOSTED_STATUS.ERROR || _hostedRuntime.status === HOSTED_STATUS.IDLE) return false if (payload?.message?.role && payload.message.role !== 'assistant') return false const ts = payload?.timestamp || Date.now() if (ts && ts === _hostedLastTargetTs) return false _hostedLastTargetTs = ts return true } function appendHostedTarget(text) { if (!_hostedSessionConfig) return if (!_hostedSessionConfig.history) _hostedSessionConfig.history = [] _hostedSessionConfig.history.push({ role: 'target', content: text, ts: Date.now() }) persistHostedRuntime() } function maybeTriggerHostedRun() { if (!_hostedSessionConfig?.enabled) return if (_hostedRuntime.status === HOSTED_STATUS.IDLE || _hostedRuntime.status === HOSTED_STATUS.PAUSED || _hostedRuntime.status === HOSTED_STATUS.ERROR) return if (_hostedRuntime.pending || _hostedBusy) return if (!wsClient.gatewayReady) { _hostedRuntime.status = HOSTED_STATUS.PAUSED; persistHostedRuntime(); updateHostedBadge(); renderHostedPanel(); return } _hostedRuntime.status = HOSTED_STATUS.IDLE runHostedAgentStep() } function compressHostedContext() { if (!_hostedSessionConfig?.history) return const history = _hostedSessionConfig.history if (history.length <= HOSTED_COMPRESS_THRESHOLD) return const sysEntry = history[0]?.role === 'system' ? history[0] : null const recent = history.slice(-8) const older = history.slice(sysEntry ? 1 : 0, -8) const summary = older.map(h => `[${h.role}] ${(h.content || '').slice(0, 80)}`).join('\n') const compressed = [] if (sysEntry) compressed.push(sysEntry) compressed.push({ role: 'user', content: `[Context summary - compressed ${older.length} entries]\n${summary}`, ts: Date.now() }) compressed.push(...recent) _hostedSessionConfig.history = compressed persistHostedRuntime() } function buildHostedMessages() { compressHostedContext() const history = _hostedSessionConfig?.history || [] const mapped = history.slice(-HOSTED_CONTEXT_MAX).map(item => { if (item.role === 'system') return { role: 'system', content: item.content } if (item.role === 'assistant') return { role: 'assistant', content: item.content } return { role: 'user', content: item.content } }) const hasUserMsg = mapped.some(m => m.role === 'user' || m.role === 'assistant') if (!hasUserMsg && _hostedSessionConfig?.prompt) { mapped.push({ role: 'user', content: _hostedSessionConfig.prompt }) } return mapped } function detectStopFromText(text) { if (!text) return false return /\b(完成|无需继续|结束|停止|done|stop|final)\b/i.test(text) } async function runHostedAgentStep() { if (_hostedBusy || !_hostedSessionConfig?.enabled) return const prompt = (_hostedSessionConfig.prompt || '').trim() const hostedSessionKey = getHostedBoundSessionKey() || getHostedSessionKey() if (!prompt) return if (!wsClient.gatewayReady || !hostedSessionKey) { _hostedRuntime.status = HOSTED_STATUS.PAUSED _hostedRuntime.lastError = 'Gateway not ready' persistHostedRuntime(); updateHostedBadge() appendHostedOutput(t('chat.hostedNeedIntervention')) return } if (_hostedRuntime.errorCount >= _hostedSessionConfig.retryLimit) { _hostedRuntime.status = HOSTED_STATUS.ERROR persistHostedRuntime(); updateHostedBadge() appendHostedOutput(t('chat.hostedErrorThreshold')) return } if (_hostedRuntime.stepCount >= _hostedSessionConfig.maxSteps) { _hostedRuntime.status = HOSTED_STATUS.IDLE persistHostedRuntime(); updateHostedBadge() return } _hostedBusy = true _hostedRuntime.pending = true _hostedRuntime.status = HOSTED_STATUS.RUNNING _hostedRuntime.lastRunAt = Date.now() _hostedRuntime.lastRunId = uuid() persistHostedRuntime(); updateHostedBadge() const delay = _hostedSessionConfig.stepDelayMs || HOSTED_DEFAULTS.stepDelayMs if (delay > 0) await new Promise(r => setTimeout(r, delay)) try { const messages = buildHostedMessages() let resultText = '' await callHostedAI(messages, (chunk) => { resultText += chunk }) _hostedRuntime.stepCount += 1 _hostedRuntime.errorCount = 0 _hostedRuntime.lastError = '' _hostedSessionConfig.history.push({ role: 'assistant', content: resultText, ts: Date.now() }) persistHostedRuntime() appendHostedOutput(resultText + ` | step=${_hostedRuntime.stepCount}`) // 如果 AI 回复中有「执行命令」类内容,通过 Gateway 发送给 Agent const instruction = resultText.trim() if (instruction && !detectStopFromText(instruction)) { _hostedRuntime.status = HOSTED_STATUS.WAITING _hostedRuntime.pending = false persistHostedRuntime(); updateHostedBadge() // 将指令发给 Gateway Agent try { await wsClient.chatSend(hostedSessionKey, instruction) } catch {} } else { _hostedRuntime.status = HOSTED_STATUS.IDLE _hostedRuntime.pending = false persistHostedRuntime(); updateHostedBadge() } } catch (e) { _hostedRuntime.errorCount = (_hostedRuntime.errorCount || 0) + 1 _hostedRuntime.lastError = e.message || String(e) _hostedRuntime.pending = false if (_hostedRuntime.errorCount >= _hostedSessionConfig.retryLimit) { _hostedRuntime.status = HOSTED_STATUS.ERROR persistHostedRuntime(); updateHostedBadge() appendHostedOutput(t('chat.hostedNeedIntervention', { reason: _hostedRuntime.lastError })) return } persistHostedRuntime(); updateHostedBadge() setTimeout(() => { _hostedBusy = false; runHostedAgentStep() }, delay) return } finally { _hostedBusy = false } } async function callHostedAI(messages, onChunk) { let config try { const raw = localStorage.getItem('clawpanel-assistant') const stored = raw ? JSON.parse(raw) : {} config = { baseUrl: stored.baseUrl || '', apiKey: stored.apiKey || '', model: stored.model || '', temperature: stored.temperature || 0.7, apiType: stored.apiType || 'openai-completions' } } catch { config = { baseUrl: '', apiKey: '', model: '', temperature: 0.7, apiType: 'openai-completions' } } if (!config.baseUrl || !config.model) throw new Error(t('chat.hostedModelNotConfigured')) const apiType = normalizeHostedApiType(config.apiType) const base = normalizeHostedBaseUrl(config.baseUrl, apiType) if (_hostedAbort) { _hostedAbort.abort(); _hostedAbort = null } _hostedAbort = new AbortController() const signal = _hostedAbort.signal const timeout = setTimeout(() => { if (_hostedAbort) _hostedAbort.abort() }, 120000) try { const headers = { 'Content-Type': 'application/json' } if (config.apiKey) headers['Authorization'] = `Bearer ${config.apiKey}` const body = { model: config.model, messages, stream: true, temperature: config.temperature || 0.7 } const resp = await fetch(base + '/chat/completions', { method: 'POST', headers, body: JSON.stringify(body), signal }) if (!resp.ok) { const errText = await resp.text().catch(() => '') let errMsg = `API error ${resp.status}` try { errMsg = JSON.parse(errText).error?.message || errMsg } catch {} throw new Error(errMsg) } const reader = resp.body.getReader() const decoder = new TextDecoder() let buffer = '' while (true) { const { done, value } = await reader.read() if (done) break buffer += decoder.decode(value, { stream: true }) const lines = buffer.split('\n') buffer = lines.pop() || '' for (const line of lines) { const trimmed = line.trim() if (!trimmed || !trimmed.startsWith('data:')) continue const data = trimmed.slice(5).trim() if (data === '[DONE]') return try { const json = JSON.parse(data); if (json.choices?.[0]?.delta?.content) onChunk(json.choices[0].delta.content) } catch {} } } } finally { clearTimeout(timeout) _hostedAbort = null } } function normalizeHostedApiType(raw) { const type = (raw || '').trim() if (type === 'anthropic' || type === 'anthropic-messages') return 'anthropic-messages' if (type === 'google-gemini' || type === 'google-generative-ai') return 'google-generative-ai' if (type === 'ollama') return 'ollama' return 'openai-completions' } function normalizeHostedBaseUrl(raw, apiType) { let base = (raw || '').trim() if (!base) throw new Error(t('chat.hostedModelNotConfigured')) if (/^\/\//.test(base)) base = `http:${base}` if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(base) && /^(localhost|(?:\d{1,3}\.){3}\d{1,3}|\[[0-9a-f:.]+\]|[^/\s]+:\d+)(?:\/|$)/i.test(base)) { base = `http://${base}` } let url try { url = new URL(base) } catch { throw new Error(t('chat.hostedModelUrlInvalid')) } if (!/^https?:$/.test(url.protocol) || url.hostname === 'tauri.localhost') { throw new Error(t('chat.hostedModelUrlInvalid')) } base = `${url.origin}${url.pathname}` .replace(/\/+$/, '') .replace(/\/api\/chat\/?$/, '') .replace(/\/api\/generate\/?$/, '') .replace(/\/api\/tags\/?$/, '') .replace(/\/api\/?$/, '') .replace(/\/chat\/completions\/?$/, '') .replace(/\/completions\/?$/, '') .replace(/\/responses\/?$/, '') .replace(/\/messages\/?$/, '') .replace(/\/models\/?$/, '') const type = normalizeHostedApiType(apiType) if (type === 'anthropic-messages') { if (!base.endsWith('/v1')) base += '/v1' return base } if (type === 'google-generative-ai') return base if (/:(11434)$/i.test(base) && !base.endsWith('/v1')) return `${base}/v1` return base } function appendHostedOutput(text) { if (!text || !_messagesEl) return const hostedSessionKey = getHostedBoundSessionKey() if (hostedSessionKey && _sessionKey && hostedSessionKey !== _sessionKey) return const wrap = document.createElement('div') wrap.className = 'msg msg-system msg-hosted' wrap.textContent = `[${t('chat.hostedAgent')}] ${text}` _messagesEl.insertBefore(wrap, _typingEl) scrollToBottom() } // ── 页面离开清理 ── export function cleanup() { _pageActive = false if (_unsubEvent) { _unsubEvent(); _unsubEvent = null } if (_unsubReady) { _unsubReady(); _unsubReady = null } if (_unsubStatus) { _unsubStatus(); _unsubStatus = null } clearTimeout(_streamSafetyTimer) clearInterval(_typingElapsedInterval) _typingElapsedInterval = null _cancelResponseWatchdog() _sendTimestamp = 0 clearTimeout(_postFinalCheck) _postFinalCheck = null if (_hostedAbort) { _hostedAbort.abort(); _hostedAbort = null } _sessionKey = null _page = null _messagesEl = null _textarea = null _sendBtn = null _statusDot = null _typingEl = null _scrollBtn = null _sessionListEl = null _cmdPanelEl = null _currentAiBubble = null _currentAiText = '' _currentAiImages = [] _currentAiVideos = [] _currentAiAudios = [] _currentAiFiles = [] _currentAiTools = [] _currentRunId = null _isStreaming = false _isSending = false _messageQueue = [] _lastHistoryHash = '' _hostedBtn = null _hostedPanelEl = null _hostedBadgeEl = null _hostedPromptEl = null _hostedEnableEl = null _hostedMaxStepsEl = null _hostedStepDelayEl = null _hostedRetryLimitEl = null _hostedSaveBtn = null _hostedPauseBtn = null _hostedStopBtn = null _hostedCloseBtn = null _hostedGlobalSyncEl = null _hostedSessionConfig = null _hostedDefaults = null _hostedRuntime = { ...HOSTED_RUNTIME_DEFAULT } _hostedBusy = false _workspaceBtn = null _workspacePanelEl = null _workspaceAgentBadgeEl = null _workspaceAgentTitleEl = null _workspacePathEl = null _workspaceCoreListEl = null _workspaceTreeEl = null _workspaceCurrentFileEl = null _workspaceMetaEl = null _workspaceEditorEl = null _workspacePreviewEl = null _workspaceEmptyEl = null _workspaceSaveBtn = null _workspaceReloadBtn = null _workspacePreviewBtn = null _workspaceInfo = null _workspaceCoreFiles = [] _workspaceTreeCache = new Map() _workspaceExpandedDirs = new Set() _workspaceCurrentAgentId = 'main' _workspaceCurrentFile = null _workspacePreviewMode = false _workspaceDirty = false _workspaceLoadedContent = '' _workspaceLoading = false _workspaceLoadSeq = 0 _workspaceOpenSeq = 0 }