mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-05-06 20:02:49 +08:00
feat: image rendering, sidebar toggle, contribute section; fix: private repo update check; bump v0.2.1
This commit is contained in:
45
CHANGELOG.md
45
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 管理面板的全部核心功能。
|
||||
|
||||
39
SECURITY.md
Normal file
39
SECURITY.md
Normal 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` 中,请确保文件权限安全
|
||||
21
package.json
21
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",
|
||||
|
||||
2
src-tauri/Cargo.lock
generated
2
src-tauri/Cargo.lock
generated
@@ -328,7 +328,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "clawpanel"
|
||||
version = "0.1.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
21
src/main.js
21
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) {
|
||||
|
||||
@@ -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)">
|
||||
|
||||
@@ -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
|
||||
// 忽略空 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 = `<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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user