Files
clawpanel/src/pages/chat.js
晴天 db30f29abf feat: v0.8.2 — 15 fixes + 4 features + 3 improvements
Fixes:
- Stop force-appending /v1 to API URLs (breaks Volcengine /v3 etc)
- SSH upgrade: --unset-all + --add for 4 git insteadOf rules
- Feishu: builtin detection, overlay→modal fix, select field, plugin version persistence
- Docker: HTML response detection, Web mode guidance
- Chat: runId dedup prevents duplicate messages
- Cron: RPC params name→id
- Channels: Gateway reload async (instant UI response), toggle cache invalidation
- Linux: auto sudo for non-root npm installs (libc geteuid)
- Control UI: dynamic hostname + auth token for remote access
- npm: mirror fallback (npmmirror→npmjs.org)
- QQBot: native binding friendly error message
- Error diagnosis: SSH vs Git-not-installed, native binding detection

Features:
- About page: company info (武汉晴辰天下网络科技有限公司)
- model-presets.js: shared module for models.js + assistant.js
- Feishu: dual plugin support (builtin vs official @larksuiteoapi)
- Assistant: provider preset quick-fill buttons

Improvements:
- Website: dynamic download links from latest.json + claw.qt.cool proxy
- Linux deploy docs: upgrade guide, Gitee mirror, sudo notes
- linux-deploy.sh: Gitee fallback + sudo npm + mirror retry
2026-03-13 00:03:09 +08:00

1588 lines
62 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 聊天页面 - 完整版,对接 OpenClaw Gateway
* 支持流式响应、Markdown 渲染、会话管理、Agent 选择、快捷指令
*/
import { api, invalidate } from '../lib/tauri-api.js'
import { navigate } from '../router.js'
import { wsClient, uuid } from '../lib/ws-client.js'
import { renderMarkdown } from '../lib/markdown.js'
import { saveMessage, saveMessages, getLocalMessages, isStorageAvailable } from '../lib/message-db.js'
import { toast } from '../components/toast.js'
import { showModal, showConfirm } from '../components/modal.js'
import { icon as svgIcon } from '../lib/icons.js'
const RENDER_THROTTLE = 30
const STORAGE_SESSION_KEY = 'clawpanel-last-session'
const STORAGE_MODEL_KEY = 'clawpanel-chat-selected-model'
const STORAGE_SIDEBAR_KEY = 'clawpanel-chat-sidebar-open'
const STORAGE_SESSION_NAMES_KEY = 'clawpanel-chat-session-names'
const COMMANDS = [
{ title: '会话', commands: [
{ cmd: '/new', desc: '新建会话', action: 'exec' },
{ cmd: '/reset', desc: '重置当前会话', action: 'exec' },
{ cmd: '/stop', desc: '停止生成', action: 'exec' },
]},
{ title: '模型', commands: [
{ cmd: '/model ', desc: '切换模型(输入模型名)', action: 'fill' },
{ cmd: '/model list', desc: '查看可用模型', action: 'exec' },
{ cmd: '/model status', desc: '当前模型状态', action: 'exec' },
]},
{ title: '思考模式', commands: [
{ cmd: '/think off', desc: '关闭深度思考', action: 'exec' },
{ cmd: '/think low', desc: '轻度思考', action: 'exec' },
{ cmd: '/think medium', desc: '中度思考', action: 'exec' },
{ cmd: '/think high', desc: '深度思考', action: 'exec' },
]},
{ title: '信息', commands: [
{ cmd: '/help', desc: '帮助信息', action: 'exec' },
{ cmd: '/status', desc: '系统状态', action: 'exec' },
{ cmd: '/context', desc: '上下文信息', action: 'exec' },
]},
]
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 _isStreaming = false, _isSending = false, _messageQueue = [], _streamStartTime = 0
let _lastRenderTime = 0, _renderPending = false, _lastHistoryHash = ''
let _streamSafetyTimer = null, _unsubEvent = null, _unsubReady = null, _unsubStatus = null
let _seenRunIds = new Set()
let _pageActive = false
let _errorTimer = null, _lastErrorMsg = null
let _attachments = []
let _hasEverConnected = false
let _availableModels = []
let _primaryModel = ''
let _selectedModel = ''
let _isApplyingModel = false
export async function render() {
const page = document.createElement('div')
page.className = 'page chat-page'
_pageActive = true
_page = page
page.innerHTML = `
<div class="chat-sidebar" id="chat-sidebar">
<div class="chat-sidebar-header">
<span>会话列表</span>
<button class="chat-sidebar-btn" id="btn-new-session" title="新建会话">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
</button>
</div>
<div class="chat-session-list" id="chat-session-list"></div>
</div>
<div class="chat-main">
<div class="chat-header">
<div class="chat-status">
<button class="chat-toggle-sidebar" id="btn-toggle-sidebar" title="会话列表">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="18" height="18"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
</button>
<span class="status-dot" id="chat-status-dot"></span>
<span class="chat-title" id="chat-title">聊天</span>
</div>
<div class="chat-header-actions">
<div class="chat-model-group">
<select class="form-input" id="chat-model-select" title="切换当前会话模型" style="width:200px;max-width:28vw;padding:6px 10px;font-size:var(--font-size-xs)">
<option value="">加载模型中...</option>
</select>
<button class="btn btn-sm btn-ghost" id="btn-refresh-models" title="刷新模型列表">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 11-2.12-9.36L23 10"/></svg>
</button>
</div>
<button class="btn btn-sm btn-ghost" id="btn-cmd" title="快捷指令">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><path d="M18 3a3 3 0 00-3 3v12a3 3 0 003 3 3 3 0 003-3 3 3 0 00-3-3H6a3 3 0 00-3 3 3 3 0 003 3 3 3 0 003-3V6a3 3 0 00-3-3 3 3 0 00-3 3 3 3 0 003 3h12a3 3 0 003-3 3 3 0 00-3-3z"/></svg>
</button>
<button class="btn btn-sm btn-ghost" id="btn-reset-session" title="重置会话">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 102.13-9.36L1 10"/></svg>
</button>
</div>
</div>
<div class="chat-messages" id="chat-messages">
<div class="typing-indicator" id="typing-indicator" style="display:none">
<span></span><span></span><span></span>
</div>
</div>
<button class="chat-scroll-btn" id="chat-scroll-btn" style="display:none">↓</button>
<div class="chat-cmd-panel" id="chat-cmd-panel" style="display:none"></div>
<div class="chat-attachments-preview" id="chat-attachments-preview" style="display:none"></div>
<div class="chat-input-area">
<input type="file" id="chat-file-input" accept="image/*" multiple style="display:none">
<button class="chat-attach-btn" id="chat-attach-btn" title="上传图片">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="18" height="18"><path d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.48"/></svg>
</button>
<div class="chat-input-wrapper">
<textarea id="chat-input" rows="1" placeholder="输入消息Enter 发送,/ 打开指令"></textarea>
</div>
<button class="chat-send-btn" id="chat-send-btn" disabled>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
</button>
</div>
<div class="chat-disconnect-bar" id="chat-disconnect-bar" style="display:none">连接已断开,正在重连...</div>
<div class="chat-connect-overlay" id="chat-connect-overlay" style="display:none">
<div class="chat-connect-card">
<div class="chat-connect-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="48" height="48"><path d="M8.5 14.5A2.5 2.5 0 0011 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 11-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 002.5 2.5z"/></svg>
</div>
<div class="chat-connect-title">Gateway 连接未就绪</div>
<div class="chat-connect-desc" id="chat-connect-desc">正在连接 Gateway...</div>
<div class="chat-connect-actions">
<button class="btn btn-primary btn-sm" id="btn-fix-connect">修复并重连</button>
<button class="btn btn-secondary btn-sm" id="btn-goto-gateway">Gateway 设置</button>
</div>
<div class="chat-connect-hint">首次使用?请确保 Gateway 已启动,或点击「修复并重连」自动修复配置</div>
</div>
</div>
</div>
`
_messagesEl = page.querySelector('#chat-messages')
_textarea = page.querySelector('#chat-input')
_sendBtn = page.querySelector('#chat-send-btn')
_statusDot = page.querySelector('#chat-status-dot')
_typingEl = page.querySelector('#typing-indicator')
_scrollBtn = page.querySelector('#chat-scroll-btn')
_sessionListEl = page.querySelector('#chat-session-list')
_cmdPanelEl = page.querySelector('#chat-cmd-panel')
_attachPreviewEl = page.querySelector('#chat-attachments-preview')
_fileInputEl = page.querySelector('#chat-file-input')
_modelSelectEl = page.querySelector('#chat-model-select')
page.querySelector('#chat-sidebar')?.classList.toggle('open', getSidebarOpen())
bindEvents(page)
bindConnectOverlay(page)
// 首次使用引导提示
showPageGuide(_messagesEl)
loadModelOptions()
// 非阻塞:先返回 DOM后台连接 Gateway
connectGateway()
return page
}
const GUIDE_KEY = 'clawpanel-guide-chat-dismissed'
function showPageGuide(container) {
if (localStorage.getItem(GUIDE_KEY)) return
const guide = document.createElement('div')
guide.className = 'chat-page-guide'
guide.innerHTML = `
<div class="chat-guide-inner">
<div class="chat-guide-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="28" height="28"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2z"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>
</div>
<div class="chat-guide-content">
<b>你正在使用「实时聊天」</b>
<p>此页面通过 <b>Gateway</b> 连接 OpenClaw 的 AI Agent对话由你部署的 OpenClaw 服务处理。</p>
<p style="opacity:0.7;font-size:11px">如需使用 ClawPanel 内置 AI 助手(独立于 OpenClaw请前往左侧菜单「AI 助手」页面。</p>
</div>
<button class="chat-guide-close" title="知道了">&times;</button>
</div>
`
guide.querySelector('.chat-guide-close').onclick = () => {
localStorage.setItem(GUIDE_KEY, '1')
guide.remove()
}
container.insertBefore(guide, container.firstChild)
}
// ── 事件绑定 ──
function bindEvents(page) {
if (_modelSelectEl) {
_modelSelectEl.addEventListener('change', () => {
_selectedModel = _modelSelectEl.value
if (_selectedModel) localStorage.setItem(STORAGE_MODEL_KEY, _selectedModel)
else localStorage.removeItem(STORAGE_MODEL_KEY)
applySelectedModel()
})
}
_textarea.addEventListener('input', () => {
_textarea.style.height = 'auto'
_textarea.style.height = Math.min(_textarea.scrollHeight, 150) + 'px'
updateSendState()
// 输入 / 时显示指令面板
if (_textarea.value === '/') showCmdPanel()
else if (!_textarea.value.startsWith('/')) hideCmdPanel()
})
_textarea.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage() }
if (e.key === 'Escape') hideCmdPanel()
})
_sendBtn.addEventListener('click', () => {
if (_isStreaming) stopGeneration()
else sendMessage()
})
page.querySelector('#btn-toggle-sidebar').addEventListener('click', () => {
const sidebar = page.querySelector('#chat-sidebar')
if (!sidebar) return
const nextOpen = !sidebar.classList.contains('open')
sidebar.classList.toggle('open', nextOpen)
setSidebarOpen(nextOpen)
})
page.querySelector('#btn-new-session').addEventListener('click', () => showNewSessionDialog())
page.querySelector('#btn-cmd').addEventListener('click', () => toggleCmdPanel())
page.querySelector('#btn-reset-session').addEventListener('click', () => resetCurrentSession())
page.querySelector('#btn-refresh-models')?.addEventListener('click', () => loadModelOptions(true))
// 文件上传
page.querySelector('#chat-attach-btn').addEventListener('click', () => _fileInputEl.click())
_fileInputEl.addEventListener('change', handleFileSelect)
// 粘贴图片Ctrl+V
_textarea.addEventListener('paste', handlePaste)
_messagesEl.addEventListener('scroll', () => {
const { scrollTop, scrollHeight, clientHeight } = _messagesEl
_scrollBtn.style.display = (scrollHeight - scrollTop - clientHeight < 80) ? 'none' : 'flex'
})
_scrollBtn.addEventListener('click', () => scrollToBottom())
_messagesEl.addEventListener('click', () => hideCmdPanel())
}
async function loadModelOptions(showToast = false) {
if (!_modelSelectEl) return
// 显示加载状态
_modelSelectEl.innerHTML = '<option value="">加载模型中...</option>'
_modelSelectEl.disabled = true
try {
invalidate('read_openclaw_config')
const configPromise = api.readOpenclawConfig()
const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('读取超时(8s),请检查配置文件')), 8000))
const config = await Promise.race([configPromise, timeoutPromise])
const providers = config?.models?.providers || {}
_primaryModel = config?.agents?.defaults?.model?.primary || ''
const models = []
const seen = new Set()
if (_primaryModel) {
seen.add(_primaryModel)
models.push(_primaryModel)
}
for (const [providerKey, provider] of Object.entries(providers)) {
for (const item of (provider?.models || [])) {
const modelId = typeof item === 'string' ? item : item?.id
if (!modelId) continue
const full = `${providerKey}/${modelId}`
if (seen.has(full)) continue
seen.add(full)
models.push(full)
}
}
_availableModels = models
const saved = localStorage.getItem(STORAGE_MODEL_KEY) || ''
_selectedModel = models.includes(saved) ? saved : (_primaryModel || models[0] || '')
renderModelSelect()
if (showToast) toast(`已刷新,共 ${models.length} 个模型`, 'success')
} catch (e) {
_availableModels = []
_primaryModel = ''
_selectedModel = ''
renderModelSelect(`加载失败: ${e.message || e}`)
if (showToast) toast('加载模型失败: ' + (e.message || e), 'error')
}
}
function renderModelSelect(errorText = '') {
if (!_modelSelectEl) return
if (!_availableModels.length) {
_modelSelectEl.innerHTML = `<option value="">${escapeAttr(errorText || '未配置模型')}</option>`
_modelSelectEl.disabled = true
_modelSelectEl.title = errorText || '请先到模型配置页面添加模型'
return
}
_modelSelectEl.disabled = _isApplyingModel
_modelSelectEl.innerHTML = _availableModels.map(full => {
const suffix = full === _primaryModel ? '(主模型)' : ''
return `<option value="${escapeAttr(full)}" ${full === _selectedModel ? 'selected' : ''}>${full}${suffix}</option>`
}).join('')
_modelSelectEl.title = _selectedModel ? `切换当前会话模型:${_selectedModel}` : '切换当前会话模型'
}
function escapeAttr(str) {
return (str || '').replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
/** 本地会话别名缓存 */
function getSessionNames() {
try { return JSON.parse(localStorage.getItem(STORAGE_SESSION_NAMES_KEY) || '{}') } catch { return {} }
}
function setSessionName(key, name) {
const names = getSessionNames()
if (name) names[key] = name
else delete names[key]
localStorage.setItem(STORAGE_SESSION_NAMES_KEY, JSON.stringify(names))
}
function getDisplayLabel(key) {
const custom = getSessionNames()[key]
return custom || parseSessionLabel(key)
}
function getSidebarOpen() {
return localStorage.getItem(STORAGE_SIDEBAR_KEY) === '1'
}
function setSidebarOpen(open) {
localStorage.setItem(STORAGE_SIDEBAR_KEY, open ? '1' : '0')
}
async function applySelectedModel() {
if (!_selectedModel) {
toast('请先选择模型', 'warning')
return
}
if (!wsClient.gatewayReady || !_sessionKey) {
toast('Gateway 未就绪,连接成功后再切换模型', 'warning')
return
}
_isApplyingModel = true
renderModelSelect()
try {
await wsClient.chatSend(_sessionKey, `/model ${_selectedModel}`)
toast(`已切换当前会话模型为 ${_selectedModel}`, 'success')
} catch (e) {
toast('切换模型失败: ' + (e.message || e), 'error')
} finally {
_isApplyingModel = false
renderModelSelect()
}
}
// ── 连接引导遮罩 ──
function bindConnectOverlay(page) {
const fixBtn = page.querySelector('#btn-fix-connect')
const gwBtn = page.querySelector('#btn-goto-gateway')
if (fixBtn) {
fixBtn.addEventListener('click', async () => {
fixBtn.disabled = true
fixBtn.textContent = '修复中...'
const desc = document.getElementById('chat-connect-desc')
try {
if (desc) desc.textContent = '正在写入配置并重载 Gateway...'
await api.autoPairDevice()
await api.reloadGateway()
if (desc) desc.textContent = '修复完成,正在重连...'
// 断开旧连接,重新发起
wsClient.disconnect()
setTimeout(() => connectGateway(), 3000)
} catch (e) {
if (desc) desc.textContent = '修复失败: ' + (e.message || e)
} finally {
fixBtn.disabled = false
fixBtn.textContent = '修复并重连'
}
})
}
if (gwBtn) {
gwBtn.addEventListener('click', () => navigate('/gateway'))
}
}
// ── 文件上传 ──
async function handleFileSelect(e) {
const files = Array.from(e.target.files || [])
if (!files.length) return
for (const file of files) {
if (!file.type.startsWith('image/')) {
toast('仅支持图片文件', 'warning')
continue
}
if (file.size > 5 * 1024 * 1024) {
toast(`${file.name} 超过 5MB 限制`, 'warning')
continue
}
try {
const base64 = await fileToBase64(file)
_attachments.push({
type: 'image',
mimeType: file.type,
fileName: file.name,
content: base64,
})
renderAttachments()
} catch (e) {
toast(`读取 ${file.name} 失败`, 'error')
}
}
_fileInputEl.value = ''
}
async function handlePaste(e) {
const items = Array.from(e.clipboardData?.items || [])
const imageItems = items.filter(item => item.type.startsWith('image/'))
if (!imageItems.length) return
e.preventDefault()
for (const item of imageItems) {
const file = item.getAsFile()
if (!file) continue
if (file.size > 5 * 1024 * 1024) { toast('粘贴的图片超过 5MB 限制', 'warning'); continue }
try {
const base64 = await fileToBase64(file)
_attachments.push({ type: 'image', mimeType: file.type || 'image/png', fileName: `paste-${Date.now()}.png`, content: base64 })
renderAttachments()
} catch (_) { toast('读取粘贴图片失败', 'error') }
}
}
function fileToBase64(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => {
const dataUrl = reader.result
const match = /^data:[^;]+;base64,(.+)$/.exec(dataUrl)
if (!match) { reject(new Error('无效的数据 URL')); return }
resolve(match[1])
}
reader.onerror = reject
reader.readAsDataURL(file)
})
}
function renderAttachments() {
if (!_attachments.length) {
_attachPreviewEl.style.display = 'none'
return
}
_attachPreviewEl.style.display = 'flex'
_attachPreviewEl.innerHTML = _attachments.map((att, idx) => `
<div class="chat-attachment-item">
<img src="data:${att.mimeType};base64,${att.content}" alt="${att.fileName}">
<button class="chat-attachment-del" data-idx="${idx}">×</button>
</div>
`).join('')
_attachPreviewEl.querySelectorAll('.chat-attachment-del').forEach(btn => {
btn.addEventListener('click', () => {
const idx = parseInt(btn.dataset.idx)
_attachments.splice(idx, 1)
renderAttachments()
})
})
updateSendState()
}
// ── Gateway 连接 ──
async function connectGateway() {
try {
// 清理旧的订阅,避免重复监听
if (_unsubStatus) { _unsubStatus(); _unsubStatus = null }
if (_unsubReady) { _unsubReady(); _unsubReady = null }
if (_unsubEvent) { _unsubEvent(); _unsubEvent = null }
// 订阅状态变化(订阅式,返回 unsub
_unsubStatus = wsClient.onStatusChange((status, errorMsg) => {
if (!_pageActive) return
updateStatusDot(status)
const bar = document.getElementById('chat-disconnect-bar')
const overlay = document.getElementById('chat-connect-overlay')
const desc = document.getElementById('chat-connect-desc')
if (status === 'ready' || status === 'connected') {
_hasEverConnected = true
if (bar) bar.style.display = 'none'
if (overlay) overlay.style.display = 'none'
} else if (status === 'error') {
// 连接错误:显示引导遮罩而非底部条
if (bar) bar.style.display = 'none'
if (overlay) {
overlay.style.display = 'flex'
if (desc) desc.textContent = errorMsg || '连接 Gateway 失败'
}
} else if (status === 'reconnecting' || status === 'disconnected') {
// 首次连接或多次重连失败时,显示引导遮罩而非底部小条
if (!_hasEverConnected) {
if (overlay) { overlay.style.display = 'flex'; if (desc) desc.textContent = '正在连接 Gateway...' }
} else {
if (bar) { bar.textContent = '连接已断开,正在重连...'; bar.style.display = 'flex' }
}
} else {
if (bar) bar.style.display = 'none'
}
})
_unsubReady = wsClient.onReady((hello, sessionKey, err) => {
if (!_pageActive) return
const overlay = document.getElementById('chat-connect-overlay')
if (err?.error) {
if (overlay) {
overlay.style.display = 'flex'
const desc = document.getElementById('chat-connect-desc')
if (desc) desc.textContent = err.message || '连接失败'
}
return
}
if (overlay) overlay.style.display = 'none'
showTyping(false) // Gateway 就绪后关闭加载动画
// 重连后恢复:保留当前 sessionKey不重复加载历史
if (!_sessionKey) {
const saved = localStorage.getItem(STORAGE_SESSION_KEY)
_sessionKey = saved || sessionKey
updateSessionTitle()
loadHistory()
}
// 始终刷新会话列表(无论是否有 sessionKey
refreshSessionList()
})
_unsubEvent = wsClient.onEvent((msg) => {
if (!_pageActive) return
handleEvent(msg)
})
// 如果已连接且 Gateway 就绪,直接复用
if (wsClient.connected && wsClient.gatewayReady) {
const saved = localStorage.getItem(STORAGE_SESSION_KEY)
_sessionKey = saved || wsClient.sessionKey
updateStatusDot('ready')
showTyping(false) // 确保关闭加载动画
updateSessionTitle()
loadHistory()
refreshSessionList()
return
}
// 如果正在连接中(重连等),等待 onReady 回调即可
if (wsClient.connected) return
// 未连接,发起新连接
const config = await api.readOpenclawConfig()
const gw = config?.gateway || {}
const host = window.__TAURI_INTERNALS__ ? `127.0.0.1:${gw.port || 18789}` : location.host
const token = gw.auth?.token || gw.authToken || ''
wsClient.connect(host, token)
} catch (e) {
toast('读取配置失败: ' + e.message, 'error')
}
}
// ── 会话管理 ──
async function refreshSessionList() {
if (!_sessionListEl || !wsClient.gatewayReady) return
try {
const result = await wsClient.sessionsList(50)
const sessions = result?.sessions || result || []
renderSessionList(sessions)
} catch (e) {
console.error('[chat] refreshSessionList error:', e)
}
}
function renderSessionList(sessions) {
if (!_sessionListEl) return
if (!sessions.length) {
_sessionListEl.innerHTML = '<div class="chat-session-empty">暂无会话</div>'
return
}
sessions.sort((a, b) => (b.updatedAt || b.lastActivity || 0) - (a.updatedAt || a.lastActivity || 0))
_sessionListEl.innerHTML = sessions.map(s => {
const key = s.sessionKey || s.key || ''
const active = key === _sessionKey ? ' active' : ''
const label = parseSessionLabel(key)
const ts = s.updatedAt || s.lastActivity || s.createdAt || 0
const timeStr = ts ? formatSessionTime(ts) : ''
const msgCount = s.messageCount || s.messages || 0
const agentId = parseSessionAgent(key)
const displayLabel = getDisplayLabel(key) || label
return `<div class="chat-session-card${active}" data-key="${escapeAttr(key)}">
<div class="chat-session-card-header">
<span class="chat-session-label" title="双击重命名">${escapeAttr(displayLabel)}</span>
<button class="chat-session-del" data-del="${escapeAttr(key)}" title="删除">×</button>
</div>
<div class="chat-session-card-meta">
${agentId && agentId !== 'main' ? `<span class="chat-session-agent">${escapeAttr(agentId)}</span>` : ''}
${msgCount > 0 ? `<span>${msgCount} 条消息</span>` : ''}
${timeStr ? `<span>${timeStr}</span>` : ''}
</div>
</div>`
}).join('')
_sessionListEl.onclick = (e) => {
const delBtn = e.target.closest('[data-del]')
if (delBtn) { e.stopPropagation(); deleteSession(delBtn.dataset.del); return }
const item = e.target.closest('[data-key]')
if (item) switchSession(item.dataset.key)
}
_sessionListEl.ondblclick = (e) => {
const labelEl = e.target.closest('.chat-session-label')
if (!labelEl) return
const card = labelEl.closest('[data-key]')
if (!card) return
e.stopPropagation()
renameSession(card.dataset.key, labelEl)
}
}
function formatSessionTime(ts) {
const d = new Date(typeof ts === 'number' && ts < 1e12 ? ts * 1000 : ts)
if (isNaN(d.getTime())) return ''
const now = new Date()
const diffMs = now - d
if (diffMs < 60000) return '刚刚'
if (diffMs < 3600000) return Math.floor(diffMs / 60000) + ' 分钟前'
if (diffMs < 86400000) return Math.floor(diffMs / 3600000) + ' 小时前'
if (diffMs < 604800000) return Math.floor(diffMs / 86400000) + ' 天前'
return `${(d.getMonth() + 1).toString().padStart(2, '0')}-${d.getDate().toString().padStart(2, '0')}`
}
function parseSessionAgent(key) {
const parts = (key || '').split(':')
return parts.length >= 2 ? parts[1] : ''
}
function parseSessionLabel(key) {
const parts = (key || '').split(':')
if (parts.length < 3) return key || '未知'
const agent = parts[1] || 'main'
const channel = parts.slice(2).join(':')
if (agent === 'main' && channel === 'main') return '主会话'
if (agent === 'main') return channel
return `${agent} / ${channel}`
}
function switchSession(newKey) {
if (newKey === _sessionKey) return
_sessionKey = newKey
localStorage.setItem(STORAGE_SESSION_KEY, newKey)
_lastHistoryHash = ''
resetStreamState()
updateSessionTitle()
clearMessages()
loadHistory()
refreshSessionList()
}
async function showNewSessionDialog() {
const defaultAgent = wsClient.snapshot?.sessionDefaults?.defaultAgentId || 'main'
// 先用默认选项立即显示弹窗
const initialOptions = [
{ value: 'main', label: 'main (默认)' },
{ value: '__new__', label: '+ 新建 Agent' }
]
showModal({
title: '新建会话',
fields: [
{ name: 'name', label: '会话名称', value: '', placeholder: '例如:翻译助手' },
{ name: 'agent', label: 'Agent', type: 'select', value: defaultAgent, options: initialOptions },
],
onConfirm: (result) => {
const name = (result.name || '').trim()
if (!name) { toast('请输入会话名称', 'warning'); return }
const agent = result.agent || defaultAgent
if (agent === '__new__') {
navigate('/agents')
toast('请在 Agent 管理页面创建新 Agent', 'info')
return
}
switchSession(`agent:${agent}:${name}`)
toast('会话已创建', 'success')
}
})
// 异步加载完整 Agent 列表并更新下拉框
try {
const agents = await api.listAgents()
const agentOptions = agents.map(a => ({
value: a.id,
label: `${a.id}${a.isDefault ? ' (默认)' : ''}${a.identityName ? ' — ' + a.identityName.split(',')[0] : ''}`
}))
agentOptions.push({ value: '__new__', label: '+ 新建 Agent' })
// 更新弹窗中的下拉框选项
const selectEl = document.querySelector('.modal-overlay [data-name="agent"]')
if (selectEl) {
const currentValue = selectEl.value
selectEl.innerHTML = agentOptions.map(o =>
`<option value="${o.value}" ${o.value === currentValue ? 'selected' : ''}>${o.label}</option>`
).join('')
}
} catch (e) {
console.warn('[chat] 加载 Agent 列表失败:', e)
}
}
async function deleteSession(key) {
const mainKey = wsClient.snapshot?.sessionDefaults?.mainSessionKey || 'agent:main:main'
if (key === mainKey) { toast('主会话不能删除', 'warning'); return }
const label = parseSessionLabel(key)
const yes = await showConfirm(`确定删除会话「${label}」?`)
if (!yes) return
try {
await wsClient.sessionsDelete(key)
toast('会话已删除', 'success')
if (key === _sessionKey) switchSession(mainKey)
else refreshSessionList()
} catch (e) {
toast('删除失败: ' + e.message, 'error')
}
}
async function resetCurrentSession() {
if (!_sessionKey) return
const label = getDisplayLabel(_sessionKey)
const yes = await showConfirm(`确定要重置会话「${label}」吗?\n\n重置后将清空该会话的所有聊天记录,此操作不可撤销。`)
if (!yes) return
try {
await wsClient.sessionsReset(_sessionKey)
clearMessages()
_lastHistoryHash = ''
appendSystemMessage('会话已重置')
toast('会话已重置', 'success')
} catch (e) {
toast('重置失败: ' + e.message, 'error')
}
}
function updateSessionTitle() {
const el = _page?.querySelector('#chat-title')
if (el) el.textContent = getDisplayLabel(_sessionKey)
}
function renameSession(key, labelEl) {
const current = getDisplayLabel(key)
const input = document.createElement('input')
input.type = 'text'
input.value = current
input.className = 'chat-session-rename-input'
input.style.cssText = 'width:100%;padding:2px 6px;border:1px solid var(--accent);border-radius:4px;background:var(--bg-secondary);color:var(--text-primary);font-size:12px;outline:none'
const originalText = labelEl.textContent
labelEl.textContent = ''
labelEl.appendChild(input)
input.focus()
input.select()
let done = false
const finish = () => {
if (done) return
done = true
const newName = input.value.trim()
if (newName && newName !== parseSessionLabel(key)) {
setSessionName(key, newName)
toast('会话已重命名', 'success')
} else if (!newName || newName === parseSessionLabel(key)) {
setSessionName(key, '') // clear custom name
}
labelEl.textContent = getDisplayLabel(key)
// 如果是当前会话,同步更新顶部标题
if (key === _sessionKey) updateSessionTitle()
}
input.addEventListener('blur', finish)
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); input.blur() }
if (e.key === 'Escape') { input.value = originalText; input.blur() }
})
}
// ── 快捷指令面板 ──
function showCmdPanel() {
if (!_cmdPanelEl) return
let html = ''
for (const group of COMMANDS) {
html += `<div class="cmd-group-title">${group.title}</div>`
for (const c of group.commands) {
html += `<div class="cmd-item" data-cmd="${c.cmd}" data-action="${c.action}">
<span class="cmd-name">${c.cmd}</span>
<span class="cmd-desc">${c.desc}</span>
</div>`
}
}
_cmdPanelEl.innerHTML = html
_cmdPanelEl.style.display = 'block'
_cmdPanelEl.onclick = (e) => {
const item = e.target.closest('.cmd-item')
if (!item) return
hideCmdPanel()
if (item.dataset.action === 'fill') {
_textarea.value = item.dataset.cmd
_textarea.focus()
updateSendState()
} else {
_textarea.value = item.dataset.cmd
sendMessage()
}
}
}
function hideCmdPanel() {
if (_cmdPanelEl) _cmdPanelEl.style.display = 'none'
}
function toggleCmdPanel() {
if (_cmdPanelEl?.style.display === 'block') hideCmdPanel()
else { _textarea.value = '/'; showCmdPanel(); _textarea.focus() }
}
// ── 消息发送 ──
function sendMessage() {
const text = _textarea.value.trim()
if (!text && !_attachments.length) return
hideCmdPanel()
_textarea.value = ''
_textarea.style.height = 'auto'
updateSendState()
const attachments = [..._attachments]
_attachments = []
renderAttachments()
if (_isSending || _isStreaming) { _messageQueue.push({ text, attachments }); return }
doSend(text, attachments)
}
async function doSend(text, attachments = []) {
appendUserMessage(text, attachments)
saveMessage({
id: uuid(), sessionKey: _sessionKey, role: 'user', content: text, timestamp: Date.now(),
attachments: attachments?.length ? attachments.map(a => ({ category: a.category || 'image', mimeType: a.mimeType || '', content: a.content || '', url: a.url || '' })) : undefined
})
showTyping(true)
_isSending = true
try {
await wsClient.chatSend(_sessionKey, text, attachments.length ? attachments : undefined)
} catch (err) {
showTyping(false)
appendSystemMessage('发送失败: ' + err.message)
} finally {
_isSending = false
updateSendState()
}
}
function processMessageQueue() {
if (_messageQueue.length === 0 || _isSending || _isStreaming) return
const msg = _messageQueue.shift()
if (typeof msg === 'string') doSend(msg, [])
else doSend(msg.text, msg.attachments || [])
}
function stopGeneration() {
if (_currentRunId) wsClient.chatAbort(_sessionKey, _currentRunId).catch(() => {})
}
// ── 事件处理(参照 clawapp 实现) ──
function handleEvent(msg) {
const { event, payload } = msg
if (!payload) return
if (event === 'chat') handleChatEvent(payload)
}
function handleChatEvent(payload) {
// sessionKey 过滤
if (payload.sessionKey && payload.sessionKey !== _sessionKey && _sessionKey) return
const { state } = payload
const runId = payload.runId
// 重复 run 过滤:跳过已完成的 runId 的后续事件Gateway 可能对同一消息触发多个 run
if (runId && state === 'final' && _seenRunIds.has(runId)) {
console.log('[chat] 跳过重复 final, runId:', runId)
return
}
if (runId && state === 'delta' && _seenRunIds.has(runId) && !_isStreaming) {
console.log('[chat] 跳过已完成 run 的 delta, runId:', runId)
return
}
if (state === 'delta') {
const c = extractChatContent(payload.message)
if (c?.images?.length) _currentAiImages = c.images
if (c?.videos?.length) _currentAiVideos = c.videos
if (c?.audios?.length) _currentAiAudios = c.audios
if (c?.files?.length) _currentAiFiles = c.files
if (c?.text && c.text.length > _currentAiText.length) {
showTyping(false)
if (!_currentAiBubble) {
_currentAiBubble = createStreamBubble()
_currentRunId = payload.runId
_isStreaming = true
_streamStartTime = Date.now()
updateSendState()
}
_currentAiText = c.text
// 每次收到 delta 重置安全超时90s 无新 delta 则强制结束)
clearTimeout(_streamSafetyTimer)
_streamSafetyTimer = setTimeout(() => {
if (_isStreaming) {
console.warn('[chat] 流式输出超时90s 无新数据),强制结束')
if (_currentAiBubble && _currentAiText) {
_currentAiBubble.innerHTML = renderMarkdown(_currentAiText)
}
appendSystemMessage('输出超时,已自动结束')
resetStreamState()
processMessageQueue()
}
}, 90000)
throttledRender()
}
return
}
if (state === 'final') {
const c = extractChatContent(payload.message)
const finalText = c?.text || ''
const finalImages = c?.images || []
const finalVideos = c?.videos || []
const finalAudios = c?.audios || []
const finalFiles = c?.files || []
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
// 忽略空 finalGateway 会为一条消息触发多个 run部分是空 final
if (!_currentAiBubble && !hasContent) return
// 标记 runId 为已处理,防止重复
if (runId) {
_seenRunIds.add(runId)
if (_seenRunIds.size > 200) {
const first = _seenRunIds.values().next().value
_seenRunIds.delete(first)
}
}
showTyping(false)
// 如果流式阶段没有创建 bubble从 final message 中提取
if (!_currentAiBubble && hasContent) {
_currentAiBubble = createStreamBubble()
_currentAiText = finalText
}
if (_currentAiBubble) {
if (_currentAiText) _currentAiBubble.innerHTML = renderMarkdown(_currentAiText)
appendImagesToEl(_currentAiBubble, _currentAiImages)
appendVideosToEl(_currentAiBubble, _currentAiVideos)
appendAudiosToEl(_currentAiBubble, _currentAiAudios)
appendFilesToEl(_currentAiBubble, _currentAiFiles)
}
// 添加时间戳 + 耗时 + token 消耗
const wrapper = _currentAiBubble?.parentElement
if (wrapper) {
const meta = document.createElement('div')
meta.className = 'msg-meta'
let parts = [`<span class="msg-time">${formatTime(new Date())}</span>`]
// 计算响应耗时
let durStr = ''
if (payload.durationMs) {
durStr = (payload.durationMs / 1000).toFixed(1) + 's'
} else if (_streamStartTime) {
durStr = ((Date.now() - _streamStartTime) / 1000).toFixed(1) + 's'
}
if (durStr) parts.push(`<span class="meta-sep">·</span><span class="msg-duration">⏱ ${durStr}</span>`)
// token 消耗(从 payload.usage 或 payload.message.usage 提取)
const usage = payload.usage || payload.message?.usage || null
if (usage) {
const inp = usage.input_tokens || usage.prompt_tokens || 0
const out = usage.output_tokens || usage.completion_tokens || 0
const total = usage.total_tokens || (inp + out)
if (total > 0) {
let tokenStr = `${total} tokens`
if (inp && out) tokenStr = `${inp}${out}`
parts.push(`<span class="meta-sep">·</span><span class="msg-tokens">${tokenStr}</span>`)
}
}
meta.innerHTML = parts.join('')
wrapper.appendChild(meta)
}
if (_currentAiText || _currentAiImages.length) {
saveMessage({
id: payload.runId || uuid(), sessionKey: _sessionKey, role: 'assistant',
content: _currentAiText, timestamp: Date.now(),
attachments: _currentAiImages.map(i => ({ category: 'image', mimeType: i.mediaType || 'image/png', url: i.url, content: i.data })).filter(a => a.url || a.content)
})
}
resetStreamState()
processMessageQueue()
return
}
if (state === 'aborted') {
showTyping(false)
if (_currentAiBubble && _currentAiText) {
_currentAiBubble.innerHTML = renderMarkdown(_currentAiText)
}
appendSystemMessage('生成已停止')
resetStreamState()
processMessageQueue()
return
}
if (state === 'error') {
const errMsg = payload.errorMessage || payload.error?.message || '未知错误'
// 连接级错误origin/pairing/auth拦截不作为聊天消息显示
if (/origin not allowed|NOT_PAIRED|PAIRING_REQUIRED|auth.*fail/i.test(errMsg)) {
console.warn('[chat] 拦截连接级错误,不显示为聊天消息:', errMsg)
const overlay = document.getElementById('chat-connect-overlay')
if (overlay) {
overlay.style.display = 'flex'
const desc = document.getElementById('chat-connect-desc')
if (desc) desc.textContent = '连接被 Gateway 拒绝,请点击「修复并重连」'
}
return
}
// 防抖:如果是相同错误且在 2 秒内,忽略(避免重复显示)
const now = Date.now()
if (_lastErrorMsg === errMsg && _errorTimer && (now - _errorTimer < 2000)) {
console.warn('[chat] 忽略重复错误:', errMsg)
return
}
_lastErrorMsg = errMsg
_errorTimer = now
// 如果正在流式输出,说明消息已经部分成功,不显示错误
if (_isStreaming || _currentAiBubble) {
console.warn('[chat] 流式中收到错误,但消息已部分成功,忽略错误提示:', errMsg)
return
}
showTyping(false)
appendSystemMessage('错误: ' + errMsg)
resetStreamState()
processMessageQueue()
return
}
}
/** 从 Gateway message 对象提取文本和所有媒体(参照 clawapp extractContent */
function extractChatContent(message) {
if (!message || typeof message !== 'object') return null
const content = message.content
if (typeof content === 'string') return { text: stripThinkingTags(content), images: [], videos: [], audios: [], files: [] }
if (Array.isArray(content)) {
const texts = [], images = [], videos = [], audios = [], files = []
for (const block of content) {
if (block.type === 'text' && typeof block.text === 'string') texts.push(block.text)
else if (block.type === 'image' && !block.omitted) {
if (block.data) images.push({ mediaType: block.mimeType || 'image/png', data: block.data })
else if (block.source?.type === 'base64' && block.source.data) images.push({ mediaType: block.source.media_type || 'image/png', data: block.source.data })
else if (block.url || block.source?.url) images.push({ url: block.url || block.source.url, mediaType: block.mimeType || 'image/png' })
}
else if (block.type === 'image_url' && block.image_url?.url) images.push({ url: block.image_url.url, mediaType: 'image/png' })
else if (block.type === 'video') {
if (block.data) videos.push({ mediaType: block.mimeType || 'video/mp4', data: block.data })
else if (block.url) videos.push({ url: block.url, mediaType: block.mimeType || 'video/mp4' })
}
else if (block.type === 'audio' || block.type === 'voice') {
if (block.data) audios.push({ mediaType: block.mimeType || 'audio/mpeg', data: block.data, duration: block.duration })
else if (block.url) audios.push({ url: block.url, mediaType: block.mimeType || 'audio/mpeg', duration: block.duration })
}
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 })
}
}
// 从 mediaUrl/mediaUrls 提取
const mediaUrls = message.mediaUrls || (message.mediaUrl ? [message.mediaUrl] : [])
for (const url of mediaUrls) {
if (!url) continue
if (/\.(mp4|webm|mov|mkv)(\?|$)/i.test(url)) videos.push({ url, mediaType: 'video/mp4' })
else if (/\.(mp3|wav|ogg|m4a|aac|flac)(\?|$)/i.test(url)) audios.push({ url, mediaType: 'audio/mpeg' })
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: '' })
}
const text = texts.length ? stripThinkingTags(texts.join('\n')) : ''
return { text, images, videos, audios, files }
}
if (typeof message.text === 'string') return { text: stripThinkingTags(message.text), images: [], videos: [], audios: [], files: [] }
return null
}
function stripThinkingTags(text) {
return text
.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 formatTime(date) {
const now = new Date()
const h = date.getHours().toString().padStart(2, '0')
const m = date.getMinutes().toString().padStart(2, '0')
const isToday = date.getFullYear() === now.getFullYear() && date.getMonth() === now.getMonth() && date.getDate() === now.getDate()
if (isToday) return `${h}:${m}`
const mon = (date.getMonth() + 1).toString().padStart(2, '0')
const day = date.getDate().toString().padStart(2, '0')
return `${mon}-${day} ${h}:${m}`
}
function formatFileSize(bytes) {
if (!bytes || bytes <= 0) return ''
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
}
/** 创建流式 AI 气泡 */
function createStreamBubble() {
showTyping(false)
const wrap = document.createElement('div')
wrap.className = 'msg msg-ai'
const bubble = document.createElement('div')
bubble.className = 'msg-bubble'
bubble.innerHTML = '<span class="stream-cursor"></span>'
wrap.appendChild(bubble)
_messagesEl.insertBefore(wrap, _typingEl)
scrollToBottom()
return bubble
}
// ── 流式渲染(节流) ──
function throttledRender() {
if (_renderPending) return
const now = performance.now()
if (now - _lastRenderTime >= RENDER_THROTTLE) {
doRender()
} else {
_renderPending = true
requestAnimationFrame(() => { _renderPending = false; doRender() })
}
}
function doRender() {
_lastRenderTime = performance.now()
if (_currentAiBubble && _currentAiText) {
_currentAiBubble.innerHTML = renderMarkdown(_currentAiText)
scrollToBottom()
}
}
// ensureAiBubble 已被 createStreamBubble 替代
function resetStreamState() {
clearTimeout(_streamSafetyTimer)
if (_currentAiBubble && (_currentAiText || _currentAiImages.length || _currentAiVideos.length || _currentAiAudios.length || _currentAiFiles.length)) {
_currentAiBubble.innerHTML = renderMarkdown(_currentAiText)
appendImagesToEl(_currentAiBubble, _currentAiImages)
appendVideosToEl(_currentAiBubble, _currentAiVideos)
appendAudiosToEl(_currentAiBubble, _currentAiAudios)
appendFilesToEl(_currentAiBubble, _currentAiFiles)
}
_renderPending = false
_lastRenderTime = 0
_currentAiBubble = null
_currentAiText = ''
_currentAiImages = []
_currentAiVideos = []
_currentAiAudios = []
_currentAiFiles = []
_currentRunId = null
_isStreaming = false
_streamStartTime = 0
_lastErrorMsg = null
_errorTimer = null
showTyping(false)
updateSendState()
}
// ── 历史消息加载 ──
async function loadHistory() {
if (!_sessionKey) return
const hasExisting = _messagesEl?.querySelector('.msg')
if (!hasExisting && isStorageAvailable()) {
const local = await getLocalMessages(_sessionKey, 200)
if (local.length) {
clearMessages()
local.forEach(msg => {
if (!msg.content && !msg.attachments?.length) return
const msgTime = msg.timestamp ? new Date(msg.timestamp) : new Date()
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)
}
})
scrollToBottom()
}
}
if (!wsClient.gatewayReady) return
try {
const result = await wsClient.chatHistory(_sessionKey, 200)
if (!result?.messages?.length) {
if (!_messagesEl.querySelector('.msg')) appendSystemMessage('还没有消息,开始聊天吧')
return
}
const deduped = dedupeHistory(result.messages)
const hash = deduped.map(m => `${m.role}:${(m.text || '').length}`).join('|')
if (hash === _lastHistoryHash && hasExisting) return
_lastHistoryHash = hash
// 正在发送/流式输出时不全量重绘,避免覆盖本地乐观渲染
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() }
}))
return
}
clearMessages()
let hasOmittedImages = false
deduped.forEach(msg => {
if (!msg.text && !msg.images?.length && !msg.videos?.length && !msg.audios?.length && !msg.files?.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 => ({
mimeType: i.mediaType || i.media_type || 'image/png',
content: i.data || i.source?.data || '',
category: 'image',
})).filter(a => a.content) : []
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)
}
})
if (hasOmittedImages) {
appendSystemMessage('部分历史图片无法显示Gateway 不保留图片原始数据,仅当前会话内可见)')
}
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() }
}))
scrollToBottom()
} catch (e) {
console.error('[chat] loadHistory error:', e)
if (!_messagesEl.querySelector('.msg')) appendSystemMessage('加载历史失败: ' + e.message)
}
}
function dedupeHistory(messages) {
const deduped = []
for (const msg of messages) {
if (msg.role === 'toolResult') continue
const c = extractContent(msg)
if (!c.text && !c.images.length && !c.videos.length && !c.audios.length && !c.files.length) continue
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') {
// 同文本去重Gateway 重试产生的重复回复)
if (c.text && last.text === c.text) continue
// 不同文本则合并
last.text = [last.text, c.text].filter(Boolean).join('\n')
last.images = [...(last.images || []), ...c.images]
last.videos = [...(last.videos || []), ...c.videos]
last.audios = [...(last.audios || []), ...c.audios]
last.files = [...(last.files || []), ...c.files]
continue
}
}
deduped.push({ role: msg.role, text: c.text, images: c.images, videos: c.videos, audios: c.audios, files: c.files, timestamp: msg.timestamp })
}
return deduped
}
function extractContent(msg) {
if (Array.isArray(msg.content)) {
const texts = [], images = [], videos = [], audios = [], files = []
for (const block of msg.content) {
if (block.type === 'text' && typeof block.text === 'string') texts.push(block.text)
else if (block.type === 'image' && !block.omitted) {
if (block.data) images.push({ mediaType: block.mimeType || 'image/png', data: block.data })
else if (block.source?.type === 'base64' && block.source.data) images.push({ mediaType: block.source.media_type || 'image/png', data: block.source.data })
else if (block.url || block.source?.url) images.push({ url: block.url || block.source.url, mediaType: block.mimeType || 'image/png' })
}
else if (block.type === 'image_url' && block.image_url?.url) images.push({ url: block.image_url.url, mediaType: 'image/png' })
else if (block.type === 'video') {
if (block.data) videos.push({ mediaType: block.mimeType || 'video/mp4', data: block.data })
else if (block.url) videos.push({ url: block.url, mediaType: block.mimeType || 'video/mp4' })
}
else if (block.type === 'audio' || block.type === 'voice') {
if (block.data) audios.push({ mediaType: block.mimeType || 'audio/mpeg', data: block.data, duration: block.duration })
else if (block.url) audios.push({ url: block.url, mediaType: block.mimeType || 'audio/mpeg', duration: block.duration })
}
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 })
}
}
const mediaUrls = msg.mediaUrls || (msg.mediaUrl ? [msg.mediaUrl] : [])
for (const url of mediaUrls) {
if (!url) continue
if (/\.(mp4|webm|mov|mkv)(\?|$)/i.test(url)) videos.push({ url, mediaType: 'video/mp4' })
else if (/\.(mp3|wav|ogg|m4a|aac|flac)(\?|$)/i.test(url)) audios.push({ url, mediaType: 'audio/mpeg' })
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 }
}
const text = typeof msg.text === 'string' ? msg.text : (typeof msg.content === 'string' ? msg.content : '')
return { text: stripThinkingTags(text), images: [], videos: [], audios: [], files: [] }
}
// ── DOM 操作 ──
function appendUserMessage(text, attachments = [], msgTime) {
const wrap = document.createElement('div')
wrap.className = 'msg msg-user'
const bubble = document.createElement('div')
bubble.className = 'msg-bubble'
if (attachments && attachments.length > 0) {
const mediaContainer = document.createElement('div')
mediaContainer.style.cssText = 'display:flex;gap:4px;margin-bottom:8px;flex-wrap:wrap'
attachments.forEach(att => {
const cat = att.category || att.type || 'image'
const src = att.data ? `data:${att.mimeType || att.mediaType || 'image/png'};base64,${att.data}`
: att.content ? `data:${att.mimeType || 'image/png'};base64,${att.content}`
: att.url || ''
if (cat === 'image' && src) {
const img = document.createElement('img')
img.src = src
img.className = 'msg-img'
img.onclick = () => showLightbox(img.src)
mediaContainer.appendChild(img)
} else if (cat === 'video' && src) {
const video = document.createElement('video')
video.src = src
video.className = 'msg-video'
video.controls = true
video.preload = 'metadata'
video.playsInline = true
mediaContainer.appendChild(video)
} else if (cat === 'audio' && src) {
const audio = document.createElement('audio')
audio.src = src
audio.className = 'msg-audio'
audio.controls = true
audio.preload = 'metadata'
mediaContainer.appendChild(audio)
} else if (att.fileName || att.name) {
const card = document.createElement('div')
card.className = 'msg-file-card'
card.innerHTML = `<span class="msg-file-icon">${svgIcon('paperclip', 16)}</span><span class="msg-file-name">${att.fileName || att.name}</span>`
mediaContainer.appendChild(card)
}
})
if (mediaContainer.children.length) bubble.appendChild(mediaContainer)
}
if (text) {
const textNode = document.createElement('div')
textNode.textContent = text
bubble.appendChild(textNode)
}
const meta = document.createElement('div')
meta.className = 'msg-meta'
meta.innerHTML = `<span class="msg-time">${formatTime(msgTime || new Date())}</span>`
wrap.appendChild(bubble)
wrap.appendChild(meta)
_messagesEl.insertBefore(wrap, _typingEl)
scrollToBottom()
}
function appendAiMessage(text, msgTime, images, videos, audios, files) {
const wrap = document.createElement('div')
wrap.className = 'msg msg-ai'
const bubble = document.createElement('div')
bubble.className = 'msg-bubble'
bubble.innerHTML = renderMarkdown(text)
appendImagesToEl(bubble, images)
appendVideosToEl(bubble, videos)
appendAudiosToEl(bubble, audios)
appendFilesToEl(bubble, files)
// 图片点击灯箱
bubble.querySelectorAll('img').forEach(img => { if (!img.onclick) img.onclick = () => showLightbox(img.src) })
const meta = document.createElement('div')
meta.className = 'msg-meta'
meta.innerHTML = `<span class="msg-time">${formatTime(msgTime || new Date())}</span>`
wrap.appendChild(bubble)
wrap.appendChild(meta)
_messagesEl.insertBefore(wrap, _typingEl)
scrollToBottom()
}
/** 渲染图片到消息气泡(支持 Anthropic/OpenAI/直接格式) */
function appendImagesToEl(el, images) {
if (!images?.length) return
const container = document.createElement('div')
container.style.cssText = 'display:flex;gap:6px;margin-top:8px;flex-wrap:wrap'
images.forEach(img => {
const imgEl = document.createElement('img')
// Anthropic 格式: { type: 'image', source: { data, media_type } }
if (img.source?.data) {
imgEl.src = `data:${img.source.media_type || 'image/png'};base64,${img.source.data}`
// 直接格式: { data, mediaType }
} else if (img.data) {
imgEl.src = `data:${img.mediaType || img.media_type || 'image/png'};base64,${img.data}`
// OpenAI 格式: { type: 'image_url', image_url: { url } }
} else if (img.image_url?.url) {
imgEl.src = img.image_url.url
// URL 格式
} else if (img.url) {
imgEl.src = img.url
} else {
return
}
imgEl.style.cssText = 'max-width:300px;max-height:300px;border-radius:6px;cursor:pointer'
imgEl.onclick = () => showLightbox(imgEl.src)
container.appendChild(imgEl)
})
if (container.children.length) el.appendChild(container)
}
/** 渲染视频到消息气泡 */
function appendVideosToEl(el, videos) {
if (!videos?.length) return
videos.forEach(vid => {
const videoEl = document.createElement('video')
videoEl.className = 'msg-video'
videoEl.controls = true
videoEl.preload = 'metadata'
videoEl.playsInline = true
if (vid.data) videoEl.src = `data:${vid.mediaType};base64,${vid.data}`
else if (vid.url) videoEl.src = vid.url
el.appendChild(videoEl)
})
}
/** 渲染音频到消息气泡 */
function appendAudiosToEl(el, audios) {
if (!audios?.length) return
audios.forEach(aud => {
const audioEl = document.createElement('audio')
audioEl.className = 'msg-audio'
audioEl.controls = true
audioEl.preload = 'metadata'
if (aud.data) audioEl.src = `data:${aud.mediaType};base64,${aud.data}`
else if (aud.url) audioEl.src = aud.url
el.appendChild(audioEl)
})
}
/** 渲染文件卡片到消息气泡 */
function appendFilesToEl(el, files) {
if (!files?.length) return
files.forEach(f => {
const card = document.createElement('div')
card.className = 'msg-file-card'
const ext = (f.name || '').split('.').pop().toLowerCase()
const fileIconMap = { pdf: 'file', doc: 'file-text', docx: 'file-text', txt: 'file-plain', md: 'file-plain', json: 'clipboard', csv: 'bar-chart', zip: 'package', rar: 'package' }
const fileIcon = svgIcon(fileIconMap[ext] || 'paperclip', 16)
const size = f.size ? formatFileSize(f.size) : ''
card.innerHTML = `<span class="msg-file-icon">${fileIcon}</span><div class="msg-file-info"><span class="msg-file-name">${f.name || '文件'}</span>${size ? `<span class="msg-file-size">${size}</span>` : ''}</div>`
if (f.url) {
card.style.cursor = 'pointer'
card.onclick = () => window.open(f.url, '_blank')
} else if (f.data) {
card.style.cursor = 'pointer'
card.onclick = () => {
const a = document.createElement('a')
a.href = `data:${f.mimeType || 'application/octet-stream'};base64,${f.data}`
a.download = f.name || '文件'
a.click()
}
}
el.appendChild(card)
})
}
/** 图片灯箱查看 */
function showLightbox(src) {
const existing = document.querySelector('.chat-lightbox')
if (existing) existing.remove()
const lb = document.createElement('div')
lb.className = 'chat-lightbox'
lb.innerHTML = `<img src="${src}" class="chat-lightbox-img" />`
lb.onclick = (e) => { if (e.target === lb || e.target.tagName !== 'IMG') lb.remove() }
document.body.appendChild(lb)
// ESC 关闭
const onKey = (e) => { if (e.key === 'Escape') { lb.remove(); document.removeEventListener('keydown', onKey) } }
document.addEventListener('keydown', onKey)
}
function appendSystemMessage(text) {
const wrap = document.createElement('div')
wrap.className = 'msg msg-system'
wrap.textContent = text
_messagesEl.insertBefore(wrap, _typingEl)
scrollToBottom()
}
function clearMessages() {
_messagesEl.querySelectorAll('.msg').forEach(m => m.remove())
}
function showTyping(show) {
if (_typingEl) _typingEl.style.display = show ? 'flex' : 'none'
if (show) scrollToBottom()
}
function scrollToBottom() {
if (!_messagesEl) return
requestAnimationFrame(() => { _messagesEl.scrollTop = _messagesEl.scrollHeight })
}
function updateSendState() {
if (!_sendBtn || !_textarea) return
if (_isStreaming) {
_sendBtn.disabled = false
_sendBtn.innerHTML = '<svg viewBox="0 0 24 24" fill="currentColor" width="20" height="20"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>'
_sendBtn.title = '停止生成'
} else {
_sendBtn.disabled = !_textarea.value.trim() && !_attachments.length
_sendBtn.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>'
_sendBtn.title = '发送'
}
}
function updateStatusDot(status) {
if (!_statusDot) return
_statusDot.className = 'status-dot'
if (status === 'ready' || status === 'connected') _statusDot.classList.add('online')
else if (status === 'connecting' || status === 'reconnecting') _statusDot.classList.add('connecting')
else _statusDot.classList.add('offline')
}
// ── 页面离开清理 ──
export function cleanup() {
_pageActive = false
if (_unsubEvent) { _unsubEvent(); _unsubEvent = null }
if (_unsubReady) { _unsubReady(); _unsubReady = null }
if (_unsubStatus) { _unsubStatus(); _unsubStatus = null }
clearTimeout(_streamSafetyTimer)
// 不断开 wsClient —— 它是全局单例,保持连接供下次进入复用
_sessionKey = null
_page = null
_messagesEl = null
_textarea = null
_sendBtn = null
_statusDot = null
_typingEl = null
_scrollBtn = null
_sessionListEl = null
_cmdPanelEl = null
_currentAiBubble = null
_currentAiText = ''
_currentAiImages = []
_currentAiVideos = []
_currentAiAudios = []
_currentAiFiles = []
_currentRunId = null
_isStreaming = false
_isSending = false
_messageQueue = []
_lastHistoryHash = ''
}