From a6e1f40a59a8885d46e2ad24a771e5d702b1d142 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=99=B4=E5=A4=A9?= Date: Wed, 4 Mar 2026 20:47:00 +0800 Subject: [PATCH] feat: image rendering, sidebar toggle, contribute section; fix: private repo update check; bump v0.2.1 --- CHANGELOG.md | 45 +++++++ SECURITY.md | 39 ++++++ package.json | 21 ++- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 7 +- src-tauri/src/commands/device.rs | 2 +- src-tauri/src/commands/pairing.rs | 31 +---- src-tauri/tauri.conf.json | 2 +- src/lib/ws-client.js | 9 +- src/main.js | 21 ++- src/pages/about.js | 30 ++++- src/pages/chat.js | 210 +++++++++++++++++++++++++----- src/pages/dashboard.js | 30 +++-- src/style/chat.css | 88 ++++++++++++- 14 files changed, 454 insertions(+), 83 deletions(-) create mode 100644 SECURITY.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d15b55..c6ccbae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,51 @@ 格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/), 版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。 +## [0.2.1] - 2026-03-04 + +### 新增 (Features) + +- **聊天图片完整支持** — AI 响应中的图片现在可以正确提取和渲染(支持 Anthropic / OpenAI / 直接格式) +- **图片灯箱查看** — 点击聊天中的图片可全屏查看,支持 ESC 关闭 +- **会话列表折叠** — 聊天页面侧边栏支持点击 ≡ 按钮收起/展开,带平滑过渡动画 +- **参与贡献入口** — 关于页面新增「参与贡献」区块,包含提交 Issue、提交 PR、贡献指南等快捷链接 + +### 修复 (Bug Fixes) + +- **聊天历史图片丢失** — `extractContent` / `dedupeHistory` / `loadHistory` 现在正确提取和渲染历史消息中的图片 +- **流式响应图片丢失** — delta / final 事件处理新增 `_currentAiImages` 收集,`resetStreamState` 正确清理 +- **私有仓库更新检测** — 检查更新失败时区分 403/404(仓库未公开)和其他错误,显示友好提示 + +### 优化 (Improvements) + +- **开源文档完善** — 新增 `SECURITY.md` 安全政策,同步版本号至 0.2.x,补充项目元数据 +- **仪表盘分波渲染** — 9 个 API 改为三波渐进加载,关键数据先显示,消除白屏等待 + +## [0.2.0] - 2026-03-04 + +### 新增 (Features) + +- **ClawPanel 自动更新检测** — 关于页面自动检查 ClawPanel 最新版本,显示更新链接 +- **系统诊断页面** — 全面检测系统状态(服务、WebSocket、Node.js、设备密钥),一键修复配对 +- **聊天连接引导遮罩** — WebSocket 连接失败时显示友好引导界面,提供「修复并重连」按钮,替代原始错误消息 +- **图片上传与粘贴** — 聊天页面支持附件上传和 Ctrl+V 粘贴图片,支持多模态对话 + +### 修复 (Bug Fixes) + +- **首次启动 origin 拒绝** — 修复 `autoPairDevice` 在设备密钥不存在时提前退出、未写入 `allowedOrigins` 的问题 +- **Gateway 配置不生效** — 写入 `allowedOrigins` 后自动 `reloadGateway`,确保新配置立即生效 +- **WebSocket 自动修复** — `_autoPairAndReconnect` 补充 `reloadGateway` 调用,修复自动配对后仍被拒绝的问题 +- **wsClient.close 不存在** — 修正为 `wsClient.disconnect()` +- **远程模型缺少视觉支持** — 添加模型时 `input` 改为 `['text', 'image']` +- **连接级错误拦截** — 拦截 `origin not allowed`、`NOT_PAIRED` 等连接级错误,不再作为聊天消息显示 + +### 优化 (Improvements) + +- **仪表盘分波渲染** — 9 个 API 请求改为三波渐进加载,关键数据先显示,消除打开时的白屏等待 +- **全页面骨架屏** — 所有页面添加 loading-placeholder 骨架占位,提升加载体验 +- **页面清理函数** — models.js 添加 `cleanup()` 清理定时器和中止控制器,防止内存泄漏 +- **发布工作流增强** — release.yml 生成分类更新日志、可点击下载链接、首次使用指南 + ## [0.1.0] - 2026-03-01 首个公开发布版本,包含 OpenClaw 管理面板的全部核心功能。 diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..bcc15f9 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,39 @@ +# 安全政策 + +## 支持的版本 + +| 版本 | 支持状态 | +|------|----------| +| 0.2.x | ✅ 安全更新 | +| < 0.2 | ❌ 不再维护 | + +## 报告安全漏洞 + +如果你发现了安全漏洞,**请不要**在公开的 Issue 中提交。 + +请通过以下方式私下报告: + +1. 发送邮件至项目维护者(在 GitHub 个人主页查看联系方式) +2. 或使用 [GitHub Security Advisories](https://github.com/qingchencloud/clawpanel/security/advisories/new) 私下报告 + +### 报告内容应包含 + +- 漏洞的详细描述 +- 复现步骤 +- 受影响的版本 +- 可能的影响范围 +- 如果有的话,建议的修复方案 + +### 响应时间 + +- **确认收到**:48 小时内 +- **初步评估**:7 个工作日内 +- **修复发布**:根据严重程度,通常在 30 天内 + +## 安全最佳实践 + +使用 ClawPanel 时,建议注意以下安全事项: + +- **Gateway Token**:如果开启局域网共享,务必设置访问密钥 +- **网络访问**:默认仅监听本机(loopback),如无必要不要开启局域网模式 +- **API Key**:模型服务商的 API Key 存储在本地 `openclaw.json` 中,请确保文件权限安全 diff --git a/package.json b/package.json index cd3eb76..88f0203 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,26 @@ { "name": "clawpanel", - "version": "1.0.0", + "version": "0.2.1", "private": true, - "description": "ClawPanel - OpenClaw 可视化管理面板", + "description": "ClawPanel - OpenClaw 可视化管理面板,基于 Tauri v2 的跨平台桌面应用", "type": "module", + "author": "qingchencloud", + "license": "MIT", + "homepage": "https://github.com/qingchencloud/clawpanel", + "repository": { + "type": "git", + "url": "https://github.com/qingchencloud/clawpanel.git" + }, + "bugs": { + "url": "https://github.com/qingchencloud/clawpanel/issues" + }, + "keywords": [ + "openclaw", + "ai-agent", + "tauri", + "desktop-app", + "management-panel" + ], "scripts": { "dev": "vite", "build": "vite build", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index a9bbb70..70f1b2b 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -328,7 +328,7 @@ dependencies = [ [[package]] name = "clawpanel" -version = "0.1.0" +version = "0.2.1" dependencies = [ "base64 0.22.1", "chrono", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 5b4277e..da55ab6 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,7 +1,12 @@ [package] name = "clawpanel" -version = "0.1.0" +version = "0.2.1" edition = "2021" +description = "ClawPanel - OpenClaw 可视化管理面板" +authors = ["qingchencloud"] +repository = "https://github.com/qingchencloud/clawpanel" +homepage = "https://github.com/qingchencloud/clawpanel" +license = "MIT" [lib] name = "clawpanel_lib" diff --git a/src-tauri/src/commands/device.rs b/src-tauri/src/commands/device.rs index e646539..6687217 100644 --- a/src-tauri/src/commands/device.rs +++ b/src-tauri/src/commands/device.rs @@ -14,7 +14,7 @@ const SCOPES: &[&str] = &[ ]; /// 获取或生成设备密钥 -fn get_or_create_key() -> Result<(String, String, SigningKey), String> { +pub(crate) fn get_or_create_key() -> Result<(String, String, SigningKey), String> { let dir = super::openclaw_dir(); let path = dir.join(DEVICE_KEY_FILE); diff --git a/src-tauri/src/commands/pairing.rs b/src-tauri/src/commands/pairing.rs index 7fe3076..aa8b0af 100644 --- a/src-tauri/src/commands/pairing.rs +++ b/src-tauri/src/commands/pairing.rs @@ -3,27 +3,12 @@ #[tauri::command] pub fn auto_pair_device() -> Result { - // 读取设备密钥 - let device_key_path = crate::commands::openclaw_dir().join("clawpanel-device-key.json"); - if !device_key_path.exists() { - return Err("设备密钥文件不存在".into()); - } + // 无论是否已配对,都确保 gateway.controlUi.allowedOrigins 已写入 + // 必须在最前面,避免因设备密钥不存在而跳过 + patch_gateway_origins(); - let device_key_content = - std::fs::read_to_string(&device_key_path).map_err(|e| format!("读取设备密钥失败: {e}"))?; - - let device_key: serde_json::Value = - serde_json::from_str(&device_key_content).map_err(|e| format!("解析设备密钥失败: {e}"))?; - - let device_id = device_key["deviceId"] - .as_str() - .ok_or("设备 ID 不存在")? - .to_string(); - - let public_key = device_key["publicKey"] - .as_str() - .ok_or("公钥不存在")? - .to_string(); + // 获取或生成设备密钥(首次安装时自动创建) + let (device_id, public_key, _) = super::device::get_or_create_key()?; // 读取或创建 paired.json let paired_path = crate::commands::openclaw_dir() @@ -44,9 +29,6 @@ pub fn auto_pair_device() -> Result { serde_json::json!({}) }; - // 无论是否已配对,都确保 gateway.controlUi.allowedOrigins 已写入 - patch_gateway_origins(); - let os_platform = std::env::consts::OS; // "windows" | "macos" | "linux" // 如果已配对,档查 platform 字段是否正确;不正确则覆盖更新, @@ -116,9 +98,6 @@ pub fn auto_pair_device() -> Result { std::fs::write(&paired_path, new_content).map_err(|e| format!("写入 paired.json 失败: {e}"))?; - // 同步写入 controlUi.allowedOrigins,允许 Tauri 的 origin 连接 Gateway - patch_gateway_origins(); - Ok("设备配对成功".into()) } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 270ede8..ec8eda2 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-config-schema/schema.json", "productName": "ClawPanel", - "version": "0.2.0", + "version": "0.2.1", "identifier": "ai.openclaw.clawpanel", "build": { "frontendDist": "../dist", diff --git a/src/lib/ws-client.js b/src/lib/ws-client.js index 67fdc71..da04245 100644 --- a/src/lib/ws-client.js +++ b/src/lib/ws-client.js @@ -216,7 +216,14 @@ export class WsClient { const result = await api.autoPairDevice() console.log('[ws] 配对结果:', result) - // 配对成功后直接重连,不需要重启 Gateway + // 配对后需要 reload Gateway 使 allowedOrigins 生效 + try { + await api.reloadGateway() + console.log('[ws] Gateway 已重载') + } catch (e) { + console.warn('[ws] reloadGateway 失败(非致命):', e) + } + console.log('[ws] 配对成功,2秒后重新连接...') setTimeout(() => { if (!this._intentionalClose) { diff --git a/src/main.js b/src/main.js index d72d335..c3959f2 100644 --- a/src/main.js +++ b/src/main.js @@ -79,9 +79,12 @@ async function autoConnectWebSocket() { const token = config?.gateway?.auth?.token || '' // 启动前先确保设备已配对 + allowedOrigins 已写入,无需用户手动操作 + let needReload = false try { - await api.autoPairDevice() - console.log('[main] 设备配对 + origins 已就绪') + const pairResult = await api.autoPairDevice() + console.log('[main] 设备配对 + origins 已就绪:', pairResult) + // autoPairDevice 会写入 allowedOrigins,需要 reload 使 Gateway 生效 + needReload = true } catch (pairErr) { console.warn('[main] autoPairDevice 失败(非致命):', pairErr) } @@ -90,13 +93,23 @@ async function autoConnectWebSocket() { try { const patched = await api.patchModelVision() if (patched) { - console.log('[main] 已为模型添加 vision 支持,重载 Gateway...') - await api.reloadGateway() + console.log('[main] 已为模型添加 vision 支持') + needReload = true } } catch (visionErr) { console.warn('[main] patchModelVision 失败(非致命):', visionErr) } + // 统一 reload Gateway(配对 origins + vision patch 合并为一次 reload) + if (needReload) { + try { + await api.reloadGateway() + console.log('[main] Gateway 已重载') + } catch (reloadErr) { + console.warn('[main] reloadGateway 失败(非致命):', reloadErr) + } + } + wsClient.connect(`127.0.0.1:${port}`, token) console.log('[main] WebSocket 连接已启动') } catch (e) { diff --git a/src/pages/about.js b/src/pages/about.js index 38a9161..1b9c048 100644 --- a/src/pages/about.js +++ b/src/pages/about.js @@ -31,6 +31,10 @@ export async function render() {
相关项目
+
+
参与贡献
+
+
快捷链接
@@ -44,6 +48,7 @@ export async function render() { loadData(page) renderCommunity(page) renderProjects(page) + renderContribute(page) renderLinks(page) return page } @@ -75,9 +80,15 @@ async function loadData(page) { } else { panelCard.innerHTML = '已是最新' } - }).catch(() => { + }).catch((err) => { const panelCard = cards.querySelector('#panel-update-meta') - if (panelCard) panelCard.innerHTML = '检查更新失败' + if (!panelCard) return + const msg = String(err?.message || err || '') + if (msg.includes('403') || msg.includes('404') || msg.includes('rate limit')) { + panelCard.innerHTML = '仓库未公开,发布后可自动检测' + } else { + panelCard.innerHTML = '检查更新失败' + } }) cards.innerHTML = ` @@ -215,6 +226,21 @@ const LINKS = [ { label: 'ClawApp 文档', url: 'https://github.com/qingchencloud/clawapp#readme' }, ] +function renderContribute(page) { + const el = page.querySelector('#contribute-section') + el.innerHTML = ` +
+ ClawPanel 是开源项目,欢迎参与贡献!遇到问题请提 Issue,功能建议和代码改进欢迎提 PR。 +
+ + ` +} + function renderLinks(page) { const el = page.querySelector('#links-list') el.innerHTML = `
diff --git a/src/pages/chat.js b/src/pages/chat.js index 89d0330..78a90bb 100644 --- a/src/pages/chat.js +++ b/src/pages/chat.js @@ -40,7 +40,7 @@ const COMMANDS = [ 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 _currentAiBubble = null, _currentAiText = '', _currentRunId = null +let _currentAiBubble = null, _currentAiText = '', _currentAiImages = [], _currentRunId = null let _isStreaming = false, _isSending = false, _messageQueue = [] let _lastRenderTime = 0, _renderPending = false, _lastHistoryHash = '' let _streamSafetyTimer = null, _unsubEvent = null, _unsubReady = null, _unsubStatus = null @@ -103,6 +103,20 @@ export async function render() {
+
` @@ -118,6 +132,7 @@ export async function render() { _fileInputEl = page.querySelector('#chat-file-input') bindEvents(page) + bindConnectOverlay(page) // 非阻塞:先返回 DOM,后台连接 Gateway connectGateway() return page @@ -166,6 +181,39 @@ function bindEvents(page) { _messagesEl.addEventListener('click', () => hideCmdPanel()) } +// ── 连接引导遮罩 ── + +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(), 1000) + } 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) { @@ -261,21 +309,37 @@ async function connectGateway() { if (!_pageActive) return updateStatusDot(status) const bar = document.getElementById('chat-disconnect-bar') - if (!bar) return - if (status === 'reconnecting' || status === 'disconnected') { - bar.textContent = '连接已断开,正在重连...' - bar.style.display = 'flex' + const overlay = document.getElementById('chat-connect-overlay') + const desc = document.getElementById('chat-connect-desc') + if (status === 'ready' || status === 'connected') { + if (bar) bar.style.display = 'none' + if (overlay) overlay.style.display = 'none' } else if (status === 'error') { - bar.textContent = errorMsg || '连接错误' - bar.style.display = 'flex' + // 连接错误:显示引导遮罩而非底部条 + 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 (bar) { bar.textContent = '连接已断开,正在重连...'; bar.style.display = 'flex' } } else { - bar.style.display = 'none' + if (bar) bar.style.display = 'none' } }) _unsubReady = wsClient.onReady((hello, sessionKey, err) => { if (!_pageActive) return - if (err?.error) { toast(err.message || '连接失败', 'error'); 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) { @@ -565,6 +629,7 @@ function handleChatEvent(payload) { if (state === 'delta') { const c = extractChatContent(payload.message) + if (c?.images?.length) _currentAiImages = c.images if (c?.text && c.text.length > _currentAiText.length) { showTyping(false) if (!_currentAiBubble) { @@ -582,16 +647,20 @@ function handleChatEvent(payload) { if (state === 'final') { const c = extractChatContent(payload.message) const finalText = c?.text || '' + const finalImages = c?.images || [] + if (finalImages.length) _currentAiImages = finalImages + const hasContent = finalText || _currentAiImages.length // 忽略空 final(Gateway 会为一条消息触发多个 run,部分是空 final) - if (!_currentAiBubble && !finalText) return + if (!_currentAiBubble && !hasContent) return showTyping(false) // 如果流式阶段没有创建 bubble,从 final message 中提取 - if (!_currentAiBubble && finalText) { + if (!_currentAiBubble && hasContent) { _currentAiBubble = createStreamBubble() _currentAiText = finalText } - if (_currentAiBubble && _currentAiText) { - _currentAiBubble.innerHTML = renderMarkdown(_currentAiText) + if (_currentAiBubble) { + if (_currentAiText) _currentAiBubble.innerHTML = renderMarkdown(_currentAiText) + appendImagesToEl(_currentAiBubble, _currentAiImages) } if (_currentAiText) { saveMessage({ id: payload.runId || uuid(), sessionKey: _sessionKey, role: 'assistant', content: _currentAiText, timestamp: Date.now() }) @@ -615,6 +684,18 @@ function handleChatEvent(payload) { 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)) { @@ -638,20 +719,23 @@ function handleChatEvent(payload) { } } -/** 从 Gateway message 对象提取文本(参照 clawapp extractContent) */ +/** 从 Gateway message 对象提取文本和图片(参照 clawapp extractContent) */ function extractChatContent(message) { if (!message || typeof message !== 'object') return null const content = message.content - if (typeof content === 'string') return { text: content } + const images = [] + if (typeof content === 'string') return { text: content, images } if (Array.isArray(content)) { const texts = [] for (const block of content) { if (block.type === 'text' && typeof block.text === 'string') texts.push(block.text) + if (block.type === 'image') images.push(block) + if (block.type === 'image_url') images.push(block) } const text = texts.length ? texts.join('\n') : '' - if (text) return { text } + return { text, images } } - if (typeof message.text === 'string') return { text: message.text } + if (typeof message.text === 'string') return { text: message.text, images } return null } @@ -694,13 +778,15 @@ function doRender() { function resetStreamState() { clearTimeout(_streamSafetyTimer) - if (_currentAiBubble && _currentAiText) { + if (_currentAiBubble && (_currentAiText || _currentAiImages.length)) { _currentAiBubble.innerHTML = renderMarkdown(_currentAiText) + appendImagesToEl(_currentAiBubble, _currentAiImages) } _renderPending = false _lastRenderTime = 0 _currentAiBubble = null _currentAiText = '' + _currentAiImages = [] _currentRunId = null _isStreaming = false _lastErrorMsg = null @@ -737,12 +823,19 @@ async function loadHistory() { _lastHistoryHash = hash clearMessages() deduped.forEach(msg => { - if (msg.role === 'user') appendUserMessage(msg.text) - else if (msg.role === 'assistant') appendAiMessage(msg.text) + if (msg.role === 'user') { + const userImages = msg.images?.length ? msg.images.map(i => ({ + mimeType: i.mediaType || i.media_type || 'image/png', + content: i.data || i.source?.data || '', + })).filter(a => a.content) : [] + appendUserMessage(msg.text, userImages) + } else if (msg.role === 'assistant') { + appendAiMessage(msg.text, msg.images) + } }) saveMessages(result.messages.map(m => { const c = extractContent(m) - return { id: m.id || uuid(), sessionKey: _sessionKey, role: m.role, content: c || '', timestamp: m.timestamp || Date.now() } + return { id: m.id || uuid(), sessionKey: _sessionKey, role: m.role, content: c.text || '', timestamp: m.timestamp || Date.now() } })) scrollToBottom() } catch (e) { @@ -755,28 +848,35 @@ function dedupeHistory(messages) { const deduped = [] for (const msg of messages) { if (msg.role === 'toolResult') continue - const text = extractContent(msg) - if (!text) continue + const c = extractContent(msg) + if (!c.text && !c.images.length) continue const last = deduped[deduped.length - 1] if (last && last.role === msg.role) { - if (msg.role === 'user' && last.text === text) continue + if (msg.role === 'user' && last.text === c.text) continue if (msg.role === 'assistant') { - last.text = [last.text, text].filter(Boolean).join('\n') + last.text = [last.text, c.text].filter(Boolean).join('\n') + last.images = [...(last.images || []), ...c.images] continue } } - deduped.push({ role: msg.role, text, timestamp: msg.timestamp }) + deduped.push({ role: msg.role, text: c.text, images: c.images, timestamp: msg.timestamp }) } return deduped } function extractContent(msg) { - if (typeof msg.text === 'string') return msg.text - if (typeof msg.content === 'string') return msg.content + const images = [] if (Array.isArray(msg.content)) { - return msg.content.filter(c => c.type === 'text').map(c => c.text).join('\n') + const texts = [] + for (const block of msg.content) { + if (block.type === 'text' && typeof block.text === 'string') texts.push(block.text) + if (block.type === 'image') images.push(block) + if (block.type === 'image_url') images.push(block) + } + return { text: texts.join('\n'), images } } - return '' + const text = typeof msg.text === 'string' ? msg.text : (typeof msg.content === 'string' ? msg.content : '') + return { text, images } } // ── DOM 操作 ── @@ -793,7 +893,8 @@ function appendUserMessage(text, attachments = []) { attachments.forEach(att => { const img = document.createElement('img') img.src = `data:${att.mimeType};base64,${att.content}` - img.style.cssText = 'max-width:200px;max-height:200px;border-radius:4px' + img.style.cssText = 'max-width:200px;max-height:200px;border-radius:4px;cursor:pointer' + img.onclick = () => showLightbox(img.src) imgContainer.appendChild(img) }) bubble.appendChild(imgContainer) @@ -810,17 +911,61 @@ function appendUserMessage(text, attachments = []) { scrollToBottom() } -function appendAiMessage(text) { +function appendAiMessage(text, images) { 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) wrap.appendChild(bubble) _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 showLightbox(src) { + const existing = document.querySelector('.chat-lightbox') + if (existing) existing.remove() + const lb = document.createElement('div') + lb.className = 'chat-lightbox' + lb.innerHTML = `` + 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' @@ -885,6 +1030,7 @@ export function cleanup() { _cmdPanelEl = null _currentAiBubble = null _currentAiText = '' + _currentAiImages = [] _currentRunId = null _isStreaming = false _isSending = false diff --git a/src/pages/dashboard.js b/src/pages/dashboard.js index 3c60e5b..c60eebf 100644 --- a/src/pages/dashboard.js +++ b/src/pages/dashboard.js @@ -39,36 +39,46 @@ export async function render() { } async function loadDashboardData(page) { - const [servicesRes, versionRes, logsRes, agentsRes, configRes, tunnelRes, mcpRes, clawappRes, backupsRes] = await Promise.allSettled([ + // 分波加载:关键数据先渲染,次要数据后填充,减少白屏等待 + const coreP = Promise.allSettled([ api.getServicesStatus(), api.getVersionInfo(), - api.readLogTail('gateway', 20), - api.listAgents(), api.readOpenclawConfig(), + ]) + const secondaryP = Promise.allSettled([ + api.listAgents(), api.getCftunnelStatus(), api.readMcpConfig(), api.getClawappStatus(), api.listBackups(), ]) + const logsP = api.readLogTail('gateway', 20).catch(() => '') + // 第一波:服务状态 + 版本 + 配置 → 立即渲染统计卡片 + const [servicesRes, versionRes, configRes] = await coreP const services = servicesRes.status === 'fulfilled' ? servicesRes.value : [] const version = versionRes.status === 'fulfilled' ? versionRes.value : {} - const logs = logsRes.status === 'fulfilled' ? logsRes.value : '' - const agents = agentsRes.status === 'fulfilled' ? agentsRes.value : [] const config = configRes.status === 'fulfilled' ? configRes.value : null + if (servicesRes.status === 'rejected') toast('服务状态加载失败', 'error') + if (versionRes.status === 'rejected') toast('版本信息加载失败', 'error') + + renderStatCards(page, services, version, [], config, null) + bindActions(page) + + // 第二波:Agent、隧道、MCP、ClawApp、备份 → 更新卡片 + 渲染总览 + const [agentsRes, tunnelRes, mcpRes, clawappRes, backupsRes] = await secondaryP + const agents = agentsRes.status === 'fulfilled' ? agentsRes.value : [] const tunnel = tunnelRes.status === 'fulfilled' ? tunnelRes.value : null const mcpConfig = mcpRes.status === 'fulfilled' ? mcpRes.value : null const clawapp = clawappRes.status === 'fulfilled' ? clawappRes.value : null const backups = backupsRes.status === 'fulfilled' ? backupsRes.value : [] - if (servicesRes.status === 'rejected') toast('服务状态加载失败', 'error') - if (versionRes.status === 'rejected') toast('版本信息加载失败', 'error') - if (logsRes.status === 'rejected') toast('日志加载失败', 'error') - renderStatCards(page, services, version, agents, config, tunnel) renderOverview(page, services, clawapp, tunnel, mcpConfig, backups, config, agents) + + // 第三波:日志(最低优先级) + const logs = await logsP renderLogs(page, logs) - bindActions(page) } function renderStatCards(page, services, version, agents, config, tunnel) { diff --git a/src/style/chat.css b/src/style/chat.css index 57a9406..ae62409 100644 --- a/src/style/chat.css +++ b/src/style/chat.css @@ -22,12 +22,21 @@ /* 会话侧边栏 */ .chat-sidebar { - width: 220px; - border-right: 1px solid var(--border); + width: 0; + min-width: 0; + border-right: none; background: var(--bg-primary); display: flex; flex-direction: column; flex-shrink: 0; + overflow: hidden; + transition: width 0.2s ease, min-width 0.2s ease, border-right 0.2s ease; +} + +.chat-sidebar.open { + width: 220px; + min-width: 220px; + border-right: 1px solid var(--border); } .chat-sidebar-header { @@ -413,6 +422,56 @@ flex-shrink: 0; } +/* 连接引导遮罩 */ +.chat-connect-overlay { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: var(--bg-primary, #fff); + z-index: 20; +} + +.chat-connect-card { + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; + max-width: 360px; + padding: 40px 32px; + text-align: center; +} + +.chat-connect-icon { + color: var(--text-tertiary); + opacity: 0.6; +} + +.chat-connect-title { + font-size: var(--font-size-lg, 18px); + font-weight: 600; + color: var(--text-primary); +} + +.chat-connect-desc { + font-size: var(--font-size-sm, 13px); + color: var(--text-secondary); + line-height: 1.5; +} + +.chat-connect-actions { + display: flex; + gap: 8px; + margin-top: 4px; +} + +.chat-connect-hint { + font-size: var(--font-size-xs, 11px); + color: var(--text-tertiary); + margin-top: 8px; +} + /* 会话列表 */ .chat-session-list { flex: 1; @@ -611,3 +670,28 @@ .chat-attachment-del:hover { background: rgba(255,0,0,0.8); } + +/* 图片灯箱 */ +.chat-lightbox { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.85); + display: flex; + align-items: center; + justify-content: center; + z-index: 9999; + cursor: pointer; + backdrop-filter: blur(4px); +} + +.chat-lightbox-img { + max-width: 90%; + max-height: 90%; + border-radius: 8px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); + cursor: default; + object-fit: contain; +}