From 205d349917d4b7b31ff48c933b47be306df0b062 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=99=B4=E5=A4=A9?= Date: Sat, 14 Mar 2026 07:09:50 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20v0.9.0=20=E2=80=94=20Usage=20analytics,?= =?UTF-8?q?=20Communication=20config,=20=E6=99=B4=E8=BE=B0=E4=BA=91=20bran?= =?UTF-8?q?ding,=20multi-agent=20channels,=207=20bug=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/armbian-deploy.md | 136 +++++++++++ package.json | 2 +- scripts/dev-api.js | 21 ++ scripts/linux-deploy.sh | 21 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/src/commands/agent.rs | 3 +- src-tauri/src/commands/config.rs | 24 ++ src-tauri/src/commands/logs.rs | 5 +- src-tauri/src/commands/memory.rs | 3 +- src-tauri/src/commands/skills.rs | 5 + src-tauri/src/lib.rs | 1 + src-tauri/tauri.conf.json | 2 +- src/components/sidebar.js | 4 + src/lib/model-presets.js | 17 +- src/lib/tauri-api.js | 1 + src/main.js | 2 + src/pages/assistant.js | 14 +- src/pages/channels.js | 23 +- src/pages/chat.js | 38 +++ src/pages/communication.js | 404 +++++++++++++++++++++++++++++++ src/pages/cron.js | 22 ++ src/pages/dashboard.js | 48 +++- src/pages/gateway.js | 32 +-- src/pages/models.js | 26 +- src/pages/usage.js | 240 ++++++++++++++++++ src/style/chat.css | 13 +- src/style/pages.css | 115 +++++++++ 28 files changed, 1163 insertions(+), 63 deletions(-) create mode 100644 docs/armbian-deploy.md create mode 100644 src/pages/communication.js create mode 100644 src/pages/usage.js diff --git a/docs/armbian-deploy.md b/docs/armbian-deploy.md new file mode 100644 index 0000000..c416816 --- /dev/null +++ b/docs/armbian-deploy.md @@ -0,0 +1,136 @@ +# Armbian / ARM 设备部署指南 + +ClawPanel 支持在 ARM 开发板(如 Orange Pi、Raspberry Pi、RK3588 等)上运行,通过 **Web 模式** 或 **Docker 模式** 部署,无需图形界面。 + +## 系统要求 + +| 项目 | 最低要求 | 推荐 | +|------|---------|------| +| 架构 | ARM64 (aarch64) | ARM64 | +| 内存 | 1GB | 2GB+ | +| 存储 | 2GB 可用空间 | 4GB+ | +| 系统 | Armbian / Debian / Ubuntu | Armbian 24+ | +| Node.js | 18+ | 22 LTS | + +> ⚠️ 当前不支持 ARM 32 位 (armv7) 的 Docker 镜像。Web 模式在 armv7 上可用(只要 Node.js 支持)。 + +## 方式一:Web 模式(推荐) + +Web 模式是纯 Node.js 服务,零 GUI 依赖,最适合 ARM 板。 + +### 一键部署 + +```bash +curl -fsSL https://raw.githubusercontent.com/qingchencloud/clawpanel/main/scripts/linux-deploy.sh | bash +``` + +国内网络推荐使用 Gitee 镜像: + +```bash +curl -fsSL https://gitee.com/QtCodeCreators/clawpanel/raw/main/scripts/linux-deploy.sh | bash +``` + +### 手动部署 + +```bash +# 1. 安装 Node.js 22 LTS +curl -fsSL https://deb.nodesource.com/setup_22.x | sudo bash - +sudo apt-get install -y nodejs git + +# 2. 克隆项目 +git clone https://github.com/qingchencloud/clawpanel.git /opt/clawpanel +cd /opt/clawpanel + +# 3. 安装依赖并构建 +npm ci --registry https://registry.npmmirror.com +npm run build + +# 4. 启动服务 +npm run serve -- --port 1420 +``` + +### 设置开机自启(systemd) + +```bash +sudo tee /etc/systemd/system/clawpanel.service << 'EOF' +[Unit] +Description=ClawPanel Web Server +After=network.target + +[Service] +Type=simple +User=root +WorkingDirectory=/opt/clawpanel +ExecStart=/usr/bin/node scripts/serve.js --port 1420 +Restart=on-failure +RestartSec=5 +Environment=NODE_ENV=production + +[Install] +WantedBy=multi-user.target +EOF + +sudo systemctl daemon-reload +sudo systemctl enable --now clawpanel +``` + +访问 `http://<板子IP>:1420` 即可使用。 + +## 方式二:Docker 模式 + +我们的 Docker 镜像已构建 `linux/arm64` 架构,ARM64 板子可直接拉取。 + +```bash +# 安装 Docker(如果还没有) +curl -fsSL https://get.docker.com | sh + +# 一键启动(OpenClaw + ClawPanel 一体) +docker run -d \ + --name openclaw \ + -p 1420:1420 \ + -p 18789:18789 \ + -v openclaw-data:/root/.openclaw \ + --restart unless-stopped \ + ghcr.io/qingchencloud/openclaw:latest +``` + +国内拉取慢可使用腾讯云镜像: + +```bash +docker run -d \ + --name openclaw \ + -p 1420:1420 \ + -p 18789:18789 \ + -v openclaw-data:/root/.openclaw \ + --restart unless-stopped \ + ccr.ccs.tencentyun.com/qingchencloud/openclaw:latest +``` + +## 性能优化建议 + +1. **内存不足时**:关闭不需要的系统服务,或增加 swap + ```bash + sudo fallocate -l 1G /swapfile + sudo chmod 600 /swapfile + sudo mkswap /swapfile + sudo swapon /swapfile + echo '/swapfile swap swap defaults 0 0' | sudo tee -a /etc/fstab + ``` + +2. **SD 卡寿命**:日志文件较多时,考虑将日志目录挂载到 tmpfs + ```bash + echo 'tmpfs /tmp tmpfs defaults,noatime,size=256m 0 0' | sudo tee -a /etc/fstab + ``` + +3. **网络**:AI 计算在云端完成,板子只需稳定网络连接即可。建议使用有线以太网。 + +## 常见问题 + +**Q: Tauri 桌面版能在 ARM 板上运行吗?** +A: 不建议。Tauri 需要 WebKitGTK + 图形界面,ARM 板通常是 headless 环境。请使用 Web 模式。 + +**Q: armv7 (32位) 板子能用吗?** +A: Web 模式可以(只要能装 Node.js 18+)。Docker 模式目前只提供 arm64 镜像。 + +**Q: 树莓派 Zero / Pi 1 能跑吗?** +A: 这些是 armv6,内存也只有 256-512MB,不推荐。建议至少树莓派 3B+ 或更新的 ARM64 板子。 diff --git a/package.json b/package.json index 64a5786..74b0c8b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "clawpanel", - "version": "0.8.6", + "version": "0.9.0", "private": true, "description": "ClawPanel - OpenClaw 可视化管理面板,基于 Tauri v2 的跨平台桌面应用", "type": "module", diff --git a/scripts/dev-api.js b/scripts/dev-api.js index 7146ade..9f2d746 100644 --- a/scripts/dev-api.js +++ b/scripts/dev-api.js @@ -2386,6 +2386,27 @@ const handlers = { } }, + // 运行时状态摘要(openclaw status --json) + get_status_summary() { + try { + const raw = execSync('openclaw status --json 2>&1', { windowsHide: true, timeout: 10000 }).toString() + // 提取第一个 JSON 对象 + const idx = raw.indexOf('{') + if (idx >= 0) { + try { return JSON.parse(raw.slice(idx)) } catch {} + // 流式解析:找到匹配的 } 结束 + let depth = 0 + for (let i = idx; i < raw.length; i++) { + if (raw[i] === '{') depth++ + else if (raw[i] === '}') { depth--; if (depth === 0) { try { return JSON.parse(raw.slice(idx, i + 1)) } catch { break } } } + } + } + return { error: '解析失败' } + } catch (e) { + return { error: e.message || String(e) } + } + }, + // 版本信息 get_version_info() { let current = null diff --git a/scripts/linux-deploy.sh b/scripts/linux-deploy.sh index d238c49..0ea7593 100644 --- a/scripts/linux-deploy.sh +++ b/scripts/linux-deploy.sh @@ -45,7 +45,26 @@ detect_os() { else OS=$(uname -s | tr '[:upper:]' '[:lower:]') fi - echo "🖥️ 系统: $OS $(uname -m)" + ARCH=$(uname -m) + echo "🖥️ 系统: $OS $ARCH" + + # ARM 架构检测和提示 + case "$ARCH" in + aarch64|arm64) + echo "✅ ARM64 架构,Web 模式和 Docker 模式均支持" + ;; + armv7*|armhf) + echo "⚠️ ARM 32位 ($ARCH):Web 模式可用,Docker 镜像仅支持 arm64" + ;; + armv6*) + echo "⚠️ ARM v6 ($ARCH):内存和性能可能不足,建议升级到 ARM64 设备" + ;; + x86_64|amd64) + ;; + *) + echo "ℹ️ 架构: $ARCH" + ;; + esac } # 安装 Node.js diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index c1e3f57..6dff49f 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -328,7 +328,7 @@ dependencies = [ [[package]] name = "clawpanel" -version = "0.8.6" +version = "0.9.0" dependencies = [ "base64 0.22.1", "chrono", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 5a2ec53..543218b 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "clawpanel" -version = "0.8.6" +version = "0.9.0" edition = "2021" description = "ClawPanel - OpenClaw 可视化管理面板" authors = ["qingchencloud"] diff --git a/src-tauri/src/commands/agent.rs b/src-tauri/src/commands/agent.rs index f993dcd..3ebde2e 100644 --- a/src-tauri/src/commands/agent.rs +++ b/src-tauri/src/commands/agent.rs @@ -25,7 +25,8 @@ pub async fn list_agents() -> Result { } let stdout = String::from_utf8_lossy(&output.stdout); - serde_json::from_str(&stdout).map_err(|e| format!("解析 JSON 失败: {e}")) + crate::commands::skills::extract_json_pub(&stdout) + .ok_or_else(|| "解析 JSON 失败: 输出中未找到有效 JSON".to_string()) } /// 创建新 agent diff --git a/src-tauri/src/commands/config.rs b/src-tauri/src/commands/config.rs index 179b157..e85138e 100644 --- a/src-tauri/src/commands/config.rs +++ b/src-tauri/src/commands/config.rs @@ -564,6 +564,30 @@ pub async fn get_version_info() -> Result { }) } +/// 获取 OpenClaw 运行时状态摘要(openclaw status --json) +/// 包含 runtimeVersion、会话列表(含 token 用量、fastMode 等标签) +#[tauri::command] +pub async fn get_status_summary() -> Result { + let output = crate::utils::openclaw_command_async() + .args(["status", "--json"]) + .output() + .await; + + match output { + Ok(o) if o.status.success() => { + let stdout = String::from_utf8_lossy(&o.stdout); + // CLI 输出可能含非 JSON 行,复用 skills 模块的 extract_json + crate::commands::skills::extract_json_pub(&stdout) + .ok_or_else(|| "解析失败: 输出中未找到有效 JSON".to_string()) + } + Ok(o) => { + let stderr = String::from_utf8_lossy(&o.stderr); + Err(format!("openclaw status 失败: {}", stderr.trim())) + } + Err(e) => Err(format!("执行 openclaw 失败: {e}")), + } +} + /// npm 包名映射 fn npm_package_name(source: &str) -> &'static str { match source { diff --git a/src-tauri/src/commands/logs.rs b/src-tauri/src/commands/logs.rs index 526329e..64a0d8f 100644 --- a/src-tauri/src/commands/logs.rs +++ b/src-tauri/src/commands/logs.rs @@ -45,9 +45,10 @@ pub fn read_log_tail(log_name: String, lines: Option) -> Result = buf.lines().collect(); diff --git a/src-tauri/src/commands/memory.rs b/src-tauri/src/commands/memory.rs index 837fa8f..2c5e875 100644 --- a/src-tauri/src/commands/memory.rs +++ b/src-tauri/src/commands/memory.rs @@ -34,7 +34,8 @@ async fn agent_workspace(agent_id: &str) -> Result { let stdout = String::from_utf8_lossy(&output.stdout); let agents: serde_json::Value = - serde_json::from_str(&stdout).map_err(|e| format!("解析 JSON 失败: {e}"))?; + crate::commands::skills::extract_json_pub(&stdout) + .ok_or_else(|| "解析 JSON 失败: 输出中未找到有效 JSON".to_string())?; if let Some(arr) = agents.as_array() { for a in arr { diff --git a/src-tauri/src/commands/skills.rs b/src-tauri/src/commands/skills.rs index ed8a4a8..156878a 100644 --- a/src-tauri/src/commands/skills.rs +++ b/src-tauri/src/commands/skills.rs @@ -220,6 +220,11 @@ pub async fn skills_clawhub_search(query: String) -> Result { Ok(Value::Array(items)) } +/// Public wrapper for extract_json, used by config.rs get_status_summary +pub fn extract_json_pub(text: &str) -> Option { + extract_json(text) +} + /// Extract the first valid JSON object or array from a string that may contain /// non-JSON lines (Node.js warnings, npm update prompts, etc.) fn extract_json(text: &str) -> Option { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2e2557d..1d92944 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -97,6 +97,7 @@ pub fn run() { config::auto_install_git, config::configure_git_https, config::invalidate_path_cache, + config::get_status_summary, // 设备密钥 + Gateway 握手 device::create_connect_frame, // 设备配对 diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 3b4d622..3f8e5a4 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-config-schema/schema.json", "productName": "ClawPanel", - "version": "0.8.6", + "version": "0.9.0", "identifier": "ai.openclaw.clawpanel", "build": { "frontendDist": "../dist", diff --git a/src/components/sidebar.js b/src/components/sidebar.js index 7bfe488..2fe5a7d 100644 --- a/src/components/sidebar.js +++ b/src/components/sidebar.js @@ -26,6 +26,7 @@ const NAV_ITEMS_FULL = [ { route: '/agents', label: 'Agent 管理', icon: 'agents' }, { route: '/gateway', label: 'Gateway', icon: 'gateway' }, { route: '/channels', label: '消息渠道', icon: 'channels' }, + { route: '/communication', label: '通信与自动化', icon: 'settings' }, { route: '/security', label: '安全设置', icon: 'security' }, ] }, @@ -34,6 +35,7 @@ const NAV_ITEMS_FULL = [ items: [ { route: '/memory', label: '记忆文件', icon: 'memory' }, { route: '/cron', label: '定时任务', icon: 'clock' }, + { route: '/usage', label: '使用情况', icon: 'bar-chart' }, ] }, { @@ -85,6 +87,8 @@ const ICONS = { skills: '', channels: '', clock: '', + 'bar-chart': '', + settings: '', debug: '', } diff --git a/src/lib/model-presets.js b/src/lib/model-presets.js index 8a9d3c2..228d200 100644 --- a/src/lib/model-presets.js +++ b/src/lib/model-presets.js @@ -11,8 +11,10 @@ export const API_TYPES = [ { value: 'google-gemini', label: 'Google Gemini' }, ] -// 服务商快捷预设 +// 服务商快捷预设(晴辰云官方置顶) export const PROVIDER_PRESETS = [ + { key: 'qtcool', label: '晴辰云', badge: '官方', baseUrl: 'https://gpt.qt.cool/v1', api: 'openai-completions', site: 'https://gpt.qt.cool/', desc: 'GPT-5 全系列开箱即用,更多模型持续接入中。每日签到送额度 · 邀请送余额 · 充值最低 3 折消耗 · 未消耗包退' }, + { key: 'shengsuanyun', label: '胜算云', hidden: true, baseUrl: 'https://router.shengsuanyun.com/api/v1', api: 'openai-completions', site: 'https://www.shengsuanyun.com/?from=CH_4BVI0BM2', desc: '国内知名 AI 模型聚合平台,支持多种主流模型' }, { key: 'openai', label: 'OpenAI 官方', baseUrl: 'https://api.openai.com/v1', api: 'openai-completions' }, { key: 'anthropic', label: 'Anthropic 官方', baseUrl: 'https://api.anthropic.com', api: 'anthropic-messages' }, { key: 'deepseek', label: 'DeepSeek', baseUrl: 'https://api.deepseek.com/v1', api: 'openai-completions' }, @@ -20,17 +22,28 @@ export const PROVIDER_PRESETS = [ { key: 'ollama', label: 'Ollama (本地)', baseUrl: 'http://127.0.0.1:11434/v1', api: 'openai-completions' }, ] -// gpt.qt.cool 推广配置 +// 晴辰云推广配置 export const QTCOOL = { baseUrl: 'https://gpt.qt.cool/v1', defaultKey: 'sk-0JDu7hyc51ZKD4iNebpFu07EUEhXmVVc', site: 'https://gpt.qt.cool/', + checkinUrl: 'https://gpt.qt.cool/checkin', usageUrl: 'https://gpt.qt.cool/user?key=', providerKey: 'qtcool', + brandName: '晴辰云', api: 'openai-completions', models: [] // 始终从 API 动态获取最新模型列表 } +// 胜算云推广配置 +export const SHENGSUANYUN = { + baseUrl: 'https://router.shengsuanyun.com/api/v1', + site: 'https://www.shengsuanyun.com/?from=CH_4BVI0BM2', + providerKey: 'shengsuanyun', + brandName: '胜算云', + api: 'openai-completions', +} + // 常用模型预设(按服务商分组) export const MODEL_PRESETS = { openai: [ diff --git a/src/lib/tauri-api.js b/src/lib/tauri-api.js index 39d1d0f..bce3fee 100644 --- a/src/lib/tauri-api.js +++ b/src/lib/tauri-api.js @@ -155,6 +155,7 @@ export const api = { // 配置(读缓存,写清缓存) getVersionInfo: () => cachedInvoke('get_version_info', {}, 30000), + getStatusSummary: () => cachedInvoke('get_status_summary', {}, 5000), readOpenclawConfig: () => cachedInvoke('read_openclaw_config'), writeOpenclawConfig: (config) => { invalidate('read_openclaw_config'); return invoke('write_openclaw_config', { config }) }, readMcpConfig: () => cachedInvoke('read_mcp_config'), diff --git a/src/main.js b/src/main.js index 40f033e..40fab1e 100644 --- a/src/main.js +++ b/src/main.js @@ -304,6 +304,8 @@ async function boot() { registerRoute('/setup', () => import('./pages/setup.js')) registerRoute('/channels', () => import('./pages/channels.js')) registerRoute('/cron', () => import('./pages/cron.js')) + registerRoute('/usage', () => import('./pages/usage.js')) + registerRoute('/communication', () => import('./pages/communication.js')) renderSidebar(sidebar) initRouter(content) diff --git a/src/pages/assistant.js b/src/pages/assistant.js index 7175d25..988d061 100644 --- a/src/pages/assistant.js +++ b/src/pages/assistant.js @@ -2522,8 +2522,9 @@ function showSettings() {
- ${PROVIDER_PRESETS.map(p => ``).join('')} + ${PROVIDER_PRESETS.filter(p => !p.hidden).map(p => ``).join('')}
+
@@ -2736,6 +2737,17 @@ function showSettings() { // 高亮选中 overlay.querySelectorAll('.ast-preset-btn').forEach(b => b.style.opacity = '0.5') btn.style.opacity = '1' + // 显示服务商详情 + const preset = PROVIDER_PRESETS.find(p => p.key === btn.dataset.key) + const detailEl = overlay.querySelector('#ast-preset-detail') + if (detailEl && preset && (preset.desc || preset.site)) { + let html = preset.desc ? `
${preset.desc}
` : '' + if (preset.site) html += `→ 访问 ${preset.label}官网` + detailEl.innerHTML = html + detailEl.style.display = 'block' + } else if (detailEl) { + detailEl.style.display = 'none' + } } }) diff --git a/src/pages/channels.js b/src/pages/channels.js index 9b89473..443ca9e 100644 --- a/src/pages/channels.js +++ b/src/pages/channels.js @@ -234,11 +234,11 @@ function renderAvailable(page, state) { el.innerHTML = Object.entries(PLATFORM_REGISTRY).map(([pid, reg]) => { const done = configuredIds.has(pid) return ` - ` }).join('') @@ -627,17 +627,26 @@ function getChannelBindingKey(pid) { return map[pid] || pid } -/** 保存渠道→Agent 绑定到 openclaw.json 的 bindings 数组 */ -async function saveChannelBinding(pid, agentId) { +/** 保存渠道→Agent 绑定到 openclaw.json 的 bindings 数组 + * 支持同一渠道多个 Agent 绑定(不同 agentId) + * oldAgentId: 编辑时替换老绑定 + */ +async function saveChannelBinding(pid, agentId, oldAgentId) { const config = await api.readOpenclawConfig() if (!config) return const channelKey = getChannelBindingKey(pid) let bindings = Array.isArray(config.bindings) ? [...config.bindings] : [] - // 移除该渠道的旧绑定 - bindings = bindings.filter(b => b.match?.channel !== channelKey) + // 编辑模式:移除旧绑定(按 channel + oldAgentId) + if (oldAgentId) { + bindings = bindings.filter(b => !(b.match?.channel === channelKey && (b.agentId || 'main') === oldAgentId)) + } - // 如果选了非空 Agent 且不是 main,添加新绑定 + // 避免重复:如果已有相同 channel+agentId 的绑定,先移除 + const effectiveAgent = agentId || 'main' + bindings = bindings.filter(b => !(b.match?.channel === channelKey && (b.agentId || 'main') === effectiveAgent)) + + // 添加新绑定(main 也明确写入,方便 UI 展示) if (agentId && agentId !== 'main') { bindings.push({ match: { channel: channelKey }, agentId }) } diff --git a/src/pages/chat.js b/src/pages/chat.js index e02c51f..e857133 100644 --- a/src/pages/chat.js +++ b/src/pages/chat.js @@ -34,6 +34,20 @@ const COMMANDS = [ { cmd: '/think medium', desc: '中度思考', action: 'exec' }, { cmd: '/think high', desc: '深度思考', action: 'exec' }, ]}, + { title: '快速模式', commands: [ + { cmd: '/fast', desc: '切换快速模式(开/关)', action: 'exec' }, + { cmd: '/fast on', desc: '开启快速模式(低延迟)', action: 'exec' }, + { cmd: '/fast off', desc: '关闭快速模式', action: 'exec' }, + ]}, + { title: '详细/推理', commands: [ + { cmd: '/verbose off', desc: '关闭详细模式', action: 'exec' }, + { cmd: '/verbose low', desc: '低详细度', action: 'exec' }, + { cmd: '/verbose high', desc: '高详细度', action: 'exec' }, + { cmd: '/reasoning off', desc: '关闭推理模式', action: 'exec' }, + { cmd: '/reasoning low', desc: '轻度推理', action: 'exec' }, + { cmd: '/reasoning medium', desc: '中度推理', action: 'exec' }, + { cmd: '/reasoning high', desc: '深度推理', action: 'exec' }, + ]}, { title: '信息', commands: [ { cmd: '/help', desc: '帮助信息', action: 'exec' }, { cmd: '/status', desc: '系统状态', action: 'exec' }, @@ -880,6 +894,16 @@ function handleEvent(msg) { if (!payload) return if (event === 'chat') handleChatEvent(payload) + + // Compaction 状态指示:上游 2026.3.12 新增 status_reaction 事件 + if (event === 'chat.status_reaction' || event === 'status_reaction') { + const reaction = payload.reaction || payload.emoji || '' + if (reaction.includes('compact') || reaction === '🗜️' || reaction === '📦') { + showCompactionHint(true) + } else if (!reaction || reaction === 'thinking' || reaction === '💭') { + showCompactionHint(false) + } + } } function handleChatEvent(payload) { @@ -1528,6 +1552,20 @@ function showTyping(show) { if (show) scrollToBottom() } +function showCompactionHint(show) { + let hint = _page?.querySelector('#compaction-hint') + if (show && !hint && _messagesEl) { + hint = document.createElement('div') + hint.id = 'compaction-hint' + hint.className = 'msg msg-system compaction-hint' + hint.innerHTML = '🗜️ 正在整理上下文(Compaction)…' + _messagesEl.insertBefore(hint, _typingEl) + scrollToBottom() + } else if (!show && hint) { + hint.remove() + } +} + function scrollToBottom() { if (!_messagesEl) return requestAnimationFrame(() => { _messagesEl.scrollTop = _messagesEl.scrollHeight }) diff --git a/src/pages/communication.js b/src/pages/communication.js new file mode 100644 index 0000000..a1057d2 --- /dev/null +++ b/src/pages/communication.js @@ -0,0 +1,404 @@ +/** + * 通信设置页面 — 消息、广播、命令、音频等 openclaw.json 配置的可视化编辑器 + * 对应上游 Dashboard 的「通信」+「自动化」合并页 + */ +import { api } from '../lib/tauri-api.js' +import { toast } from '../components/toast.js' +import { icon } from '../lib/icons.js' + +let _page = null, _config = null, _dirty = false + +export async function render() { + const page = document.createElement('div') + page.className = 'page' + _page = page + + page.innerHTML = ` + +
+ + + + + +
+ +
+
+
+
+ ` + + // Tab 切换 + page.querySelectorAll('.comm-tab').forEach(tab => { + tab.onclick = () => { + page.querySelectorAll('.comm-tab').forEach(t => { t.classList.remove('active', 'btn-primary'); t.classList.add('btn-secondary') }) + tab.classList.remove('btn-secondary'); tab.classList.add('active', 'btn-primary') + renderTab(page, tab.dataset.tab) + } + }) + + // 保存按钮 + page.querySelector('#btn-comm-save').onclick = saveConfig + + await loadConfig(page) + return page +} + +export function cleanup() { _page = null; _config = null; _dirty = false } + +async function loadConfig(page) { + try { + _config = await api.readOpenclawConfig() + if (!_config) _config = {} + renderTab(page, 'messages') + } catch (e) { + page.querySelector('#comm-content').innerHTML = `
加载配置失败: ${esc(e?.message || e)}
` + } +} + +function markDirty() { + _dirty = true + const btn = _page?.querySelector('#btn-comm-save') + if (btn) btn.disabled = false +} + +async function saveConfig() { + if (!_config || !_dirty) return + const btn = _page?.querySelector('#btn-comm-save') + if (btn) { btn.disabled = true; btn.textContent = '保存中...' } + try { + // 从当前表单收集值到 _config + collectCurrentTab() + await api.writeOpenclawConfig(_config) + _dirty = false + toast('配置已保存,正在重载 Gateway...', 'info') + try { await api.reloadGateway(); toast('Gateway 已重载', 'success') } catch {} + } catch (e) { + toast('保存失败: ' + e, 'error') + } finally { + if (btn) { btn.disabled = !_dirty; btn.innerHTML = `${icon('save', 14)} 保存` } + } +} + +function collectCurrentTab() { + if (!_page) return + const activeTab = _page.querySelector('.comm-tab.active')?.dataset.tab + if (activeTab === 'messages') collectMessages() + else if (activeTab === 'broadcast') collectBroadcast() + else if (activeTab === 'commands') collectCommands() + else if (activeTab === 'hooks') collectHooks() + else if (activeTab === 'approvals') collectApprovals() +} + +// ── Tab 渲染 ── + +function renderTab(page, tab) { + const el = page.querySelector('#comm-content') + if (tab === 'messages') renderMessages(el) + else if (tab === 'broadcast') renderBroadcast(el) + else if (tab === 'commands') renderCommands(el) + else if (tab === 'hooks') renderHooks(el) + else if (tab === 'approvals') renderApprovals(el) +} + +// ── 消息设置 ── + +function renderMessages(el) { + const m = _config?.messages || {} + const sr = m.statusReactions || {} + el.innerHTML = ` +
+
回复设置
+
+ + +
每条 AI 回复开头自动加的前缀。支持 {model}、{provider}、{thinkingLevel} 等变量。设为 auto 则显示 Agent 名称
+
+
+ + +
收到消息时自动添加的 emoji 反应(确认已收到)
+
+
+ + +
+
+
+ +
回复发送成功后自动删除之前的确认 emoji
+
+ +
+
+
+ +
不向用户显示 ⚠️ 工具执行错误
+
+ +
+
+ +
+
状态反应 Emoji
+
+
+ +
在消息渠道中用 emoji 表示 AI 当前状态(思考中、执行工具、完成等)
+
+ +
+
+ +
+
消息队列
+
+ + +
合并快速连续消息的等待时间(毫秒),避免 AI 对每条消息逐一回复
+
+
+ + +
等待处理的消息队列最大长度
+
+
+ +
+
群聊设置
+
+ + +
群聊中回溯多少条历史消息作为上下文
+
+
+ ` + el.querySelectorAll('input, select').forEach(inp => { + inp.addEventListener('change', markDirty) + inp.addEventListener('input', markDirty) + }) +} + +function collectMessages() { + if (!_config) return + const g = (id) => _page?.querySelector('#' + id) + const v = (id) => g(id)?.value?.trim() || undefined + const n = (id) => { const x = parseInt(g(id)?.value); return isNaN(x) ? undefined : x } + const c = (id) => g(id)?.checked || false + + if (!_config.messages) _config.messages = {} + const m = _config.messages + m.responsePrefix = v('msg-responsePrefix') + m.ackReaction = v('msg-ackReaction') + m.ackReactionScope = v('msg-ackReactionScope') || undefined + m.removeAckAfterReply = c('msg-removeAckAfterReply') || undefined + m.suppressToolErrors = c('msg-suppressToolErrors') || undefined + + if (!m.statusReactions) m.statusReactions = {} + m.statusReactions.enabled = c('msg-sr-enabled') || undefined + + const debounceMs = n('msg-debounceMs') + if (debounceMs != null) { + if (!m.inbound) m.inbound = {} + m.inbound.debounceMs = debounceMs + } + const cap = n('msg-queueCap') + if (cap != null) { + if (!m.queue) m.queue = {} + m.queue.cap = cap + } + const groupHistoryLimit = n('msg-groupHistoryLimit') + if (groupHistoryLimit != null) { + if (!m.groupChat) m.groupChat = {} + m.groupChat.historyLimit = groupHistoryLimit + } +} + +// ── 广播设置 ── + +function renderBroadcast(el) { + const b = _config?.broadcast || {} + el.innerHTML = ` +
+
广播策略
+
+ + +
当消息需要广播给多个 Agent 时的处理策略。并行更快,顺序更可控
+
+
+ ` + el.querySelectorAll('input, select').forEach(inp => { + inp.addEventListener('change', markDirty) + }) +} + +function collectBroadcast() { + if (!_config) return + const strategy = _page?.querySelector('#bc-strategy')?.value + if (strategy) { + if (!_config.broadcast) _config.broadcast = {} + _config.broadcast.strategy = strategy + } +} + +// ── 命令配置 ── + +function renderCommands(el) { + const cmd = _config?.commands || {} + el.innerHTML = ` +
+
斜杠命令
+ ${toggleRow('cmd-text', '文本命令解析', '允许通过 / 前缀在聊天中执行命令', cmd.text !== false)} + ${toggleRow('cmd-bash', 'Bash 命令', '允许用 ! 前缀或 /bash 在聊天中执行 Shell 命令(危险)', !!cmd.bash)} + ${toggleRow('cmd-config', '/config 命令', '允许在聊天中查看/修改配置', !!cmd.config)} + ${toggleRow('cmd-debug', '/debug 命令', '允许在聊天中查看调试信息', !!cmd.debug)} + ${toggleRow('cmd-restart', '重启命令', '允许通过命令重启 Gateway', cmd.restart !== false)} +
+
+
原生命令注册
+
+ + +
在支持的渠道(Telegram、Discord)自动注册原生命令菜单
+
+
+ ` + el.querySelectorAll('input, select').forEach(inp => { + inp.addEventListener('change', markDirty) + }) +} + +function collectCommands() { + if (!_config) return + const c = (id) => _page?.querySelector('#' + id)?.checked + if (!_config.commands) _config.commands = {} + const cmd = _config.commands + cmd.text = c('cmd-text') === false ? false : undefined + cmd.bash = c('cmd-bash') || undefined + cmd.config = c('cmd-config') || undefined + cmd.debug = c('cmd-debug') || undefined + cmd.restart = c('cmd-restart') === false ? false : undefined + const native = _page?.querySelector('#cmd-native')?.value + cmd.native = native === 'true' ? true : native === 'false' ? false : 'auto' +} + +// ── Webhook ── + +function renderHooks(el) { + const h = _config?.hooks || {} + el.innerHTML = ` +
+
Webhook 设置
+ ${toggleRow('hooks-enabled', '启用 Webhook', '允许外部服务通过 HTTP 触发 AI 执行', !!h.enabled)} +
+ + +
Gateway 上暴露的 Webhook 接收路径
+
+
+ + +
外部请求需在 Header 中携带此 Token 才能触发 Webhook
+
+
+ + +
Webhook 触发的 Agent 会话标识。留空则每次自动生成
+
+
+ + +
+
+ ` + el.querySelectorAll('input, select').forEach(inp => { + inp.addEventListener('change', markDirty) + inp.addEventListener('input', markDirty) + }) +} + +function collectHooks() { + if (!_config) return + const v = (id) => _page?.querySelector('#' + id)?.value?.trim() || undefined + const n = (id) => { const x = parseInt(_page?.querySelector('#' + id)?.value); return isNaN(x) ? undefined : x } + const c = (id) => _page?.querySelector('#' + id)?.checked + if (!_config.hooks) _config.hooks = {} + const h = _config.hooks + h.enabled = c('hooks-enabled') || undefined + h.path = v('hooks-path') + h.token = v('hooks-token') + h.defaultSessionKey = v('hooks-defaultSessionKey') + h.maxBodyBytes = n('hooks-maxBodyBytes') +} + +// ── 执行审批 ── + +function renderApprovals(el) { + const a = _config?.approvals?.exec || {} + el.innerHTML = ` +
+
执行审批转发
+
当 AI 请求执行命令时,将审批请求转发到消息渠道,方便在手机上审批
+ ${toggleRow('approvals-enabled', '启用审批转发', '将执行审批请求转发到配置的消息渠道', !!a.enabled)} +
+ + +
+ ${toggleRow('approvals-forwardExec', '转发执行请求', '将 exec 审批请求转发到渠道(默认关闭,低风险场景可开启)', !!a.enabled)} +
+ ` + el.querySelectorAll('input, select').forEach(inp => { + inp.addEventListener('change', markDirty) + }) +} + +function collectApprovals() { + if (!_config) return + const c = (id) => _page?.querySelector('#' + id)?.checked + const v = (id) => _page?.querySelector('#' + id)?.value + if (!_config.approvals) _config.approvals = {} + if (!_config.approvals.exec) _config.approvals.exec = {} + const a = _config.approvals.exec + a.enabled = c('approvals-enabled') || undefined + a.mode = v('approvals-mode') || undefined +} + +// ── 工具函数 ── + +function toggleRow(id, label, hint, checked) { + return ` +
+
+ +
${hint}
+
+ +
+ ` +} + +function esc(str) { + return (str || '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') +} diff --git a/src/pages/cron.js b/src/pages/cron.js index 7558ca2..73a48a8 100644 --- a/src/pages/cron.js +++ b/src/pages/cron.js @@ -315,6 +315,11 @@ async function openTaskDialog(job, page, state) {
不选则使用默认 Agent 执行
+
+ + +
配置了多个消息渠道时必须指定,否则任务会报错
+
${shortcutsHtml}
@@ -340,6 +345,19 @@ async function openTaskDialog(job, page, state) { width: 500, }) + // 异步加载渠道列表 + api.readOpenclawConfig().then(cfg => { + const channels = cfg?.channels || {} + const channelIds = Object.keys(channels).filter(k => k !== 'defaults') + if (channelIds.length <= 1) return // 单渠道或无渠道不需要选 + const select = modal.querySelector('select[name="deliveryChannel"]') + if (!select) return + const current = job?.delivery?.channel || '' + select.innerHTML = `` + channelIds.map(ch => + `` + ).join('') + }).catch(() => {}) + // 异步加载 Agent 列表并更新下拉框(不阻塞弹窗显示) api.listAgents().then(res => { const agents = Array.isArray(res) ? res : (res?.agents || []) @@ -404,6 +422,8 @@ async function openTaskDialog(job, page, state) { patch.schedule = { kind: 'cron', expr: schedule } patch.payload = { kind: 'agentTurn', message } if (agentId) patch.agentId = agentId + const deliveryChannel = modal.querySelector('select[name="deliveryChannel"]')?.value + if (deliveryChannel) patch.delivery = { channel: deliveryChannel } await wsClient.request('cron.update', { id: job.id, patch }) toast('任务已更新', 'success') } else { @@ -414,6 +434,8 @@ async function openTaskDialog(job, page, state) { payload: { kind: 'agentTurn', message }, } if (agentId) params.agentId = agentId + const deliveryChannel = modal.querySelector('select[name="deliveryChannel"]')?.value + if (deliveryChannel) params.delivery = { channel: deliveryChannel } await wsClient.request('cron.add', params) toast('任务已创建', 'success') } diff --git a/src/pages/dashboard.js b/src/pages/dashboard.js index d6bcdb2..506cde3 100644 --- a/src/pages/dashboard.js +++ b/src/pages/dashboard.js @@ -67,6 +67,7 @@ async function loadDashboardData(page) { api.listAgents(), api.readMcpConfig(), api.listBackups(), + api.getStatusSummary(), ]) const logsP = api.readLogTail('gateway', 20).catch(() => '') @@ -98,13 +99,14 @@ async function loadDashboardData(page) { renderStatCards(page, services, version, [], config) // 第二波:Agent、MCP、备份 → 更新卡片 + 渲染总览 - const [agentsRes, mcpRes, backupsRes] = await secondaryP + const [agentsRes, mcpRes, backupsRes, statusRes] = await secondaryP const agents = agentsRes.status === 'fulfilled' ? agentsRes.value : [] const mcpConfig = mcpRes.status === 'fulfilled' ? mcpRes.value : null const backups = backupsRes.status === 'fulfilled' ? backupsRes.value : [] + const statusSummary = statusRes.status === 'fulfilled' ? statusRes.value : null renderStatCards(page, services, version, agents, config) - renderOverview(page, services, mcpConfig, backups, config, agents) + renderOverview(page, services, mcpConfig, backups, config, agents, statusSummary) // 第三波:日志(最低优先级) const logs = await logsP @@ -168,7 +170,7 @@ function renderStatCards(page, services, version, agents, config) { ` } -function renderOverview(page, services, mcpConfig, backups, config, agents) { +function renderOverview(page, services, mcpConfig, backups, config, agents, statusSummary) { const containerEl = page.querySelector('#dashboard-overview-container') const gw = services.find(s => s.label === 'ai.openclaw.gateway') const mcpCount = mcpConfig?.mcpServers ? Object.keys(mcpConfig.mcpServers).length : 0 @@ -185,6 +187,8 @@ function renderOverview(page, services, mcpConfig, backups, config, agents) { const latestBackup = backups.length > 0 ? backups.sort((a,b) => b.created_at - a.created_at)[0] : null const lastUpdate = config?.meta?.lastTouchedVersion || '未知' + const runtimeVer = statusSummary?.runtimeVersion || null + const sessions = statusSummary?.sessions || null const gwPort = config?.gateway?.port || 18789 const primaryModel = config?.agents?.defaults?.model?.primary || '未设置' @@ -258,12 +262,13 @@ function renderOverview(page, services, mcpConfig, backups, config, agents) {
-
配置版本
-
${lastUpdate}
-
openclaw.json
+
运行时版本
+
${runtimeVer || lastUpdate}
+
${runtimeVer ? 'OpenClaw Runtime' : 'openclaw.json'}
+ ${renderSessionStatus(sessions)} ` @@ -277,6 +282,37 @@ function renderOverview(page, services, mcpConfig, backups, config, agents) { }) } +function renderSessionStatus(sessions) { + if (!sessions || !sessions.recent || sessions.recent.length === 0) return '' + const rows = sessions.recent.slice(0, 5).map(s => { + const pct = s.percentUsed ?? 0 + const barColor = pct > 80 ? 'var(--error)' : pct > 50 ? 'var(--warning)' : 'var(--success)' + const flags = (s.flags || []).map(f => `${escapeHtml(f)}`).join('') + const model = s.model ? `${escapeHtml(s.model)}` : '' + const tokens = s.totalTokens != null && s.totalTokens > 0 ? `${Math.round(s.totalTokens / 1000)}k` : '0' + const ctx = s.contextTokens != null ? `${Math.round(s.contextTokens / 1000)}k` : '—' + const remaining = s.remainingTokens != null ? `${Math.round(s.remainingTokens / 1000)}k` : ctx + const key = escapeHtml(s.key || '').replace(/^agent:main:/, '') + return `
+
+ ${key || '—'} + ${model}${flags} +
+
+
+
+
${tokens} / ${ctx} · 剩余 ${remaining} · ${pct}%
+
` + }) + const defaultModel = sessions.defaults?.model || '—' + const defaultCtx = sessions.defaults?.contextTokens ? `${Math.round(sessions.defaults.contextTokens / 1000)}k` : '—' + return ` +
+
活跃会话 ${sessions.count || 0} 个 · 默认模型 ${escapeHtml(defaultModel)} · 上下文 ${defaultCtx}
+
${rows.join('')}
+
` +} + function renderLogs(page, logs) { const logsEl = page.querySelector('#recent-logs') if (!logs) { diff --git a/src/pages/gateway.js b/src/pages/gateway.js index 2d9e5de..e7c9ac0 100644 --- a/src/pages/gateway.js +++ b/src/pages/gateway.js @@ -121,35 +121,6 @@ function renderConfig(page, state) { -
-
- - 运行模式 -
-
- - -
-
-
@@ -325,8 +296,7 @@ async function saveConfig(page, state) { const port = parseInt(page.querySelector('#gw-port')?.value) || 18789 const bindRadio = page.querySelector('input[name="gw-bind"]:checked') const bind = bindRadio?.value || 'loopback' - const modeRadio = page.querySelector('input[name="gw-mode"]:checked') - const mode = modeRadio?.value || 'local' + const mode = 'local' const authModeRadio = page.querySelector('input[name="gw-auth-mode"]:checked') const authMode = authModeRadio?.value || 'token' const authToken = page.querySelector('#gw-token')?.value || '' diff --git a/src/pages/models.js b/src/pages/models.js index 6beac83..c65e4dd 100644 --- a/src/pages/models.js +++ b/src/pages/models.js @@ -32,11 +32,12 @@ export async function render() {
${icon('gift', 22)} - ClawPanel 公益 AI 接口计划 + 晴辰云 AI 接口 + 官方
- Token 费用?我们帮你出了。调用成本由项目组内部承担,GPT-5 全系列模型开箱即用。
- 无需注册、无需付费、支持 OpenAI 兼容接口 — 点击即享。 + 每日签到送免费额度 · 邀请好友送余额 · 充值最低 3 折消耗 · 未消耗余额随时包退
+ GPT-5 全系列模型开箱即用,更多主流模型持续接入中。OpenAI 兼容接口,一键配置
@@ -779,19 +780,20 @@ function bindTopActions(page, state) { // 添加服务商(带预设快捷选择) function addProvider(page, state) { // 构建预设按钮 HTML - const presetsHtml = PROVIDER_PRESETS.map(p => - `` + const presetsHtml = PROVIDER_PRESETS.filter(p => !p.hidden).map(p => + `` ).join('') const overlay = document.createElement('div') overlay.className = 'modal-overlay' overlay.innerHTML = ` -