diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e640ba..54c5ca0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,23 @@ 格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/), 版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。 +## [0.11.6] - 2026-04-07 + +### 新功能 (Features) + +- **Skills 多 Agent 支持** — Skills 页面新增 Agent 选择器,不同 Agent 可独立管理各自的 Skills 目录;后端 Rust/Node.js 双端均支持 agent_id 参数路由 +- **助手工具模式流式输出** — 晴辰助手工具调用模式从非流式改为流式,AI 文字逐 token 打字机显示,tool_calls 分块累积后再执行 + +### 改进 (Improvements) + +- **OpenClaw 4.5 兼容** — 实时聊天页面支持全部 Agent 事件流(lifecycle / item / plan / approval / thinking / command_output),新增 3 分钟终极超时和实时计时器,解决无回复时 UI 永远卡住的问题 +- **热更新替换为稳定版下载** — 关于页和全局更新横幅不再展示热更新/重载,改为引导用户前往官网或 GitHub 下载最新稳定版 + +### 修复 (Fixes) + +- **Gateway 状态抖动** — 仪表盘刷新增加 5 秒节流和并发保护;TCP 端口检测增加重试(1s+2s);Gateway 停止判定从 2 次提高到 3 次连续检测;自动重启前增加 3 秒延迟确认 +- **助手空灰色气泡** — 修复流式响应 0 内容块时静默成功导致空消息持久化的 bug;新增流内错误事件捕获、渲染时过滤空消息、finally 块清理机制 + ## [0.11.5] - 2026-04-07 ### 新功能 (Features) diff --git a/docs/index.html b/docs/index.html index 92a84d9..08f0068 100644 --- a/docs/index.html +++ b/docs/index.html @@ -34,7 +34,7 @@ "description": "OpenClaw AI Agent 可视化管理面板,基于 Tauri v2 的跨平台桌面应用。内置晴辰助手支持工具调用,晴辰云 AI 接口一键接入。支持仪表盘监控、多模型配置、消息渠道管理、内置 QQ 机器人、实时 AI 聊天、记忆管理、Agent 管理、网关配置、内网穿透等功能。支持 11 种语言。", "url": "https://claw.qt.cool/", "downloadUrl": "https://github.com/qingchencloud/clawpanel/releases/latest", - "softwareVersion": "0.11.5", + "softwareVersion": "0.11.6", "author": { "@type": "Organization", "name": "晴辰云 QingchenCloud", @@ -1155,7 +1155,7 @@
-
v0.11.5 最新版
+
v0.11.6 最新版

下载安装

选择你的操作系统,一键下载安装

@@ -1165,11 +1165,11 @@

macOS

支持 Apple Silicon 和 Intel 芯片

` : '' return `
${imagesHtml}${textPart ? escHtml(textPart) : ''}
` } else if (m.role === 'assistant') { + // 跳过空的 AI 消息(历史脏数据),除非正在流式中(最后一条是占位符) + const isLastMsg = idx === session.messages.length - 1 + if (!m.content && !m.toolHistory?.length && !(isLastMsg && _isStreaming)) return '' const toolHtml = renderToolBlocks(m.toolHistory) return `
${toolHtml}
${renderMarkdown(m.content)}
` } @@ -3609,14 +3711,14 @@ async function sendMessageDirect(text) { try { if (toolsEnabled) { - // ── 工具模式:非流式,支持 tool_calls 循环 ── + // ── 工具模式:流式 + tool_calls 循环 ── const aiMsgContainers = _messagesEl?.querySelectorAll('.ast-msg-ai') const lastContainer = aiMsgContainers?.[aiMsgContainers.length - 1] const result = await callAIWithTools(contextMessages, // onStatus (status) => { - if (lastBubble) lastBubble.innerHTML = `${escHtml(status)}` + if (lastBubble && !aiMsg.content) lastBubble.innerHTML = `${escHtml(status)}` }, // onToolProgress (history) => { @@ -3627,13 +3729,31 @@ async function sendMessageDirect(text) { const bubble = lastContainer.querySelector('.ast-msg-bubble-ai') lastContainer.innerHTML = toolHtml + (bubble ? bubble.outerHTML : '') if (_messagesEl) _messagesEl.scrollTop = _messagesEl.scrollHeight + }, + // onChunk — 流式打字机效果(需从 container 重新查询 bubble,因为 onToolProgress 会替换 innerHTML) + (chunk) => { + aiMsg.content += chunk + throttledSave() + const bubble = lastContainer?.querySelector('.ast-msg-bubble-ai') || lastBubble + if (bubble) { + const now = Date.now() + if (now - _lastRenderTime > 50) { + bubble.innerHTML = renderMarkdown(aiMsg.content) + '' + if (_messagesEl) _messagesEl.scrollTop = _messagesEl.scrollHeight + _lastRenderTime = now + } + } } ) - aiMsg.content = result.content + // result.content 可能在流式中已经通过 onChunk 累积到 aiMsg.content + // 但如果有额外内容(如 reasoning 回退),以 result 为准 + if (result.content && !aiMsg.content) aiMsg.content = result.content if (result.toolHistory.length > 0) { aiMsg.toolHistory = result.toolHistory } + const finalBubble = lastContainer?.querySelector('.ast-msg-bubble-ai') || lastBubble + if (finalBubble && aiMsg.content) finalBubble.innerHTML = renderMarkdown(aiMsg.content) renderMessages() } else { // ── 普通流式模式 ── @@ -3707,6 +3827,11 @@ async function sendMessageDirect(text) { stopStreamRefresh() if (_sendBtn) _sendBtn.innerHTML = sendIcon() if (_textarea) _textarea.focus() + // 清理空的 AI 消息(防止持久化空气泡) + const _lastMsg = session.messages[session.messages.length - 1] + if (_lastMsg?.role === 'assistant' && !_lastMsg.content && !_lastMsg.toolHistory?.length) { + session.messages.pop() + } session.updatedAt = Date.now() flushSave() if (getSessionStatus(session.id) !== 'error') { @@ -3749,7 +3874,7 @@ async function retryAIResponse(session) { const lastContainer = aiMsgContainers?.[aiMsgContainers.length - 1] const result = await callAIWithTools(contextMessages, - (status) => { if (lastBubble) lastBubble.innerHTML = `${escHtml(status)}` }, + (status) => { if (lastBubble && !aiMsg.content) lastBubble.innerHTML = `${escHtml(status)}` }, (history) => { aiMsg.toolHistory = history throttledSave() @@ -3758,10 +3883,26 @@ async function retryAIResponse(session) { const bubble = lastContainer.querySelector('.ast-msg-bubble-ai') lastContainer.innerHTML = toolHtml + (bubble ? bubble.outerHTML : '') if (_messagesEl) _messagesEl.scrollTop = _messagesEl.scrollHeight + }, + // onChunk — 流式打字机效果(需从 container 重新查询 bubble) + (chunk) => { + aiMsg.content += chunk + throttledSave() + const bubble = lastContainer?.querySelector('.ast-msg-bubble-ai') || lastBubble + if (bubble) { + const now = Date.now() + if (now - _lastRenderTime > 50) { + bubble.innerHTML = renderMarkdown(aiMsg.content) + '' + if (_messagesEl) _messagesEl.scrollTop = _messagesEl.scrollHeight + _lastRenderTime = now + } + } } ) - aiMsg.content = result.content + if (result.content && !aiMsg.content) aiMsg.content = result.content if (result.toolHistory.length > 0) aiMsg.toolHistory = result.toolHistory + const retryFinalBubble = lastContainer?.querySelector('.ast-msg-bubble-ai') || lastBubble + if (retryFinalBubble && aiMsg.content) retryFinalBubble.innerHTML = renderMarkdown(aiMsg.content) renderMessages() } else { await callAI(contextMessages, (chunk) => { @@ -3823,6 +3964,11 @@ async function retryAIResponse(session) { stopStreamRefresh() if (_sendBtn) _sendBtn.innerHTML = sendIcon() if (_textarea) _textarea.focus() + // 清理空的 AI 消息(防止持久化空气泡) + const _retryLastMsg = session.messages[session.messages.length - 1] + if (_retryLastMsg?.role === 'assistant' && !_retryLastMsg.content && !_retryLastMsg.toolHistory?.length) { + session.messages.pop() + } session.updatedAt = Date.now() flushSave() if (getSessionStatus(session.id) !== 'error') { diff --git a/src/pages/dashboard.js b/src/pages/dashboard.js index 67fa741..2171a83 100644 --- a/src/pages/dashboard.js +++ b/src/pages/dashboard.js @@ -9,6 +9,8 @@ import { navigate } from '../router.js' import { t } from '../lib/i18n.js' let _unsubGw = null +let _loadInFlight = false +let _lastGwChangeLoad = 0 export async function render() { const page = document.createElement('div') @@ -52,9 +54,12 @@ export async function render() { }) page.__retryLoad = () => loadDashboardData(page).catch(() => {}) - // 监听 Gateway 状态变化,自动刷新仪表盘 + // 监听 Gateway 状态变化,节流刷新仪表盘(至少间隔 5 秒,防止状态抖动导致 UI 闪烁) if (_unsubGw) _unsubGw() _unsubGw = onGatewayChange(() => { + const now = Date.now() + if (now - _lastGwChangeLoad < 5000) return + _lastGwChangeLoad = now loadDashboardData(page) }) @@ -106,6 +111,13 @@ function syncDashboardInstanceScope() { } async function loadDashboardData(page, fullRefresh = false) { + // 并发保护:如果上一次加载仍在进行,跳过本次(fullRefresh 除外) + if (_loadInFlight && !fullRefresh) return + _loadInFlight = true + try { await _loadDashboardDataInner(page, fullRefresh) } finally { _loadInFlight = false } +} + +async function _loadDashboardDataInner(page, fullRefresh) { syncDashboardInstanceScope() // 分波加载:关键数据先渲染,次要数据后填充,减少白屏等待 // 轻量调用(读文件)每次都做;重量调用(spawn CLI/网络请求)只在首次或手动刷新时做 diff --git a/src/pages/skills.js b/src/pages/skills.js index 4f63ade..91a24a1 100644 --- a/src/pages/skills.js +++ b/src/pages/skills.js @@ -7,6 +7,7 @@ import { toast } from '../components/toast.js' import { t } from '../lib/i18n.js' let _loadSeq = 0 +let _selectedAgentId = null // null = default (main) function esc(str) { if (!str) return '' @@ -16,11 +17,34 @@ function esc(str) { export async function render() { const page = document.createElement('div') page.className = 'page' + + // 加载 Agent 列表 + let agents = [] + try { + const list = await api.listAgents() + if (Array.isArray(list)) agents = list + } catch {} + + const agentOptions = agents.length > 1 + ? `
+ + +
` + : '' + page.innerHTML = ` + ${agentOptions}
${t('skills.tabInstalled')}
${t('skills.tabStore')}
@@ -41,6 +65,19 @@ export async function render() { ` bindEvents(page) loadSkills(page) + + // Agent 选择器变化时刷新 + const agentSelect = page.querySelector('#skills-agent-select') + if (agentSelect) { + agentSelect.addEventListener('change', () => { + const val = agentSelect.value + _selectedAgentId = (val === 'main') ? null : val + _storeIndex = null // 清除商店缓存 + _installedNames = new Set() + loadSkills(page) + }) + } + return page } @@ -55,7 +92,7 @@ async function loadSkills(page) {
` try { - const data = await api.skillsList() + const data = await api.skillsList(_selectedAgentId) if (seq !== _loadSeq) return renderSkills(el, data) } catch (e) { @@ -206,7 +243,7 @@ async function handleInfo(page, name) { detail.innerHTML = `
${t('skills.loadingDetail')}
` detail.scrollIntoView({ behavior: 'smooth', block: 'nearest' }) try { - const skill = await api.skillsInfo(name) + const skill = await api.skillsInfo(name, _selectedAgentId) const s = skill || {} const reqs = s.requirements || {} const miss = s.missing || {} @@ -272,7 +309,7 @@ async function loadStore(page) { _storeIndex = await api.skillhubIndex() // 获取已安装列表用于标记 try { - const data = await api.skillsList() + const data = await api.skillsList(_selectedAgentId) _installedNames = new Set((data?.skills || []).map(s => s.name)) } catch { _installedNames = new Set() } renderStoreItems(results, _storeIndex) @@ -346,7 +383,7 @@ async function handleStoreInstall(page, btn) { btn.disabled = true btn.textContent = t('skills.installing') try { - await api.skillhubInstall(slug) + await api.skillhubInstall(slug, _selectedAgentId) toast(t('skills.skillInstalled', { name: slug }), 'success') btn.textContent = t('skills.installed') btn.classList.remove('btn-primary') @@ -367,7 +404,7 @@ async function handleSkillUninstall(page, btn) { btn.disabled = true btn.textContent = t('skills.uninstalling') try { - await api.skillsUninstall(name) + await api.skillsUninstall(name, _selectedAgentId) toast(t('skills.uninstalled', { name }), 'success') await loadSkills(page) } catch (e) {