mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-05-06 20:02:49 +08:00
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:
@@ -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...', '修复完成,正在重連...'),
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user