feat: image rendering, sidebar toggle, contribute section; fix: private repo update check; bump v0.2.1

This commit is contained in:
晴天
2026-03-04 20:47:00 +08:00
parent 59c84b5eaf
commit a6e1f40a59
14 changed files with 454 additions and 83 deletions

View File

@@ -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 管理面板的全部核心功能。

39
SECURITY.md Normal file
View File

@@ -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` 中,请确保文件权限安全

View File

@@ -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",

2
src-tauri/Cargo.lock generated
View File

@@ -328,7 +328,7 @@ dependencies = [
[[package]]
name = "clawpanel"
version = "0.1.0"
version = "0.2.1"
dependencies = [
"base64 0.22.1",
"chrono",

View File

@@ -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"

View File

@@ -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);

View File

@@ -3,27 +3,12 @@
#[tauri::command]
pub fn auto_pair_device() -> Result<String, String> {
// 读取设备密钥
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<String, String> {
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<String, String> {
std::fs::write(&paired_path, new_content).map_err(|e| format!("写入 paired.json 失败: {e}"))?;
// 同步写入 controlUi.allowedOrigins允许 Tauri 的 origin 连接 Gateway
patch_gateway_origins();
Ok("设备配对成功".into())
}

View File

@@ -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",

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -31,6 +31,10 @@ export async function render() {
<div class="config-section-title">相关项目</div>
<div id="projects-list"></div>
</div>
<div class="config-section">
<div class="config-section-title">参与贡献</div>
<div id="contribute-section"></div>
</div>
<div class="config-section">
<div class="config-section-title">快捷链接</div>
<div id="links-list"></div>
@@ -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 = '<span style="color:var(--success)">已是最新</span>'
}
}).catch(() => {
}).catch((err) => {
const panelCard = cards.querySelector('#panel-update-meta')
if (panelCard) panelCard.innerHTML = '<span style="color:var(--text-tertiary)">检查更新失败</span>'
if (!panelCard) return
const msg = String(err?.message || err || '')
if (msg.includes('403') || msg.includes('404') || msg.includes('rate limit')) {
panelCard.innerHTML = '<span style="color:var(--text-tertiary)">仓库未公开,发布后可自动检测</span>'
} else {
panelCard.innerHTML = '<span style="color:var(--text-tertiary)">检查更新失败</span>'
}
})
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 = `
<div style="font-size:var(--font-size-sm);color:var(--text-secondary);margin-bottom:12px">
ClawPanel 是开源项目,欢迎参与贡献!遇到问题请提 Issue功能建议和代码改进欢迎提 PR。
</div>
<div style="display:flex;flex-wrap:wrap;gap:8px">
<a class="btn btn-primary btn-sm" href="https://github.com/qingchencloud/clawpanel/issues/new" target="_blank" rel="noopener">提交 Issue</a>
<a class="btn btn-secondary btn-sm" href="https://github.com/qingchencloud/clawpanel/pulls" target="_blank" rel="noopener">提交 PR</a>
<a class="btn btn-secondary btn-sm" href="https://github.com/qingchencloud/clawpanel/blob/main/CONTRIBUTING.md" target="_blank" rel="noopener">贡献指南</a>
<a class="btn btn-secondary btn-sm" href="https://github.com/qingchencloud/clawpanel/issues" target="_blank" rel="noopener">查看 Issues</a>
</div>
`
}
function renderLinks(page) {
const el = page.querySelector('#links-list')
el.innerHTML = `<div style="display:flex;flex-wrap:wrap;gap:var(--space-sm)">

View File

@@ -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() {
</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>
`
@@ -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
// 忽略空 finalGateway 会为一条消息触发多个 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 = `<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'
@@ -885,6 +1030,7 @@ export function cleanup() {
_cmdPanelEl = null
_currentAiBubble = null
_currentAiText = ''
_currentAiImages = []
_currentRunId = null
_isStreaming = false
_isSending = false

View File

@@ -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) {

View File

@@ -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;
}