mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-07-10 06:52:31 +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._connected = false
|
||||||
this._gatewayReady = false
|
this._gatewayReady = false
|
||||||
this._handshaking = false
|
this._handshaking = false
|
||||||
|
this._connecting = false
|
||||||
this._intentionalClose = false
|
this._intentionalClose = false
|
||||||
this._snapshot = null
|
this._snapshot = null
|
||||||
this._hello = null
|
this._hello = null
|
||||||
@@ -50,6 +51,7 @@ export class WsClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get connected() { return this._connected }
|
get connected() { return this._connected }
|
||||||
|
get connecting() { return this._connecting }
|
||||||
get gatewayReady() { return this._gatewayReady }
|
get gatewayReady() { return this._gatewayReady }
|
||||||
get snapshot() { return this._snapshot }
|
get snapshot() { return this._snapshot }
|
||||||
get hello() { return this._hello }
|
get hello() { return this._hello }
|
||||||
@@ -72,7 +74,12 @@ export class WsClient {
|
|||||||
this._token = token || ''
|
this._token = token || ''
|
||||||
// 自动检测协议:如果页面通过 HTTPS 加载(反代场景),使用 wss://
|
// 自动检测协议:如果页面通过 HTTPS 加载(反代场景),使用 wss://
|
||||||
const proto = opts.secure ?? (typeof location !== 'undefined' && location.protocol === 'https:') ? 'wss' : 'ws'
|
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()
|
this._doConnect()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,6 +109,7 @@ export class WsClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_doConnect() {
|
_doConnect() {
|
||||||
|
this._connecting = true
|
||||||
this._closeWs()
|
this._closeWs()
|
||||||
this._gatewayReady = false
|
this._gatewayReady = false
|
||||||
this._handshaking = false
|
this._handshaking = false
|
||||||
@@ -113,6 +121,7 @@ export class WsClient {
|
|||||||
|
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
if (wsId !== this._wsId) return
|
if (wsId !== this._wsId) return
|
||||||
|
this._connecting = false
|
||||||
this._reconnectAttempts = 0
|
this._reconnectAttempts = 0
|
||||||
this._setConnected(true)
|
this._setConnected(true)
|
||||||
this._startPing()
|
this._startPing()
|
||||||
@@ -135,6 +144,7 @@ export class WsClient {
|
|||||||
ws.onclose = (e) => {
|
ws.onclose = (e) => {
|
||||||
if (wsId !== this._wsId) return
|
if (wsId !== this._wsId) return
|
||||||
this._ws = null
|
this._ws = null
|
||||||
|
this._connecting = false
|
||||||
this._clearChallengeTimer()
|
this._clearChallengeTimer()
|
||||||
if (e.code === 4001 || e.code === 4003 || e.code === 4004) {
|
if (e.code === 4001 || e.code === 4003 || e.code === 4004) {
|
||||||
this._setConnected(false, 'auth_failed', e.reason || 'Token 认证失败')
|
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 _sendBtn = null, _statusDot = null, _typingEl = null, _scrollBtn = null
|
||||||
let _sessionListEl = null, _cmdPanelEl = null, _attachPreviewEl = null, _fileInputEl = null
|
let _sessionListEl = null, _cmdPanelEl = null, _attachPreviewEl = null, _fileInputEl = null
|
||||||
let _modelSelectEl = 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 _isStreaming = false, _isSending = false, _messageQueue = [], _streamStartTime = 0
|
||||||
let _lastRenderTime = 0, _renderPending = false, _lastHistoryHash = ''
|
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 _streamSafetyTimer = null, _unsubEvent = null, _unsubReady = null, _unsubStatus = null
|
||||||
let _seenRunIds = new Set()
|
let _seenRunIds = new Set()
|
||||||
let _pageActive = false
|
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 _errorTimer = null, _lastErrorMsg = null
|
||||||
let _attachments = []
|
let _attachments = []
|
||||||
let _hasEverConnected = false
|
let _hasEverConnected = false
|
||||||
@@ -187,6 +193,7 @@ const GUIDE_KEY = 'clawpanel-guide-chat-dismissed'
|
|||||||
|
|
||||||
function showPageGuide(container) {
|
function showPageGuide(container) {
|
||||||
if (localStorage.getItem(GUIDE_KEY)) return
|
if (localStorage.getItem(GUIDE_KEY)) return
|
||||||
|
if (!container || container.querySelector('.chat-page-guide')) return
|
||||||
const guide = document.createElement('div')
|
const guide = document.createElement('div')
|
||||||
guide.className = 'chat-page-guide'
|
guide.className = 'chat-page-guide'
|
||||||
guide.innerHTML = `
|
guide.innerHTML = `
|
||||||
@@ -263,8 +270,24 @@ function bindEvents(page) {
|
|||||||
_messagesEl.addEventListener('scroll', () => {
|
_messagesEl.addEventListener('scroll', () => {
|
||||||
const { scrollTop, scrollHeight, clientHeight } = _messagesEl
|
const { scrollTop, scrollHeight, clientHeight } = _messagesEl
|
||||||
_scrollBtn.style.display = (scrollHeight - scrollTop - clientHeight < 80) ? 'none' : 'flex'
|
_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())
|
_messagesEl.addEventListener('click', () => hideCmdPanel())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -472,6 +495,7 @@ function fileToBase64(file) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderAttachments() {
|
function renderAttachments() {
|
||||||
|
if (!_attachPreviewEl) return
|
||||||
if (!_attachments.length) {
|
if (!_attachments.length) {
|
||||||
_attachPreviewEl.style.display = 'none'
|
_attachPreviewEl.style.display = 'none'
|
||||||
return
|
return
|
||||||
@@ -575,7 +599,7 @@ async function connectGateway() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 如果正在连接中(重连等),等待 onReady 回调即可
|
// 如果正在连接中(重连等),等待 onReady 回调即可
|
||||||
if (wsClient.connected) return
|
if (wsClient.connected || wsClient.connecting || wsClient.gatewayReady) return
|
||||||
|
|
||||||
// 未连接,发起新连接
|
// 未连接,发起新连接
|
||||||
const config = await api.readOpenclawConfig()
|
const config = await api.readOpenclawConfig()
|
||||||
@@ -853,6 +877,10 @@ function toggleCmdPanel() {
|
|||||||
function sendMessage() {
|
function sendMessage() {
|
||||||
const text = _textarea.value.trim()
|
const text = _textarea.value.trim()
|
||||||
if (!text && !_attachments.length) return
|
if (!text && !_attachments.length) return
|
||||||
|
if (!wsClient.gatewayReady || !_sessionKey) {
|
||||||
|
toast('Gateway 未就绪,连接成功后再发送', 'warning')
|
||||||
|
return
|
||||||
|
}
|
||||||
hideCmdPanel()
|
hideCmdPanel()
|
||||||
_textarea.value = ''
|
_textarea.value = ''
|
||||||
_textarea.style.height = 'auto'
|
_textarea.style.height = 'auto'
|
||||||
@@ -865,6 +893,10 @@ function sendMessage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function doSend(text, attachments = []) {
|
async function doSend(text, attachments = []) {
|
||||||
|
if (!wsClient.gatewayReady || !_sessionKey) {
|
||||||
|
toast('Gateway 未就绪,连接成功后再发送', 'warning')
|
||||||
|
return
|
||||||
|
}
|
||||||
appendUserMessage(text, attachments)
|
appendUserMessage(text, attachments)
|
||||||
saveMessage({
|
saveMessage({
|
||||||
id: uuid(), sessionKey: _sessionKey, role: 'user', content: text, timestamp: Date.now(),
|
id: uuid(), sessionKey: _sessionKey, role: 'user', content: text, timestamp: Date.now(),
|
||||||
@@ -900,6 +932,26 @@ function handleEvent(msg) {
|
|||||||
const { event, payload } = msg
|
const { event, payload } = msg
|
||||||
if (!payload) return
|
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)
|
if (event === 'chat') handleChatEvent(payload)
|
||||||
|
|
||||||
// Compaction 状态指示:上游 2026.3.12 新增 status_reaction 事件
|
// Compaction 状态指示:上游 2026.3.12 新增 status_reaction 事件
|
||||||
@@ -936,6 +988,7 @@ function handleChatEvent(payload) {
|
|||||||
if (c?.videos?.length) _currentAiVideos = c.videos
|
if (c?.videos?.length) _currentAiVideos = c.videos
|
||||||
if (c?.audios?.length) _currentAiAudios = c.audios
|
if (c?.audios?.length) _currentAiAudios = c.audios
|
||||||
if (c?.files?.length) _currentAiFiles = c.files
|
if (c?.files?.length) _currentAiFiles = c.files
|
||||||
|
if (c?.tools?.length) _currentAiTools = c.tools
|
||||||
if (c?.text && c.text.length > _currentAiText.length) {
|
if (c?.text && c.text.length > _currentAiText.length) {
|
||||||
showTyping(false)
|
showTyping(false)
|
||||||
if (!_currentAiBubble) {
|
if (!_currentAiBubble) {
|
||||||
@@ -971,11 +1024,17 @@ function handleChatEvent(payload) {
|
|||||||
const finalVideos = c?.videos || []
|
const finalVideos = c?.videos || []
|
||||||
const finalAudios = c?.audios || []
|
const finalAudios = c?.audios || []
|
||||||
const finalFiles = c?.files || []
|
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 (finalImages.length) _currentAiImages = finalImages
|
||||||
if (finalVideos.length) _currentAiVideos = finalVideos
|
if (finalVideos.length) _currentAiVideos = finalVideos
|
||||||
if (finalAudios.length) _currentAiAudios = finalAudios
|
if (finalAudios.length) _currentAiAudios = finalAudios
|
||||||
if (finalFiles.length) _currentAiFiles = finalFiles
|
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)
|
// 忽略空 final(Gateway 会为一条消息触发多个 run,部分是空 final)
|
||||||
if (!_currentAiBubble && !hasContent) return
|
if (!_currentAiBubble && !hasContent) return
|
||||||
// 标记 runId 为已处理,防止重复
|
// 标记 runId 为已处理,防止重复
|
||||||
@@ -998,6 +1057,7 @@ function handleChatEvent(payload) {
|
|||||||
appendVideosToEl(_currentAiBubble, _currentAiVideos)
|
appendVideosToEl(_currentAiBubble, _currentAiVideos)
|
||||||
appendAudiosToEl(_currentAiBubble, _currentAiAudios)
|
appendAudiosToEl(_currentAiBubble, _currentAiAudios)
|
||||||
appendFilesToEl(_currentAiBubble, _currentAiFiles)
|
appendFilesToEl(_currentAiBubble, _currentAiFiles)
|
||||||
|
appendToolsToEl(_currentAiBubble, finalTools.length ? finalTools : _currentAiTools)
|
||||||
}
|
}
|
||||||
// 添加时间戳 + 耗时 + token 消耗
|
// 添加时间戳 + 耗时 + token 消耗
|
||||||
const wrapper = _currentAiBubble?.parentElement
|
const wrapper = _currentAiBubble?.parentElement
|
||||||
@@ -1092,8 +1152,24 @@ function handleChatEvent(payload) {
|
|||||||
/** 从 Gateway message 对象提取文本和所有媒体(参照 clawapp extractContent) */
|
/** 从 Gateway message 对象提取文本和所有媒体(参照 clawapp extractContent) */
|
||||||
function extractChatContent(message) {
|
function extractChatContent(message) {
|
||||||
if (!message || typeof message !== 'object') return null
|
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
|
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)) {
|
if (Array.isArray(content)) {
|
||||||
const texts = [], images = [], videos = [], audios = [], files = []
|
const texts = [], images = [], videos = [], audios = [], files = []
|
||||||
for (const block of content) {
|
for (const block of content) {
|
||||||
@@ -1115,6 +1191,34 @@ function extractChatContent(message) {
|
|||||||
else if (block.type === 'file' || block.type === 'document') {
|
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 })
|
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 提取
|
// 从 mediaUrl/mediaUrls 提取
|
||||||
const mediaUrls = message.mediaUrls || (message.mediaUrl ? [message.mediaUrl] : [])
|
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: '' })
|
else files.push({ url, name: url.split('/').pop().split('?')[0] || '文件', mimeType: '' })
|
||||||
}
|
}
|
||||||
const text = texts.length ? stripThinkingTags(texts.join('\n')) : ''
|
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
|
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) {
|
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(/<\s*think(?:ing)?\s*>[\s\S]*?<\s*\/\s*think(?:ing)?\s*>/gi, '')
|
||||||
.replace(/Conversation info \(untrusted metadata\):\s*```json[\s\S]*?```\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, '')
|
.replace(/\[Queued messages while agent was busy\]\s*---\s*Queued #\d+\s*/gi, '')
|
||||||
.trim()
|
.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) {
|
function formatTime(date) {
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const h = date.getHours().toString().padStart(2, '0')
|
const h = date.getHours().toString().padStart(2, '0')
|
||||||
@@ -1160,6 +1321,7 @@ function formatFileSize(bytes) {
|
|||||||
|
|
||||||
/** 创建流式 AI 气泡 */
|
/** 创建流式 AI 气泡 */
|
||||||
function createStreamBubble() {
|
function createStreamBubble() {
|
||||||
|
if (!_messagesEl || !_typingEl) return null
|
||||||
showTyping(false)
|
showTyping(false)
|
||||||
const wrap = document.createElement('div')
|
const wrap = document.createElement('div')
|
||||||
wrap.className = 'msg msg-ai'
|
wrap.className = 'msg msg-ai'
|
||||||
@@ -1197,12 +1359,13 @@ function doRender() {
|
|||||||
|
|
||||||
function resetStreamState() {
|
function resetStreamState() {
|
||||||
clearTimeout(_streamSafetyTimer)
|
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)
|
_currentAiBubble.innerHTML = renderMarkdown(_currentAiText)
|
||||||
appendImagesToEl(_currentAiBubble, _currentAiImages)
|
appendImagesToEl(_currentAiBubble, _currentAiImages)
|
||||||
appendVideosToEl(_currentAiBubble, _currentAiVideos)
|
appendVideosToEl(_currentAiBubble, _currentAiVideos)
|
||||||
appendAudiosToEl(_currentAiBubble, _currentAiAudios)
|
appendAudiosToEl(_currentAiBubble, _currentAiAudios)
|
||||||
appendFilesToEl(_currentAiBubble, _currentAiFiles)
|
appendFilesToEl(_currentAiBubble, _currentAiFiles)
|
||||||
|
appendToolsToEl(_currentAiBubble, _currentAiTools)
|
||||||
}
|
}
|
||||||
_renderPending = false
|
_renderPending = false
|
||||||
_lastRenderTime = 0
|
_lastRenderTime = 0
|
||||||
@@ -1212,6 +1375,7 @@ function resetStreamState() {
|
|||||||
_currentAiVideos = []
|
_currentAiVideos = []
|
||||||
_currentAiAudios = []
|
_currentAiAudios = []
|
||||||
_currentAiFiles = []
|
_currentAiFiles = []
|
||||||
|
_currentAiTools = []
|
||||||
_currentRunId = null
|
_currentRunId = null
|
||||||
_isStreaming = false
|
_isStreaming = false
|
||||||
_streamStartTime = 0
|
_streamStartTime = 0
|
||||||
@@ -1224,8 +1388,9 @@ function resetStreamState() {
|
|||||||
// ── 历史消息加载 ──
|
// ── 历史消息加载 ──
|
||||||
|
|
||||||
async function loadHistory() {
|
async function loadHistory() {
|
||||||
if (!_sessionKey) return
|
if (!_sessionKey || !_messagesEl) return
|
||||||
const hasExisting = _messagesEl?.querySelector('.msg')
|
_isLoadingHistory = true
|
||||||
|
const hasExisting = _messagesEl.querySelector('.msg')
|
||||||
if (!hasExisting && isStorageAvailable()) {
|
if (!hasExisting && isStorageAvailable()) {
|
||||||
const local = await getLocalMessages(_sessionKey, 200)
|
const local = await getLocalMessages(_sessionKey, 200)
|
||||||
if (local.length) {
|
if (local.length) {
|
||||||
@@ -1236,17 +1401,17 @@ async function loadHistory() {
|
|||||||
if (msg.role === 'user') appendUserMessage(msg.content || '', msg.attachments || null, msgTime)
|
if (msg.role === 'user') appendUserMessage(msg.content || '', msg.attachments || null, msgTime)
|
||||||
else if (msg.role === 'assistant') {
|
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 }))
|
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()
|
scrollToBottom()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!wsClient.gatewayReady) return
|
if (!wsClient.gatewayReady) { _isLoadingHistory = false; return }
|
||||||
try {
|
try {
|
||||||
const result = await wsClient.chatHistory(_sessionKey, 200)
|
const result = await wsClient.chatHistory(_sessionKey, 200)
|
||||||
if (!result?.messages?.length) {
|
if (!result?.messages?.length) {
|
||||||
if (!_messagesEl.querySelector('.msg')) appendSystemMessage('还没有消息,开始聊天吧')
|
if (_messagesEl && !_messagesEl.querySelector('.msg')) appendSystemMessage('还没有消息,开始聊天吧')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const deduped = dedupeHistory(result.messages)
|
const deduped = dedupeHistory(result.messages)
|
||||||
@@ -1258,15 +1423,17 @@ async function loadHistory() {
|
|||||||
if (hasExisting && (_isSending || _isStreaming || _messageQueue.length > 0)) {
|
if (hasExisting && (_isSending || _isStreaming || _messageQueue.length > 0)) {
|
||||||
saveMessages(result.messages.map(m => {
|
saveMessages(result.messages.map(m => {
|
||||||
const c = extractContent(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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
clearMessages()
|
clearMessages()
|
||||||
let hasOmittedImages = false
|
let hasOmittedImages = false
|
||||||
deduped.forEach(msg => {
|
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()
|
const msgTime = msg.timestamp ? new Date(msg.timestamp) : new Date()
|
||||||
if (msg.role === 'user') {
|
if (msg.role === 'user') {
|
||||||
const userAtts = msg.images?.length ? msg.images.map(i => ({
|
const userAtts = msg.images?.length ? msg.images.map(i => ({
|
||||||
@@ -1277,7 +1444,7 @@ async function loadHistory() {
|
|||||||
if (msg.images?.length && !userAtts.length) hasOmittedImages = true
|
if (msg.images?.length && !userAtts.length) hasOmittedImages = true
|
||||||
appendUserMessage(msg.text, userAtts, msgTime)
|
appendUserMessage(msg.text, userAtts, msgTime)
|
||||||
} else if (msg.role === 'assistant') {
|
} 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) {
|
if (hasOmittedImages) {
|
||||||
@@ -1285,25 +1452,33 @@ async function loadHistory() {
|
|||||||
}
|
}
|
||||||
saveMessages(result.messages.map(m => {
|
saveMessages(result.messages.map(m => {
|
||||||
const c = extractContent(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()
|
scrollToBottom()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[chat] loadHistory error:', 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) {
|
function dedupeHistory(messages) {
|
||||||
const deduped = []
|
const deduped = []
|
||||||
for (const msg of messages) {
|
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)
|
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]
|
const last = deduped[deduped.length - 1]
|
||||||
if (last && last.role === msg.role) {
|
if (last && last.role === role) {
|
||||||
if (msg.role === 'user' && last.text === c.text) continue
|
if (role === 'user' && last.text === c.text) continue
|
||||||
if (msg.role === 'assistant') {
|
if (role === 'assistant') {
|
||||||
// 同文本去重(Gateway 重试产生的重复回复)
|
// 同文本去重(Gateway 重试产生的重复回复)
|
||||||
if (c.text && last.text === c.text) continue
|
if (c.text && last.text === c.text) continue
|
||||||
// 不同文本则合并
|
// 不同文本则合并
|
||||||
@@ -1312,15 +1487,34 @@ function dedupeHistory(messages) {
|
|||||||
last.videos = [...(last.videos || []), ...c.videos]
|
last.videos = [...(last.videos || []), ...c.videos]
|
||||||
last.audios = [...(last.audios || []), ...c.audios]
|
last.audios = [...(last.audios || []), ...c.audios]
|
||||||
last.files = [...(last.files || []), ...c.files]
|
last.files = [...(last.files || []), ...c.files]
|
||||||
|
tools.forEach(t => upsertTool(last.tools, t))
|
||||||
continue
|
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
|
return deduped
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractContent(msg) {
|
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)) {
|
if (Array.isArray(msg.content)) {
|
||||||
const texts = [], images = [], videos = [], audios = [], files = []
|
const texts = [], images = [], videos = [], audios = [], files = []
|
||||||
for (const block of msg.content) {
|
for (const block of msg.content) {
|
||||||
@@ -1342,6 +1536,34 @@ function extractContent(msg) {
|
|||||||
else if (block.type === 'file' || block.type === 'document') {
|
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 })
|
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] : [])
|
const mediaUrls = msg.mediaUrls || (msg.mediaUrl ? [msg.mediaUrl] : [])
|
||||||
for (const url of mediaUrls) {
|
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 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: '' })
|
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 : '')
|
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 操作 ──
|
// ── DOM 操作 ──
|
||||||
@@ -1420,12 +1642,16 @@ function appendUserMessage(text, attachments = [], msgTime) {
|
|||||||
scrollToBottom()
|
scrollToBottom()
|
||||||
}
|
}
|
||||||
|
|
||||||
function appendAiMessage(text, msgTime, images, videos, audios, files) {
|
function appendAiMessage(text, msgTime, images, videos, audios, files, tools) {
|
||||||
const wrap = document.createElement('div')
|
const wrap = document.createElement('div')
|
||||||
wrap.className = 'msg msg-ai'
|
wrap.className = 'msg msg-ai'
|
||||||
const bubble = document.createElement('div')
|
const bubble = document.createElement('div')
|
||||||
bubble.className = 'msg-bubble'
|
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)
|
appendImagesToEl(bubble, images)
|
||||||
appendVideosToEl(bubble, videos)
|
appendVideosToEl(bubble, videos)
|
||||||
appendAudiosToEl(bubble, audios)
|
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) {
|
function showLightbox(src) {
|
||||||
const existing = document.querySelector('.chat-lightbox')
|
const existing = document.querySelector('.chat-lightbox')
|
||||||
@@ -1552,6 +1873,8 @@ function appendSystemMessage(text) {
|
|||||||
|
|
||||||
function clearMessages() {
|
function clearMessages() {
|
||||||
_messagesEl.querySelectorAll('.msg').forEach(m => m.remove())
|
_messagesEl.querySelectorAll('.msg').forEach(m => m.remove())
|
||||||
|
_autoScrollEnabled = true
|
||||||
|
_lastScrollTop = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
function showTyping(show) {
|
function showTyping(show) {
|
||||||
@@ -1573,11 +1896,17 @@ function showCompactionHint(show) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function scrollToBottom() {
|
function scrollToBottom(force = false) {
|
||||||
if (!_messagesEl) return
|
if (!_messagesEl) return
|
||||||
|
if (!force && !_autoScrollEnabled) return
|
||||||
requestAnimationFrame(() => { _messagesEl.scrollTop = _messagesEl.scrollHeight })
|
requestAnimationFrame(() => { _messagesEl.scrollTop = _messagesEl.scrollHeight })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isAtBottom() {
|
||||||
|
if (!_messagesEl) return true
|
||||||
|
return _messagesEl.scrollHeight - _messagesEl.scrollTop - _messagesEl.clientHeight < 80
|
||||||
|
}
|
||||||
|
|
||||||
function updateSendState() {
|
function updateSendState() {
|
||||||
if (!_sendBtn || !_textarea) return
|
if (!_sendBtn || !_textarea) return
|
||||||
if (_isStreaming) {
|
if (_isStreaming) {
|
||||||
@@ -1624,6 +1953,7 @@ export function cleanup() {
|
|||||||
_currentAiVideos = []
|
_currentAiVideos = []
|
||||||
_currentAiAudios = []
|
_currentAiAudios = []
|
||||||
_currentAiFiles = []
|
_currentAiFiles = []
|
||||||
|
_currentAiTools = []
|
||||||
_currentRunId = null
|
_currentRunId = null
|
||||||
_isStreaming = false
|
_isStreaming = false
|
||||||
_isSending = false
|
_isSending = false
|
||||||
|
|||||||
@@ -41,7 +41,14 @@ export async function render() {
|
|||||||
bindActions(page)
|
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 状态变化,自动刷新仪表盘
|
// 监听 Gateway 状态变化,自动刷新仪表盘
|
||||||
if (_unsubGw) _unsubGw()
|
if (_unsubGw) _unsubGw()
|
||||||
@@ -61,19 +68,23 @@ let _dashboardInitialized = false
|
|||||||
async function loadDashboardData(page, fullRefresh = false) {
|
async function loadDashboardData(page, fullRefresh = false) {
|
||||||
// 分波加载:关键数据先渲染,次要数据后填充,减少白屏等待
|
// 分波加载:关键数据先渲染,次要数据后填充,减少白屏等待
|
||||||
// 轻量调用(读文件)每次都做;重量调用(spawn CLI/网络请求)只在首次或手动刷新时做
|
// 轻量调用(读文件)每次都做;重量调用(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.getServicesStatus(),
|
||||||
api.readOpenclawConfig(),
|
api.readOpenclawConfig(),
|
||||||
// 版本信息:首次加载或手动刷新时才查询(避免 ARM 设备上频繁查 npm registry)
|
// 版本信息:首次加载或手动刷新时才查询(避免 ARM 设备上频繁查 npm registry)
|
||||||
(!_dashboardInitialized || fullRefresh) ? api.getVersionInfo() : Promise.resolve(null),
|
(!_dashboardInitialized || fullRefresh) ? api.getVersionInfo() : Promise.resolve(null),
|
||||||
])
|
]), 15000)
|
||||||
const secondaryP = Promise.allSettled([
|
const secondaryP = withTimeout(Promise.allSettled([
|
||||||
api.listAgents(),
|
api.listAgents(),
|
||||||
api.readMcpConfig(),
|
api.readMcpConfig(),
|
||||||
api.listBackups(),
|
api.listBackups(),
|
||||||
// getStatusSummary 是最重的调用(spawn openclaw status --json),只在首次加载时调用
|
// getStatusSummary 是最重的调用(spawn openclaw status --json),只在首次加载时调用
|
||||||
(!_dashboardInitialized || fullRefresh) ? api.getStatusSummary() : Promise.resolve(null),
|
(!_dashboardInitialized || fullRefresh) ? api.getStatusSummary() : Promise.resolve(null),
|
||||||
])
|
]), 15000).catch(() => [{ status: 'rejected' }, { status: 'rejected' }, { status: 'rejected' }, { status: 'rejected' }])
|
||||||
const logsP = api.readLogTail('gateway', 20).catch(() => '')
|
const logsP = api.readLogTail('gateway', 20).catch(() => '')
|
||||||
|
|
||||||
// 第一波:服务状态 + 配置 + 版本 → 立即渲染统计卡片
|
// 第一波:服务状态 + 配置 + 版本 → 立即渲染统计卡片
|
||||||
|
|||||||
@@ -855,6 +855,55 @@
|
|||||||
color: var(--text-tertiary);
|
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 {
|
.chat-page-guide {
|
||||||
margin: 0 16px 8px;
|
margin: 0 16px 8px;
|
||||||
|
|||||||
Reference in New Issue
Block a user