v0.4.0: Gateway 进程守护、配置自愈、双配置同步、流式超时、模型删除安全切换

This commit is contained in:
晴天
2026-03-05 20:44:47 +08:00
parent d27d5cc8af
commit 79cd15e1c4
30 changed files with 2257 additions and 295 deletions

5
.gitignore vendored
View File

@@ -29,6 +29,11 @@ release_body.md.bak
# 内部开发文档(不入公开仓)
BLOCKING_ISSUES_REPORT.md
__clawapp-chat-ref.js
# 大文件(宣传视频)
docs/promo-video.mp4
docs/promo-web.mp4
# IDE / 编辑器
.idea/

View File

@@ -5,6 +5,34 @@
格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)
版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
## [0.4.0] - 2026-03-05
### 新增 (Features)
- **Gateway 进程守护** — 检测到 Gateway 意外停止时自动重启(最多 3 次60s 冷却期),用户主动停止不干预
- **守护恢复横幅** — 连续重启失败后顶部弹出恢复选项(重试启动 / 从备份恢复 / 服务管理 / 查看日志)
- **配置文件自愈** — 读取 `openclaw.json` 时自动剥离 UTF-8 BOMJSON 损坏时自动从 `.bak` 恢复
- **双配置同步** — 保存模型配置时自动同步到 agent 运行时注册表(`models.json`),包括新增/修改/删除 provider 和 model
- **流式输出安全超时** — 90 秒无新数据自动结束流式输出,防止 UI 卡死
- **聊天响应耗时显示** — AI 回复时间戳后显示响应耗时(如 `20:09 · 1.7s`
- **跨天时间显示** — 非当天消息显示日期(如 `03-04 20:09`),当天仅显示时间
- **仪表盘自动刷新** — Gateway 状态变化时自动刷新仪表盘数据,无需手动刷新
### 修复 (Bug Fixes)
- **401 无效令牌** — 修复 `models.json`agent 运行时注册表)与 `openclaw.json` provider 配置不同步导致的认证失败
- **删除模型后 Gateway 崩溃** — 删除模型/渠道后自动切换主模型到第一个可用模型,同步清理 `models.json` 中已删除的 provider 和 model
- **WebSocket 连接被拒** — `allowedOrigins` 改为通配符 `["*"]`,兼容所有 Tauri 运行模式
- **模型测试触发 Gateway 重启** — 测试结果保存改用 `saveConfigOnly`,不再触发不必要的重启
- **主模型配置不生效** — `applyDefaultModel` 同步更新到各 agent 的模型覆盖配置,防止 agent 级别旧值覆盖全局默认
- **WS 代理报错刷屏** — Vite 配置静默处理 Gateway 不可达时的 proxy error
- **历史图片丢失提示** — 刷新后 Gateway 不返回图片原始数据时显示友好提示
### 优化 (Improvements)
- **拖拽排序重写** — 模型拖拽排序改用 Pointer Events 实现,兼容 Tauri WebView2/WKWebView
- **用户消息附件保存** — 发送的图片附件保存到本地缓存,支持页面内恢复
## [0.3.0] - 2026-03-04
### 新增 (Features)

View File

@@ -23,8 +23,20 @@
---
<p align="center">
<img src="docs/terminal-demo.gif" width="800" alt="ClawPanel 安装演示">
</p>
<p align="center">
<a href="https://claw.qt.cool/#video">
<img src="https://img.shields.io/badge/%E2%96%B6%20%E6%BC%94%E7%A4%BA%E8%A7%86%E9%A2%91-38%E7%A7%92%E5%BF%AB%E9%80%9F%E4%BA%86%E8%A7%A3-6366f1?style=for-the-badge" alt="演示视频">
</a>
</p>
ClawPanel 是 [OpenClaw](https://github.com/1186258278/OpenClawChineseTranslation) AI Agent 框架的可视化管理面板,提供服务管控、模型配置、日志查看、记忆管理等核心功能,一站式管理你的 OpenClaw 实例。
> 🌐 **官网**: [claw.qt.cool](https://claw.qt.cool/)  |  📦 **下载**: [GitHub Releases](https://github.com/qingchencloud/clawpanel/releases/latest)
## 下载安装
前往 [Releases](https://github.com/qingchencloud/clawpanel/releases/latest) 页面下载最新版本,根据你的系统选择对应安装包:
@@ -65,6 +77,10 @@ ClawPanel 是 [OpenClaw](https://github.com/1186258278/OpenClawChineseTranslatio
## 功能特性
<p align="center">
<img src="docs/feature-showcase.gif" width="800" alt="功能矩阵">
</p>
- **仪表盘** — 系统概览,服务状态实时监控,快捷操作
- **服务管理** — OpenClaw 启停控制、版本检测与一键升级、Gateway 安装/卸载、配置备份与还原
- **模型配置** — 多服务商管理、模型增删改查、批量连通性测试、延迟检测、拖拽排序、自动保存+撤销
@@ -78,6 +94,10 @@ ClawPanel 是 [OpenClaw](https://github.com/1186258278/OpenClawChineseTranslatio
## 功能截图
<p align="center">
<img src="docs/quick-stats.gif" width="800" alt="ClawPanel 数据概览">
</p>
<p align="center">
<img src="docs/01.png" width="800" alt="仪表盘">
</p>
@@ -135,6 +155,10 @@ ClawPanel 是 [OpenClaw](https://github.com/1186258278/OpenClawChineseTranslatio
## 技术架构
<p align="center">
<img src="docs/architecture.gif" width="800" alt="ClawPanel 生态架构">
</p>
| 层级 | 技术 | 说明 |
|------|------|------|
| 前端 | Vanilla JS + Vite | 零框架依赖,轻量快速 |

View File

@@ -4,8 +4,8 @@
| 版本 | 支持状态 |
|------|----------|
| 0.2.x | ✅ 安全更新 |
| < 0.2 | ❌ 不再维护 |
| 0.3.x | ✅ 安全更新 |
| < 0.3 | ❌ 不再维护 |
## 报告安全漏洞

BIN
docs/architecture.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 923 KiB

BIN
docs/feature-showcase.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 MiB

View File

@@ -8,21 +8,21 @@
<meta name="keywords" content="ClawPanel, OpenClaw, AI Agent, AI 智能体, 管理面板, 可视化管理, 快速搭建, 一键安装, 桌面应用, 跨平台, Tauri, Tauri v2, Rust, 开源, 免费, LLM, 大语言模型, 多模型, 模型配置, OpenAI, DeepSeek, Kimi, Anthropic, Claude, 实时聊天, AI 对话, 流式响应, 记忆管理, Agent 管理, 多 Agent, 网关配置, Gateway, 服务管理, 日志查看, 内网穿透, Cloudflare Tunnel, 系统诊断, WebSocket, 仪表盘, 监控, 配置管理, 私有部署, 本地部署, 自托管, AI 工具, AI 平台, 智能体平台, 人工智能, 深度学习, 自然语言处理, NLP, 模型调度, 模型切换, 备选模型, 开箱即用, 零代码, 低代码, admin panel, dashboard, open source AI, self-hosted AI, local AI, AI management">
<meta name="author" content="晴辰云 QingchenCloud">
<meta name="robots" content="index, follow, max-snippet:-1, max-image-preview:large">
<link rel="canonical" href="https://qingchencloud.github.io/clawpanel/">
<link rel="canonical" href="https://claw.qt.cool/">
<meta property="og:title" content="ClawPanel - OpenClaw AI Agent 可视化管理面板 | 快速搭建你的 AI 智能体">
<meta property="og:description" content="基于 Tauri v2 的跨平台桌面应用,为 OpenClaw AI Agent 提供可视化管理。支持多模型配置、实时 AI 聊天、记忆管理、内网穿透等 10+ 功能模块。一键安装,开源免费。">
<meta property="og:type" content="website">
<meta property="og:url" content="https://qingchencloud.github.io/clawpanel/">
<meta property="og:url" content="https://claw.qt.cool/">
<meta property="og:site_name" content="ClawPanel">
<meta property="og:locale" content="zh_CN">
<meta property="og:image" content="https://qingchencloud.github.io/clawpanel/01.png">
<meta property="og:image" content="https://claw.qt.cool/01.png">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="675">
<meta property="og:image:alt" content="ClawPanel 仪表盘截图">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="ClawPanel - OpenClaw AI Agent 可视化管理面板">
<meta name="twitter:description" content="基于 Tauri v2 的跨平台桌面应用。多模型配置、实时 AI 聊天、Agent 管理、内网穿透,一站式管理你的 AI 智能体。">
<meta name="twitter:image" content="https://qingchencloud.github.io/clawpanel/01.png">
<meta name="twitter:image" content="https://claw.qt.cool/01.png">
<link rel="icon" href="./logo.png" type="image/png">
<script type="application/ld+json">
{
@@ -32,7 +32,7 @@
"applicationCategory": "DeveloperApplication",
"operatingSystem": "Windows, macOS, Linux",
"description": "OpenClaw AI Agent 可视化管理面板,基于 Tauri v2 的跨平台桌面应用。支持仪表盘监控、多模型配置、实时 AI 聊天、记忆管理、Agent 管理、网关配置、内网穿透等功能。",
"url": "https://qingchencloud.github.io/clawpanel/",
"url": "https://claw.qt.cool/",
"downloadUrl": "https://github.com/qingchencloud/clawpanel/releases/latest",
"softwareVersion": "0.3.0",
"author": {
@@ -46,7 +46,7 @@
"price": "0",
"priceCurrency": "CNY"
},
"screenshot": "https://qingchencloud.github.io/clawpanel/01.png",
"screenshot": "https://claw.qt.cool/01.png",
"keywords": "OpenClaw, AI Agent, 管理面板, Tauri, 跨平台, 开源, 免费, LLM, 多模型"
}
</script>
@@ -119,7 +119,7 @@
/* ══════════════ Gradient Text ══════════════ */
.gradient-text { background: linear-gradient(135deg, #6366f1, #a855f7, #22d3ee, #6366f1); background-size: 300% 300%; -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; animation: gradShift 6s ease-in-out infinite; position: relative; }
@keyframes gradShift { 0%,100% { background-position: 0% 50%; } 50% { background-position: 100% 50%; } }
.shimmer { position: relative; overflow: hidden; display: inline-block; }
.shimmer { position: relative; overflow: hidden; display: inline-block; vertical-align: baseline; }
.shimmer::after { content:''; position: absolute; top: 0; left: -100%; width: 60%; height: 100%; background: linear-gradient(90deg, transparent, rgba(255,255,255,0.15), transparent); animation: shimmer 3s ease-in-out infinite; }
@keyframes shimmer { 0% { left: -100%; } 100% { left: 200%; } }
@@ -283,6 +283,51 @@
.footer-links { display: flex; gap: 24px; }
.footer-link { color: var(--text-t); transition: color 0.2s; } .footer-link:hover { color: var(--text-s); }
/* ══════════════ Video Demo Section ══════════════ */
.video-demo-wrap { max-width: 960px; margin: 0 auto; position: relative; padding: 0 16px; }
.video-demo-wrap::before { content:''; position: absolute; inset: -2px; border-radius: 18px; background: conic-gradient(from var(--border-angle,0deg), #6366f1, #a855f7, #22d3ee, #10b981, #6366f1); z-index: 0; animation: borderSpin 4s linear infinite; opacity: 0.5; }
.video-demo-wrap::after { content:''; position: absolute; inset: -2px; border-radius: 18px; background: conic-gradient(from var(--border-angle,0deg), #6366f1, #a855f7, #22d3ee, #10b981, #6366f1); z-index: 0; animation: borderSpin 4s linear infinite; filter: blur(20px); opacity: 0.3; }
.video-demo-frame { border-radius: 16px; overflow: hidden; position: relative; background: #000; z-index: 1; box-shadow: 0 20px 60px -15px rgba(99,102,241,0.2); transition: box-shadow 0.5s; }
.video-demo-wrap:hover .video-demo-frame { box-shadow: 0 30px 80px -15px rgba(99,102,241,0.35); }
.video-demo-frame video { width: 100%; display: block; }
.video-play-overlay { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; background: rgba(0,0,0,0.35); cursor: pointer; transition: background 0.3s; z-index: 2; }
.video-play-overlay:hover { background: rgba(0,0,0,0.15); }
.video-play-overlay.hidden { display: none; }
.play-btn { width: 80px; height: 80px; border-radius: 50%; background: rgba(99,102,241,0.3); backdrop-filter: blur(12px); border: 2px solid rgba(255,255,255,0.25); display: flex; align-items: center; justify-content: center; transition: transform 0.3s, background 0.3s, box-shadow 0.3s; box-shadow: 0 8px 32px rgba(99,102,241,0.3); }
.play-btn:hover { transform: scale(1.12); background: rgba(99,102,241,0.5); box-shadow: 0 12px 40px rgba(99,102,241,0.5); }
.play-btn svg { width: 32px; height: 32px; fill: #fff; margin-left: 4px; }
.video-demo-badge { display: inline-flex; align-items: center; gap: 6px; padding: 6px 14px; border-radius: 99px; font-size: 13px; color: var(--accent); background: var(--accent-5); border: 1px solid rgba(99,102,241,0.15); margin-bottom: 16px; white-space: nowrap; }
.video-demo-badge svg { width: 14px; height: 14px; flex-shrink: 0; }
.video-demo-glow { position: absolute; bottom: -40px; left: 50%; transform: translateX(-50%); width: 70%; height: 80px; background: radial-gradient(ellipse, rgba(99,102,241,0.12), transparent 70%); filter: blur(30px); pointer-events: none; z-index: 0; }
html:not(.dark) .video-demo-glow { background: radial-gradient(ellipse, rgba(99,102,241,0.06), transparent 70%); }
/* ══════════════ Download Section ══════════════ */
.download-grid { display: grid; grid-template-columns: repeat(3,1fr); gap: 20px; margin-bottom: 32px; }
.download-card { border-radius: 20px; border: 1px solid var(--border); background: var(--bg-card); backdrop-filter: blur(8px); padding: 32px 24px; text-align: center; transition: all 0.35s cubic-bezier(0.16,1,0.3,1); position: relative; overflow: hidden; }
.download-card:hover { transform: translateY(-6px); border-color: var(--border-h); box-shadow: 0 16px 48px -12px rgba(99,102,241,0.18); }
.download-card::before { content:''; position: absolute; inset: 0; background: radial-gradient(300px circle at var(--mx,50%) var(--my,50%), rgba(99,102,241,0.06), transparent 60%); opacity: 0; transition: opacity 0.4s; pointer-events: none; }
.download-card:hover::before { opacity: 1; }
.download-icon { font-size: 40px; margin-bottom: 16px; display: block; }
.download-card h3 { font-size: 18px; font-weight: 800; margin-bottom: 6px; }
.download-card .dl-desc { font-size: 13px; color: var(--text-s); margin-bottom: 20px; }
.dl-links { display: flex; flex-direction: column; gap: 8px; }
.dl-link { display: flex; align-items: center; justify-content: space-between; padding: 10px 14px; border-radius: 10px; border: 1px solid var(--border); background: var(--bg-card-s); font-size: 13px; font-weight: 600; color: var(--text); transition: all 0.25s; text-decoration: none; }
.dl-link:hover { border-color: var(--border-h); background: var(--accent-5); color: var(--accent); }
.dl-link .dl-format { font-size: 11px; color: var(--text-t); font-weight: 400; }
.dl-link:hover .dl-format { color: var(--accent); opacity: 0.7; }
.dl-link svg { width: 16px; height: 16px; flex-shrink: 0; opacity: 0.4; transition: opacity 0.2s; }
.dl-link:hover svg { opacity: 1; color: var(--accent); }
.download-version { display: inline-flex; align-items: center; gap: 6px; padding: 6px 16px; border-radius: 99px; font-size: 14px; font-weight: 600; color: var(--accent); background: var(--accent-5); border: 1px solid rgba(99,102,241,0.15); margin-bottom: 16px; }
.download-version .pulse { width: 6px; height: 6px; border-radius: 50%; background: #22c55e; animation: pulse 2s ease-in-out infinite; }
.download-note { font-size: 13px; color: var(--text-t); margin-top: 24px; }
.download-note a { color: var(--accent); text-decoration: underline; text-underline-offset: 2px; }
@media (max-width: 768px) { .download-grid { grid-template-columns: 1fr; } }
/* ══════════════ Architecture GIF ══════════════ */
.arch-gif-wrap { max-width: 700px; margin: 0 auto 40px; border-radius: 16px; overflow: hidden; border: 1px solid var(--border); box-shadow: var(--card-shadow); transition: transform 0.5s cubic-bezier(0.16,1,0.3,1); }
.arch-gif-wrap:hover { transform: translateY(-4px); }
.arch-gif-wrap img { width: 100%; display: block; }
/* ══════════════ Lightbox ══════════════ */
.lightbox { position: fixed; inset: 0; z-index: 200; background: rgba(0,0,0,0.92); backdrop-filter: blur(8px); display: none; align-items: center; justify-content: center; cursor: zoom-out; padding: 24px; }
.lightbox.active { display: flex; }
@@ -337,7 +382,6 @@
<body>
<!-- ══════════════ Nav ══════════════ -->
<div class="noise"></div>
<nav class="nav">
<div class="scroll-progress" id="scroll-progress"></div>
<div class="nav-inner">
@@ -347,6 +391,7 @@
<a href="#tech" class="nav-link">架构</a>
<a href="#quickstart" class="nav-link">开始</a>
<a href="#community" class="nav-link">社区</a>
<a href="#download" class="nav-link">下载</a>
<a href="https://github.com/qingchencloud/clawpanel" target="_blank" rel="noopener" class="nav-link">GitHub</a>
<button id="theme-toggle" class="theme-btn" aria-label="切换主题">
<svg class="sun" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="5"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/></svg>
@@ -379,18 +424,16 @@
<div class="aurora">
<div class="aurora-band aurora-1"></div>
<div class="aurora-band aurora-2"></div>
<div class="aurora-band aurora-3"></div>
</div>
<div class="grid-bg"></div>
<div class="orb orb-1" id="orb1"></div>
<div class="orb orb-2" id="orb2"></div>
<div class="particles" id="particles"></div>
<div class="hero-inner">
<div class="reveal hero-badge"><span class="pulse"></span> v0.3.0 已发布 — 社区交流 &amp; 高级视觉特效</div>
<h1 class="reveal hero-title">管理你的 <span class="gradient-text shimmer">AI Agent</span><br>从未如此直观</h1>
<p class="reveal hero-subtitle">ClawPanel 是 <strong>OpenClaw</strong> 的可视化管理面板,基于 Tauri v2 构建。<br>仪表盘、模型配置、实时聊天、记忆管理 — 一站式掌控。</p>
<div class="reveal hero-cta">
<a href="https://github.com/qingchencloud/clawpanel/releases/latest" target="_blank" rel="noopener" class="btn btn-primary">
<a href="#download" class="btn btn-primary">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path d="M12 15V3m0 12l-4-4m4 4l4-4M2 17l.621 2.485A2 2 0 004.561 21h14.878a2 2 0 001.94-1.515L22 17"/></svg>
下载最新版
</a>
@@ -411,10 +454,38 @@
<div class="grid-bg"></div>
<div class="container" style="position:relative;z-index:10">
<div class="stats-grid">
<div class="reveal stat-card float-a"><div class="stat-value c-indigo counter" data-target="10">0</div><div class="stat-label">功能模块</div></div>
<div class="reveal stat-card float-b"><div class="stat-value c-purple">Tauri v2</div><div class="stat-label">桌面框架</div></div>
<div class="reveal stat-card float-a"><div class="stat-value c-cyan">Vanilla JS</div><div class="stat-label">前端框架</div></div>
<div class="reveal stat-card float-b"><div class="stat-value c-emerald counter" data-target="100" data-suffix="%">0</div><div class="stat-label">开源免费</div></div>
<div class="reveal stat-card"><div class="stat-value c-indigo counter" data-target="10">0</div><div class="stat-label">功能模块</div></div>
<div class="reveal stat-card"><div class="stat-value c-purple">Tauri v2</div><div class="stat-label">桌面框架</div></div>
<div class="reveal stat-card"><div class="stat-value c-cyan">3</div><div class="stat-label">跨平台支持</div></div>
<div class="reveal stat-card"><div class="stat-value c-emerald">MIT</div><div class="stat-label">开源协议</div></div>
</div>
</div>
</section>
<!-- ══════════════ Video Demo ══════════════ -->
<section class="section" style="padding-top:48px">
<div class="grid-bg"></div>
<div class="container" style="position:relative;z-index:10">
<div class="section-header">
<div class="reveal video-demo-badge">
<svg viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg>
38 秒快速了解
</div>
<h2 class="reveal section-title">产品 <span class="gradient-text">演示视频</span></h2>
<p class="reveal section-desc">从仪表盘到记忆管理,一个视频看完所有核心功能</p>
</div>
<div class="reveal video-demo-wrap">
<div class="video-demo-frame">
<div class="video-play-overlay" id="videoOverlay" onclick="playDemoVideo()">
<div class="play-btn">
<svg viewBox="0 0 24 24"><polygon points="5 3 19 12 5 21 5 3"/></svg>
</div>
</div>
<video id="demoVideo" poster="./01.png" preload="metadata" playsinline controls onclick="toggleDemoVideo()">
<source src="./promo-web.mp4" type="video/mp4">
</video>
</div>
<div class="video-demo-glow"></div>
</div>
</div>
</section>
@@ -424,7 +495,7 @@
<div class="orb orb-3"></div>
<div class="container" style="position:relative;z-index:10">
<div class="section-header">
<h2 class="reveal section-title">强大的 <span class="gradient-text shimmer">功能矩阵</span></h2>
<h2 class="reveal section-title">强大的 <span class="gradient-text">功能矩阵</span></h2>
<p class="reveal section-desc">一个面板,管理 OpenClaw 的方方面面</p>
</div>
@@ -432,7 +503,7 @@
<div class="showcase-row">
<div class="reveal screenshot-frame img-first" onclick="openLightbox('./02.png')"><img src="./02.png" alt="实时聊天" loading="lazy"></div>
<div>
<div class="reveal showcase-tag c-indigo" style="background:var(--accent-10)">💬 核心功能</div>
<div class="reveal showcase-tag c-indigo" style="background:var(--accent-10)"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg> 核心功能</div>
<h3 class="reveal showcase-title">实时 AI 对话</h3>
<p class="reveal showcase-desc">WebSocket 直连 Gateway流式响应实时显示。支持图片附件、多模态交互、Markdown 渲染、会话管理与快捷指令。</p>
<ul class="reveal showcase-list">
@@ -447,7 +518,7 @@
<!-- 模型配置 -->
<div class="showcase-row">
<div>
<div class="reveal showcase-tag" style="color:#a855f7;background:rgba(168,85,247,0.1)">🧠 配置中心</div>
<div class="reveal showcase-tag" style="color:#a855f7;background:rgba(168,85,247,0.1)"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></svg> 配置中心</div>
<h3 class="reveal showcase-title">多模型灵活调配</h3>
<p class="reveal showcase-desc">支持 OpenAI、DeepSeek、Kimi 等多家服务商。可视化管理模型列表,一键设为主模型,自动备选切换。</p>
<ul class="reveal showcase-list">
@@ -464,7 +535,7 @@
<div class="showcase-row">
<div class="reveal screenshot-frame img-first" onclick="openLightbox('./08.png')"><img src="./08.png" alt="记忆文件" loading="lazy"></div>
<div>
<div class="reveal showcase-tag" style="color:#22d3ee;background:rgba(34,211,238,0.1)">🗂️ 数据管理</div>
<div class="reveal showcase-tag" style="color:#22d3ee;background:rgba(34,211,238,0.1)"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 20h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.93a2 2 0 0 1-1.66-.9l-.82-1.2A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13c0 1.1.9 2 2 2Z"/></svg> 数据管理</div>
<h3 class="reveal showcase-title">让 Agent 拥有记忆</h3>
<p class="reveal showcase-desc">在线编辑 Agent 核心配置文件AGENTS.md、SOUL.md 等),管理工作记忆和记忆归档。支持 ZIP 一键打包导出。</p>
<ul class="reveal showcase-list">
@@ -479,7 +550,7 @@
<!-- Gateway -->
<div class="showcase-row">
<div>
<div class="reveal showcase-tag" style="color:#10b981;background:rgba(16,185,129,0.1)">🌐 网关管控</div>
<div class="reveal showcase-tag" style="color:#10b981;background:rgba(16,185,129,0.1)"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/></svg> 网关管控</div>
<h3 class="reveal showcase-title">安全访问,可视化配置</h3>
<p class="reveal showcase-desc">Gateway 端口、绑定范围、运行模式、Token / 密码认证方式,卡片式选项直观配置,即改即生效。</p>
<ul class="reveal showcase-list">
@@ -496,7 +567,7 @@
<div class="showcase-row">
<div class="reveal screenshot-frame img-first" onclick="openLightbox('./06.png')"><img src="./06.png" alt="Agent 管理" loading="lazy"></div>
<div>
<div class="reveal showcase-tag" style="color:#f97316;background:rgba(249,115,22,0.1)">🤖 智能体</div>
<div class="reveal showcase-tag" style="color:#f97316;background:rgba(249,115,22,0.1)"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="10" x="3" y="11" rx="2"/><circle cx="12" cy="5" r="2"/><path d="M12 7v4"/><line x1="8" x2="8" y1="16" y2="16"/><line x1="16" x2="16" y1="16" y2="16"/></svg> 智能体</div>
<h3 class="reveal showcase-title">多 Agent 协作管理</h3>
<p class="reveal showcase-desc">创建和管理多个 AI Agent配置各自的身份、模型和独立工作区。支持备份、编辑与一键切换。</p>
<ul class="reveal showcase-list">
@@ -519,16 +590,16 @@
<p class="reveal section-desc">运维、监控、诊断,面面俱到</p>
</div>
<div class="gallery-grid">
<div class="reveal gallery-card" onclick="openLightbox('./03.png')"><img src="./03.png" alt="服务管理" loading="lazy"><div class="gallery-label">⚙️ 服务管理</div></div>
<div class="reveal gallery-card" onclick="openLightbox('./04.png')"><img src="./04.png" alt="日志查看" loading="lazy"><div class="gallery-label">📋 日志查看</div></div>
<div class="reveal gallery-card" onclick="openLightbox('./09.png')"><img src="./09.png" alt="扩展工具" loading="lazy"><div class="gallery-label">🔌 扩展工具</div></div>
<div class="reveal gallery-card" onclick="openLightbox('./10.png')"><img src="./10.png" alt="系统诊断" loading="lazy"><div class="gallery-label">🔍 系统诊断</div></div>
<div class="reveal gallery-card" onclick="openLightbox('./03.png')"><img src="./03.png" alt="服务管理" loading="lazy"><div class="gallery-label"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline;vertical-align:-2px"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></svg> 服务管理</div></div>
<div class="reveal gallery-card" onclick="openLightbox('./04.png')"><img src="./04.png" alt="日志查看" loading="lazy"><div class="gallery-label"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline;vertical-align:-2px"><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><path d="M15 2H9a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V3a1 1 0 0 0-1-1z"/><path d="M12 11h4"/><path d="M12 16h4"/><path d="M8 11h.01"/><path d="M8 16h.01"/></svg> 日志查看</div></div>
<div class="reveal gallery-card" onclick="openLightbox('./09.png')"><img src="./09.png" alt="扩展工具" loading="lazy"><div class="gallery-label"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline;vertical-align:-2px"><path d="M12 22v-5"/><path d="M9 8V2"/><path d="M15 8V2"/><path d="M18 8v5a4 4 0 0 1-4 4h-4a4 4 0 0 1-4-4V8Z"/></svg> 扩展工具</div></div>
<div class="reveal gallery-card" onclick="openLightbox('./10.png')"><img src="./10.png" alt="系统诊断" loading="lazy"><div class="gallery-label"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline;vertical-align:-2px"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg> 系统诊断</div></div>
</div>
<div class="info-grid">
<div class="reveal info-card"><div class="ic">⚙️</div><h4>服务管理</h4><p>启停控制、版本检测、一键升级、配置备份与恢复</p></div>
<div class="reveal info-card"><div class="ic">📋</div><h4>日志查看</h4><p>多日志源实时查看、关键字搜索、自动滚动跟踪</p></div>
<div class="reveal info-card"><div class="ic">🔌</div><h4>扩展工具</h4><p>cftunnel 内网穿透、ClawApp 移动客户端管理</p></div>
<div class="reveal info-card"><div class="ic">🔍</div><h4>系统诊断</h4><p>全面健康检测、WebSocket 测试、一键修复配对</p></div>
<div class="reveal info-card"><div class="ic"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></svg></div><h4>服务管理</h4><p>启停控制、版本检测、一键升级、配置备份与恢复</p></div>
<div class="reveal info-card"><div class="ic"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><path d="M15 2H9a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V3a1 1 0 0 0-1-1z"/><path d="M12 11h4"/><path d="M12 16h4"/><path d="M8 11h.01"/><path d="M8 16h.01"/></svg></div><h4>日志查看</h4><p>多日志源实时查看、关键字搜索、自动滚动跟踪</p></div>
<div class="reveal info-card"><div class="ic"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22v-5"/><path d="M9 8V2"/><path d="M15 8V2"/><path d="M18 8v5a4 4 0 0 1-4 4h-4a4 4 0 0 1-4-4V8Z"/></svg></div><h4>扩展工具</h4><p>cftunnel 内网穿透、ClawApp 移动客户端管理</p></div>
<div class="reveal info-card"><div class="ic"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg></div><h4>系统诊断</h4><p>全面健康检测、WebSocket 测试、一键修复配对</p></div>
</div>
</div>
</section>
@@ -541,57 +612,30 @@
<h2 class="reveal section-title"><span class="gradient-text">技术架构</span></h2>
<p class="reveal section-desc">精挑细选的技术栈,追求极致性能与开发体验</p>
</div>
<div class="reveal arch-gif-wrap">
<img src="./architecture.gif" alt="ClawPanel 生态架构动画" loading="lazy">
</div>
<div class="tech-grid" id="tech-grid">
<div class="reveal tech-card">
<div class="tech-header"><div class="tech-icon" style="background:rgba(99,102,241,0.1)">🦀</div><div><div class="tech-name">Rust + Tauri v2</div><div class="tech-sub">后端运行时</div></div></div>
<div class="tech-header"><div class="tech-icon" style="background:rgba(99,102,241,0.1)"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#6366f1" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M7 7h.01"/><path d="M17 7h.01"/><path d="M7 17h.01"/><path d="M17 17h.01"/></svg></div><div><div class="tech-name">Rust + Tauri v2</div><div class="tech-sub">后端运行时</div></div></div>
<div class="tech-desc">原生编译,内存安全,跨平台打包。通过 IPC 与前端高效通信Shell Plugin 执行本地命令。</div>
</div>
<div class="reveal tech-card">
<div class="tech-header"><div class="tech-icon" style="background:rgba(234,179,8,0.1)"></div><div><div class="tech-name">Vanilla JS + Vite</div><div class="tech-sub">前端架构</div></div></div>
<div class="tech-header"><div class="tech-icon" style="background:rgba(234,179,8,0.1)"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#eab308" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2 3 14h9l-1 8 10-12h-9l1-8z"/></svg></div><div><div class="tech-name">Vanilla JS + Vite</div><div class="tech-sub">前端架构</div></div></div>
<div class="tech-desc">零框架依赖SPA 路由组件化设计。Vite 极速 HMR支持浏览器 mock 模式独立调试。</div>
</div>
<div class="reveal tech-card">
<div class="tech-header"><div class="tech-icon" style="background:rgba(34,211,238,0.1)">🔌</div><div><div class="tech-name">WebSocket + RPC</div><div class="tech-sub">实时通信</div></div></div>
<div class="tech-header"><div class="tech-icon" style="background:rgba(34,211,238,0.1)"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#22d3ee" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 11a9 9 0 0 1 9 9"/><path d="M4 4a16 16 0 0 1 16 16"/><circle cx="5" cy="19" r="1"/></svg></div><div><div class="tech-name">WebSocket + RPC</div><div class="tech-sub">实时通信</div></div></div>
<div class="tech-desc">WsClient 管理 WebSocket 连接、心跳保活与自动重连。RPC 请求-响应 + 事件订阅双模式。</div>
</div>
<div class="reveal tech-card">
<div class="tech-header"><div class="tech-icon" style="background:rgba(168,85,247,0.1)">🎨</div><div><div class="tech-name">CSS Variables</div><div class="tech-sub">主题系统</div></div></div>
<div class="tech-header"><div class="tech-icon" style="background:rgba(168,85,247,0.1)"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#a855f7" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="13.5" cy="6.5" r=".5" fill="#a855f7"/><circle cx="17.5" cy="10.5" r=".5" fill="#a855f7"/><circle cx="8.5" cy="7.5" r=".5" fill="#a855f7"/><circle cx="6.5" cy="12.5" r=".5" fill="#a855f7"/><path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.926 0 1.648-.746 1.648-1.688 0-.437-.18-.835-.437-1.125-.29-.289-.438-.652-.438-1.125a1.64 1.64 0 0 1 1.668-1.668h1.996c3.051 0 5.555-2.503 5.555-5.554C21.965 6.012 17.461 2 12 2z"/></svg></div><div><div class="tech-name">CSS Variables</div><div class="tech-sub">主题系统</div></div></div>
<div class="tech-desc">暗色 / 亮色主题无闪烁切换,玻璃拟态 UI 风格CSS 自定义属性实现全局样式管理。</div>
</div>
</div>
</div>
</section>
<!-- ══════════════ Marquee ══════════════ -->
<div class="marquee-wrap">
<div class="marquee-track">
<span class="marquee-item">Rust</span><span class="marquee-item marquee-dot">·</span>
<span class="marquee-item">Tauri v2</span><span class="marquee-item marquee-dot">·</span>
<span class="marquee-item">Vanilla JS</span><span class="marquee-item marquee-dot">·</span>
<span class="marquee-item">Vite</span><span class="marquee-item marquee-dot">·</span>
<span class="marquee-item">WebSocket</span><span class="marquee-item marquee-dot">·</span>
<span class="marquee-item">CSS Variables</span><span class="marquee-item marquee-dot">·</span>
<span class="marquee-item">IPC Bridge</span><span class="marquee-item marquee-dot">·</span>
<span class="marquee-item">Ed25519</span><span class="marquee-item marquee-dot">·</span>
<span class="marquee-item">Shell Plugin</span><span class="marquee-item marquee-dot">·</span>
<span class="marquee-item">SPA Router</span><span class="marquee-item marquee-dot">·</span>
<span class="marquee-item">Glassmorphism</span><span class="marquee-item marquee-dot">·</span>
<span class="marquee-item">Cross-Platform</span><span class="marquee-item marquee-dot">·</span>
<span class="marquee-item">Rust</span><span class="marquee-item marquee-dot">·</span>
<span class="marquee-item">Tauri v2</span><span class="marquee-item marquee-dot">·</span>
<span class="marquee-item">Vanilla JS</span><span class="marquee-item marquee-dot">·</span>
<span class="marquee-item">Vite</span><span class="marquee-item marquee-dot">·</span>
<span class="marquee-item">WebSocket</span><span class="marquee-item marquee-dot">·</span>
<span class="marquee-item">CSS Variables</span><span class="marquee-item marquee-dot">·</span>
<span class="marquee-item">IPC Bridge</span><span class="marquee-item marquee-dot">·</span>
<span class="marquee-item">Ed25519</span><span class="marquee-item marquee-dot">·</span>
<span class="marquee-item">Shell Plugin</span><span class="marquee-item marquee-dot">·</span>
<span class="marquee-item">SPA Router</span><span class="marquee-item marquee-dot">·</span>
<span class="marquee-item">Glassmorphism</span><span class="marquee-item marquee-dot">·</span>
<span class="marquee-item">Cross-Platform</span><span class="marquee-item marquee-dot">·</span>
</div>
</div>
<!-- ══════════════ Quickstart ══════════════ -->
<section id="quickstart" class="section">
<div class="container-sm" style="position:relative;z-index:10">
@@ -632,21 +676,84 @@
</div>
<div class="eco-list">
<a href="https://github.com/1186258278/OpenClawChineseTranslation" target="_blank" rel="noopener" class="reveal eco-card">
<div><h3>🧠 OpenClaw</h3><p>AI Agent 框架 — ClawPanel 的核心管理目标</p></div>
<div><h3><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline;vertical-align:-2px"><path d="M12 5a3 3 0 1 0-5.997.125 4 4 0 0 0-2.526 5.77 4 4 0 0 0 .556 6.588A4 4 0 1 0 12 18Z"/><path d="M12 5a3 3 0 1 1 5.997.125 4 4 0 0 1 2.526 5.77 4 4 0 0 1-.556 6.588A4 4 0 1 1 12 18Z"/><path d="M15 13a4.5 4.5 0 0 1-3-4 4.5 4.5 0 0 1-3 4"/><path d="M17.599 6.5a3 3 0 0 0 .399-1.375"/><path d="M6.003 5.125A3 3 0 0 0 6.401 6.5"/><path d="M3.477 10.896a4 4 0 0 1 .585-.396"/><path d="M19.938 10.5a4 4 0 0 1 .585.396"/><path d="M6 18a4 4 0 0 1-1.967-.516"/><path d="M19.967 17.484A4 4 0 0 1 18 18"/></svg> OpenClaw</h3><p>AI Agent 框架 — ClawPanel 的核心管理目标</p></div>
<span class="eco-arrow"></span>
</a>
<a href="https://github.com/qingchencloud/clawapp" target="_blank" rel="noopener" class="reveal eco-card">
<div><h3>📱 ClawApp</h3><p>跨平台移动聊天客户端,随时随地与 AI Agent 对话</p></div>
<div><h3><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline;vertical-align:-2px"><rect width="14" height="20" x="5" y="2" rx="2" ry="2"/><path d="M12 18h.01"/></svg> ClawApp</h3><p>跨平台移动聊天客户端,随时随地与 AI Agent 对话</p></div>
<span class="eco-arrow"></span>
</a>
<a href="https://github.com/qingchencloud/cftunnel" target="_blank" rel="noopener" class="reveal eco-card">
<div><h3>🔗 cftunnel</h3><p>Cloudflare Tunnel 内网穿透工具,一键暴露本地服务</p></div>
<div><h3><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline;vertical-align:-2px"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg> cftunnel</h3><p>Cloudflare Tunnel 内网穿透工具,一键暴露本地服务</p></div>
<span class="eco-arrow"></span>
</a>
</div>
</div>
</section>
<!-- ══════════════ Download ══════════════ -->
<section id="download" class="section cta-section">
<div class="orb orb-2" style="top:auto;bottom:-100px"></div>
<div class="container-sm" style="position:relative;z-index:10">
<div class="section-header">
<div class="reveal download-version"><span class="pulse"></span> v0.3.0 最新版</div>
<h2 class="reveal section-title"><span class="gradient-text">下载安装</span></h2>
<p class="reveal section-desc">选择你的操作系统,一键下载安装</p>
</div>
<div class="download-grid">
<div class="reveal download-card">
<span class="download-icon"><svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="var(--text)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20.94c1.5 0 2.75 1.06 4 1.06 3 0 6-8 6-12.22A4.91 4.91 0 0 0 17 5c-2.22 0-4 1.44-5 2-1-.56-2.78-2-5-2a4.9 4.9 0 0 0-5 4.78C2 14 5 22 8 22c1.25 0 2.5-1.06 4-1.06Z"/><path d="M10 2c1 .5 2 2 2 5"/></svg></span>
<h3>macOS</h3>
<p class="dl-desc">支持 Apple Silicon 和 Intel 芯片</p>
<div class="dl-links">
<a class="dl-link" href="https://github.com/qingchencloud/clawpanel/releases/latest/download/ClawPanel_0.3.0_aarch64.dmg" target="_blank" rel="noopener">
Apple Silicon (M1/M2/M3/M4)
<span class="dl-format">.dmg</span>
</a>
<a class="dl-link" href="https://github.com/qingchencloud/clawpanel/releases/latest/download/ClawPanel_0.3.0_x64.dmg" target="_blank" rel="noopener">
Intel 芯片
<span class="dl-format">.dmg</span>
</a>
</div>
</div>
<div class="reveal download-card">
<span class="download-icon"><svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="var(--text)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8"/><path d="M12 17v4"/><path d="m6 8 4 4-4 4"/><path d="M12 16h4"/></svg></span>
<h3>Windows</h3>
<p class="dl-desc">支持 Windows 10 及以上版本</p>
<div class="dl-links">
<a class="dl-link" href="https://github.com/qingchencloud/clawpanel/releases/latest/download/ClawPanel_0.3.0_x64-setup.exe" target="_blank" rel="noopener">
安装程序
<span class="dl-format">.exe</span>
</a>
<a class="dl-link" href="https://github.com/qingchencloud/clawpanel/releases/latest/download/ClawPanel_0.3.0_x64_en-US.msi" target="_blank" rel="noopener">
MSI 安装包
<span class="dl-format">.msi</span>
</a>
</div>
</div>
<div class="reveal download-card">
<span class="download-icon"><svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="var(--text)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="m4 17 2 2 4-4"/><path d="M12 13h8"/><path d="M12 17h8"/><path d="M12 21h8"/><path d="M3 7V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v2"/><path d="M3 11v2a2 2 0 0 0 2 2h1"/><path d="M21 11v2a2 2 0 0 1-2 2h-1"/></svg></span>
<h3>Linux</h3>
<p class="dl-desc">支持主流 Linux 发行版</p>
<div class="dl-links">
<a class="dl-link" href="https://github.com/qingchencloud/clawpanel/releases/latest/download/ClawPanel_0.3.0_amd64.AppImage" target="_blank" rel="noopener">
通用版
<span class="dl-format">.AppImage</span>
</a>
<a class="dl-link" href="https://github.com/qingchencloud/clawpanel/releases/latest/download/ClawPanel_0.3.0_amd64.deb" target="_blank" rel="noopener">
Debian / Ubuntu
<span class="dl-format">.deb</span>
</a>
</div>
</div>
</div>
<div class="reveal download-note" style="text-align:center">
<p>查看 <a href="https://github.com/qingchencloud/clawpanel/releases" target="_blank" rel="noopener">所有版本</a> · 需要帮助?阅读 <a href="https://github.com/qingchencloud/clawpanel#readme" target="_blank" rel="noopener">安装文档</a></p>
<p style="margin-top:12px"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline;vertical-align:-2px;opacity:0.7"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg> 国内网络下载慢?加入 <a href="https://qt.cool/c/OpenClaw" target="_blank" rel="noopener">QQ 群</a><a href="https://qt.cool/c/OpenClawWx" target="_blank" rel="noopener">微信群</a> 获取安装包直传</p>
</div>
</div>
</section>
<!-- ══════════════ Community ══════════════ -->
<section id="community" class="section">
<div class="orb orb-1" style="top:auto;bottom:-200px;left:auto;right:-100px"></div>
@@ -690,25 +797,6 @@
</div>
</section>
<!-- ══════════════ CTA ══════════════ -->
<section class="section cta-section">
<div class="orb orb-2" style="top:auto;bottom:-100px"></div>
<div class="container" style="position:relative;z-index:10">
<h2 class="reveal section-title" style="font-size:2.5rem">准备好了吗?</h2>
<p class="reveal section-desc" style="margin-bottom:40px">下载 ClawPanel开始管理你的 AI Agent</p>
<div class="reveal cta-buttons">
<a href="https://github.com/qingchencloud/clawpanel/releases/latest" target="_blank" rel="noopener" class="btn btn-primary">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path d="M12 15V3m0 12l-4-4m4 4l4-4M2 17l.621 2.485A2 2 0 004.561 21h14.878a2 2 0 001.94-1.515L22 17"/></svg>
立即下载
</a>
<a href="https://github.com/qingchencloud/clawpanel" target="_blank" rel="noopener" class="btn btn-outline">
<svg viewBox="0 0 24 24" fill="currentColor" style="width:18px;height:18px"><path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z"/></svg>
Star on GitHub
</a>
</div>
</div>
</section>
<!-- ══════════════ Footer ══════════════ -->
<footer class="footer">
<div class="footer-inner">
@@ -817,19 +905,6 @@
});
});
/* ── Particles ── */
(function() {
var c = document.getElementById('particles');
for (var i = 0; i < 30; i++) {
var p = document.createElement('span');
p.className = 'particle';
p.style.left = Math.random() * 100 + '%';
p.style.animationDuration = (8 + Math.random() * 12) + 's';
p.style.animationDelay = (Math.random() * 10) + 's';
p.style.width = p.style.height = (1 + Math.random() * 2) + 'px';
c.appendChild(p);
}
})();
/* ── Scroll Progress Bar ── */
var progressBar = document.getElementById('scroll-progress');
@@ -866,6 +941,23 @@
});
});
/* ── Video Demo Controls ── */
function playDemoVideo() {
var v = document.getElementById('demoVideo');
var o = document.getElementById('videoOverlay');
v.play();
o.classList.add('hidden');
}
function toggleDemoVideo() {
var v = document.getElementById('demoVideo');
var o = document.getElementById('videoOverlay');
if (v.paused) { v.play(); o.classList.add('hidden'); }
else { v.pause(); o.classList.remove('hidden'); }
}
document.getElementById('demoVideo').addEventListener('ended', function() {
document.getElementById('videoOverlay').classList.remove('hidden');
});
/* ── 3D Tilt on Tech & Gallery Cards ── */
document.querySelectorAll('.tech-card, .gallery-card, .eco-card').forEach(function(card) {
card.classList.add('tilt-card');

BIN
docs/quick-stats.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 967 KiB

BIN
docs/terminal-demo.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 MiB

View File

@@ -1,6 +1,6 @@
{
"name": "clawpanel",
"version": "0.2.1",
"version": "0.4.0",
"private": true,
"description": "ClawPanel - OpenClaw 可视化管理面板,基于 Tauri v2 的跨平台桌面应用",
"type": "module",

759
scripts/dev-api.js Normal file
View File

@@ -0,0 +1,759 @@
/**
* ClawPanel 开发模式 API 插件
* 在 Vite 开发服务器上提供真实 API 端点,替代 mock 数据
* 使浏览器模式能真正管理 OpenClaw 实例
*/
import fs from 'fs'
import path from 'path'
import { homedir, networkInterfaces } from 'os'
import { execSync, spawn } from 'child_process'
import crypto from 'crypto'
const OPENCLAW_DIR = path.join(homedir(), '.openclaw')
const CONFIG_PATH = path.join(OPENCLAW_DIR, 'openclaw.json')
const MCP_CONFIG_PATH = path.join(OPENCLAW_DIR, 'mcp.json')
const LOGS_DIR = path.join(OPENCLAW_DIR, 'logs')
const BACKUPS_DIR = path.join(OPENCLAW_DIR, 'backups')
const DEVICE_KEY_FILE = path.join(OPENCLAW_DIR, 'clawpanel-device-key.json')
const DEVICES_DIR = path.join(OPENCLAW_DIR, 'devices')
const PAIRED_PATH = path.join(DEVICES_DIR, 'paired.json')
const isMac = process.platform === 'darwin'
const SCOPES = ['operator.admin', 'operator.approvals', 'operator.pairing', 'operator.read', 'operator.write']
function readBody(req) {
return new Promise((resolve) => {
let body = ''
req.on('data', chunk => body += chunk)
req.on('end', () => {
try { resolve(JSON.parse(body || '{}')) }
catch { resolve({}) }
})
})
}
function getUid() {
if (!isMac) return 0
return execSync('id -u').toString().trim()
}
function stripUiFields(config) {
const providers = config?.models?.providers
if (!providers) return config
for (const p of Object.values(providers)) {
if (!Array.isArray(p.models)) continue
for (const m of p.models) {
if (typeof m !== 'object') continue
delete m.lastTestAt
delete m.latency
delete m.testStatus
delete m.testError
if (!m.name && m.id) m.name = m.id
}
}
return config
}
// === Ed25519 设备密钥管理 ===
function getOrCreateDeviceKey() {
if (fs.existsSync(DEVICE_KEY_FILE)) {
const data = JSON.parse(fs.readFileSync(DEVICE_KEY_FILE, 'utf8'))
// 从存储的 hex 密钥重建 Node.js KeyObject
const privDer = Buffer.concat([
Buffer.from('302e020100300506032b657004220420', 'hex'), // PKCS8 Ed25519 header
Buffer.from(data.secretKey, 'hex'),
])
const privateKey = crypto.createPrivateKey({ key: privDer, format: 'der', type: 'pkcs8' })
return { deviceId: data.deviceId, publicKey: data.publicKey, privateKey }
}
// 生成新密钥对
const keyPair = crypto.generateKeyPairSync('ed25519')
const pubDer = keyPair.publicKey.export({ type: 'spki', format: 'der' })
const privDer = keyPair.privateKey.export({ type: 'pkcs8', format: 'der' })
const pubRaw = pubDer.slice(-32)
const privRaw = privDer.slice(-32)
const deviceId = crypto.createHash('sha256').update(pubRaw).digest('hex')
const publicKey = Buffer.from(pubRaw).toString('base64url')
const secretHex = Buffer.from(privRaw).toString('hex')
const keyData = { deviceId, publicKey, secretKey: secretHex }
if (!fs.existsSync(OPENCLAW_DIR)) fs.mkdirSync(OPENCLAW_DIR, { recursive: true })
fs.writeFileSync(DEVICE_KEY_FILE, JSON.stringify(keyData, null, 2))
return { deviceId, publicKey, privateKey: keyPair.privateKey }
}
function getLocalIps() {
const ips = []
const ifaces = networkInterfaces()
for (const name in ifaces) {
for (const iface of ifaces[name]) {
if (iface.family === 'IPv4' && !iface.internal) ips.push(iface.address)
}
}
return ips
}
function patchGatewayOrigins() {
if (!fs.existsSync(CONFIG_PATH)) return false
const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'))
const origins = [
'tauri://localhost',
'https://tauri.localhost',
'http://localhost',
'http://localhost:1420',
'http://127.0.0.1:1420',
]
for (const ip of getLocalIps()) {
origins.push(`http://${ip}:1420`)
}
const newOrigins = [...new Set(origins)]
const existing = config?.gateway?.controlUi?.allowedOrigins || []
// 幂等:已包含所有需要的 origin 时跳过写入
if (newOrigins.every(o => existing.includes(o))) return false
if (!config.gateway) config.gateway = {}
if (!config.gateway.controlUi) config.gateway.controlUi = {}
config.gateway.controlUi.allowedOrigins = newOrigins
fs.copyFileSync(CONFIG_PATH, CONFIG_PATH + '.bak')
fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2))
return true
}
// === macOS 服务管理 ===
function macCheckService(label) {
try {
const uid = getUid()
const output = execSync(`launchctl print gui/${uid}/${label} 2>&1`).toString()
let state = '', pid = null
for (const line of output.split('\n')) {
if (!line.startsWith('\t') || line.startsWith('\t\t')) continue
const trimmed = line.trim()
if (trimmed.startsWith('pid = ')) pid = parseInt(trimmed.slice(6)) || null
if (trimmed.startsWith('state = ')) state = trimmed.slice(8).trim()
}
// 有 PID 则用 kill -0 验证进程是否存活(比 state 字符串更可靠)
if (pid) {
try { execSync(`kill -0 ${pid} 2>&1`); return { running: true, pid } } catch {}
}
// 无 PID 时 fallback 到 pgreplaunchctl 可能还没刷出 PID
if (state === 'running' || state === 'waiting') {
try {
const pgrepOut = execSync(`pgrep -f "openclaw.*gateway" 2>/dev/null`).toString().trim()
if (pgrepOut) {
const fallbackPid = parseInt(pgrepOut.split('\n')[0]) || null
if (fallbackPid) return { running: true, pid: fallbackPid }
}
} catch {}
}
return { running: state === 'running', pid }
} catch {
return { running: false, pid: null }
}
}
function macStartService(label) {
const uid = getUid()
const plistPath = path.join(homedir(), `Library/LaunchAgents/${label}.plist`)
if (!fs.existsSync(plistPath)) throw new Error(`plist 不存在: ${plistPath}`)
try { execSync(`launchctl bootstrap gui/${uid} "${plistPath}" 2>&1`) } catch {}
try { execSync(`launchctl kickstart gui/${uid}/${label} 2>&1`) } catch {}
}
function macStopService(label) {
const uid = getUid()
try { execSync(`launchctl bootout gui/${uid}/${label} 2>&1`) } catch {}
}
function macRestartService(label) {
const uid = getUid()
const plistPath = path.join(homedir(), `Library/LaunchAgents/${label}.plist`)
try { execSync(`launchctl bootout gui/${uid}/${label} 2>&1`) } catch {}
// 等待进程退出
for (let i = 0; i < 15; i++) {
const { running } = macCheckService(label)
if (!running) break
execSync('sleep 0.2')
}
try { execSync(`launchctl bootstrap gui/${uid} "${plistPath}" 2>&1`) } catch {}
try { execSync(`launchctl kickstart -k gui/${uid}/${label} 2>&1`) } catch {}
}
// === Windows 服务管理 ===
function winStartGateway() {
// 确保日志目录存在
if (!fs.existsSync(LOGS_DIR)) fs.mkdirSync(LOGS_DIR, { recursive: true })
const logPath = path.join(LOGS_DIR, 'gateway.log')
const errPath = path.join(LOGS_DIR, 'gateway.err.log')
const out = fs.openSync(logPath, 'a')
const err = fs.openSync(errPath, 'a')
// 写入启动标记到日志
const timestamp = new Date().toISOString()
fs.appendFileSync(logPath, `\n[${timestamp}] [ClawPanel] Starting Gateway on Windows...\n`)
const child = spawn('openclaw', ['gateway'], {
detached: true,
stdio: ['ignore', out, err],
shell: true,
cwd: homedir(),
})
child.unref()
}
function winStopGateway() {
const { running, pid } = winCheckGateway()
if (!running || !pid) throw new Error('Gateway 未运行')
try {
execSync(`taskkill /F /PID ${pid} /T`, { timeout: 5000 })
} catch (e) {
throw new Error('停止失败: ' + (e.message || e))
}
}
function winCheckGateway() {
const port = readGatewayPort()
try {
// 用 netstat 精确查找监听指定端口的进程 PID
const out = execSync(`netstat -ano | findstr ":${port}" | findstr "LISTENING"`, { timeout: 3000 }).toString().trim()
if (!out) return { running: false, pid: null }
// 提取 PID最后一列
const parts = out.split('\n')[0].trim().split(/\s+/)
const pid = parseInt(parts[parts.length - 1]) || null
if (!pid) return { running: false, pid: null }
// 验证进程是否为 node/openclaw排除其他程序碰巧占用同端口
try {
const taskOut = execSync(`tasklist /FI "PID eq ${pid}" /FO CSV /NH`, { timeout: 3000 }).toString().trim()
const isGateway = /node|openclaw/i.test(taskOut)
return { running: isGateway, pid: isGateway ? pid : null }
} catch {
return { running: true, pid }
}
} catch {
return { running: false, pid: null }
}
}
function readGatewayPort() {
try {
const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'))
return config?.gateway?.port || 18789
} catch {
return 18789
}
}
// === API Handlers ===
const handlers = {
// 配置读写
read_openclaw_config() {
if (!fs.existsSync(CONFIG_PATH)) throw new Error('openclaw.json 不存在,请先安装 OpenClaw')
const content = fs.readFileSync(CONFIG_PATH, 'utf8')
return JSON.parse(content)
},
write_openclaw_config({ config }) {
const bak = CONFIG_PATH + '.bak'
if (fs.existsSync(CONFIG_PATH)) fs.copyFileSync(CONFIG_PATH, bak)
const cleaned = stripUiFields(config)
fs.writeFileSync(CONFIG_PATH, JSON.stringify(cleaned, null, 2))
return true
},
read_mcp_config() {
if (!fs.existsSync(MCP_CONFIG_PATH)) return {}
return JSON.parse(fs.readFileSync(MCP_CONFIG_PATH, 'utf8'))
},
write_mcp_config({ config }) {
fs.writeFileSync(MCP_CONFIG_PATH, JSON.stringify(config, null, 2))
return true
},
// 服务管理
get_services_status() {
const label = 'ai.openclaw.gateway'
const { running, pid } = isMac ? macCheckService(label) : winCheckGateway()
const cliInstalled = isMac
? fs.existsSync('/opt/homebrew/bin/openclaw') || fs.existsSync('/usr/local/bin/openclaw')
: (() => {
try { return fs.existsSync(path.join(process.env.APPDATA || '', 'npm', 'openclaw.cmd')) }
catch { return false }
})()
return [{ label, running, pid, description: 'OpenClaw Gateway', cli_installed: cliInstalled }]
},
start_service({ label }) {
if (isMac) { macStartService(label); return true }
winStartGateway()
return true
},
stop_service({ label }) {
if (isMac) { macStopService(label); return true }
winStopGateway()
return true
},
async restart_service({ label }) {
if (isMac) { macRestartService(label); return true }
try { winStopGateway() } catch {}
// 等待进程退出
for (let i = 0; i < 10; i++) {
const { running } = winCheckGateway()
if (!running) break
await new Promise(r => setTimeout(r, 500))
}
winStartGateway()
return true
},
reload_gateway() {
if (!isMac) throw new Error('非 macOS 请使用 Tauri 桌面应用')
// Gateway 不支持 SIGHUP 热重载,改为完整重启
macRestartService('ai.openclaw.gateway')
return 'Gateway 已重启'
},
restart_gateway() {
if (!isMac) throw new Error('非 macOS 请使用 Tauri 桌面应用')
macRestartService('ai.openclaw.gateway')
return 'Gateway 已重启'
},
// 安装检测
check_installation() {
return { installed: fs.existsSync(CONFIG_PATH), path: OPENCLAW_DIR, platform: isMac ? 'macos' : process.platform }
},
check_node() {
try {
const ver = execSync('node --version 2>&1').toString().trim()
return { installed: true, version: ver }
} catch {
return { installed: false, version: null }
}
},
// 版本信息
get_version_info() {
let current = null
if (isMac) {
try {
const target = fs.readlinkSync('/opt/homebrew/bin/openclaw')
const pkgPath = path.resolve('/opt/homebrew/bin', target, '..', 'package.json')
current = JSON.parse(fs.readFileSync(pkgPath, 'utf8')).version
} catch {}
}
if (!current) {
try { current = execSync('openclaw --version 2>&1').toString().trim().split(/\s+/).pop() } catch {}
}
return { current, latest: null, update_available: false, source: 'chinese' }
},
// 模型测试
async test_model({ baseUrl, apiKey, modelId }) {
const url = `${baseUrl.replace(/\/+$/, '')}/chat/completions`
const body = JSON.stringify({
model: modelId,
messages: [{ role: 'user', content: 'Hi' }],
max_tokens: 16,
stream: false
})
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 30000)
try {
const headers = { 'Content-Type': 'application/json' }
if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`
const resp = await fetch(url, { method: 'POST', headers, body, signal: controller.signal })
clearTimeout(timeout)
if (!resp.ok) {
const text = await resp.text()
let msg = `HTTP ${resp.status}`
try { msg = JSON.parse(text).error?.message || msg } catch {}
throw new Error(msg)
}
const data = await resp.json()
const content = data.choices?.[0]?.message?.content
const reasoning = data.choices?.[0]?.message?.reasoning_content
return content || (reasoning ? `[reasoning] ${reasoning}` : '(无回复内容)')
} catch (e) {
clearTimeout(timeout)
if (e.name === 'AbortError') throw new Error('请求超时 (30s)')
throw e
}
},
async list_remote_models({ baseUrl, apiKey }) {
const url = `${baseUrl.replace(/\/+$/, '')}/models`
const headers = {}
if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 15000)
try {
const resp = await fetch(url, { headers, signal: controller.signal })
clearTimeout(timeout)
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
const data = await resp.json()
const ids = (data.data || []).map(m => m.id).sort()
if (!ids.length) throw new Error('该服务商返回了空的模型列表')
return ids
} catch (e) {
clearTimeout(timeout)
if (e.name === 'AbortError') throw new Error('请求超时 (15s)')
throw e
}
},
// 日志
read_log_tail({ logName, lines = 100 }) {
const logFiles = {
'gateway': 'gateway.log',
'gateway-err': 'gateway.err.log',
'guardian': 'guardian.log',
'guardian-backup': 'guardian-backup.log',
'config-audit': 'config-audit.log',
}
const file = logFiles[logName] || logFiles['gateway']
const logPath = path.join(LOGS_DIR, file)
if (!fs.existsSync(logPath)) return ''
try {
return execSync(`tail -${lines} "${logPath}" 2>&1`).toString()
} catch {
const content = fs.readFileSync(logPath, 'utf8')
return content.split('\n').slice(-lines).join('\n')
}
},
search_log({ logName, query, maxResults = 50 }) {
const logFiles = {
'gateway': 'gateway.log',
'gateway-err': 'gateway.err.log',
}
const file = logFiles[logName] || logFiles['gateway']
const logPath = path.join(LOGS_DIR, file)
if (!fs.existsSync(logPath)) return []
try {
const output = execSync(`grep -i "${query.replace(/"/g, '\\"')}" "${logPath}" | tail -${maxResults} 2>&1`).toString()
return output.split('\n').filter(Boolean)
} catch {
return []
}
},
// Agent 管理
list_agents() {
const result = [{ id: 'main', isDefault: true, identityName: null, model: null, workspace: null }]
const agentsDir = path.join(OPENCLAW_DIR, 'agents')
if (fs.existsSync(agentsDir)) {
try {
for (const entry of fs.readdirSync(agentsDir)) {
if (entry === 'main') continue
const p = path.join(agentsDir, entry)
if (fs.statSync(p).isDirectory()) {
result.push({ id: entry, isDefault: false, identityName: null, model: null, workspace: null })
}
}
} catch {}
}
return result
},
// 记忆文件
list_memory_files({ category, agent_id }) {
const suffix = agent_id && agent_id !== 'main' ? `/agents/${agent_id}` : ''
const dir = path.join(OPENCLAW_DIR, 'workspace' + suffix, category || 'memory')
if (!fs.existsSync(dir)) return []
return fs.readdirSync(dir).filter(f => f.endsWith('.md'))
},
read_memory_file({ path: filePath, agent_id }) {
const suffix = agent_id && agent_id !== 'main' ? `/agents/${agent_id}` : ''
const full = path.join(OPENCLAW_DIR, 'workspace' + suffix, filePath)
if (!fs.existsSync(full)) return ''
return fs.readFileSync(full, 'utf8')
},
write_memory_file({ path: filePath, content, category, agent_id }) {
const suffix = agent_id && agent_id !== 'main' ? `/agents/${agent_id}` : ''
const full = path.join(OPENCLAW_DIR, 'workspace' + suffix, filePath)
const dir = path.dirname(full)
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
fs.writeFileSync(full, content)
return true
},
delete_memory_file({ path: filePath, agent_id }) {
const suffix = agent_id && agent_id !== 'main' ? `/agents/${agent_id}` : ''
const full = path.join(OPENCLAW_DIR, 'workspace' + suffix, filePath)
if (fs.existsSync(full)) fs.unlinkSync(full)
return true
},
export_memory_zip({ category, agent_id }) {
throw new Error('ZIP 导出仅在 Tauri 桌面应用中可用')
},
// 备份管理
list_backups() {
if (!fs.existsSync(BACKUPS_DIR)) return []
return fs.readdirSync(BACKUPS_DIR)
.filter(f => f.endsWith('.json'))
.map(name => {
const stat = fs.statSync(path.join(BACKUPS_DIR, name))
return { name, size: stat.size, created_at: Math.floor((stat.birthtimeMs || stat.mtimeMs) / 1000) }
})
.sort((a, b) => b.created_at - a.created_at)
},
create_backup() {
if (!fs.existsSync(BACKUPS_DIR)) fs.mkdirSync(BACKUPS_DIR, { recursive: true })
const now = new Date()
const pad = n => String(n).padStart(2, '0')
const name = `openclaw-${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}.json`
fs.copyFileSync(CONFIG_PATH, path.join(BACKUPS_DIR, name))
return { name, size: fs.statSync(path.join(BACKUPS_DIR, name)).size }
},
restore_backup({ name }) {
if (name.includes('..') || name.includes('/') || name.includes('\\')) throw new Error('非法文件名')
const src = path.join(BACKUPS_DIR, name)
if (!fs.existsSync(src)) throw new Error('备份不存在')
if (fs.existsSync(CONFIG_PATH)) handlers.create_backup()
fs.copyFileSync(src, CONFIG_PATH)
return true
},
delete_backup({ name }) {
if (name.includes('..') || name.includes('/') || name.includes('\\')) throw new Error('非法文件名')
const p = path.join(BACKUPS_DIR, name)
if (fs.existsSync(p)) fs.unlinkSync(p)
return true
},
// Vision 补丁
patch_model_vision() {
if (!fs.existsSync(CONFIG_PATH)) return false
const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'))
let changed = false
const providers = config?.models?.providers
if (providers) {
for (const p of Object.values(providers)) {
if (!Array.isArray(p.models)) continue
for (const m of p.models) {
if (typeof m === 'object' && !m.input) {
m.input = ['text', 'image']
changed = true
}
}
}
}
if (changed) {
fs.copyFileSync(CONFIG_PATH, CONFIG_PATH + '.bak')
fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2))
}
return changed
},
// Gateway 安装/卸载
install_gateway() {
try { execSync('openclaw --version 2>&1') } catch { throw new Error('openclaw CLI 未安装') }
return execSync('openclaw gateway install 2>&1').toString() || 'Gateway 服务已安装'
},
upgrade_openclaw({ source = 'chinese' } = {}) {
const OPENCLAW_DIR = path.join(homedir(), '.openclaw')
const pkg = source === 'official' ? '@anthropic-ai/claw' : '@qingchencloud/openclaw-zh'
const npmBin = isMac ? 'npm' : 'npm.cmd'
try {
const out = execSync(`${npmBin} install ${pkg}@latest --prefix "${OPENCLAW_DIR}" 2>&1`, { timeout: 120000 }).toString()
return `升级完成 (${source})\n${out.slice(-200)}`
} catch (e) {
throw new Error('升级失败: ' + (e.stderr?.toString() || e.message).slice(-300))
}
},
uninstall_gateway() {
if (isMac) {
const uid = getUid()
try { execSync(`launchctl bootout gui/${uid}/ai.openclaw.gateway 2>&1`) } catch {}
const plist = path.join(homedir(), 'Library/LaunchAgents/ai.openclaw.gateway.plist')
if (fs.existsSync(plist)) fs.unlinkSync(plist)
}
return 'Gateway 服务已卸载'
},
get_deploy_config() {
try {
const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'))
const gw = config.gateway || {}
return { gatewayUrl: `http://127.0.0.1:${gw.port || 18789}`, authToken: gw.auth?.token || '', version: null }
} catch {
return { gatewayUrl: 'http://127.0.0.1:18789', authToken: '', version: null }
}
},
get_npm_registry() {
const regFile = path.join(OPENCLAW_DIR, 'npm-registry.txt')
if (fs.existsSync(regFile)) return fs.readFileSync(regFile, 'utf8').trim() || 'https://registry.npmmirror.com'
return 'https://registry.npmmirror.com'
},
set_npm_registry({ registry }) {
fs.writeFileSync(path.join(OPENCLAW_DIR, 'npm-registry.txt'), registry.trim())
return true
},
// 扩展工具
get_cftunnel_status() {
if (!isMac) return { installed: false }
try {
const ver = execSync('cftunnel --version 2>&1').toString().trim()
let running = false, pid = null
try {
const pgrepOut = execSync('pgrep -f cloudflared 2>/dev/null').toString().trim()
if (pgrepOut) { running = true; pid = parseInt(pgrepOut.split('\n')[0]) || null }
} catch {}
// 读取 config.yml 获取 tunnel_name 和 routes
let tunnel_name = '', routes = []
const cfgPath = path.join(homedir(), '.cftunnel/config.yml')
if (fs.existsSync(cfgPath)) {
const cfgText = fs.readFileSync(cfgPath, 'utf8')
const nameMatch = cfgText.match(/^\s+name:\s*(.+)$/m)
if (nameMatch) tunnel_name = nameMatch[1].trim()
// 解析 routes 数组
const routeBlocks = cfgText.split(/^\s+-\s+name:/m).slice(1)
routes = routeBlocks.map(block => {
const lines = ('name:' + block).split('\n')
const get = key => { const l = lines.find(x => x.trim().startsWith(key + ':')); return l ? l.split(':').slice(1).join(':').trim() : '' }
return { name: get('name'), domain: get('hostname'), service: get('service') }
}).filter(r => r.name)
}
return { installed: true, version: ver, running, pid, tunnel_name, routes }
} catch {
return { installed: false }
}
},
get_clawapp_status() {
if (!isMac) return { installed: false, running: false, pid: null, port: 3210, url: 'http://localhost:3210' }
// 检测 ClawApp 进程是否运行Node 服务监听 3210 端口)
let running = false, pid = null, port = 3210
try {
const lsofOut = execSync('lsof -i :3210 -t 2>/dev/null').toString().trim()
if (lsofOut) { running = true; pid = parseInt(lsofOut.split('\n')[0]) || null }
} catch {}
// 检测是否安装
const clawappDir = path.join(homedir(), 'Desktop/clawapp')
const installed = fs.existsSync(clawappDir)
return { installed, running, pid, port, url: `http://localhost:${port}` }
},
// 设备配对 + Gateway 握手
auto_pair_device() {
const originsChanged = patchGatewayOrigins()
const { deviceId, publicKey } = getOrCreateDeviceKey()
if (!fs.existsSync(DEVICES_DIR)) fs.mkdirSync(DEVICES_DIR, { recursive: true })
let paired = {}
if (fs.existsSync(PAIRED_PATH)) paired = JSON.parse(fs.readFileSync(PAIRED_PATH, 'utf8'))
const platform = process.platform === 'darwin' ? 'macos' : process.platform
if (paired[deviceId]) {
if (paired[deviceId].platform !== platform) {
paired[deviceId].platform = platform
paired[deviceId].deviceFamily = 'desktop'
fs.writeFileSync(PAIRED_PATH, JSON.stringify(paired, null, 2))
return { message: '设备已配对(已修正平台字段)', changed: true }
}
return { message: '设备已配对', changed: originsChanged }
}
const nowMs = Date.now()
paired[deviceId] = {
deviceId, publicKey, platform, deviceFamily: 'desktop',
clientId: 'openclaw-control-ui', clientMode: 'ui',
role: 'operator', roles: ['operator'],
scopes: SCOPES, approvedScopes: SCOPES, tokens: {},
createdAtMs: nowMs, approvedAtMs: nowMs,
}
fs.writeFileSync(PAIRED_PATH, JSON.stringify(paired, null, 2))
return { message: '设备配对成功', changed: true }
},
check_pairing_status() {
if (!fs.existsSync(DEVICE_KEY_FILE)) return { paired: false }
const keyData = JSON.parse(fs.readFileSync(DEVICE_KEY_FILE, 'utf8'))
if (!fs.existsSync(PAIRED_PATH)) return { paired: false }
const paired = JSON.parse(fs.readFileSync(PAIRED_PATH, 'utf8'))
return { paired: !!paired[keyData.deviceId] }
},
create_connect_frame({ nonce, gatewayToken }) {
const { deviceId, publicKey, privateKey } = getOrCreateDeviceKey()
const signedAt = Date.now()
const platform = process.platform === 'darwin' ? 'macos' : process.platform
const scopesStr = SCOPES.join(',')
const payloadStr = `v3|${deviceId}|openclaw-control-ui|ui|operator|${scopesStr}|${signedAt}|${gatewayToken || ''}|${nonce || ''}|${platform}|desktop`
const signature = crypto.sign(null, Buffer.from(payloadStr), privateKey)
const sigB64 = Buffer.from(signature).toString('base64url')
const idHex = (signedAt & 0xFFFFFFFF).toString(16).padStart(8, '0')
const rndHex = Math.floor(Math.random() * 0xFFFF).toString(16).padStart(4, '0')
return {
type: 'req',
id: `connect-${idHex}-${rndHex}`,
method: 'connect',
params: {
minProtocol: 3, maxProtocol: 3,
client: { id: 'openclaw-control-ui', version: '1.0.0', platform, deviceFamily: 'desktop', mode: 'ui' },
role: 'operator', scopes: SCOPES, caps: [],
auth: { token: gatewayToken || '' },
device: { id: deviceId, publicKey, signedAt, nonce: nonce || '', signature: sigB64 },
locale: 'zh-CN', userAgent: 'ClawPanel/1.0.0 (web)',
},
}
},
check_panel_update() { return { latest: null, url: 'https://github.com/qingchencloud/clawpanel/releases' } },
write_env_file({ path: p, config }) {
const expanded = p.startsWith('~/') ? path.join(homedir(), p.slice(2)) : p
if (!expanded.startsWith(OPENCLAW_DIR)) throw new Error('只允许写入 ~/.openclaw/ 下的文件')
const dir = path.dirname(expanded)
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
fs.writeFileSync(expanded, config)
return true
},
}
// === Vite 插件 ===
export function devApiPlugin() {
return {
name: 'clawpanel-dev-api',
configureServer(server) {
console.log('[dev-api] 开发 API 已启动,配置目录:', OPENCLAW_DIR)
console.log('[dev-api] 平台:', isMac ? 'macOS' : process.platform)
server.middlewares.use(async (req, res, next) => {
if (!req.url?.startsWith('/__api/')) return next()
const cmd = req.url.slice(7).split('?')[0]
const handler = handlers[cmd]
if (!handler) {
res.statusCode = 404
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ error: `未实现的命令: ${cmd}` }))
return
}
try {
const args = await readBody(req)
const result = await handler(args)
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify(result))
} catch (e) {
res.statusCode = 500
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ error: e.message || String(e) }))
}
})
}
}
}

View File

@@ -50,9 +50,44 @@ fn backups_dir() -> PathBuf {
#[tauri::command]
pub fn read_openclaw_config() -> Result<Value, String> {
let path = super::openclaw_dir().join("openclaw.json");
let content = fs::read_to_string(&path).map_err(|e| format!("读取配置失败: {e}"))?;
let mut config: Value =
serde_json::from_str(&content).map_err(|e| format!("解析 JSON 失败: {e}"))?;
let raw = fs::read(&path).map_err(|e| format!("读取配置失败: {e}"))?;
// 自愈:自动剥离 UTF-8 BOMEF BB BF防止 JSON 解析失败
let content = if raw.starts_with(&[0xEF, 0xBB, 0xBF]) {
String::from_utf8_lossy(&raw[3..]).into_owned()
} else {
String::from_utf8_lossy(&raw).into_owned()
};
// 解析 JSON失败时尝试从备份恢复
let mut config: Value = match serde_json::from_str(&content) {
Ok(v) => {
// BOM 被剥离过,静默写回干净文件
if raw.starts_with(&[0xEF, 0xBB, 0xBF]) {
let _ = fs::write(&path, &content);
}
v
}
Err(e) => {
// JSON 解析失败,尝试从备份恢复
let bak = super::openclaw_dir().join("openclaw.json.bak");
if bak.exists() {
let bak_raw = fs::read(&bak).map_err(|e2| format!("备份也读取失败: {e2}"))?;
let bak_content = if bak_raw.starts_with(&[0xEF, 0xBB, 0xBF]) {
String::from_utf8_lossy(&bak_raw[3..]).into_owned()
} else {
String::from_utf8_lossy(&bak_raw).into_owned()
};
let bak_config: Value = serde_json::from_str(&bak_content)
.map_err(|e2| format!("配置损坏且备份也无效: 原始={e}, 备份={e2}"))?;
// 备份有效,恢复主文件
let _ = fs::write(&path, &bak_content);
bak_config
} else {
return Err(format!("配置 JSON 损坏且无备份: {e}"));
}
}
};
// 自动清理 UI 专属字段,防止污染配置导致 CLI 启动失败
if has_ui_fields(&config) {
@@ -74,10 +109,121 @@ pub fn write_openclaw_config(config: Value) -> Result<(), String> {
let bak = super::openclaw_dir().join("openclaw.json.bak");
let _ = fs::copy(&path, &bak);
// 清理 UI 专属字段,避免 CLI schema 校验失败
let cleaned = strip_ui_fields(config);
let cleaned = strip_ui_fields(config.clone());
// 写入
let json = serde_json::to_string_pretty(&cleaned).map_err(|e| format!("序列化失败: {e}"))?;
fs::write(&path, json).map_err(|e| format!("写入失败: {e}"))
fs::write(&path, &json).map_err(|e| format!("写入失败: {e}"))?;
// 同步 provider 配置到所有 agent 的 models.json运行时注册表
sync_providers_to_agent_models(&config);
Ok(())
}
/// 将 openclaw.json 的 models.providers 完整同步到每个 agent 的 models.json
/// 包括:同步 baseUrl/apiKey/api、删除已移除的 provider、删除已移除的 model、
/// 确保 Gateway 运行时不会引用 openclaw.json 中已不存在的模型
fn sync_providers_to_agent_models(config: &Value) {
let src_providers = config.pointer("/models/providers")
.and_then(|p| p.as_object());
// 收集 openclaw.json 中所有有效的 provider/model 组合
let mut valid_models: std::collections::HashSet<String> = std::collections::HashSet::new();
if let Some(providers) = src_providers {
for (pk, pv) in providers {
if let Some(models) = pv.get("models").and_then(|m| m.as_array()) {
for m in models {
let id = m.get("id").and_then(|v| v.as_str())
.or_else(|| m.as_str());
if let Some(id) = id {
valid_models.insert(format!("{}/{}", pk, id));
}
}
}
}
}
// 收集所有 agent ID
let mut agent_ids = vec!["main".to_string()];
if let Some(Value::Array(list)) = config.pointer("/agents/list") {
for agent in list {
if let Some(id) = agent.get("id").and_then(|v| v.as_str()) {
if id != "main" {
agent_ids.push(id.to_string());
}
}
}
}
let agents_dir = super::openclaw_dir().join("agents");
for agent_id in &agent_ids {
let models_path = agents_dir.join(agent_id).join("agent").join("models.json");
if !models_path.exists() {
continue;
}
let Ok(content) = fs::read_to_string(&models_path) else { continue };
let Ok(mut models_json) = serde_json::from_str::<Value>(&content) else { continue };
let mut changed = false;
// 同步 providers
if let Some(dst_providers) = models_json.get_mut("providers").and_then(|p| p.as_object_mut()) {
// 1. 删除 openclaw.json 中已不存在的 provider
if let Some(src) = src_providers {
let to_remove: Vec<String> = dst_providers.keys()
.filter(|k| !src.contains_key(k.as_str()))
.cloned().collect();
for k in to_remove {
dst_providers.remove(&k);
changed = true;
}
// 2. 同步存在的 provider 的 baseUrl/apiKey/api + 清理已删除的 models
for (provider_name, src_provider) in src.iter() {
if let Some(dst_provider) = dst_providers.get_mut(provider_name) {
if let Some(dst_obj) = dst_provider.as_object_mut() {
// 同步连接信息
for field in ["baseUrl", "apiKey", "api"] {
if let Some(src_val) = src_provider.get(field).and_then(|v| v.as_str()) {
if dst_obj.get(field).and_then(|v| v.as_str()) != Some(src_val) {
dst_obj.insert(field.to_string(), Value::String(src_val.to_string()));
changed = true;
}
}
}
// 清理已删除的 models
if let Some(dst_models) = dst_obj.get_mut("models").and_then(|m| m.as_array_mut()) {
let src_model_ids: std::collections::HashSet<String> = src_provider
.get("models").and_then(|m| m.as_array())
.map(|arr| arr.iter().filter_map(|m| {
m.get("id").and_then(|v| v.as_str())
.or_else(|| m.as_str())
.map(|s| s.to_string())
}).collect())
.unwrap_or_default();
let before = dst_models.len();
dst_models.retain(|m| {
let id = m.get("id").and_then(|v| v.as_str())
.or_else(|| m.as_str())
.unwrap_or("");
src_model_ids.contains(id)
});
if dst_models.len() != before {
changed = true;
}
}
}
}
}
}
}
if changed {
if let Ok(new_json) = serde_json::to_string_pretty(&models_json) {
let _ = fs::write(&models_path, new_json);
}
}
}
}
/// 检测配置中是否包含 UI 专属字段
@@ -333,15 +479,10 @@ pub async fn upgrade_openclaw(app: tauri::AppHandle, source: String) -> Result<S
let pkg_name = npm_package_name(&source);
let pkg = format!("{}@latest", pkg_name);
// 切换源时,或者未安装时(检测 source 和 target或者目前未安装
// 如果系统里已经安装了别的源,先卸载
// 切换源时需要卸载旧包,但为避免安装失败导致 CLI 丢失,
// 先安装新包,成功后再卸载旧包
let old_pkg = npm_package_name(&current_source);
if current_source != source {
// 先检查是否真的安装了旧包如果没有安装npm uninstall 会报错但不影响
let _ = app.emit("upgrade-log", format!("清理遗留环境 ({old_pkg})..."));
let _ = app.emit("upgrade-progress", 5);
let _ = npm_command().args(["uninstall", "-g", old_pkg]).output();
}
let need_uninstall_old = current_source != source;
let _ = app.emit("upgrade-log", format!("$ npm install -g {pkg}"));
let _ = app.emit("upgrade-progress", 10);
@@ -404,8 +545,14 @@ pub async fn upgrade_openclaw(app: tauri::AppHandle, source: String) -> Result<S
return Err("升级失败,请查看日志".into());
}
// 安装成功后再卸载旧包(确保 CLI 始终可用)
if need_uninstall_old {
let _ = app.emit("upgrade-log", format!("清理旧版本 ({old_pkg})..."));
let _ = npm_command().args(["uninstall", "-g", old_pkg]).output();
}
// 切换源后重装 Gateway 服务
if current_source != source {
if need_uninstall_old {
let _ = app.emit("upgrade-log", "正在重装 Gateway 服务(更新启动路径)...");
// 先停掉旧的
#[cfg(target_os = "macos")]

View File

@@ -115,12 +115,8 @@ fn patch_gateway_origins() {
return;
};
// Tauri v2: macOS/Linux 用 tauri://localhostWindows 用 https://tauri.localhost
let origins = serde_json::json!([
"tauri://localhost",
"https://tauri.localhost",
"http://localhost"
]);
// 放行全部 origin确保 Tauri 正式/开发模式、Web 模式都能连接
let origins = serde_json::json!(["*"]);
if let Some(obj) = config.as_object_mut() {
let gateway = obj

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.3.0",
"version": "0.4.0",
"identifier": "ai.openclaw.clawpanel",
"build": {
"frontendDist": "../dist",

View File

@@ -6,19 +6,54 @@ import { api } from './tauri-api.js'
let _openclawReady = false
let _gatewayRunning = false
let _platform = '' // 'macos' | 'win32' | ...
let _listeners = []
let _gwListeners = []
let _gwStopCount = 0 // 连续检测到"停止"的次数,防抖用
let _isUpgrading = false // 升级/切换版本期间,阻止 setup 跳转
let _userStopped = false // 用户主动停止,不自动拉起
let _autoRestartCount = 0 // 自动重启次数
let _lastRestartTime = 0 // 上次重启时间
const MAX_AUTO_RESTART = 3 // 最大连续自动重启次数
const RESTART_COOLDOWN = 60000 // 重启冷却期 60s
let _guardianListeners = [] // 守护放弃时的回调
/** openclaw 是否就绪CLI 已安装 + 配置文件存在) */
export function isOpenclawReady() {
// 升级期间视为就绪,避免跳转到 setup
if (_isUpgrading) return true
return _openclawReady
}
/** 标记升级中(阻止 setup 跳转) */
export function setUpgrading(v) { _isUpgrading = !!v }
export function isUpgrading() { return _isUpgrading }
/** 标记用户主动停止 Gateway不触发自动重启 */
export function setUserStopped(v) { _userStopped = !!v }
/** 重置自动重启计数(用户手动启动后重置) */
export function resetAutoRestart() { _autoRestartCount = 0; _userStopped = false }
/** 监听守护放弃事件连续重启失败后触发UI 可弹出恢复选项) */
export function onGuardianGiveUp(fn) {
_guardianListeners.push(fn)
return () => { _guardianListeners = _guardianListeners.filter(cb => cb !== fn) }
}
/** Gateway 是否正在运行 */
export function isGatewayRunning() {
return _gatewayRunning
}
/** 获取后端平台 ('macos' | 'win32') */
export function getPlatform() {
return _platform
}
export function isMacPlatform() {
return _platform === 'macos'
}
/** 监听 Gateway 状态变化 */
export function onGatewayChange(fn) {
_gwListeners.push(fn)
@@ -33,6 +68,9 @@ export async function detectOpenclawStatus() {
api.getServicesStatus(),
])
const configExists = installation.status === 'fulfilled' && installation.value?.installed
if (installation.status === 'fulfilled' && installation.value?.platform) {
_platform = installation.value.platform
}
const cliInstalled = services.status === 'fulfilled'
&& services.value?.length > 0
&& services.value[0]?.cli_installed !== false
@@ -50,17 +88,62 @@ export async function detectOpenclawStatus() {
}
function _setGatewayRunning(val) {
const changed = _gatewayRunning !== val
const wasRunning = _gatewayRunning
const changed = wasRunning !== val
_gatewayRunning = val
if (changed) _gwListeners.forEach(fn => { try { fn(val) } catch {} })
if (changed) {
if (val) {
// Gateway 恢复运行,重置计数
_autoRestartCount = 0
} else if (wasRunning && !_userStopped && !_isUpgrading && _openclawReady) {
// Gateway 意外停止,尝试自动重启
_tryAutoRestart()
}
_gwListeners.forEach(fn => { try { fn(val) } catch {} })
}
}
/** 刷新 Gateway 运行状态(轻量,仅查服务状态) */
async function _tryAutoRestart() {
const now = Date.now()
// 冷却期内不重复重启
if (now - _lastRestartTime < RESTART_COOLDOWN) return
if (_autoRestartCount >= MAX_AUTO_RESTART) {
console.warn(`[guardian] Gateway 已连续自动重启 ${MAX_AUTO_RESTART} 次,停止守护,请手动检查`)
_guardianListeners.forEach(fn => { try { fn() } catch {} })
return
}
_autoRestartCount++
_lastRestartTime = now
console.log(`[guardian] Gateway 意外停止,自动重启 (${_autoRestartCount}/${MAX_AUTO_RESTART})...`)
try {
await api.startService('ai.openclaw.gateway')
console.log('[guardian] Gateway 自动重启成功')
} catch (e) {
console.error('[guardian] Gateway 自动重启失败:', e)
}
}
/** 刷新 Gateway 运行状态(轻量,仅查服务状态)
* 防抖running→stopped 需要连续 2 次检测才切换,避免瞬态误判 */
export async function refreshGatewayStatus() {
try {
const services = await api.getServicesStatus()
if (services?.length > 0) _setGatewayRunning(services[0]?.running === true)
} catch {}
if (services?.length > 0) {
const nowRunning = services[0]?.running === true
if (nowRunning) {
_gwStopCount = 0
_setGatewayRunning(true)
} else {
_gwStopCount++
if (_gwStopCount >= 2 || !_gatewayRunning) {
_setGatewayRunning(false)
}
}
}
} catch {
_gwStopCount++
if (_gwStopCount >= 2) _setGatewayRunning(false)
}
return _gatewayRunning
}

View File

@@ -5,6 +5,17 @@
const isTauri = !!window.__TAURI_INTERNALS__
// 写操作不应静默回退 mock否则会“假成功”
const NO_MOCK_CMDS = new Set([
'start_service', 'stop_service', 'restart_service',
'upgrade_openclaw', 'install_gateway', 'uninstall_gateway',
'write_openclaw_config', 'write_mcp_config',
'create_backup', 'restore_backup', 'delete_backup',
'write_memory_file', 'delete_memory_file',
'set_npm_registry', 'reload_gateway', 'restart_gateway',
'auto_pair_device',
])
// 预加载 Tauri invoke避免每次 API 调用都做动态 import
const _invokeReady = isTauri
? import('@tauri-apps/api/core').then(m => m.invoke)
@@ -74,10 +85,38 @@ async function invoke(cmd, args = {}) {
logRequest(cmd, args, duration, false)
return result
}
const result = mockInvoke(cmd, args)
const duration = Date.now() - start
logRequest(cmd, args, duration, false)
return result
// Web 模式:优先调用 Vite 开发 API真实后端失败时回退 mock
try {
const result = await webInvoke(cmd, args)
const duration = Date.now() - start
logRequest(cmd, args, duration, false)
return result
} catch (e) {
// 写操作不回退 mock直接报错避免“假成功”
if (NO_MOCK_CMDS.has(cmd)) {
logRequest(cmd, args, Date.now() - start, false)
throw e
}
console.warn(`[api] webInvoke(${cmd}) failed:`, e.message, '→ fallback mock')
const result = mockInvoke(cmd, args)
const duration = Date.now() - start
logRequest(cmd, args, duration, false)
return result
}
}
// Web 模式:通过 Vite 开发服务器的 API 端点调用真实后端
async function webInvoke(cmd, args) {
const resp = await fetch(`/__api/${cmd}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(args),
})
if (!resp.ok) {
const data = await resp.json().catch(() => ({ error: `HTTP ${resp.status}` }))
throw new Error(data.error || `HTTP ${resp.status}`)
}
return resp.json()
}
// Mock 数据,方便纯浏览器开发调试

View File

@@ -4,7 +4,7 @@
import { registerRoute, initRouter, navigate, setDefaultRoute } from './router.js'
import { renderSidebar } from './components/sidebar.js'
import { initTheme } from './lib/theme.js'
import { detectOpenclawStatus, isOpenclawReady, isGatewayRunning, onGatewayChange, startGatewayPoll } from './lib/app-state.js'
import { detectOpenclawStatus, isOpenclawReady, isGatewayRunning, onGatewayChange, startGatewayPoll, onGuardianGiveUp, resetAutoRestart } from './lib/app-state.js'
import { wsClient } from './lib/ws-client.js'
import { api } from './lib/tauri-api.js'
@@ -67,6 +67,11 @@ async function boot() {
wsClient.disconnect()
}
})
// 守护放弃时,弹出恢复选项
onGuardianGiveUp(() => {
showGuardianRecovery()
})
}
})
}
@@ -83,8 +88,12 @@ async function autoConnectWebSocket() {
try {
const pairResult = await api.autoPairDevice()
console.log('[main] 设备配对 + origins 已就绪:', pairResult)
// autoPairDevice 会写入 allowedOrigins需要 reload 使 Gateway 生效
needReload = true
// 仅在配置实际变更时才需要 reloaddev-api 返回 {changed}Tauri 返回字符串)
if (typeof pairResult === 'object' && pairResult.changed) {
needReload = true
} else if (typeof pairResult === 'string' && pairResult !== '设备已配对') {
needReload = true
}
} catch (pairErr) {
console.warn('[main] autoPairDevice 失败(非致命):', pairErr)
}
@@ -110,7 +119,8 @@ async function autoConnectWebSocket() {
}
}
wsClient.connect(`127.0.0.1:${port}`, token)
const host = window.__TAURI_INTERNALS__ ? `127.0.0.1:${port}` : location.host
wsClient.connect(host, token)
console.log('[main] WebSocket 连接已启动')
} catch (e) {
console.error('[main] 自动连接 WebSocket 失败:', e)
@@ -136,13 +146,51 @@ function setupGatewayBanner() {
banner.querySelector('#btn-gw-start')?.addEventListener('click', async (e) => {
const btn = e.target
btn.disabled = true
btn.classList.add('btn-loading')
btn.textContent = '启动中...'
try {
await api.startService('ai.openclaw.gateway')
} catch (err) {
btn.textContent = '启动失败,重试'
btn.disabled = false
const errMsg = err.message || String(err)
banner.innerHTML = `
<div class="gw-banner-content">
<span class="gw-banner-icon">⚠</span>
<span>启动失败: ${errMsg}</span>
<button class="btn btn-sm btn-primary" id="btn-gw-start">重试</button>
<a class="btn btn-sm btn-ghost" href="#/logs" style="color:inherit;text-decoration:underline">查看日志</a>
</div>
`
update(false)
return
}
// 轮询等待实际启动
const t0 = Date.now()
while (Date.now() - t0 < 30000) {
try {
const s = await api.getServicesStatus()
const gw = s?.find?.(x => x.label === 'ai.openclaw.gateway') || s?.[0]
if (gw?.running) { update(true); return }
} catch {}
const sec = Math.floor((Date.now() - t0) / 1000)
btn.textContent = `启动中... ${sec}s`
await new Promise(r => setTimeout(r, 1500))
}
// 超时后尝试获取日志帮助排查
let logHint = ''
try {
const logs = await api.readLogTail('gateway', 5)
if (logs?.trim()) logHint = `<div style="font-size:12px;margin-top:4px;opacity:0.8;font-family:monospace;white-space:pre-wrap">${logs.trim().split('\n').slice(-3).join('\n')}</div>`
} catch {}
banner.innerHTML = `
<div class="gw-banner-content">
<span class="gw-banner-icon">⚠</span>
<span>启动超时Gateway 可能仍在启动中</span>
<button class="btn btn-sm btn-primary" id="btn-gw-start">重试</button>
<a class="btn btn-sm btn-ghost" href="#/logs" style="color:inherit;text-decoration:underline">查看日志</a>
</div>
${logHint}
`
update(false)
})
}
}
@@ -151,4 +199,36 @@ function setupGatewayBanner() {
onGatewayChange(update)
}
function showGuardianRecovery() {
const banner = document.getElementById('gw-banner')
if (!banner) return
banner.classList.remove('gw-banner-hidden')
banner.innerHTML = `
<div class="gw-banner-content" style="flex-wrap:wrap;gap:8px">
<span class="gw-banner-icon">🛠</span>
<span>Gateway 反复启动失败,可能配置有误</span>
<button class="btn btn-sm btn-primary" id="btn-gw-recover-restart">重试启动</button>
<button class="btn btn-sm btn-secondary" id="btn-gw-recover-backup">从备份恢复</button>
<a class="btn btn-sm btn-ghost" href="#/services" style="color:inherit;text-decoration:underline">服务管理</a>
<a class="btn btn-sm btn-ghost" href="#/logs" style="color:inherit;text-decoration:underline">查看日志</a>
</div>
`
banner.querySelector('#btn-gw-recover-restart')?.addEventListener('click', async (e) => {
const btn = e.target
btn.disabled = true
btn.textContent = '启动中...'
resetAutoRestart()
try {
await api.startService('ai.openclaw.gateway')
btn.textContent = '已发送启动命令'
} catch (err) {
btn.textContent = '启动失败'
btn.disabled = false
}
})
banner.querySelector('#btn-gw-recover-backup')?.addEventListener('click', () => {
navigate('/services')
})
}
boot()

View File

@@ -5,6 +5,7 @@
import { api } from '../lib/tauri-api.js'
import { toast } from '../components/toast.js'
import { showUpgradeModal } from '../components/modal.js'
import { setUpgrading } from '../lib/app-state.js'
export async function render() {
const page = document.createElement('div')
@@ -15,7 +16,7 @@ export async function render() {
<img src="/images/logo-brand.png" alt="ClawPanel" style="height:48px;width:auto">
<div>
<h1 class="page-title" style="margin:0">ClawPanel</h1>
<p class="page-desc" style="margin:0">OpenClaw 可视化管理面板</p>
<p class="page-desc" style="margin:0">OpenClaw 可视化管理面板 · <a href="https://claw.qt.cool" target="_blank" rel="noopener" style="color:var(--primary)">claw.qt.cool</a></p>
</div>
</div>
<div class="stat-cards" id="version-cards">
@@ -119,17 +120,25 @@ async function loadData(page) {
upgradeBtn.onclick = async () => {
const modal = showUpgradeModal()
let unlistenLog, unlistenProgress
setUpgrading(true)
try {
const { listen } = await import('@tauri-apps/api/event')
unlistenLog = await listen('upgrade-log', (e) => modal.appendLog(e.payload))
unlistenProgress = await listen('upgrade-progress', (e) => modal.setProgress(e.payload))
if (window.__TAURI_INTERNALS__) {
try {
const { listen } = await import('@tauri-apps/api/event')
unlistenLog = await listen('upgrade-log', (e) => modal.appendLog(e.payload))
unlistenProgress = await listen('upgrade-progress', (e) => modal.setProgress(e.payload))
} catch { /* Web 模式无 Tauri event */ }
} else {
modal.appendLog('Web 模式:升级过程日志不可用,请等待完成...')
}
const msg = await api.upgradeOpenclaw()
modal.setDone(msg)
modal.setDone(typeof msg === 'string' ? msg : (msg?.message || '升级完成'))
loadData(page)
} catch (e) {
modal.appendLog(String(e))
modal.setError('升级失败')
} finally {
setUpgrading(false)
unlistenLog?.()
unlistenProgress?.()
}
@@ -220,6 +229,7 @@ function renderProjects(page) {
}
const LINKS = [
{ label: 'Claw 项目官网', url: 'https://claw.qt.cool', primary: true },
{ label: 'cftunnel 官网', url: 'https://cftunnel.qt.cool' },
{ label: 'cftunnel 桌面客户端', url: 'https://github.com/qingchencloud/cftunnel-app/releases' },
{ label: 'OpenClaw 中文翻译', url: 'https://github.com/1186258278/OpenClawChineseTranslation' },
@@ -244,6 +254,6 @@ function renderContribute(page) {
function renderLinks(page) {
const el = page.querySelector('#links-list')
el.innerHTML = `<div style="display:flex;flex-wrap:wrap;gap:var(--space-sm)">
${LINKS.map(l => `<a class="btn btn-secondary btn-sm" href="${l.url}" target="_blank" rel="noopener">${l.label}</a>`).join('')}
${LINKS.map(l => `<a class="btn ${l.primary ? 'btn-primary' : 'btn-secondary'} btn-sm" href="${l.url}" target="_blank" rel="noopener">${l.label}</a>`).join('')}
</div>`
}

View File

@@ -40,13 +40,14 @@ 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 = '', _currentAiImages = [], _currentRunId = null
let _isStreaming = false, _isSending = false, _messageQueue = []
let _currentAiBubble = null, _currentAiText = '', _currentAiImages = [], _currentAiVideos = [], _currentAiAudios = [], _currentAiFiles = [], _currentRunId = null
let _isStreaming = false, _isSending = false, _messageQueue = [], _streamStartTime = 0
let _lastRenderTime = 0, _renderPending = false, _lastHistoryHash = ''
let _streamSafetyTimer = null, _unsubEvent = null, _unsubReady = null, _unsubStatus = null
let _pageActive = false
let _errorTimer = null, _lastErrorMsg = null
let _attachments = []
let _hasEverConnected = false
export async function render() {
const page = document.createElement('div')
@@ -317,6 +318,7 @@ async function connectGateway() {
const overlay = document.getElementById('chat-connect-overlay')
const desc = document.getElementById('chat-connect-desc')
if (status === 'ready' || status === 'connected') {
_hasEverConnected = true
if (bar) bar.style.display = 'none'
if (overlay) overlay.style.display = 'none'
} else if (status === 'error') {
@@ -327,7 +329,12 @@ async function connectGateway() {
if (desc) desc.textContent = errorMsg || '连接 Gateway 失败'
}
} else if (status === 'reconnecting' || status === 'disconnected') {
if (bar) { bar.textContent = '连接已断开,正在重连...'; bar.style.display = 'flex' }
// 首次连接或多次重连失败时,显示引导遮罩而非底部小条
if (!_hasEverConnected) {
if (overlay) { overlay.style.display = 'flex'; if (desc) desc.textContent = '正在连接 Gateway...' }
} else {
if (bar) { bar.textContent = '连接已断开,正在重连...'; bar.style.display = 'flex' }
}
} else {
if (bar) bar.style.display = 'none'
}
@@ -380,7 +387,7 @@ async function connectGateway() {
// 未连接,发起新连接
const config = await api.readOpenclawConfig()
const gw = config?.gateway || {}
const host = `127.0.0.1:${gw.port || 18789}`
const host = window.__TAURI_INTERNALS__ ? `127.0.0.1:${gw.port || 18789}` : location.host
const token = gw.auth?.token || gw.authToken || ''
wsClient.connect(host, token)
} catch (e) {
@@ -592,7 +599,10 @@ function sendMessage() {
async function doSend(text, attachments = []) {
appendUserMessage(text, attachments)
saveMessage({ id: uuid(), sessionKey: _sessionKey, role: 'user', content: text, timestamp: Date.now() })
saveMessage({
id: uuid(), sessionKey: _sessionKey, role: 'user', content: text, timestamp: Date.now(),
attachments: attachments?.length ? attachments.map(a => ({ category: a.category || 'image', mimeType: a.mimeType || '', content: a.content || '', url: a.url || '' })) : undefined
})
showTyping(true)
_isSending = true
try {
@@ -635,15 +645,32 @@ function handleChatEvent(payload) {
if (state === 'delta') {
const c = extractChatContent(payload.message)
if (c?.images?.length) _currentAiImages = c.images
if (c?.videos?.length) _currentAiVideos = c.videos
if (c?.audios?.length) _currentAiAudios = c.audios
if (c?.files?.length) _currentAiFiles = c.files
if (c?.text && c.text.length > _currentAiText.length) {
showTyping(false)
if (!_currentAiBubble) {
_currentAiBubble = createStreamBubble()
_currentRunId = payload.runId
_isStreaming = true
_streamStartTime = Date.now()
updateSendState()
}
_currentAiText = c.text
// 每次收到 delta 重置安全超时90s 无新 delta 则强制结束)
clearTimeout(_streamSafetyTimer)
_streamSafetyTimer = setTimeout(() => {
if (_isStreaming) {
console.warn('[chat] 流式输出超时90s 无新数据),强制结束')
if (_currentAiBubble && _currentAiText) {
_currentAiBubble.innerHTML = renderMarkdown(_currentAiText)
}
appendSystemMessage('输出超时,已自动结束')
resetStreamState()
processMessageQueue()
}
}, 90000)
throttledRender()
}
return
@@ -653,8 +680,14 @@ function handleChatEvent(payload) {
const c = extractChatContent(payload.message)
const finalText = c?.text || ''
const finalImages = c?.images || []
const finalVideos = c?.videos || []
const finalAudios = c?.audios || []
const finalFiles = c?.files || []
if (finalImages.length) _currentAiImages = finalImages
const hasContent = finalText || _currentAiImages.length
if (finalVideos.length) _currentAiVideos = finalVideos
if (finalAudios.length) _currentAiAudios = finalAudios
if (finalFiles.length) _currentAiFiles = finalFiles
const hasContent = finalText || _currentAiImages.length || _currentAiVideos.length || _currentAiAudios.length || _currentAiFiles.length
// 忽略空 finalGateway 会为一条消息触发多个 run部分是空 final
if (!_currentAiBubble && !hasContent) return
showTyping(false)
@@ -666,9 +699,32 @@ function handleChatEvent(payload) {
if (_currentAiBubble) {
if (_currentAiText) _currentAiBubble.innerHTML = renderMarkdown(_currentAiText)
appendImagesToEl(_currentAiBubble, _currentAiImages)
appendVideosToEl(_currentAiBubble, _currentAiVideos)
appendAudiosToEl(_currentAiBubble, _currentAiAudios)
appendFilesToEl(_currentAiBubble, _currentAiFiles)
}
if (_currentAiText) {
saveMessage({ id: payload.runId || uuid(), sessionKey: _sessionKey, role: 'assistant', content: _currentAiText, timestamp: Date.now() })
// 添加时间戳 + 耗时
const wrapper = _currentAiBubble?.parentElement
if (wrapper) {
const time = document.createElement('div')
time.className = 'msg-time'
let timeStr = formatTime(new Date())
// 计算响应耗时
if (payload.durationMs) {
timeStr += ` · ${(payload.durationMs / 1000).toFixed(1)}s`
} else if (_streamStartTime) {
const dur = ((Date.now() - _streamStartTime) / 1000).toFixed(1)
timeStr += ` · ${dur}s`
}
time.textContent = timeStr
wrapper.appendChild(time)
}
if (_currentAiText || _currentAiImages.length) {
saveMessage({
id: payload.runId || uuid(), sessionKey: _sessionKey, role: 'assistant',
content: _currentAiText, timestamp: Date.now(),
attachments: _currentAiImages.map(i => ({ category: 'image', mimeType: i.mediaType || 'image/png', url: i.url, content: i.data })).filter(a => a.url || a.content)
})
}
resetStreamState()
processMessageQueue()
@@ -724,26 +780,75 @@ 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
const images = []
if (typeof content === 'string') return { text: content, images }
if (typeof content === 'string') return { text: stripThinkingTags(content), images: [], videos: [], audios: [], files: [] }
if (Array.isArray(content)) {
const texts = []
const texts = [], images = [], videos = [], audios = [], files = []
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)
else if (block.type === 'image' && !block.omitted) {
if (block.data) images.push({ mediaType: block.mimeType || 'image/png', data: block.data })
else if (block.source?.type === 'base64' && block.source.data) images.push({ mediaType: block.source.media_type || 'image/png', data: block.source.data })
else if (block.url || block.source?.url) images.push({ url: block.url || block.source.url, mediaType: block.mimeType || 'image/png' })
}
else if (block.type === 'image_url' && block.image_url?.url) images.push({ url: block.image_url.url, mediaType: 'image/png' })
else if (block.type === 'video') {
if (block.data) videos.push({ mediaType: block.mimeType || 'video/mp4', data: block.data })
else if (block.url) videos.push({ url: block.url, mediaType: block.mimeType || 'video/mp4' })
}
else if (block.type === 'audio' || block.type === 'voice') {
if (block.data) audios.push({ mediaType: block.mimeType || 'audio/mpeg', data: block.data, duration: block.duration })
else if (block.url) audios.push({ url: block.url, mediaType: block.mimeType || 'audio/mpeg', duration: block.duration })
}
else if (block.type === 'file' || block.type === 'document') {
files.push({ url: block.url || '', name: block.fileName || block.name || '文件', mimeType: block.mimeType || '', size: block.size, data: block.data })
}
}
const text = texts.length ? texts.join('\n') : ''
return { text, images }
// 从 mediaUrl/mediaUrls 提取
const mediaUrls = message.mediaUrls || (message.mediaUrl ? [message.mediaUrl] : [])
for (const url of mediaUrls) {
if (!url) continue
if (/\.(mp4|webm|mov|mkv)(\?|$)/i.test(url)) videos.push({ url, mediaType: 'video/mp4' })
else if (/\.(mp3|wav|ogg|m4a|aac|flac)(\?|$)/i.test(url)) audios.push({ url, mediaType: 'audio/mpeg' })
else if (/\.(jpe?g|png|gif|webp|heic|svg)(\?|$)/i.test(url)) images.push({ url, mediaType: 'image/png' })
else files.push({ url, name: url.split('/').pop().split('?')[0] || '文件', mimeType: '' })
}
const text = texts.length ? stripThinkingTags(texts.join('\n')) : ''
return { text, images, videos, audios, files }
}
if (typeof message.text === 'string') return { text: message.text, images }
if (typeof message.text === 'string') return { text: stripThinkingTags(message.text), images: [], videos: [], audios: [], files: [] }
return null
}
function stripThinkingTags(text) {
return text
.replace(/<\s*think(?:ing)?\s*>[\s\S]*?<\s*\/\s*think(?:ing)?\s*>/gi, '')
.replace(/Conversation info \(untrusted metadata\):\s*```json[\s\S]*?```\s*/gi, '')
.replace(/\[Queued messages while agent was busy\]\s*---\s*Queued #\d+\s*/gi, '')
.trim()
}
function formatTime(date) {
const now = new Date()
const h = date.getHours().toString().padStart(2, '0')
const m = date.getMinutes().toString().padStart(2, '0')
const isToday = date.getFullYear() === now.getFullYear() && date.getMonth() === now.getMonth() && date.getDate() === now.getDate()
if (isToday) return `${h}:${m}`
const mon = (date.getMonth() + 1).toString().padStart(2, '0')
const day = date.getDate().toString().padStart(2, '0')
return `${mon}-${day} ${h}:${m}`
}
function formatFileSize(bytes) {
if (!bytes || bytes <= 0) return ''
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
}
/** 创建流式 AI 气泡 */
function createStreamBubble() {
showTyping(false)
@@ -783,17 +888,24 @@ function doRender() {
function resetStreamState() {
clearTimeout(_streamSafetyTimer)
if (_currentAiBubble && (_currentAiText || _currentAiImages.length)) {
if (_currentAiBubble && (_currentAiText || _currentAiImages.length || _currentAiVideos.length || _currentAiAudios.length || _currentAiFiles.length)) {
_currentAiBubble.innerHTML = renderMarkdown(_currentAiText)
appendImagesToEl(_currentAiBubble, _currentAiImages)
appendVideosToEl(_currentAiBubble, _currentAiVideos)
appendAudiosToEl(_currentAiBubble, _currentAiAudios)
appendFilesToEl(_currentAiBubble, _currentAiFiles)
}
_renderPending = false
_lastRenderTime = 0
_currentAiBubble = null
_currentAiText = ''
_currentAiImages = []
_currentAiVideos = []
_currentAiAudios = []
_currentAiFiles = []
_currentRunId = null
_isStreaming = false
_streamStartTime = 0
_lastErrorMsg = null
_errorTimer = null
showTyping(false)
@@ -804,13 +916,19 @@ function resetStreamState() {
async function loadHistory() {
if (!_sessionKey) return
if (isStorageAvailable()) {
const hasExisting = _messagesEl?.querySelector('.msg')
if (!hasExisting && isStorageAvailable()) {
const local = await getLocalMessages(_sessionKey, 200)
if (local.length) {
clearMessages()
local.forEach(msg => {
if (msg.role === 'user') appendUserMessage(msg.content || '')
else appendAiMessage(msg.content || '')
if (!msg.content && !msg.attachments?.length) return
const msgTime = msg.timestamp ? new Date(msg.timestamp) : new Date()
if (msg.role === 'user') appendUserMessage(msg.content || '', msg.attachments || null, msgTime)
else if (msg.role === 'assistant') {
const images = (msg.attachments || []).filter(a => a.category === 'image').map(a => ({ mediaType: a.mimeType, data: a.content, url: a.url }))
appendAiMessage(msg.content || '', msgTime, images)
}
})
scrollToBottom()
}
@@ -824,23 +942,41 @@ async function loadHistory() {
}
const deduped = dedupeHistory(result.messages)
const hash = deduped.map(m => `${m.role}:${(m.text || '').length}`).join('|')
if (hash === _lastHistoryHash && _messagesEl.querySelector('.msg')) return
if (hash === _lastHistoryHash && hasExisting) return
_lastHistoryHash = hash
// 正在发送/流式输出时不全量重绘,避免覆盖本地乐观渲染
if (hasExisting && (_isSending || _isStreaming || _messageQueue.length > 0)) {
saveMessages(result.messages.map(m => {
const c = extractContent(m)
return { id: m.id || uuid(), sessionKey: _sessionKey, role: m.role, content: c?.text || '', timestamp: m.timestamp || Date.now() }
}))
return
}
clearMessages()
let hasOmittedImages = false
deduped.forEach(msg => {
if (!msg.text && !msg.images?.length && !msg.videos?.length && !msg.audios?.length && !msg.files?.length) return
const msgTime = msg.timestamp ? new Date(msg.timestamp) : new Date()
if (msg.role === 'user') {
const userImages = msg.images?.length ? msg.images.map(i => ({
const userAtts = msg.images?.length ? msg.images.map(i => ({
mimeType: i.mediaType || i.media_type || 'image/png',
content: i.data || i.source?.data || '',
category: 'image',
})).filter(a => a.content) : []
appendUserMessage(msg.text, userImages)
if (msg.images?.length && !userAtts.length) hasOmittedImages = true
appendUserMessage(msg.text, userAtts, msgTime)
} else if (msg.role === 'assistant') {
appendAiMessage(msg.text, msg.images)
appendAiMessage(msg.text, msgTime, msg.images, msg.videos, msg.audios, msg.files)
}
})
if (hasOmittedImages) {
appendSystemMessage('部分历史图片无法显示Gateway 不保留图片原始数据,仅当前会话内可见)')
}
saveMessages(result.messages.map(m => {
const c = extractContent(m)
return { id: m.id || uuid(), sessionKey: _sessionKey, role: m.role, content: c.text || '', 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) {
@@ -854,55 +990,109 @@ function dedupeHistory(messages) {
for (const msg of messages) {
if (msg.role === 'toolResult') continue
const c = extractContent(msg)
if (!c.text && !c.images.length) continue
if (!c.text && !c.images.length && !c.videos.length && !c.audios.length && !c.files.length) continue
const last = deduped[deduped.length - 1]
if (last && last.role === msg.role) {
if (msg.role === 'user' && last.text === c.text) continue
if (msg.role === 'assistant') {
// 同文本去重Gateway 重试产生的重复回复)
if (c.text && last.text === c.text) continue
// 不同文本则合并
last.text = [last.text, c.text].filter(Boolean).join('\n')
last.images = [...(last.images || []), ...c.images]
last.videos = [...(last.videos || []), ...c.videos]
last.audios = [...(last.audios || []), ...c.audios]
last.files = [...(last.files || []), ...c.files]
continue
}
}
deduped.push({ role: msg.role, text: c.text, images: c.images, timestamp: msg.timestamp })
deduped.push({ role: msg.role, text: c.text, images: c.images, videos: c.videos, audios: c.audios, files: c.files, timestamp: msg.timestamp })
}
return deduped
}
function extractContent(msg) {
const images = []
if (Array.isArray(msg.content)) {
const texts = []
const texts = [], images = [], videos = [], audios = [], files = []
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)
else if (block.type === 'image' && !block.omitted) {
if (block.data) images.push({ mediaType: block.mimeType || 'image/png', data: block.data })
else if (block.source?.type === 'base64' && block.source.data) images.push({ mediaType: block.source.media_type || 'image/png', data: block.source.data })
else if (block.url || block.source?.url) images.push({ url: block.url || block.source.url, mediaType: block.mimeType || 'image/png' })
}
else if (block.type === 'image_url' && block.image_url?.url) images.push({ url: block.image_url.url, mediaType: 'image/png' })
else if (block.type === 'video') {
if (block.data) videos.push({ mediaType: block.mimeType || 'video/mp4', data: block.data })
else if (block.url) videos.push({ url: block.url, mediaType: block.mimeType || 'video/mp4' })
}
else if (block.type === 'audio' || block.type === 'voice') {
if (block.data) audios.push({ mediaType: block.mimeType || 'audio/mpeg', data: block.data, duration: block.duration })
else if (block.url) audios.push({ url: block.url, mediaType: block.mimeType || 'audio/mpeg', duration: block.duration })
}
else if (block.type === 'file' || block.type === 'document') {
files.push({ url: block.url || '', name: block.fileName || block.name || '文件', mimeType: block.mimeType || '', size: block.size, data: block.data })
}
}
return { text: texts.join('\n'), images }
const mediaUrls = msg.mediaUrls || (msg.mediaUrl ? [msg.mediaUrl] : [])
for (const url of mediaUrls) {
if (!url) continue
if (/\.(mp4|webm|mov|mkv)(\?|$)/i.test(url)) videos.push({ url, mediaType: 'video/mp4' })
else if (/\.(mp3|wav|ogg|m4a|aac|flac)(\?|$)/i.test(url)) audios.push({ url, mediaType: 'audio/mpeg' })
else if (/\.(jpe?g|png|gif|webp|heic|svg)(\?|$)/i.test(url)) images.push({ url, mediaType: 'image/png' })
else files.push({ url, name: url.split('/').pop().split('?')[0] || '文件', mimeType: '' })
}
return { text: stripThinkingTags(texts.join('\n')), images, videos, audios, files }
}
const text = typeof msg.text === 'string' ? msg.text : (typeof msg.content === 'string' ? msg.content : '')
return { text, images }
return { text: stripThinkingTags(text), images: [], videos: [], audios: [], files: [] }
}
// ── DOM 操作 ──
function appendUserMessage(text, attachments = []) {
function appendUserMessage(text, attachments = [], msgTime) {
const wrap = document.createElement('div')
wrap.className = 'msg msg-user'
const bubble = document.createElement('div')
bubble.className = 'msg-bubble'
if (attachments.length > 0) {
const imgContainer = document.createElement('div')
imgContainer.style.cssText = 'display:flex;gap:4px;margin-bottom:8px;flex-wrap:wrap'
if (attachments && attachments.length > 0) {
const mediaContainer = document.createElement('div')
mediaContainer.style.cssText = 'display:flex;gap:4px;margin-bottom:8px;flex-wrap:wrap'
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;cursor:pointer'
img.onclick = () => showLightbox(img.src)
imgContainer.appendChild(img)
const cat = att.category || att.type || 'image'
const src = att.data ? `data:${att.mimeType || att.mediaType || 'image/png'};base64,${att.data}`
: att.content ? `data:${att.mimeType || 'image/png'};base64,${att.content}`
: att.url || ''
if (cat === 'image' && src) {
const img = document.createElement('img')
img.src = src
img.className = 'msg-img'
img.onclick = () => showLightbox(img.src)
mediaContainer.appendChild(img)
} else if (cat === 'video' && src) {
const video = document.createElement('video')
video.src = src
video.className = 'msg-video'
video.controls = true
video.preload = 'metadata'
video.playsInline = true
mediaContainer.appendChild(video)
} else if (cat === 'audio' && src) {
const audio = document.createElement('audio')
audio.src = src
audio.className = 'msg-audio'
audio.controls = true
audio.preload = 'metadata'
mediaContainer.appendChild(audio)
} else if (att.fileName || att.name) {
const card = document.createElement('div')
card.className = 'msg-file-card'
card.innerHTML = `<span class="msg-file-icon">📎</span><span class="msg-file-name">${att.fileName || att.name}</span>`
mediaContainer.appendChild(card)
}
})
bubble.appendChild(imgContainer)
if (mediaContainer.children.length) bubble.appendChild(mediaContainer)
}
if (text) {
@@ -911,19 +1101,35 @@ function appendUserMessage(text, attachments = []) {
bubble.appendChild(textNode)
}
const time = document.createElement('div')
time.className = 'msg-time'
time.textContent = formatTime(msgTime || new Date())
wrap.appendChild(bubble)
wrap.appendChild(time)
_messagesEl.insertBefore(wrap, _typingEl)
scrollToBottom()
}
function appendAiMessage(text, images) {
function appendAiMessage(text, msgTime, images, videos, audios, files) {
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)
appendVideosToEl(bubble, videos)
appendAudiosToEl(bubble, audios)
appendFilesToEl(bubble, files)
// 图片点击灯箱
bubble.querySelectorAll('img').forEach(img => { if (!img.onclick) img.onclick = () => showLightbox(img.src) })
const time = document.createElement('div')
time.className = 'msg-time'
time.textContent = formatTime(msgTime || new Date())
wrap.appendChild(bubble)
wrap.appendChild(time)
_messagesEl.insertBefore(wrap, _typingEl)
scrollToBottom()
}
@@ -957,6 +1163,62 @@ function appendImagesToEl(el, images) {
if (container.children.length) el.appendChild(container)
}
/** 渲染视频到消息气泡 */
function appendVideosToEl(el, videos) {
if (!videos?.length) return
videos.forEach(vid => {
const videoEl = document.createElement('video')
videoEl.className = 'msg-video'
videoEl.controls = true
videoEl.preload = 'metadata'
videoEl.playsInline = true
if (vid.data) videoEl.src = `data:${vid.mediaType};base64,${vid.data}`
else if (vid.url) videoEl.src = vid.url
el.appendChild(videoEl)
})
}
/** 渲染音频到消息气泡 */
function appendAudiosToEl(el, audios) {
if (!audios?.length) return
audios.forEach(aud => {
const audioEl = document.createElement('audio')
audioEl.className = 'msg-audio'
audioEl.controls = true
audioEl.preload = 'metadata'
if (aud.data) audioEl.src = `data:${aud.mediaType};base64,${aud.data}`
else if (aud.url) audioEl.src = aud.url
el.appendChild(audioEl)
})
}
/** 渲染文件卡片到消息气泡 */
function appendFilesToEl(el, files) {
if (!files?.length) return
files.forEach(f => {
const card = document.createElement('div')
card.className = 'msg-file-card'
const ext = (f.name || '').split('.').pop().toLowerCase()
const iconMap = { pdf: '📄', doc: '📝', docx: '📝', txt: '📃', md: '📃', json: '📋', csv: '📊', zip: '📦', rar: '📦' }
const icon = iconMap[ext] || '📎'
const size = f.size ? formatFileSize(f.size) : ''
card.innerHTML = `<span class="msg-file-icon">${icon}</span><div class="msg-file-info"><span class="msg-file-name">${f.name || '文件'}</span>${size ? `<span class="msg-file-size">${size}</span>` : ''}</div>`
if (f.url) {
card.style.cursor = 'pointer'
card.onclick = () => window.open(f.url, '_blank')
} else if (f.data) {
card.style.cursor = 'pointer'
card.onclick = () => {
const a = document.createElement('a')
a.href = `data:${f.mimeType || 'application/octet-stream'};base64,${f.data}`
a.download = f.name || '文件'
a.click()
}
}
el.appendChild(card)
})
}
/** 图片灯箱查看 */
function showLightbox(src) {
const existing = document.querySelector('.chat-lightbox')
@@ -1036,6 +1298,9 @@ export function cleanup() {
_currentAiBubble = null
_currentAiText = ''
_currentAiImages = []
_currentAiVideos = []
_currentAiAudios = []
_currentAiFiles = []
_currentRunId = null
_isStreaming = false
_isSending = false

View File

@@ -3,6 +3,9 @@
*/
import { api } from '../lib/tauri-api.js'
import { toast } from '../components/toast.js'
import { onGatewayChange } from '../lib/app-state.js'
let _unsubGw = null
export async function render() {
const page = document.createElement('div')
@@ -35,9 +38,20 @@ export async function render() {
// 异步加载数据
loadDashboardData(page)
// 监听 Gateway 状态变化,自动刷新仪表盘
if (_unsubGw) _unsubGw()
_unsubGw = onGatewayChange(() => {
loadDashboardData(page)
})
return page
}
export function cleanup() {
if (_unsubGw) { _unsubGw(); _unsubGw = null }
}
async function loadDashboardData(page) {
// 分波加载:关键数据先渲染,次要数据后填充,减少白屏等待
const coreP = Promise.allSettled([
@@ -97,7 +111,7 @@ function renderStatCards(page, services, version, agents, config, tunnel) {
<span class="status-dot ${gw?.running ? 'running' : 'stopped'}"></span>
</div>
<div class="stat-card-value">${gw?.running ? '运行中' : '已停止'}</div>
<div class="stat-card-meta">${gw?.pid ? 'PID: ' + gw.pid : '未启动'}</div>
<div class="stat-card-meta">${gw?.pid ? 'PID: ' + gw.pid : (gw?.running ? '端口检测' : '未启动')}</div>
</div>
<div class="stat-card">
<div class="stat-card-header">
@@ -183,7 +197,7 @@ function renderOverview(page, services, clawapp, tunnel, mcpConfig, backups, con
Cloudflare 隧道
</div>
<div class="overview-value" style="color: ${tunnel?.running ? 'var(--success)' : (tunnel?.installed ? 'var(--warning)' : 'var(--text-tertiary)')}">
${tunnel?.running ? tunnel.tunnel_name : (tunnel?.installed ? '已停止' : '未安装')}
${tunnel?.running ? (tunnel.tunnel_name || '运行中') : (tunnel?.installed ? '已停止' : '未安装')}
</div>
</div>
<div class="overview-item">
@@ -257,17 +271,41 @@ function bindActions(page) {
btnRestart?.addEventListener('click', async () => {
btnRestart.disabled = true
btnRestart.classList.add('btn-loading')
btnRestart.textContent = '重启中...'
try {
await api.restartService('ai.openclaw.gateway')
toast('Gateway 已重启', 'success')
setTimeout(() => loadDashboardData(page), 500)
} catch (e) {
toast('重启失败: ' + e, 'error')
} finally {
btnRestart.disabled = false
btnRestart.classList.remove('btn-loading')
btnRestart.textContent = '重启 Gateway'
return
}
// 轮询等待实际重启完成
const t0 = Date.now()
while (Date.now() - t0 < 30000) {
try {
const s = await api.getServicesStatus()
const gw = s?.find?.(x => x.label === 'ai.openclaw.gateway') || s?.[0]
if (gw?.running) {
toast(`Gateway 已重启 (PID: ${gw.pid})`, 'success')
btnRestart.disabled = false
btnRestart.classList.remove('btn-loading')
btnRestart.textContent = '重启 Gateway'
loadDashboardData(page)
return
}
} catch {}
const sec = Math.floor((Date.now() - t0) / 1000)
btnRestart.textContent = `重启中... ${sec}s`
await new Promise(r => setTimeout(r, 1500))
}
toast('重启超时Gateway 可能仍在启动中', 'warning')
btnRestart.disabled = false
btnRestart.classList.remove('btn-loading')
btnRestart.textContent = '重启 Gateway'
loadDashboardData(page)
})
btnUpdate?.addEventListener('click', async () => {

View File

@@ -227,13 +227,15 @@ function bindEvents(page) {
async function handleCftunnelAction(page, action) {
const label = action === 'up' ? '启动' : '停止'
const btn = page.querySelector(`[data-action="cftunnel-${action === 'up' ? 'up' : 'down'}"]`)
if (btn) { btn.classList.add('btn-loading'); btn.disabled = true; btn.textContent = `${label}中...` }
try {
toast(`正在${label}隧道...`, 'info')
await api.cftunnelAction(action)
toast(`隧道已${label}`, 'success')
await loadCftunnel(page)
} catch (e) {
toast(`${label}失败: ${e}`, 'error')
if (btn) { btn.classList.remove('btn-loading'); btn.disabled = false; btn.textContent = `${label}隧道` }
}
}
@@ -284,18 +286,22 @@ async function handleInstallCftunnel(page) {
let unlistenLog, unlistenProgress
try {
const { listen } = await import('@tauri-apps/api/event')
unlistenLog = await listen('install-log', (e) => {
logBox.textContent += e.payload + '\n'
logBox.scrollTop = logBox.scrollHeight
})
unlistenProgress = await listen('install-progress', (e) => {
const progress = e.payload
progressFill.style.width = progress + '%'
progressText.textContent = `安装中... ${progress}%`
})
if (window.__TAURI_INTERNALS__) {
try {
const { listen } = await import('@tauri-apps/api/event')
unlistenLog = await listen('install-log', (e) => {
logBox.textContent += e.payload + '\n'
logBox.scrollTop = logBox.scrollHeight
})
unlistenProgress = await listen('install-progress', (e) => {
const progress = e.payload
progressFill.style.width = progress + '%'
progressText.textContent = `安装中... ${progress}%`
})
} catch { /* Web 模式无 Tauri event */ }
} else {
logBox.textContent += 'Web 模式:安装日志不可用,请等待完成...\n'
}
await api.installCftunnel()
@@ -338,18 +344,22 @@ async function handleInstallClawapp(page) {
let unlistenLog, unlistenProgress
try {
const { listen } = await import('@tauri-apps/api/event')
unlistenLog = await listen('install-log', (e) => {
logBox.textContent += e.payload + '\n'
logBox.scrollTop = logBox.scrollHeight
})
unlistenProgress = await listen('install-progress', (e) => {
const progress = e.payload
progressFill.style.width = progress + '%'
progressText.textContent = `安装中... ${progress}%`
})
if (window.__TAURI_INTERNALS__) {
try {
const { listen } = await import('@tauri-apps/api/event')
unlistenLog = await listen('install-log', (e) => {
logBox.textContent += e.payload + '\n'
logBox.scrollTop = logBox.scrollHeight
})
unlistenProgress = await listen('install-progress', (e) => {
const progress = e.payload
progressFill.style.width = progress + '%'
progressText.textContent = `安装中... ${progress}%`
})
} catch { /* Web 模式无 Tauri event */ }
} else {
logBox.textContent += 'Web 模式:安装日志不可用,请等待完成...\n'
}
await api.installClawapp()

View File

@@ -33,11 +33,13 @@ export async function render() {
page.querySelector('#btn-save-gw').onclick = async () => {
const btn = page.querySelector('#btn-save-gw')
btn.disabled = true
btn.classList.add('btn-loading')
btn.textContent = '保存中...'
try {
await saveConfig(page, state)
} finally {
btn.disabled = false
btn.classList.remove('btn-loading')
btn.innerHTML = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><path d="M19 21H5a2 2 0 01-2-2V5a2 2 0 012-2h11l5 5v11a2 2 0 01-2 2z"/><path d="M17 21v-8H7v8"/><path d="M7 3v5h8"/></svg> 保存并生效`
}
}

View File

@@ -75,6 +75,10 @@ export function cleanup() {
async function loadLog(page, logName) {
const el = page.querySelector('#log-content')
const refreshBtn = page.querySelector('#btn-refresh')
// 显示加载状态
el.innerHTML = '<div class="log-loading"><div class="service-spinner"></div><span style="color:var(--text-tertiary);margin-left:8px">加载日志中...</span></div>'
if (refreshBtn) { refreshBtn.classList.add('btn-loading'); refreshBtn.disabled = true }
try {
const content = await api.readLogTail(logName, 200)
if (!content || !content.trim()) {
@@ -89,6 +93,8 @@ async function loadLog(page, logName) {
} catch (e) {
el.innerHTML = '<div style="color:var(--error);padding:12px">加载日志失败: ' + e + '</div>'
toast('加载日志失败: ' + e, 'error')
} finally {
if (refreshBtn) { refreshBtn.classList.remove('btn-loading'); refreshBtn.disabled = false }
}
}

View File

@@ -292,9 +292,9 @@ function renderModelCards(providerKey, models, primary, search) {
const testTime = m.lastTestAt ? formatTestTime(m.lastTestAt) : ''
if (testTime) meta.push(testTime)
return `
<div class="model-card" data-model-id="${id}" data-full="${full}" draggable="true"
style="background:${bgColor};border:1px solid ${borderColor};padding:10px 14px;border-radius:var(--radius-md);margin-bottom:8px;display:flex;align-items:center;gap:10px;cursor:grab">
<span style="color:var(--text-tertiary);cursor:grab;user-select:none;font-size:16px;padding-right:4px">⋮⋮</span>
<div class="model-card" data-model-id="${id}" data-full="${full}"
style="background:${bgColor};border:1px solid ${borderColor};padding:10px 14px;border-radius:var(--radius-md);margin-bottom:8px;display:flex;align-items:center;gap:10px">
<span class="drag-handle" style="color:var(--text-tertiary);cursor:grab;user-select:none;font-size:16px;padding:4px;touch-action:none">⋮⋮</span>
<input type="checkbox" class="model-checkbox" data-model-id="${id}" style="flex-shrink:0;cursor:pointer">
<div style="flex:1;min-width:0">
<div style="display:flex;align-items:center;gap:8px">
@@ -363,28 +363,45 @@ function autoSave(state) {
_saveTimer = setTimeout(() => doAutoSave(state), 300)
}
// 仅保存配置,不重启 Gateway用于测试结果等元数据持久化
async function saveConfigOnly(state) {
try {
const primary = getCurrentPrimary(state.config)
if (primary) applyDefaultModel(state)
await api.writeOpenclawConfig(state.config)
} catch (e) {
toast('保存失败: ' + e, 'error')
}
}
async function doAutoSave(state) {
try {
const primary = getCurrentPrimary(state.config)
if (primary) applyDefaultModel(state)
await api.writeOpenclawConfig(state.config)
// 提示用户需要重启 Gateway
const restartBtn = document.createElement('button')
restartBtn.className = 'btn btn-sm btn-primary'
restartBtn.textContent = '立即重启'
restartBtn.style.marginLeft = '8px'
restartBtn.onclick = async () => {
try {
toast('正在重启 Gateway...', 'info')
await api.restartGateway()
toast('Gateway 重启成功', 'success')
} catch (e) {
toast('重启失败: ' + e.message, 'error')
// 重启 Gateway 使配置生效Gateway 不支持 SIGHUP 热重载)
toast('配置已保存,正在重启 Gateway...', 'info')
try {
await api.restartGateway()
toast('配置已生效Gateway 已重启', 'success')
} catch (e) {
// 重启失败时提供手动重试按钮
const restartBtn = document.createElement('button')
restartBtn.className = 'btn btn-sm btn-primary'
restartBtn.textContent = '重试'
restartBtn.style.marginLeft = '8px'
restartBtn.onclick = async () => {
try {
toast('正在重启 Gateway...', 'info')
await api.restartGateway()
toast('Gateway 重启成功', 'success')
} catch (e2) {
toast('重启失败: ' + e2.message, 'error')
}
}
toast('配置已保存,但 Gateway 重启失败: ' + e.message, 'warning', { action: restartBtn })
}
toast('配置已保存,需要重启 Gateway 生效', 'warning', { action: restartBtn })
} catch (e) {
toast('自动保存失败: ' + e, 'error')
}
@@ -426,54 +443,98 @@ function bindProviderButtons(listEl, page, state) {
}
})
// 绑定拖拽排序 (Drag & Drop)
// 绑定拖拽排序Pointer 事件实现,兼容 Tauri WebView2/WKWebView
listEl.querySelectorAll('.provider-models').forEach(container => {
let dragged = null
container.addEventListener('dragstart', e => {
dragged = e.target.closest('.model-card')
if (dragged) {
dragged.style.opacity = '0.5'
e.dataTransfer.effectAllowed = 'move'
}
})
container.addEventListener('dragend', e => {
if (dragged) {
dragged.style.opacity = '1'
dragged = null
}
})
container.addEventListener('dragover', e => {
let placeholder = null
let startY = 0
// 仅从拖拽手柄启动
container.addEventListener('pointerdown', e => {
const handle = e.target.closest('.drag-handle')
if (!handle) return
const card = handle.closest('.model-card')
if (!card) return
e.preventDefault()
const targetCard = e.target.closest('.model-card')
if (dragged && targetCard && dragged !== targetCard) {
const bounding = targetCard.getBoundingClientRect()
const offset = bounding.y + bounding.height / 2
if (e.clientY > offset) {
targetCard.after(dragged)
} else {
targetCard.before(dragged)
dragged = card
startY = e.clientY
// 创建占位符
placeholder = document.createElement('div')
placeholder.style.cssText = `height:${card.offsetHeight}px;border:2px dashed var(--border);border-radius:var(--radius-md);margin-bottom:8px;background:var(--bg-secondary)`
card.after(placeholder)
// 浮动拖拽元素
const rect = card.getBoundingClientRect()
card.style.position = 'fixed'
card.style.left = rect.left + 'px'
card.style.top = rect.top + 'px'
card.style.width = rect.width + 'px'
card.style.zIndex = '9999'
card.style.opacity = '0.85'
card.style.boxShadow = '0 8px 24px rgba(0,0,0,0.2)'
card.style.pointerEvents = 'none'
card.setPointerCapture(e.pointerId)
})
container.addEventListener('pointermove', e => {
if (!dragged || !placeholder) return
e.preventDefault()
// 移动浮动元素
const dy = e.clientY - startY
const origTop = parseFloat(dragged.style.top)
dragged.style.top = (origTop + dy) + 'px'
startY = e.clientY
// 查找目标位置
const siblings = [...container.querySelectorAll('.model-card:not([style*="position: fixed"])')].filter(c => c !== dragged)
for (const sibling of siblings) {
const rect = sibling.getBoundingClientRect()
const midY = rect.top + rect.height / 2
if (e.clientY < midY) {
sibling.before(placeholder)
return
}
}
// 放到最后
if (siblings.length) siblings[siblings.length - 1].after(placeholder)
})
container.addEventListener('drop', e => {
e.preventDefault()
if (!dragged) return
container.addEventListener('pointerup', e => {
if (!dragged || !placeholder) return
// 恢复样式
dragged.style.position = ''
dragged.style.left = ''
dragged.style.top = ''
dragged.style.width = ''
dragged.style.zIndex = ''
dragged.style.opacity = ''
dragged.style.boxShadow = ''
dragged.style.pointerEvents = ''
// 把卡片放到占位符位置
placeholder.before(dragged)
placeholder.remove()
// 保存新顺序
const section = container.closest('[data-provider]')
if (!section) return
const providerKey = section.dataset.provider
const provider = state.config.models.providers[providerKey]
if (!provider) return
if (section) {
const providerKey = section.dataset.provider
const provider = state.config.models.providers[providerKey]
if (provider) {
const newOrderIds = [...container.querySelectorAll('.model-card')].map(c => c.dataset.modelId)
pushUndo(state)
const oldModels = [...provider.models]
provider.models = newOrderIds.map(id => oldModels.find(m => (typeof m === 'string' ? m : m.id) === id))
autoSave(state)
}
}
// 获取新的顺序
const newOrderIds = [...container.querySelectorAll('.model-card')].map(c => c.dataset.modelId)
pushUndo(state)
const oldModels = [...provider.models]
provider.models = newOrderIds.map(id => oldModels.find(m => (typeof m === 'string' ? m : m.id) === id))
// 更新状态不重新渲染以保持列表稳定
autoSave(state)
dragged = null
placeholder = null
})
})
@@ -584,7 +645,28 @@ function setPrimary(state, full) {
}
// 应用默认模型primary + 其余自动成为备选
// 确保 primary 指向的模型仍然存在,不存在则自动切到第一个可用模型
function ensureValidPrimary(state) {
const primary = getCurrentPrimary(state.config)
const allModels = collectAllModels(state.config)
if (allModels.length === 0) {
// 所有模型都没了,清空 primary
if (state.config.agents?.defaults?.model) {
state.config.agents.defaults.model.primary = ''
}
return
}
const exists = allModels.some(m => m.full === primary)
if (!exists) {
// primary 指向已删除的模型,自动切到第一个
const newPrimary = allModels[0].full
setPrimary(state, newPrimary)
toast(`主模型已自动切换为 ${newPrimary}`, 'info')
}
}
function applyDefaultModel(state) {
ensureValidPrimary(state)
const primary = getCurrentPrimary(state.config)
const allModels = collectAllModels(state.config)
const fallbacks = allModels.filter(m => m.full !== primary).map(m => m.full)
@@ -597,6 +679,16 @@ function applyDefaultModel(state) {
modelsMap[primary] = {}
for (const fb of fallbacks) modelsMap[fb] = {}
defaults.models = modelsMap
// 同步到各 agent 的模型覆盖配置,避免 agent 级别的旧值覆盖全局默认
const list = state.config.agents?.list
if (Array.isArray(list)) {
for (const agent of list) {
if (agent.model && typeof agent.model === 'object' && agent.model.primary) {
agent.model.primary = primary
}
}
}
}
// 顶部按钮事件
@@ -1141,7 +1233,7 @@ async function testModel(btn, state, providerKey, idx) {
renderProviders(page, state)
renderDefaultBar(page, state)
}
// 持久化测试结果
autoSave(state)
// 持久化测试结果(仅保存,不重启 Gateway
saveConfigOnly(state)
}
}

View File

@@ -5,6 +5,7 @@
import { api } from '../lib/tauri-api.js'
import { toast } from '../components/toast.js'
import { showConfirm, showUpgradeModal } from '../components/modal.js'
import { isMacPlatform, setUpgrading, setUserStopped, resetAutoRestart } from '../lib/app-state.js'
// HTML 转义,防止 XSS
function escapeHtml(str) {
@@ -168,10 +169,9 @@ function renderServices(container, services) {
: gw.running
? `<button class="btn btn-secondary btn-sm" data-action="restart" data-label="${gw.label}">重启</button>
<button class="btn btn-danger btn-sm" data-action="stop" data-label="${gw.label}">停止</button>
<button class="btn btn-danger btn-sm" data-action="uninstall-gateway">卸载</button>`
${isMacPlatform() ? '<button class="btn btn-danger btn-sm" data-action="uninstall-gateway">卸载</button>' : ''}`
: `<button class="btn btn-primary btn-sm" data-action="start" data-label="${gw.label}">启动</button>
<button class="btn btn-primary btn-sm" data-action="install-gateway">安装</button>
<button class="btn btn-danger btn-sm" data-action="uninstall-gateway">卸载</button>`
${isMacPlatform() ? '<button class="btn btn-primary btn-sm" data-action="install-gateway">安装</button><button class="btn btn-danger btn-sm" data-action="uninstall-gateway">卸载</button>' : ''}`
}
</div>
</div>`
@@ -285,12 +285,94 @@ function bindEvents(page) {
// ===== 服务操作 =====
const ACTION_LABELS = { start: '启动', stop: '停止', restart: '重启' }
const POLL_INTERVAL = 1500 // 轮询间隔 ms
const POLL_TIMEOUT = 30000 // 最长等待 30s
async function handleServiceAction(action, label, page) {
const fn = { start: api.startService, stop: api.stopService, restart: api.restartService }[action]
toast(`正在${ACTION_LABELS[action]} ${label}...`, 'info')
await fn(label)
toast(`${ACTION_LABELS[action]} ${label} 成功`, 'success')
const actionLabel = ACTION_LABELS[action]
const expectRunning = action !== 'stop'
// 通知守护模块:用户主动操作
if (action === 'stop') setUserStopped(true)
if (action === 'start') resetAutoRestart()
// 找到触发按钮所在的 service-card替换按钮区域为加载状态
const card = page.querySelector(`.service-card[data-label="${label}"]`)
const actionsEl = card?.querySelector('.service-actions')
const origHtml = actionsEl?.innerHTML || ''
let cancelled = false
if (actionsEl) {
actionsEl.innerHTML = `
<div class="service-loading">
<div class="service-spinner"></div>
<span class="service-loading-text">正在${actionLabel}...</span>
<button class="btn btn-sm btn-ghost service-cancel-btn" style="display:none">取消等待</button>
</div>`
const cancelBtn = actionsEl.querySelector('.service-cancel-btn')
if (cancelBtn) {
cancelBtn.addEventListener('click', () => { cancelled = true })
}
}
// 更新状态点为加载中
const dot = card?.querySelector('.status-dot')
if (dot) { dot.className = 'status-dot loading' }
try {
await fn(label)
} catch (e) {
toast(`${actionLabel}命令失败: ${e.message || e}`, 'error')
if (actionsEl) actionsEl.innerHTML = origHtml
if (dot) dot.className = 'status-dot stopped'
return
}
// 轮询等待实际状态变化
const startTime = Date.now()
let showedCancel = false
const loadingText = actionsEl?.querySelector('.service-loading-text')
const cancelBtn = actionsEl?.querySelector('.service-cancel-btn')
while (!cancelled) {
const elapsed = Date.now() - startTime
// 5 秒后显示取消按钮
if (!showedCancel && elapsed > 5000 && cancelBtn) {
cancelBtn.style.display = ''
showedCancel = true
}
// 更新等待时间
if (loadingText) {
const sec = Math.floor(elapsed / 1000)
loadingText.textContent = `正在${actionLabel}... ${sec}s`
}
// 超时
if (elapsed > POLL_TIMEOUT) {
toast(`${actionLabel}超时Gateway 可能仍在启动中`, 'warning')
break
}
// 检查实际状态
try {
const services = await api.getServicesStatus()
const svc = services?.find?.(s => s.label === label) || services?.[0]
if (svc && svc.running === expectRunning) {
toast(`${label}${actionLabel}${svc.pid ? ' (PID: ' + svc.pid + ')' : ''}`, 'success')
await loadServices(page)
return
}
} catch {}
await new Promise(r => setTimeout(r, POLL_INTERVAL))
}
if (cancelled) {
toast('已取消等待,可稍后刷新查看状态', 'info')
}
await loadServices(page)
}
@@ -323,17 +405,26 @@ async function handleDeleteBackup(name, page) {
async function doUpgradeWithModal(source, page) {
const modal = showUpgradeModal()
let unlistenLog, unlistenProgress
setUpgrading(true)
try {
const { listen } = await import('@tauri-apps/api/event')
unlistenLog = await listen('upgrade-log', (e) => modal.appendLog(e.payload))
unlistenProgress = await listen('upgrade-progress', (e) => modal.setProgress(e.payload))
// Tauri 环境下监听实时日志Web 模式跳过
if (window.__TAURI_INTERNALS__) {
try {
const { listen } = await import('@tauri-apps/api/event')
unlistenLog = await listen('upgrade-log', (e) => modal.appendLog(e.payload))
unlistenProgress = await listen('upgrade-progress', (e) => modal.setProgress(e.payload))
} catch { /* Web 模式无 Tauri event */ }
} else {
modal.appendLog('Web 模式:升级过程日志不可用,请等待完成...')
}
const msg = await api.upgradeOpenclaw(source)
modal.setDone(msg)
modal.setDone(typeof msg === 'string' ? msg : (msg?.message || '升级完成'))
await loadVersion(page)
} catch (e) {
modal.appendLog(String(e))
modal.setError('升级失败')
} finally {
setUpgrading(false)
unlistenLog?.()
unlistenProgress?.()
}
@@ -356,19 +447,33 @@ async function handleSwitchSource(target, page) {
// ===== Gateway 安装/卸载 =====
async function handleInstallGateway(btn, page) {
btn.classList.add('btn-loading')
btn.textContent = '安装中...'
await api.installGateway()
toast('Gateway 服务已安装', 'success')
await loadServices(page)
try {
await api.installGateway()
toast('Gateway 服务已安装', 'success')
await loadServices(page)
} catch (e) {
toast('安装失败: ' + e, 'error')
btn.classList.remove('btn-loading')
btn.textContent = '安装'
}
}
async function handleUninstallGateway(btn, page) {
const yes = await showConfirm('确定要卸载 Gateway 服务吗?\n这会停止服务并移除 LaunchAgent。')
if (!yes) return
btn.classList.add('btn-loading')
btn.textContent = '卸载中...'
await api.uninstallGateway()
toast('Gateway 服务已卸载', 'success')
await loadServices(page)
try {
await api.uninstallGateway()
toast('Gateway 服务已卸载', 'success')
await loadServices(page)
} catch (e) {
toast('卸载失败: ' + e, 'error')
btn.classList.remove('btn-loading')
btn.textContent = '卸载'
}
}
async function handleSaveRegistry(btn, page) {

View File

@@ -5,6 +5,7 @@
import { api } from '../lib/tauri-api.js'
import { showUpgradeModal } from '../components/modal.js'
import { toast } from '../components/toast.js'
import { setUpgrading } from '../lib/app-state.js'
export async function render() {
const page = document.createElement('div')
@@ -177,10 +178,17 @@ function bindEvents(page, nodeOk) {
const modal = showUpgradeModal()
let unlistenLog, unlistenProgress
setUpgrading(true)
try {
const { listen } = await import('@tauri-apps/api/event')
unlistenLog = await listen('upgrade-log', (e) => modal.appendLog(e.payload))
unlistenProgress = await listen('upgrade-progress', (e) => modal.setProgress(e.payload))
if (window.__TAURI_INTERNALS__) {
try {
const { listen } = await import('@tauri-apps/api/event')
unlistenLog = await listen('upgrade-log', (e) => modal.appendLog(e.payload))
unlistenProgress = await listen('upgrade-progress', (e) => modal.setProgress(e.payload))
} catch { /* Web 模式无 Tauri event */ }
} else {
modal.appendLog('Web 模式:安装日志不可用,请等待完成...')
}
// 先设置镜像源
if (registry) {
@@ -206,6 +214,7 @@ function bindEvents(page, nodeOk) {
modal.appendLog(String(e))
modal.setError('安装失败')
} finally {
setUpgrading(false)
unlistenLog?.()
unlistenProgress?.()
}

View File

@@ -687,6 +687,78 @@
backdrop-filter: blur(4px);
}
/* 消息时间戳 */
.msg-time {
font-size: 11px;
color: var(--text-tertiary);
margin-top: 4px;
padding: 0 4px;
}
.msg-user .msg-time { text-align: right; }
.msg-ai .msg-time { text-align: left; }
/* 消息内图片 */
.msg-img {
max-width: 200px;
max-height: 200px;
border-radius: 6px;
cursor: pointer;
object-fit: cover;
}
/* 消息内视频 */
.msg-video {
max-width: 320px;
max-height: 240px;
border-radius: 6px;
margin-top: 8px;
}
/* 消息内音频 */
.msg-audio {
margin-top: 8px;
max-width: 280px;
height: 36px;
}
/* 文件卡片 */
.msg-file-card {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
margin-top: 8px;
background: var(--bg-tertiary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-md);
font-size: var(--font-size-sm);
transition: background 0.15s;
}
.msg-file-card:hover {
background: var(--bg-hover);
}
.msg-file-icon {
font-size: 18px;
flex-shrink: 0;
}
.msg-file-info {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.msg-file-name {
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 200px;
}
.msg-file-size {
font-size: 11px;
color: var(--text-tertiary);
}
.chat-lightbox-img {
max-width: 90%;
max-height: 90%;

View File

@@ -1,3 +1,25 @@
/* 按钮加载状态 — 任意按钮加上 .btn-loading 即可获得内联 spinner */
.btn.btn-loading {
pointer-events: none;
opacity: 0.75;
}
.btn.btn-loading::before {
content: '';
display: inline-block;
width: 14px;
height: 14px;
border: 2px solid currentColor;
border-top-color: transparent;
border-radius: 50%;
animation: btn-spin 0.7s linear infinite;
margin-right: 6px;
vertical-align: -2px;
flex-shrink: 0;
}
@keyframes btn-spin {
to { transform: rotate(360deg); }
}
/* 骨架屏 */
.skeleton {
background: linear-gradient(90deg, var(--bg-secondary) 25%, var(--bg-tertiary, var(--bg-card-hover)) 50%, var(--bg-secondary) 75%);

View File

@@ -96,6 +96,63 @@
.service-actions {
display: flex;
gap: var(--space-sm);
align-items: center;
}
/* 服务操作加载状态 */
.service-loading {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.service-spinner {
width: 18px;
height: 18px;
border: 2px solid var(--border-primary);
border-top-color: var(--primary);
border-radius: 50%;
animation: service-spin 0.8s linear infinite;
}
@keyframes service-spin {
to { transform: rotate(360deg); }
}
.service-loading-text {
font-size: var(--font-size-sm);
color: var(--text-secondary);
font-variant-numeric: tabular-nums;
min-width: 100px;
}
.service-cancel-btn {
font-size: var(--font-size-xs) !important;
color: var(--text-tertiary) !important;
padding: 2px 8px !important;
transition: color var(--transition-fast);
}
.service-cancel-btn:hover {
color: var(--error) !important;
}
/* 状态点:加载中脉冲动画 */
.status-dot.loading {
background: var(--warning, #f59e0b);
animation: dot-pulse 1s ease-in-out infinite;
}
@keyframes dot-pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.4; transform: scale(0.8); }
}
/* 日志加载状态 */
.log-loading {
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-xl) 0;
}
/* 日志工具栏 */

View File

@@ -1,10 +1,31 @@
import { defineConfig } from 'vite'
import { devApiPlugin } from './scripts/dev-api.js'
import fs from 'fs'
import path from 'path'
import { homedir } from 'os'
// 读取 Gateway 端口(启动时读取一次)
let gatewayPort = 18789
try {
const cfg = JSON.parse(fs.readFileSync(path.join(homedir(), '.openclaw', 'openclaw.json'), 'utf8'))
gatewayPort = cfg?.gateway?.port || 18789
} catch {}
export default defineConfig({
plugins: [devApiPlugin()],
clearScreen: false,
server: {
port: 1420,
strictPort: true,
proxy: {
'/ws': {
target: `ws://127.0.0.1:${gatewayPort}`,
ws: true,
configure: (proxy) => {
proxy.on('error', () => {})
},
},
},
},
envPrefix: ['VITE_', 'TAURI_'],
build: {