chore(release): v0.13.3

- 修复 #212 AI 消息气泡空白
- 修复 #215 HTTPS 下 WebSocket Mixed Content
- 修复 #219 多实例版本检测错误
- 修复引擎切换后仪表盘无限加载
- 修复热更新假更新循环(macOS/Linux)
- CI release 构建前自动同步版本号
This commit is contained in:
晴天
2026-04-16 13:55:26 +08:00
parent 55e8365cab
commit 36eaa64bf4
25 changed files with 204 additions and 50 deletions

View File

@@ -322,6 +322,20 @@ export function renderSidebar(el) {
if (eng) {
navigate(eng.isReady() ? eng.getDefaultRoute() : eng.getSetupRoute())
}
}).catch(err => {
console.error('[sidebar] 切换引擎失败:', err)
toast(t('engine.switchFailed') || '引擎切换失败,请稍后重试', 'error')
renderSidebar(el)
// 恢复内容区:重新加载当前路由或显示错误占位
const contentEl = document.getElementById('content')
if (contentEl) {
const hash = window.location.hash.slice(1) || '/'
if (hash) {
reloadCurrentRoute()
} else {
contentEl.innerHTML = `<div class="page" style="padding:32px;color:var(--error)">加载失败,请刷新页面重试</div>`
}
}
})
}
return

View File

@@ -175,6 +175,15 @@ export function render() {
<div style="font-size:13px;font-weight:600;font-family:var(--font-mono, monospace)">http://127.0.0.1:${port}</div>
</div>
</div>
<div class="card hm-dash-open-panel" style="cursor:pointer;border-left:4px solid var(--accent,#6366f1)">
<div class="card-body" style="padding:16px">
<div style="font-size:12px;color:var(--text-tertiary);margin-bottom:6px;display:flex;align-items:center;gap:6px">
${t('engine.dashOpenPanel')}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="12" height="12" style="opacity:.6"><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
</div>
<div style="font-size:14px;font-weight:600">${t('engine.dashOpenPanelDesc')}</div>
</div>
</div>
</div>
<!-- 模型配置区 -->
@@ -345,6 +354,8 @@ export function render() {
el.querySelectorAll('.hm-dash-link').forEach(btn => {
btn.addEventListener('click', () => { window.location.hash = '#' + btn.dataset.route })
})
// Open panel card
el.querySelector('.hm-dash-open-panel')?.addEventListener('click', () => { window.location.hash = '#/h/chat' })
// Provider presets — 点击填充 URL
el.querySelectorAll('.hm-preset-btn').forEach(btn => {
btn.addEventListener('click', () => {

View File

@@ -2,7 +2,7 @@
* 引擎管理器
* 管理多引擎OpenClaw / Hermes Agent / ...)的注册、切换和状态
*/
import { api } from './tauri-api.js'
import { api, invalidate } from './tauri-api.js'
import { registerRoute, setDefaultRoute } from '../router.js'
const _engines = {}
@@ -72,9 +72,12 @@ export async function activateEngine(id, persist = true) {
return
}
// 清理旧引擎
if (_activeEngine && _activeEngine.id !== id && _activeEngine.cleanup) {
try { _activeEngine.cleanup() } catch {}
// 清理旧引擎 + 重置 API 缓存与 in-flight避免旧引擎 pending 请求阻塞新引擎页面
if (_activeEngine && _activeEngine.id !== id) {
if (_activeEngine.cleanup) {
try { _activeEngine.cleanup() } catch {}
}
try { invalidate() } catch {}
}
_activeEngine = engine
@@ -90,8 +93,13 @@ export async function activateEngine(id, persist = true) {
// 切换时启动新引擎(检测安装状态等),初始化由 main.js 处理
if (persist && engine.boot) {
try { await engine.boot() } catch (e) {
console.warn('[engine-manager] boot 失败:', e)
try {
await Promise.race([
engine.boot(),
new Promise((_, reject) => setTimeout(() => reject(new Error('engine boot timeout')), 10000))
])
} catch (e) {
console.warn('[engine-manager] boot 失败或超时:', e)
}
}

View File

@@ -92,11 +92,15 @@ function cachedInvoke(cmd, args = {}, ttl = CACHE_TTL) {
function invalidate(...cmds) {
if (!cmds.length) {
_cache.clear()
_inflight.clear()
return
}
for (const [k] of _cache) {
if (cmds.some(c => k.startsWith(c))) _cache.delete(k)
}
for (const [k] of _inflight) {
if (cmds.some(c => k.startsWith(c))) _inflight.delete(k)
}
}
// 导出 invalidate 供外部使用
@@ -364,7 +368,7 @@ export const api = {
// 前端热更新
checkFrontendUpdate: () => invoke('check_frontend_update'),
downloadFrontendUpdate: (url, expectedHash) => invoke('download_frontend_update', { url, expectedHash: expectedHash || '' }),
downloadFrontendUpdate: (url, expectedHash, version) => invoke('download_frontend_update', { url, expectedHash: expectedHash || '', version: version || '' }),
rollbackFrontendUpdate: () => invoke('rollback_frontend_update'),
getUpdateStatus: () => invoke('get_update_status'),

View File

@@ -90,6 +90,12 @@ export default {
newVersionAvailable: _('发现新版本 v{version},请前往下载更新', 'New version v{version} available, please download to update', '發現新版本 v{version},請前往下載更新'),
versionMismatch: _('前端版本 v{frontend} 与应用版本 v{binary} 不一致', 'Frontend v{frontend} does not match app v{binary}', '前端版本 v{frontend} 與應用版本 v{binary} 不一致', 'フロントエンド v{frontend} とアプリ v{binary} が一致しません', '프런트엔드 v{frontend}과 앱 v{binary}이 일치하지 않습니다'),
hotUpdateDeprecated: _('热更新已弃用,请下载完整安装包以获得最佳体验', 'Hot update is deprecated, please download the full installer for the best experience', '熱更新已棄用,請下載完整安裝包以獲得最佳體驗', 'ホットアップデートは非推奨です。最高の体験のためにフルインストーラーをダウンロードしてください', '핫 업데이트는 더 이상 사용되지 않습니다. 최상의 경험을 위해 전체 설치 프로그램을 다운로드하세요'),
hotUpdateNow: _('立即热更新', 'Hot Update Now', '立即熱更新', '今すぐホットアップデート', '지금 핫 업데이트'),
hotUpdateDownloading: _('正在下载更新...', 'Downloading update...', '正在下載更新...', '更新をダウンロード中...', '업데이트 다운로드 중...'),
hotUpdateDone: _('更新已下载,重启后生效', 'Update downloaded, restart to apply', '更新已下載,重啟後生效', '更新をダウンロードしました。再起動して適用してください', '업데이트가 다운로드되었습니다. 적용하려면 다시 시작하세요.'),
hotUpdateFailed: _('更新下载失败', 'Update download failed', '更新下載失敗', '更新のダウンロードに失敗しました', '업데이트 다운로드 실패'),
restartApp: _('重启应用', 'Restart App', '重啟應用', 'アプリを再起動', '앱 다시 시작'),
restartLater: _('稍后重启', 'Restart Later', '稍後重啟', '後で再起動', '나중에 다시 시작'),
downloadFullInstaller: _('下载完整安装包', 'Download Full Installer', '下載完整安裝包', 'フルインストーラーをダウンロード', '전체 설치 프로그램 다운로드'),
upToDate: _('已是最新', 'Up to date', '', '最新です', '최신 상태', 'Đã cập nhật', 'Actualizado', 'Atualizado', 'Актуально', 'À jour', 'Aktuell'),
checkUpdateFailed: _('暂无法检查更新', 'Unable to check for updates', '暫無法檢查更新', '更新を確認できません', '업데이트 확인 실패', 'Kiểm tra cập nhật thất bại', 'Error al verificar actualizaciones', 'Falha ao verificar atualizações', 'Ошибка проверки обновлений', 'Échec de la vérification des mises à jour', 'Update-Prüfung fehlgeschlagen'),

View File

@@ -2,6 +2,7 @@ import { _ } from '../helper.js'
export default {
switchedTo: _('已切换到 {name} 模式', 'Switched to {name} mode', '已切換到 {name} 模式', '{name} モードに切り替えました', '{name} 모드로 전환됨'),
switchFailed: _('引擎切换失败,请稍后重试', 'Engine switch failed, please try again later', '引擎切換失敗,請稍後重試', 'エンジンの切り替えに失敗しました。後でもう一度お試しください', '엔진 전환에 실패했습니다. 잠시 후 다시 시도해 주세요'),
hermesSetupDesc: _('安装并配置 Hermes Agent', 'Install and configure Hermes Agent', '安裝並配置 Hermes Agent'),
hermesPhaseClickHint: _('点击可返回此步骤', 'Click to go back to this step', '點擊可返回此步驟', 'このステップに戻るにはクリック', '이 단계로 돌아가려면 클릭'),
hermesSetupIntro: _(
@@ -106,6 +107,8 @@ export default {
dashRestarting: _('正在重启...', 'Restarting...', '正在重啟...'),
dashQuickActions: _('快捷操作', 'Quick Actions', '快捷操作'),
dashOpenChat: _('打开对话', 'Open Chat', '開啟對話'),
dashOpenPanel: _('打开面板', 'Open Panel', '開啟面板'),
dashOpenPanelDesc: _('Hermes 对话面板', 'Hermes Chat Panel', 'Hermes 對話面板'),
dashOpenCron: _('定时任务', 'Cron Jobs', '定時任務'),
dashOpenSetup: _('重新配置', 'Reconfigure', '重新配置'),
dashNoModel: _('未配置', 'Not configured', '未配置'),

View File

@@ -110,6 +110,8 @@
"dashRestarting": "重启中…",
"dashQuickActions": "快捷操作",
"dashOpenChat": "打开对话",
"dashOpenPanel": "打开面板",
"dashOpenPanelDesc": "Hermes 对话面板",
"dashOpenCron": "定时任务",
"dashOpenSetup": "安装配置",
"dashModelConfig": "模型配置",

View File

@@ -813,6 +813,9 @@ async function checkGlobalUpdate() {
if (dismissed === ver) return
const changelog = info.manifest?.changelog || ''
const canHotUpdate = isTauriRuntime()
&& info.manifest?.downloadUrl
&& info.manifest?.hash
banner.classList.remove('update-banner-hidden')
banner.innerHTML = `
@@ -822,6 +825,7 @@ async function checkGlobalUpdate() {
<span class="update-banner-ver">${t('about.versionAvailable', { version: ver })}</span>
${changelog ? `<span class="update-banner-changelog">· ${changelog}</span>` : ''}
</div>
${canHotUpdate ? `<button class="btn btn-sm btn-primary" id="btn-hot-update">${t('about.hotUpdateNow')}</button>` : ''}
<a class="btn btn-sm" href="https://claw.qt.cool" target="_blank" rel="noopener">${t('about.downloadFromWebsite')}</a>
<a class="btn btn-sm" href="https://github.com/qingchencloud/clawpanel/releases" target="_blank" rel="noopener">${t('about.downloadFromGitHub')}</a>
<button class="update-banner-close" id="btn-update-dismiss" title="${t('about.dismissVersion')}">✕</button>
@@ -833,6 +837,34 @@ async function checkGlobalUpdate() {
localStorage.setItem('clawpanel_update_dismissed', ver)
banner.classList.add('update-banner-hidden')
})
// 热更新按钮
const hotUpdateBtn = banner.querySelector('#btn-hot-update')
if (hotUpdateBtn && canHotUpdate) {
hotUpdateBtn.addEventListener('click', async () => {
hotUpdateBtn.disabled = true
hotUpdateBtn.textContent = t('about.hotUpdateDownloading')
try {
await api.downloadFrontendUpdate(
info.manifest.downloadUrl,
info.manifest.hash,
ver
)
hotUpdateBtn.style.display = 'none'
toast(t('about.hotUpdateDone'), 'success')
// 在 banner 中插入重启按钮
const rebootBtn = document.createElement('button')
rebootBtn.className = 'btn btn-sm btn-primary'
rebootBtn.textContent = t('about.restartApp')
rebootBtn.onclick = () => api.relaunchApp().catch(() => {})
banner.querySelector('.update-banner-text').after(rebootBtn)
} catch (err) {
hotUpdateBtn.disabled = false
hotUpdateBtn.textContent = t('about.hotUpdateNow')
toast(t('about.hotUpdateFailed') + ': ' + (err.message || err), 'error')
}
})
}
} catch {
// 检查失败静默忽略
}

View File

@@ -388,7 +388,8 @@ async function testWebSocketAdv(page) {
const rawToken = config?.gateway?.auth?.token
const token = (typeof rawToken === 'string') ? rawToken : ''
const wsHost = isTauriRuntime() ? `127.0.0.1:${port}` : location.host
const url = `ws://${wsHost}/ws?token=${encodeURIComponent(token)}`
const wsScheme = location.protocol === 'https:' ? 'wss' : 'ws'
const url = `${wsScheme}://${wsHost}/ws?token=${encodeURIComponent(token)}`
log(`${url}`)
const ws = new WebSocket(url)

View File

@@ -126,19 +126,20 @@ async function _loadDashboardDataInner(page, fullRefresh) {
promise,
new Promise((_, reject) => setTimeout(() => reject(new Error(`超时(${ms/1000}s)`)), ms))
])
const coreP = withTimeout(Promise.allSettled([
api.getServicesStatus(),
api.readOpenclawConfig(),
// 每个请求独立超时:避免单个慢请求拖垮整体渲染
const coreP = Promise.allSettled([
withTimeout(api.getServicesStatus(), 12000),
withTimeout(api.readOpenclawConfig(), 5000),
// 版本信息:首次加载或手动刷新时才查询(避免 ARM 设备上频繁查 npm registry
(!_dashboardInitialized || fullRefresh || !_dashboardVersionCache) ? api.getVersionInfo() : Promise.resolve(_dashboardVersionCache),
api.readPanelConfig(),
]), 15000)
const secondaryP = withTimeout(Promise.allSettled([
api.listAgents(),
api.readMcpConfig(),
api.listBackups(),
api.listConfiguredPlatforms().catch(() => []),
]), 15000).catch(() => [{ status: 'rejected' }, { status: 'rejected' }, { status: 'rejected' }, { status: 'rejected' }])
(!_dashboardInitialized || fullRefresh || !_dashboardVersionCache) ? withTimeout(api.getVersionInfo(), 8000) : Promise.resolve(_dashboardVersionCache),
withTimeout(api.readPanelConfig(), 5000),
])
const secondaryP = Promise.allSettled([
withTimeout(api.listAgents(), 10000),
withTimeout(api.readMcpConfig(), 10000),
withTimeout(api.listBackups(), 10000),
withTimeout(api.listConfiguredPlatforms(), 10000).catch(() => []),
])
const logsP = api.readLogTail('gateway', 20).catch(() => '')
// 第一波:服务状态 + 配置 + 版本 → 立即渲染统计卡片
@@ -200,7 +201,7 @@ async function _loadDashboardDataInner(page, fullRefresh) {
if (shouldLoadStatusSummary) {
try {
statusSummary = (!_dashboardInitialized || fullRefresh || !_dashboardStatusSummaryCache)
? await withTimeout(api.getStatusSummary(), 15000)
? await withTimeout(api.getStatusSummary(), 10000)
: _dashboardStatusSummaryCache
_dashboardStatusSummaryCache = statusSummary
} catch {

View File

@@ -19,7 +19,12 @@ export function setDefaultRoute(path) {
}
export function navigate(path) {
const current = window.location.hash.slice(1)
window.location.hash = path
// 如果 hash 没有实际变化,手动触发加载(引擎切换等场景兜底)
if (current === path) {
reloadCurrentRoute()
}
}
export function initRouter(contentEl) {

View File

@@ -1,3 +1,8 @@
/* AI bubble markdown layout fix: #212 */
.msg-ai .msg-bubble .msg-text {
display: block;
color: var(--text-primary);
}
/**
* 聊天页面样式
* 使用 clawpanel CSS 变量