mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-05-06 20:02:49 +08:00
fix: cherry-pick PR#94 improvements + dashboard loading fix
- ws-client: connection dedup (_connecting state), connect() guard, global singleton - chat: 8 null guards (sendMessage/doSend/createStreamBubble/renderAttachments/showPageGuide/loadHistory) - chat: auto-scroll control (wheel/touch/scrollBtn, disable on scroll-up) - chat: tool call rendering (appendToolsToEl, collectToolsFromMessage, upsertTool, mergeToolEventData) - chat: tool event tracking (agent tool events -> _toolEventData/_toolRunIndex) - chat: extractChatContent/extractContent/dedupeHistory full tools support - chat.css: .msg-tool collapsible card styles - dashboard: .catch() on loadDashboardData fire-and-forget, error state + retry button
This commit is contained in:
@@ -38,6 +38,7 @@ export class WsClient {
|
||||
this._connected = false
|
||||
this._gatewayReady = false
|
||||
this._handshaking = false
|
||||
this._connecting = false
|
||||
this._intentionalClose = false
|
||||
this._snapshot = null
|
||||
this._hello = null
|
||||
@@ -50,6 +51,7 @@ export class WsClient {
|
||||
}
|
||||
|
||||
get connected() { return this._connected }
|
||||
get connecting() { return this._connecting }
|
||||
get gatewayReady() { return this._gatewayReady }
|
||||
get snapshot() { return this._snapshot }
|
||||
get hello() { return this._hello }
|
||||
@@ -72,7 +74,12 @@ export class WsClient {
|
||||
this._token = token || ''
|
||||
// 自动检测协议:如果页面通过 HTTPS 加载(反代场景),使用 wss://
|
||||
const proto = opts.secure ?? (typeof location !== 'undefined' && location.protocol === 'https:') ? 'wss' : 'ws'
|
||||
this._url = `${proto}://${host}/ws?token=${encodeURIComponent(this._token)}`
|
||||
const nextUrl = `${proto}://${host}/ws?token=${encodeURIComponent(this._token)}`
|
||||
if (this._connecting || this._handshaking || this._gatewayReady) {
|
||||
if (this._url === nextUrl) return
|
||||
}
|
||||
if (this._ws && (this._ws.readyState === WebSocket.OPEN || this._ws.readyState === WebSocket.CONNECTING)) return
|
||||
this._url = nextUrl
|
||||
this._doConnect()
|
||||
}
|
||||
|
||||
@@ -102,6 +109,7 @@ export class WsClient {
|
||||
}
|
||||
|
||||
_doConnect() {
|
||||
this._connecting = true
|
||||
this._closeWs()
|
||||
this._gatewayReady = false
|
||||
this._handshaking = false
|
||||
@@ -113,6 +121,7 @@ export class WsClient {
|
||||
|
||||
ws.onopen = () => {
|
||||
if (wsId !== this._wsId) return
|
||||
this._connecting = false
|
||||
this._reconnectAttempts = 0
|
||||
this._setConnected(true)
|
||||
this._startPing()
|
||||
@@ -135,6 +144,7 @@ export class WsClient {
|
||||
ws.onclose = (e) => {
|
||||
if (wsId !== this._wsId) return
|
||||
this._ws = null
|
||||
this._connecting = false
|
||||
this._clearChallengeTimer()
|
||||
if (e.code === 4001 || e.code === 4003 || e.code === 4004) {
|
||||
this._setConnected(false, 'auth_failed', e.reason || 'Token 认证失败')
|
||||
@@ -411,4 +421,6 @@ export class WsClient {
|
||||
}
|
||||
}
|
||||
|
||||
export const wsClient = new WsClient()
|
||||
const _g = typeof window !== 'undefined' ? window : globalThis
|
||||
if (!_g.__clawpanelWsClient) _g.__clawpanelWsClient = new WsClient()
|
||||
export const wsClient = _g.__clawpanelWsClient
|
||||
|
||||
@@ -59,12 +59,18 @@ 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 = [], _currentRunId = 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 _attachments = []
|
||||
let _hasEverConnected = false
|
||||
@@ -187,6 +193,7 @@ 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 = `
|
||||
@@ -263,8 +270,24 @@ function bindEvents(page) {
|
||||
_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)
|
||||
})
|
||||
_scrollBtn.addEventListener('click', () => scrollToBottom())
|
||||
_messagesEl.addEventListener('click', () => hideCmdPanel())
|
||||
}
|
||||
|
||||
@@ -472,6 +495,7 @@ function fileToBase64(file) {
|
||||
}
|
||||
|
||||
function renderAttachments() {
|
||||
if (!_attachPreviewEl) return
|
||||
if (!_attachments.length) {
|
||||
_attachPreviewEl.style.display = 'none'
|
||||
return
|
||||
@@ -575,7 +599,7 @@ async function connectGateway() {
|
||||
}
|
||||
|
||||
// 如果正在连接中(重连等),等待 onReady 回调即可
|
||||
if (wsClient.connected) return
|
||||
if (wsClient.connected || wsClient.connecting || wsClient.gatewayReady) return
|
||||
|
||||
// 未连接,发起新连接
|
||||
const config = await api.readOpenclawConfig()
|
||||
@@ -853,6 +877,10 @@ function toggleCmdPanel() {
|
||||
function sendMessage() {
|
||||
const text = _textarea.value.trim()
|
||||
if (!text && !_attachments.length) return
|
||||
if (!wsClient.gatewayReady || !_sessionKey) {
|
||||
toast('Gateway 未就绪,连接成功后再发送', 'warning')
|
||||
return
|
||||
}
|
||||
hideCmdPanel()
|
||||
_textarea.value = ''
|
||||
_textarea.style.height = 'auto'
|
||||
@@ -865,6 +893,10 @@ function sendMessage() {
|
||||
}
|
||||
|
||||
async function doSend(text, attachments = []) {
|
||||
if (!wsClient.gatewayReady || !_sessionKey) {
|
||||
toast('Gateway 未就绪,连接成功后再发送', 'warning')
|
||||
return
|
||||
}
|
||||
appendUserMessage(text, attachments)
|
||||
saveMessage({
|
||||
id: uuid(), sessionKey: _sessionKey, role: 'user', content: text, timestamp: Date.now(),
|
||||
@@ -900,6 +932,26 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
if (event === 'chat') handleChatEvent(payload)
|
||||
|
||||
// Compaction 状态指示:上游 2026.3.12 新增 status_reaction 事件
|
||||
@@ -936,6 +988,7 @@ function handleChatEvent(payload) {
|
||||
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) {
|
||||
@@ -971,11 +1024,17 @@ function handleChatEvent(payload) {
|
||||
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: '工具' })).filter(Boolean)
|
||||
}
|
||||
if (finalImages.length) _currentAiImages = finalImages
|
||||
if (finalVideos.length) _currentAiVideos = finalVideos
|
||||
if (finalAudios.length) _currentAiAudios = finalAudios
|
||||
if (finalFiles.length) _currentAiFiles = finalFiles
|
||||
const hasContent = finalText || _currentAiImages.length || _currentAiVideos.length || _currentAiAudios.length || _currentAiFiles.length
|
||||
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 为已处理,防止重复
|
||||
@@ -998,6 +1057,7 @@ function handleChatEvent(payload) {
|
||||
appendVideosToEl(_currentAiBubble, _currentAiVideos)
|
||||
appendAudiosToEl(_currentAiBubble, _currentAiAudios)
|
||||
appendFilesToEl(_currentAiBubble, _currentAiFiles)
|
||||
appendToolsToEl(_currentAiBubble, finalTools.length ? finalTools : _currentAiTools)
|
||||
}
|
||||
// 添加时间戳 + 耗时 + token 消耗
|
||||
const wrapper = _currentAiBubble?.parentElement
|
||||
@@ -1092,8 +1152,24 @@ function handleChatEvent(payload) {
|
||||
/** 从 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 || '工具',
|
||||
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: [] }
|
||||
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) {
|
||||
@@ -1115,6 +1191,34 @@ function extractChatContent(message) {
|
||||
else if (block.type === 'file' || block.type === 'document') {
|
||||
files.push({ url: block.url || '', name: block.fileName || block.name || '文件', 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 || '工具',
|
||||
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 || '工具',
|
||||
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] : [])
|
||||
@@ -1126,20 +1230,77 @@ function extractChatContent(message) {
|
||||
else files.push({ url, name: url.split('/').pop().split('?')[0] || '文件', mimeType: '' })
|
||||
}
|
||||
const text = texts.length ? stripThinkingTags(texts.join('\n')) : ''
|
||||
return { text, images, videos, audios, files }
|
||||
return { text, images, videos, audios, files, tools }
|
||||
}
|
||||
if (typeof message.text === 'string') return { text: stripThinkingTags(message.text), images: [], videos: [], audios: [], files: [] }
|
||||
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, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
function stripThinkingTags(text) {
|
||||
return 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')
|
||||
@@ -1160,6 +1321,7 @@ function formatFileSize(bytes) {
|
||||
|
||||
/** 创建流式 AI 气泡 */
|
||||
function createStreamBubble() {
|
||||
if (!_messagesEl || !_typingEl) return null
|
||||
showTyping(false)
|
||||
const wrap = document.createElement('div')
|
||||
wrap.className = 'msg msg-ai'
|
||||
@@ -1197,12 +1359,13 @@ function doRender() {
|
||||
|
||||
function resetStreamState() {
|
||||
clearTimeout(_streamSafetyTimer)
|
||||
if (_currentAiBubble && (_currentAiText || _currentAiImages.length || _currentAiVideos.length || _currentAiAudios.length || _currentAiFiles.length)) {
|
||||
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
|
||||
@@ -1212,6 +1375,7 @@ function resetStreamState() {
|
||||
_currentAiVideos = []
|
||||
_currentAiAudios = []
|
||||
_currentAiFiles = []
|
||||
_currentAiTools = []
|
||||
_currentRunId = null
|
||||
_isStreaming = false
|
||||
_streamStartTime = 0
|
||||
@@ -1224,8 +1388,9 @@ function resetStreamState() {
|
||||
// ── 历史消息加载 ──
|
||||
|
||||
async function loadHistory() {
|
||||
if (!_sessionKey) return
|
||||
const hasExisting = _messagesEl?.querySelector('.msg')
|
||||
if (!_sessionKey || !_messagesEl) return
|
||||
_isLoadingHistory = true
|
||||
const hasExisting = _messagesEl.querySelector('.msg')
|
||||
if (!hasExisting && isStorageAvailable()) {
|
||||
const local = await getLocalMessages(_sessionKey, 200)
|
||||
if (local.length) {
|
||||
@@ -1236,17 +1401,17 @@ async function loadHistory() {
|
||||
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)
|
||||
appendAiMessage(msg.content || '', msgTime, images, [], [], [], [])
|
||||
}
|
||||
})
|
||||
scrollToBottom()
|
||||
}
|
||||
}
|
||||
if (!wsClient.gatewayReady) return
|
||||
if (!wsClient.gatewayReady) { _isLoadingHistory = false; return }
|
||||
try {
|
||||
const result = await wsClient.chatHistory(_sessionKey, 200)
|
||||
if (!result?.messages?.length) {
|
||||
if (!_messagesEl.querySelector('.msg')) appendSystemMessage('还没有消息,开始聊天吧')
|
||||
if (_messagesEl && !_messagesEl.querySelector('.msg')) appendSystemMessage('还没有消息,开始聊天吧')
|
||||
return
|
||||
}
|
||||
const deduped = dedupeHistory(result.messages)
|
||||
@@ -1258,15 +1423,17 @@ async function loadHistory() {
|
||||
if (hasExisting && (_isSending || _isStreaming || _messageQueue.length > 0)) {
|
||||
saveMessages(result.messages.map(m => {
|
||||
const c = extractContent(m)
|
||||
return { id: m.id || uuid(), sessionKey: _sessionKey, role: m.role, content: c?.text || '', timestamp: m.timestamp || Date.now() }
|
||||
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) return
|
||||
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 => ({
|
||||
@@ -1277,7 +1444,7 @@ async function loadHistory() {
|
||||
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)
|
||||
appendAiMessage(msg.text, msgTime, msg.images, msg.videos, msg.audios, msg.files, msg.tools)
|
||||
}
|
||||
})
|
||||
if (hasOmittedImages) {
|
||||
@@ -1285,25 +1452,33 @@ async function loadHistory() {
|
||||
}
|
||||
saveMessages(result.messages.map(m => {
|
||||
const c = extractContent(m)
|
||||
return { id: m.id || uuid(), sessionKey: _sessionKey, role: m.role, content: c?.text || '', timestamp: m.timestamp || Date.now() }
|
||||
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.querySelector('.msg')) appendSystemMessage('加载历史失败: ' + e.message)
|
||||
if (_messagesEl && !_messagesEl.querySelector('.msg')) appendSystemMessage('加载历史失败: ' + e.message)
|
||||
} finally {
|
||||
_isLoadingHistory = false
|
||||
}
|
||||
}
|
||||
|
||||
function dedupeHistory(messages) {
|
||||
const deduped = []
|
||||
for (const msg of messages) {
|
||||
if (msg.role === 'toolResult') continue
|
||||
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) continue
|
||||
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 === msg.role) {
|
||||
if (msg.role === 'user' && last.text === c.text) continue
|
||||
if (msg.role === 'assistant') {
|
||||
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
|
||||
// 不同文本则合并
|
||||
@@ -1312,15 +1487,34 @@ function dedupeHistory(messages) {
|
||||
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: msg.role, text: c.text, images: c.images, videos: c.videos, audios: c.audios, files: c.files, timestamp: msg.timestamp })
|
||||
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 || '工具',
|
||||
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) {
|
||||
@@ -1342,6 +1536,34 @@ function extractContent(msg) {
|
||||
else if (block.type === 'file' || block.type === 'document') {
|
||||
files.push({ url: block.url || '', name: block.fileName || block.name || '文件', 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 || '工具',
|
||||
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 || '工具',
|
||||
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) {
|
||||
@@ -1351,10 +1573,10 @@ function extractContent(msg) {
|
||||
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] || '文件', mimeType: '' })
|
||||
}
|
||||
return { text: stripThinkingTags(texts.join('\n')), images, videos, audios, files }
|
||||
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: [] }
|
||||
return { text: stripThinkingTags(text), images: [], videos: [], audios: [], files: [], tools }
|
||||
}
|
||||
|
||||
// ── DOM 操作 ──
|
||||
@@ -1420,12 +1642,16 @@ function appendUserMessage(text, attachments = [], msgTime) {
|
||||
scrollToBottom()
|
||||
}
|
||||
|
||||
function appendAiMessage(text, msgTime, images, videos, audios, files) {
|
||||
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'
|
||||
bubble.innerHTML = renderMarkdown(text)
|
||||
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)
|
||||
@@ -1528,6 +1754,101 @@ function appendFilesToEl(el, files) {
|
||||
})
|
||||
}
|
||||
|
||||
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 || '工具',
|
||||
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 || '工具',
|
||||
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' ? '失败' : '成功'
|
||||
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 || '工具')} · ${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 = `<div class="msg-tool-block"><div class="msg-tool-title">参数</div><pre>${escapeHtml(inputJson || '无参数')}</pre></div>`
|
||||
+ `<div class="msg-tool-block"><div class="msg-tool-title">结果</div><pre>${escapeHtml(outputJson || '无结果')}</pre></div>`
|
||||
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')
|
||||
@@ -1552,6 +1873,8 @@ function appendSystemMessage(text) {
|
||||
|
||||
function clearMessages() {
|
||||
_messagesEl.querySelectorAll('.msg').forEach(m => m.remove())
|
||||
_autoScrollEnabled = true
|
||||
_lastScrollTop = 0
|
||||
}
|
||||
|
||||
function showTyping(show) {
|
||||
@@ -1573,11 +1896,17 @@ function showCompactionHint(show) {
|
||||
}
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
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) {
|
||||
@@ -1624,6 +1953,7 @@ export function cleanup() {
|
||||
_currentAiVideos = []
|
||||
_currentAiAudios = []
|
||||
_currentAiFiles = []
|
||||
_currentAiTools = []
|
||||
_currentRunId = null
|
||||
_isStreaming = false
|
||||
_isSending = false
|
||||
|
||||
@@ -41,7 +41,14 @@ export async function render() {
|
||||
bindActions(page)
|
||||
|
||||
// 异步加载数据
|
||||
loadDashboardData(page)
|
||||
loadDashboardData(page).catch(e => {
|
||||
console.error('[dashboard] loadDashboardData 异常:', e)
|
||||
const cardsEl = page.querySelector('#stat-cards')
|
||||
if (cardsEl && cardsEl.querySelector('.loading-placeholder')) {
|
||||
cardsEl.innerHTML = `<div class="stat-card" style="grid-column:1/-1;text-align:center;color:var(--text-secondary)"><div>加载失败: ${escapeHtml(String(e?.message || e))}</div><button class="btn btn-sm btn-secondary" style="margin-top:8px" onclick="this.closest('.page')&&this.closest('.page').__retryLoad?.()">重试</button></div>`
|
||||
}
|
||||
})
|
||||
page.__retryLoad = () => loadDashboardData(page).catch(() => {})
|
||||
|
||||
// 监听 Gateway 状态变化,自动刷新仪表盘
|
||||
if (_unsubGw) _unsubGw()
|
||||
@@ -61,19 +68,23 @@ let _dashboardInitialized = false
|
||||
async function loadDashboardData(page, fullRefresh = false) {
|
||||
// 分波加载:关键数据先渲染,次要数据后填充,减少白屏等待
|
||||
// 轻量调用(读文件)每次都做;重量调用(spawn CLI/网络请求)只在首次或手动刷新时做
|
||||
const coreP = Promise.allSettled([
|
||||
const withTimeout = (promise, ms) => Promise.race([
|
||||
promise,
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error(`超时(${ms/1000}s)`)), ms))
|
||||
])
|
||||
const coreP = withTimeout(Promise.allSettled([
|
||||
api.getServicesStatus(),
|
||||
api.readOpenclawConfig(),
|
||||
// 版本信息:首次加载或手动刷新时才查询(避免 ARM 设备上频繁查 npm registry)
|
||||
(!_dashboardInitialized || fullRefresh) ? api.getVersionInfo() : Promise.resolve(null),
|
||||
])
|
||||
const secondaryP = Promise.allSettled([
|
||||
]), 15000)
|
||||
const secondaryP = withTimeout(Promise.allSettled([
|
||||
api.listAgents(),
|
||||
api.readMcpConfig(),
|
||||
api.listBackups(),
|
||||
// getStatusSummary 是最重的调用(spawn openclaw status --json),只在首次加载时调用
|
||||
(!_dashboardInitialized || fullRefresh) ? api.getStatusSummary() : Promise.resolve(null),
|
||||
])
|
||||
]), 15000).catch(() => [{ status: 'rejected' }, { status: 'rejected' }, { status: 'rejected' }, { status: 'rejected' }])
|
||||
const logsP = api.readLogTail('gateway', 20).catch(() => '')
|
||||
|
||||
// 第一波:服务状态 + 配置 + 版本 → 立即渲染统计卡片
|
||||
|
||||
@@ -855,6 +855,55 @@
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* 工具调用 */
|
||||
.msg-tool {
|
||||
margin-bottom: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.msg-tool-item {
|
||||
border: 1px solid var(--border-primary, var(--border));
|
||||
background: var(--bg-tertiary, var(--bg-secondary));
|
||||
border-radius: var(--radius-md, 8px);
|
||||
padding: 8px 10px;
|
||||
}
|
||||
.msg-tool-item > summary {
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
list-style: none;
|
||||
}
|
||||
.msg-tool-item > summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
.msg-tool-body {
|
||||
margin-top: 8px;
|
||||
display: none;
|
||||
gap: 8px;
|
||||
}
|
||||
.msg-tool-item[open] > .msg-tool-body {
|
||||
display: grid;
|
||||
}
|
||||
.msg-tool-block {
|
||||
background: var(--bg-primary, var(--bg));
|
||||
border: 1px solid var(--border-primary, var(--border));
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
padding: 8px 10px;
|
||||
}
|
||||
.msg-tool-title {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.msg-tool-block pre {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-size: 11px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* 首次引导提示 */
|
||||
.chat-page-guide {
|
||||
margin: 0 16px 8px;
|
||||
|
||||
Reference in New Issue
Block a user