feat(chat): OpenClaw 4.5 compatibility — fix silent no-reply + add working state indicators

Problem:
- Sending a message with no response left users stuck with an empty typing
  indicator forever (no timeout, no error, no feedback)
- Users had no visibility into what OpenClaw was doing (thinking, planning,
  executing tools, running commands, waiting for approval)
- The response watchdog only reacted to chat delta/final events, ignoring
  all agent processing events

Root cause:
- Watchdog looped every 15s polling history but had no ultimate timeout
- Agent events (lifecycle, item, plan, approval, thinking, command_output)
  were not handled — only tool stream events updated the typing hint
- When OpenClaw processed silently (no delta), the UI showed nothing useful

Fix:
1. Handle ALL OpenClaw 4.5 agent event streams in handleEvent():
   - lifecycle: "AI is processing…" on phase start
   - item: structured execution steps (tool/command/search/analysis)
   - plan: "AI is planning…"
   - approval: "Waiting for approval…"
   - thinking: "AI is thinking…"
   - command_output: "Running command…"
   - compaction/error: appropriate indicators

2. Enhanced watchdog mechanism:
   - 3-minute ultimate timeout that persists across watchdog re-polls
   - Agent events reset the polling timer (OpenClaw is alive) but not
     the ultimate timeout
   - After 30s shows "Still waiting…" with elapsed time
   - At 3min shows clear error: "Check if OpenClaw is running"

3. Live elapsed timer in typing indicator:
   - Shows seconds elapsed since message was sent (after 5s)
   - Updates every 5s while waiting
   - Properly cleaned up on response/error/page exit

4. Config compatibility check: no changes needed — our code already uses
   canonical config paths, no legacy aliases affected by 4.5 removal.

Files: chat.js, chat.css, locales/modules/chat.js
This commit is contained in:
晴天
2026-04-07 13:34:21 +08:00
parent f781f2afa6
commit 35423f428b
3 changed files with 196 additions and 25 deletions

View File

@@ -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...', '修复完成,正在重連...'),

View File

@@ -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 }

View File

@@ -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; }