diff --git a/src/locales/modules/chat.js b/src/locales/modules/chat.js index 990d953..33c4407 100644 --- a/src/locales/modules/chat.js +++ b/src/locales/modules/chat.js @@ -42,6 +42,7 @@ export default { sendFailed: _('发送失败: ', 'Send failed: ', '發送失敗: ', '送信失敗', '전송 실패'), usingTool: _('正在使用工具: {name}', 'Using tool: {name}'), streamTimeout: _('输出超时,已自动结束', 'Output timed out, auto-ended', '輸出逾時,已自動結束'), + elapsedTime: _('{seconds}秒', '{seconds}s'), generationStopped: _('生成已停止', 'Generation stopped'), errorPrefix: _('错误: ', 'Error: ', '錯誤: '), connectionRejected: _('连接被 Gateway 拒绝,请点击「修复并重连」', 'Connection rejected by Gateway, click "Fix & Reconnect"', '連線被 Gateway 拒絕,請点擊「修复並重連」'), @@ -57,6 +58,16 @@ export default { tool: _('工具', 'Tool'), file: _('文件', 'File', '檔案'), compacting: _('正在整理上下文(Compaction)…', 'Compacting context…'), + aiProcessing: _('AI 正在处理…', 'AI is processing…', 'AI 正在處理…'), + aiThinking: _('AI 正在思考…', 'AI is thinking…', 'AI 正在思考…'), + aiPlanning: _('AI 正在规划…', 'AI is planning…', 'AI 正在規劃…'), + aiExecuting: _('正在执行: {title}', 'Executing: {title}', '正在執行: {title}'), + aiSearching: _('正在搜索…', 'Searching…', '正在搜尋…'), + aiAnalyzing: _('正在分析…', 'Analyzing…', '正在分析…'), + commandRunning: _('正在运行命令…', 'Running command…', '正在執行命令…'), + waitingApproval: _('等待操作批准…', 'Waiting for approval…', '等待操作批准…'), + responseTimeout: _('等待回复超时({seconds}秒无响应),请检查 OpenClaw 是否正常运行', 'Response timed out ({seconds}s no response). Please check if OpenClaw is running.', '等待回覆逾時({seconds}秒無回應),請檢查 OpenClaw 是否正常運行'), + stillWaiting: _('仍在等待回复…', 'Still waiting for response…', '仍在等待回覆…'), remaining: _('剩余', 'Remaining', '剩餘'), connectFailed: _('连接失败', 'Connection failed', '連線失敗'), fixDoneReconnecting: _('修复完成,正在重连...', 'Fix done, reconnecting...', '修复完成,正在重連...'), diff --git a/src/pages/chat.js b/src/pages/chat.js index ea0e0be..74b385b 100644 --- a/src/pages/chat.js +++ b/src/pages/chat.js @@ -75,6 +75,7 @@ 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 = [] @@ -1566,6 +1567,7 @@ async function doSend(text, attachments = []) { } catch (err) { showTyping(false) _cancelResponseWatchdog() + _sendTimestamp = 0 appendSystemMessage(`${t('chat.sendFailed')}${err.message}`) } finally { _isSending = false @@ -1590,28 +1592,89 @@ function handleEvent(msg) { const { event, payload } = msg if (!payload) return - if (event === 'agent' && payload?.stream === 'tool' && payload?.data?.toolCallId) { - const ts = payload.ts - const toolCallId = payload.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 (payload.data?.args && current.input == null) current.input = payload.data.args - if (payload.data?.meta && current.output == null) current.output = payload.data.meta - if (typeof payload.data?.isError === 'boolean' && current.status == null) current.status = payload.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) + // ── 处理所有 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 })) + } } - // 工具执行反馈:更新 typing 提示文字 - const toolName = payload.data?.name || payload.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}`) } } @@ -2036,31 +2099,87 @@ function doRender() { } // ── 响应看门狗:防止页面卡在等待状态 ── +const WATCHDOG_INTERVAL = 15000 // 15s 轮询间隔 +const ULTIMATE_TIMEOUT = 180000 // 3 分钟终极超时 function _startResponseWatchdog() { - _cancelResponseWatchdog() + // 只清除轮询定时器,不清除终极超时(终极超时应持续到收到响应) + 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) { - console.log('[chat] 响应看门狗触发:15s 无 delta,刷新历史') + 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() } } - }, 15000) + }, 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() { @@ -2078,6 +2197,8 @@ function _schedulePostFinalCheck() { 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) @@ -2100,6 +2221,7 @@ function resetStreamState() { _streamStartTime = 0 _lastErrorMsg = null _errorTimer = null + _sendTimestamp = 0 showTyping(false) updateSendState() } @@ -2596,12 +2718,35 @@ function clearMessages() { _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() } @@ -3091,7 +3236,10 @@ export function cleanup() { 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 } diff --git a/src/style/chat.css b/src/style/chat.css index 1b2fa4b..cd90848 100644 --- a/src/style/chat.css +++ b/src/style/chat.css @@ -762,6 +762,18 @@ white-space: nowrap; } +.typing-elapsed { + width: auto !important; + height: auto !important; + background: none !important; + animation: none !important; + font-size: 11px; + color: var(--text-muted, #999); + margin-left: 8px; + white-space: nowrap; + opacity: 0.7; +} + @keyframes typing-bounce { 0%, 60%, 100% { transform: translateY(0); opacity: 0.4; } 30% { transform: translateY(-6px); opacity: 1; }