mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-05-12 02:20:58 +08:00
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
1588 lines
62 KiB
JavaScript
1588 lines
62 KiB
JavaScript
/**
|
||
* 聊天页面 - 完整版,对接 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="知道了">×</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, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>')
|
||
}
|
||
|
||
/** 本地会话别名缓存 */
|
||
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
|
||
// 忽略空 final(Gateway 会为一条消息触发多个 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 = ''
|
||
}
|