feat: v0.9.0 — Usage analytics, Communication config, 晴辰云 branding, multi-agent channels, 7 bug fixes

This commit is contained in:
晴天
2026-03-14 07:09:50 +08:00
parent 8bd8b82351
commit 205d349917
28 changed files with 1163 additions and 63 deletions

136
docs/armbian-deploy.md Normal file
View File

@@ -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 板子。

View File

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

View File

@@ -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

View File

@@ -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

2
src-tauri/Cargo.lock generated
View File

@@ -328,7 +328,7 @@ dependencies = [
[[package]]
name = "clawpanel"
version = "0.8.6"
version = "0.9.0"
dependencies = [
"base64 0.22.1",
"chrono",

View File

@@ -1,6 +1,6 @@
[package]
name = "clawpanel"
version = "0.8.6"
version = "0.9.0"
edition = "2021"
description = "ClawPanel - OpenClaw 可视化管理面板"
authors = ["qingchencloud"]

View File

@@ -25,7 +25,8 @@ pub async fn list_agents() -> Result<Value, String> {
}
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

View File

@@ -564,6 +564,30 @@ pub async fn get_version_info() -> Result<VersionInfo, String> {
})
}
/// 获取 OpenClaw 运行时状态摘要openclaw status --json
/// 包含 runtimeVersion、会话列表含 token 用量、fastMode 等标签)
#[tauri::command]
pub async fn get_status_summary() -> Result<Value, String> {
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 {

View File

@@ -45,9 +45,10 @@ pub fn read_log_tail(log_name: String, lines: Option<u32>) -> Result<String, Str
file.seek(SeekFrom::Start(start_pos))
.map_err(|e| format!("Seek 失败: {e}"))?;
let mut buf = String::new();
file.read_to_string(&mut buf)
let mut raw = Vec::new();
file.read_to_end(&mut raw)
.map_err(|e| format!("读取日志失败: {e}"))?;
let buf = String::from_utf8_lossy(&raw).into_owned();
let mut all_lines: Vec<&str> = buf.lines().collect();

View File

@@ -34,7 +34,8 @@ async fn agent_workspace(agent_id: &str) -> Result<PathBuf, String> {
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 {

View File

@@ -220,6 +220,11 @@ pub async fn skills_clawhub_search(query: String) -> Result<Value, String> {
Ok(Value::Array(items))
}
/// Public wrapper for extract_json, used by config.rs get_status_summary
pub fn extract_json_pub(text: &str) -> Option<Value> {
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<Value> {

View File

@@ -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,
// 设备配对

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

View File

@@ -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: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14.7 6.3a1 1 0 000 1.4l1.6 1.6a1 1 0 001.4 0l3.77-3.77a6 6 0 01-7.94 7.94l-6.91 6.91a2.12 2.12 0 01-3-3l6.91-6.91a6 6 0 017.94-7.94l-3.76 3.76z"/></svg>',
channels: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>',
clock: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>',
'bar-chart': '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="20" x2="12" y2="10"/><line x1="18" y1="20" x2="18" y2="4"/><line x1="6" y1="20" x2="6" y2="16"/></svg>',
settings: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/></svg>',
debug: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/><circle cx="12" cy="12" r="3"/></svg>',
}

View File

@@ -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: [

View File

@@ -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'),

View File

@@ -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)

View File

@@ -2522,8 +2522,9 @@ function showSettings() {
<div class="form-group" style="margin-bottom:8px">
<label class="form-label">快捷选择</label>
<div id="ast-provider-presets" style="display:flex;flex-wrap:wrap;gap:6px">
${PROVIDER_PRESETS.map(p => `<button class="btn btn-sm btn-secondary ast-preset-btn" data-key="${p.key}" data-url="${escHtml(p.baseUrl)}" data-api="${p.api}" style="font-size:12px;padding:3px 10px">${p.label}</button>`).join('')}
${PROVIDER_PRESETS.filter(p => !p.hidden).map(p => `<button class="btn btn-sm btn-secondary ast-preset-btn" data-key="${p.key}" data-url="${escHtml(p.baseUrl)}" data-api="${p.api}" style="font-size:12px;padding:3px 10px">${p.label}${p.badge ? ' <span style="font-size:9px;background:var(--accent);color:#fff;padding:1px 4px;border-radius:6px;margin-left:3px">' + p.badge + '</span>' : ''}</button>`).join('')}
</div>
<div id="ast-preset-detail" style="display:none;margin-top:6px;padding:8px 12px;background:var(--bg-tertiary);border-radius:var(--radius-md);font-size:12px"></div>
</div>
<div style="display:flex;gap:10px">
<div class="form-group" style="flex:1">
@@ -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 ? `<div style="color:var(--text-secondary);line-height:1.5">${preset.desc}</div>` : ''
if (preset.site) html += `<a href="${preset.site}" target="_blank" style="color:var(--accent);text-decoration:none;font-size:11px;margin-top:3px;display:inline-block">→ 访问 ${preset.label}官网</a>`
detailEl.innerHTML = html
detailEl.style.display = 'block'
} else if (detailEl) {
detailEl.style.display = 'none'
}
}
})

View File

@@ -234,11 +234,11 @@ function renderAvailable(page, state) {
el.innerHTML = Object.entries(PLATFORM_REGISTRY).map(([pid, reg]) => {
const done = configuredIds.has(pid)
return `
<button class="platform-pick ${done ? 'configured' : ''}" data-pid="${pid}">
<button class="platform-pick" data-pid="${pid}">
<span class="platform-emoji">${icon(reg.iconName, 28)}</span>
<span class="platform-pick-name">${reg.label}</span>
<span class="platform-pick-desc">${reg.desc}</span>
${done ? `<span class="platform-pick-badge">已接入</span>` : ''}
${done ? `<span class="platform-pick-badge" style="color:var(--success)">已接入 · 点击绑定新 Agent</span>` : ''}
</button>
`
}).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 })
}

View File

@@ -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 })

404
src/pages/communication.js Normal file
View File

@@ -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 = `
<div class="page-header">
<h1 class="page-title">通信与自动化</h1>
<p class="page-desc">管理 AI 在各消息渠道中的行为方式:如何回复消息、支持哪些命令、如何接收外部通知等</p>
</div>
<div class="comm-toolbar" style="display:flex;gap:8px;margin-bottom:var(--space-lg);flex-wrap:wrap">
<button class="btn btn-sm btn-primary comm-tab active" data-tab="messages">消息</button>
<button class="btn btn-sm btn-secondary comm-tab" data-tab="broadcast">广播</button>
<button class="btn btn-sm btn-secondary comm-tab" data-tab="commands">命令</button>
<button class="btn btn-sm btn-secondary comm-tab" data-tab="hooks">Webhook</button>
<button class="btn btn-sm btn-secondary comm-tab" data-tab="approvals">执行审批</button>
<div style="flex:1"></div>
<button class="btn btn-sm btn-primary" id="btn-comm-save" disabled>${icon('save', 14)} 保存</button>
</div>
<div id="comm-content">
<div class="stat-card loading-placeholder" style="height:200px"></div>
</div>
`
// 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 = `<div style="color:var(--error)">加载配置失败: ${esc(e?.message || e)}</div>`
}
}
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 = `
<div class="config-section">
<div class="config-section-title">回复设置</div>
<div class="form-group">
<label class="form-label">回复前缀</label>
<input class="form-input" id="msg-responsePrefix" value="${esc(m.responsePrefix || '')}" placeholder="如 [{model}] 或 auto">
<div class="form-hint">每条 AI 回复开头自动加的前缀。支持 {model}、{provider}、{thinkingLevel} 等变量。设为 auto 则显示 Agent 名称</div>
</div>
<div class="form-group">
<label class="form-label">确认反应 Emoji</label>
<input class="form-input" id="msg-ackReaction" value="${esc(m.ackReaction || '')}" placeholder="如 👀 或留空禁用" style="max-width:200px">
<div class="form-hint">收到消息时自动添加的 emoji 反应(确认已收到)</div>
</div>
<div class="form-group">
<label class="form-label">确认反应范围</label>
<select class="form-input" id="msg-ackReactionScope" style="max-width:300px">
<option value="group-mentions" ${(m.ackReactionScope || 'group-mentions') === 'group-mentions' ? 'selected' : ''}>群聊 @提及时</option>
<option value="group-all" ${m.ackReactionScope === 'group-all' ? 'selected' : ''}>群聊所有消息</option>
<option value="direct" ${m.ackReactionScope === 'direct' ? 'selected' : ''}>仅私聊</option>
<option value="all" ${m.ackReactionScope === 'all' ? 'selected' : ''}>所有消息</option>
<option value="off" ${m.ackReactionScope === 'off' ? 'selected' : ''}>关闭</option>
</select>
</div>
<div class="form-group" style="display:flex;align-items:center;justify-content:space-between">
<div>
<label class="form-label" style="margin:0">回复后移除确认反应</label>
<div class="form-hint" style="margin:0">回复发送成功后自动删除之前的确认 emoji</div>
</div>
<label class="toggle-switch"><input type="checkbox" id="msg-removeAckAfterReply" ${m.removeAckAfterReply ? 'checked' : ''}><span class="toggle-slider"></span></label>
</div>
<div class="form-group" style="display:flex;align-items:center;justify-content:space-between">
<div>
<label class="form-label" style="margin:0">隐藏工具错误</label>
<div class="form-hint" style="margin:0">不向用户显示 ⚠️ 工具执行错误</div>
</div>
<label class="toggle-switch"><input type="checkbox" id="msg-suppressToolErrors" ${m.suppressToolErrors ? 'checked' : ''}><span class="toggle-slider"></span></label>
</div>
</div>
<div class="config-section">
<div class="config-section-title">状态反应 Emoji</div>
<div class="form-group" style="display:flex;align-items:center;justify-content:space-between">
<div>
<label class="form-label" style="margin:0">启用状态反应</label>
<div class="form-hint" style="margin:0">在消息渠道中用 emoji 表示 AI 当前状态(思考中、执行工具、完成等)</div>
</div>
<label class="toggle-switch"><input type="checkbox" id="msg-sr-enabled" ${sr.enabled ? 'checked' : ''}><span class="toggle-slider"></span></label>
</div>
</div>
<div class="config-section">
<div class="config-section-title">消息队列</div>
<div class="form-group">
<label class="form-label">防抖延迟(毫秒)</label>
<input class="form-input" id="msg-debounceMs" type="number" value="${m.inbound?.debounceMs || m.queue?.debounceMs || ''}" placeholder="默认无延迟" style="max-width:200px">
<div class="form-hint">合并快速连续消息的等待时间(毫秒),避免 AI 对每条消息逐一回复</div>
</div>
<div class="form-group">
<label class="form-label">队列上限</label>
<input class="form-input" id="msg-queueCap" type="number" value="${m.queue?.cap || ''}" placeholder="默认无限制" style="max-width:200px">
<div class="form-hint">等待处理的消息队列最大长度</div>
</div>
</div>
<div class="config-section">
<div class="config-section-title">群聊设置</div>
<div class="form-group">
<label class="form-label">群聊历史条数</label>
<input class="form-input" id="msg-groupHistoryLimit" type="number" value="${m.groupChat?.historyLimit || ''}" placeholder="默认自动" style="max-width:200px">
<div class="form-hint">群聊中回溯多少条历史消息作为上下文</div>
</div>
</div>
`
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 = `
<div class="config-section">
<div class="config-section-title">广播策略</div>
<div class="form-group">
<label class="form-label">广播处理方式</label>
<select class="form-input" id="bc-strategy" style="max-width:300px">
<option value="parallel" ${(b.strategy || 'parallel') === 'parallel' ? 'selected' : ''}>并行parallel— 同时发送给所有目标</option>
<option value="sequential" ${b.strategy === 'sequential' ? 'selected' : ''}>顺序sequential— 逐个发送,严格有序</option>
</select>
<div class="form-hint">当消息需要广播给多个 Agent 时的处理策略。并行更快,顺序更可控</div>
</div>
</div>
`
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 = `
<div class="config-section">
<div class="config-section-title">斜杠命令</div>
${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)}
</div>
<div class="config-section">
<div class="config-section-title">原生命令注册</div>
<div class="form-group">
<label class="form-label">原生命令</label>
<select class="form-input" id="cmd-native" style="max-width:200px">
<option value="auto" ${(cmd.native === 'auto' || cmd.native === undefined) ? 'selected' : ''}>自动</option>
<option value="true" ${cmd.native === true ? 'selected' : ''}>启用</option>
<option value="false" ${cmd.native === false ? 'selected' : ''}>禁用</option>
</select>
<div class="form-hint">在支持的渠道Telegram、Discord自动注册原生命令菜单</div>
</div>
</div>
`
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 = `
<div class="config-section">
<div class="config-section-title">Webhook 设置</div>
${toggleRow('hooks-enabled', '启用 Webhook', '允许外部服务通过 HTTP 触发 AI 执行', !!h.enabled)}
<div class="form-group">
<label class="form-label">Webhook 路径</label>
<input class="form-input" id="hooks-path" value="${esc(h.path || '')}" placeholder="/hooks默认" style="max-width:300px">
<div class="form-hint">Gateway 上暴露的 Webhook 接收路径</div>
</div>
<div class="form-group">
<label class="form-label">认证 Token</label>
<input class="form-input" id="hooks-token" type="password" value="${esc(h.token || '')}" placeholder="可选,用于验证 Webhook 请求">
<div class="form-hint">外部请求需在 Header 中携带此 Token 才能触发 Webhook</div>
</div>
<div class="form-group">
<label class="form-label">默认 Session Key</label>
<input class="form-input" id="hooks-defaultSessionKey" value="${esc(h.defaultSessionKey || '')}" placeholder="自动生成 hook:<uuid>">
<div class="form-hint">Webhook 触发的 Agent 会话标识。留空则每次自动生成</div>
</div>
<div class="form-group">
<label class="form-label">请求体大小限制(字节)</label>
<input class="form-input" id="hooks-maxBodyBytes" type="number" value="${h.maxBodyBytes || ''}" placeholder="默认无限制" style="max-width:200px">
</div>
</div>
`
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 = `
<div class="config-section">
<div class="config-section-title">执行审批转发</div>
<div class="form-hint" style="margin-bottom:var(--space-md)">当 AI 请求执行命令时,将审批请求转发到消息渠道,方便在手机上审批</div>
${toggleRow('approvals-enabled', '启用审批转发', '将执行审批请求转发到配置的消息渠道', !!a.enabled)}
<div class="form-group">
<label class="form-label">转发模式</label>
<select class="form-input" id="approvals-mode" style="max-width:300px">
<option value="session" ${(a.mode || 'session') === 'session' ? 'selected' : ''}>原会话session— 发到发起请求的会话</option>
<option value="targets" ${a.mode === 'targets' ? 'selected' : ''}>指定目标targets— 发到配置的目标渠道</option>
<option value="both" ${a.mode === 'both' ? 'selected' : ''}>两者都发both</option>
</select>
</div>
${toggleRow('approvals-forwardExec', '转发执行请求', '将 exec 审批请求转发到渠道(默认关闭,低风险场景可开启)', !!a.enabled)}
</div>
`
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 `
<div class="form-group" style="display:flex;align-items:center;justify-content:space-between">
<div>
<label class="form-label" style="margin:0">${label}</label>
<div class="form-hint" style="margin:0">${hint}</div>
</div>
<label class="toggle-switch"><input type="checkbox" id="${id}" ${checked ? 'checked' : ''}><span class="toggle-slider"></span></label>
</div>
`
}
function esc(str) {
return (str || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
}

View File

@@ -315,6 +315,11 @@ async function openTaskDialog(job, page, state) {
<select class="form-input" name="agentId">${agentOptionsHtml}</select>
<div class="form-hint">不选则使用默认 Agent 执行</div>
</div>
<div class="form-group">
<label class="form-label">投递渠道</label>
<select class="form-input" name="deliveryChannel"><option value="">无(主会话)</option></select>
<div class="form-hint">配置了多个消息渠道时必须指定,否则任务会报错</div>
</div>
<div class="form-group">
<label class="form-label">执行周期</label>
<div style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:8px">${shortcutsHtml}</div>
@@ -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 = `<option value="">无(主会话)</option>` + channelIds.map(ch =>
`<option value="${escapeAttr(ch)}" ${ch === current ? 'selected' : ''}>${escapeHtml(ch)}</option>`
).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')
}

View File

@@ -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) {
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
</div>
<div class="overview-card-body">
<div class="overview-card-title">配置版本</div>
<div class="overview-card-value" style="font-size:var(--font-size-sm)">${lastUpdate}</div>
<div class="overview-card-meta">openclaw.json</div>
<div class="overview-card-title">运行时版本</div>
<div class="overview-card-value" style="font-size:var(--font-size-sm)">${runtimeVer || lastUpdate}</div>
<div class="overview-card-meta">${runtimeVer ? 'OpenClaw Runtime' : 'openclaw.json'}</div>
</div>
</div>
</div>
${renderSessionStatus(sessions)}
</div>
`
@@ -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 => `<span class="session-flag">${escapeHtml(f)}</span>`).join('')
const model = s.model ? `<span class="session-model">${escapeHtml(s.model)}</span>` : ''
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 `<div class="session-row">
<div class="session-row-header">
<span class="session-key" title="${escapeHtml(s.key || '')}">${key || '—'}</span>
${model}${flags}
</div>
<div class="session-bar-wrap">
<div class="session-bar" style="width:${Math.min(pct, 100)}%;background:${barColor}"></div>
</div>
<div class="session-row-meta">${tokens} / ${ctx} · 剩余 ${remaining} · ${pct}%</div>
</div>`
})
const defaultModel = sessions.defaults?.model || '—'
const defaultCtx = sessions.defaults?.contextTokens ? `${Math.round(sessions.defaults.contextTokens / 1000)}k` : '—'
return `
<div class="config-section" style="margin-top:16px">
<div class="config-section-title">活跃会话 <span style="font-weight:normal;color:var(--text-tertiary);font-size:var(--font-size-xs)">${sessions.count || 0} 个 · 默认模型 ${escapeHtml(defaultModel)} · 上下文 ${defaultCtx}</span></div>
<div class="session-list">${rows.join('')}</div>
</div>`
}
function renderLogs(page, logs) {
const logsEl = page.querySelector('#recent-logs')
if (!logs) {

View File

@@ -121,35 +121,6 @@ function renderConfig(page, state) {
</div>
</div>
<div class="config-section">
<div class="config-section-title">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="18" height="18"><path d="M22 11.08V12a10 10 0 11-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
运行模式
</div>
<div class="gw-option-cards">
<label class="gw-option-card ${gw.mode === 'remote' ? '' : 'selected'}" data-mode="local">
<input type="radio" name="gw-mode" value="local" ${gw.mode === 'remote' ? '' : 'checked'} hidden>
<div class="gw-option-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg>
</div>
<div class="gw-option-text">
<div class="gw-option-title">本地模式</div>
<div class="gw-option-desc">模型跑在这台电脑上(如 Ollama不需要联网</div>
</div>
</label>
<label class="gw-option-card ${gw.mode === 'remote' ? 'selected' : ''}" data-mode="remote">
<input type="radio" name="gw-mode" value="remote" ${gw.mode === 'remote' ? 'checked' : ''} hidden>
<div class="gw-option-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>
</div>
<div class="gw-option-text">
<div class="gw-option-title">云端模式</div>
<div class="gw-option-desc">调用线上 AI 服务OpenAI、Claude 等),大多数人选这个</div>
</div>
</label>
</div>
</div>
<div class="config-section">
<div class="config-section-title">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="18" height="18"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0110 0v4"/></svg>
@@ -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 || ''

View File

@@ -32,11 +32,12 @@ export async function render() {
<div style="flex:1;min-width:240px">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px">
<span style="font-size:20px">${icon('gift', 22)}</span>
<span style="font-weight:700;font-size:16px;letter-spacing:0.3px">ClawPanel 公益 AI 接口计划</span>
<span style="font-weight:700;font-size:16px;letter-spacing:0.3px">晴辰云 AI 接口</span>
<span style="font-size:10px;background:rgba(255,255,255,0.2);padding:2px 8px;border-radius:10px;font-weight:600">官方</span>
</div>
<div style="font-size:13px;color:rgba(255,255,255,0.65);line-height:1.7">
Token 费用我们帮你出了。调用成本由项目组内部承担GPT-5 全系列模型开箱即用。<br>
无需注册、无需付费、支持 OpenAI 兼容接口 — 点击即享。
每日签到送免费额度 · 邀请好友送余额 · 充值最低 3 折消耗 · 未消耗余额随时包退<br>
GPT-5 全系列模型开箱即用,更多主流模型持续接入中。OpenAI 兼容接口,一键配置
</div>
</div>
<div style="display:flex;flex-direction:column;gap:10px;align-items:flex-end">
@@ -779,19 +780,20 @@ function bindTopActions(page, state) {
// 添加服务商(带预设快捷选择)
function addProvider(page, state) {
// 构建预设按钮 HTML
const presetsHtml = PROVIDER_PRESETS.map(p =>
`<button class="btn btn-sm btn-secondary preset-btn" data-preset="${p.key}" style="margin:0 6px 6px 0">${p.label}</button>`
const presetsHtml = PROVIDER_PRESETS.filter(p => !p.hidden).map(p =>
`<button class="btn btn-sm btn-secondary preset-btn" data-preset="${p.key}" style="margin:0 6px 6px 0">${p.label}${p.badge ? ' <span style="font-size:9px;background:var(--accent);color:#fff;padding:1px 5px;border-radius:8px;margin-left:4px">' + p.badge + '</span>' : ''}</button>`
).join('')
const overlay = document.createElement('div')
overlay.className = 'modal-overlay'
overlay.innerHTML = `
<div class="modal">
<div class="modal" style="max-height:85vh;overflow-y:auto">
<div class="modal-title">添加服务商</div>
<div class="form-group">
<label class="form-label">快捷选择</label>
<div style="display:flex;flex-wrap:wrap">${presetsHtml}</div>
<div class="form-hint">选择常用服务商自动填充,或手动填写下方信息</div>
<div id="preset-detail" style="display:none;margin-top:8px;padding:10px 14px;background:var(--bg-tertiary);border-radius:var(--radius-md);font-size:var(--font-size-sm)"></div>
</div>
<div class="form-group">
<label class="form-label">服务商名称</label>
@@ -835,6 +837,18 @@ function addProvider(page, state) {
// 高亮选中的预设
overlay.querySelectorAll('.preset-btn').forEach(b => b.style.opacity = '0.5')
btn.style.opacity = '1'
// 显示服务商详情(官网、描述)
const detailEl = overlay.querySelector('#preset-detail')
if (detailEl) {
if (preset.desc || preset.site) {
let html = preset.desc ? `<div style="color:var(--text-secondary);line-height:1.6">${preset.desc}</div>` : ''
if (preset.site) html += `<a href="${preset.site}" target="_blank" style="color:var(--accent);text-decoration:none;font-size:12px;margin-top:4px;display:inline-block">→ 访问 ${preset.label}官网</a>`
detailEl.innerHTML = html
detailEl.style.display = 'block'
} else {
detailEl.style.display = 'none'
}
}
}
})

240
src/pages/usage.js Normal file
View File

@@ -0,0 +1,240 @@
/**
* 使用情况页面 — 对接 OpenClaw Gateway sessions.usage API
* 展示 Token 用量、费用、Top Models/Providers/Tools/Agents 等分析数据
*/
import { wsClient } from '../lib/ws-client.js'
import { toast } from '../components/toast.js'
import { icon } from '../lib/icons.js'
let _page = null, _unsubReady = null
export async function render() {
const page = document.createElement('div')
page.className = 'page'
_page = page
page.innerHTML = `
<div class="page-header">
<h1 class="page-title">使用情况</h1>
<p class="page-desc">查看 Token 消耗、API 费用和模型使用统计</p>
</div>
<div class="usage-toolbar" style="display:flex;gap:8px;align-items:center;margin-bottom:var(--space-lg);flex-wrap:wrap">
<button class="btn btn-sm ${_days === 1 ? 'btn-primary' : 'btn-secondary'}" data-days="1">今天</button>
<button class="btn btn-sm ${_days === 7 ? 'btn-primary' : 'btn-secondary'}" data-days="7">7天</button>
<button class="btn btn-sm ${_days === 30 ? 'btn-primary' : 'btn-secondary'}" data-days="30">30天</button>
<button class="btn btn-sm btn-secondary" id="btn-usage-refresh">${icon('refresh-cw', 14)} 刷新</button>
</div>
<div id="usage-content">
<div class="stat-card loading-placeholder" style="height:120px"></div>
</div>
`
page.querySelectorAll('[data-days]').forEach(btn => {
btn.onclick = () => {
_days = parseInt(btn.dataset.days)
page.querySelectorAll('[data-days]').forEach(b => { b.classList.remove('btn-primary'); b.classList.add('btn-secondary') })
btn.classList.remove('btn-secondary'); btn.classList.add('btn-primary')
loadUsage(page)
}
})
page.querySelector('#btn-usage-refresh')?.addEventListener('click', () => loadUsage(page))
loadUsage(page)
return page
}
export function cleanup() {
_page = null
if (_unsubReady) { _unsubReady(); _unsubReady = null }
}
let _days = 7
async function loadUsage(page) {
const el = page.querySelector('#usage-content')
el.innerHTML = `<div class="stat-card loading-placeholder" style="height:120px"></div>
<div class="stat-card loading-placeholder" style="height:200px;margin-top:var(--space-md)"></div>`
if (!wsClient.connected) {
el.innerHTML = `<div class="usage-empty">
<div style="color:var(--text-tertiary);margin-bottom:8px">Gateway 连接中...</div>
<div class="form-hint">等待 Gateway 连接就绪后自动加载</div>
</div>`
// 自动等待连接就绪后重试
if (_unsubReady) _unsubReady()
_unsubReady = wsClient.onReady(() => {
if (_unsubReady) { _unsubReady(); _unsubReady = null }
if (_page) loadUsage(_page)
})
return
}
try {
const now = new Date()
const end = now.toISOString().slice(0, 10)
const start = new Date(now.getTime() - (_days - 1) * 86400000).toISOString().slice(0, 10)
const data = await wsClient.request('sessions.usage', { startDate: start, endDate: end, limit: 20 })
renderUsage(el, data)
} catch (e) {
el.innerHTML = `<div class="usage-empty">
<div style="color:var(--error);margin-bottom:8px">加载失败: ${esc(e?.message || e)}</div>
<div class="form-hint">可能需要更新 OpenClaw 到 2026.3.11+ 以支持 Usage API</div>
<button class="btn btn-secondary btn-sm" style="margin-top:8px" onclick="this.closest('.page').querySelector('#btn-usage-refresh').click()">重试</button>
</div>`
}
}
function renderUsage(el, data) {
if (!data) { el.innerHTML = '<div class="usage-empty">暂无数据</div>'; return }
const t = data.totals || {}
const a = data.aggregates || {}
const msgs = a.messages || {}
const tools = a.tools || {}
const fmtTokens = (n) => {
if (n == null || n === 0) return '0'
if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M'
if (n >= 1e3) return (n / 1e3).toFixed(1) + 'k'
return String(n)
}
const fmtCost = (n) => n != null && n > 0 ? '$' + n.toFixed(4) : '$0'
const fmtRate = (errors, total) => {
if (!total) return '—'
const pct = (errors / total * 100).toFixed(1)
return pct + '%'
}
// ── 概览卡片 ──
const overviewHtml = `
<div class="stat-cards" style="margin-bottom:var(--space-lg)">
<div class="stat-card">
<div class="stat-card-header"><span class="stat-card-label">消息</span></div>
<div class="stat-card-value">${msgs.total || 0}</div>
<div class="stat-card-meta">${msgs.user || 0} 用户 · ${msgs.assistant || 0} 助手</div>
</div>
<div class="stat-card">
<div class="stat-card-header"><span class="stat-card-label">工具调用</span></div>
<div class="stat-card-value">${tools.totalCalls || 0}</div>
<div class="stat-card-meta">${tools.uniqueTools || 0} 种工具</div>
</div>
<div class="stat-card">
<div class="stat-card-header"><span class="stat-card-label">错误</span></div>
<div class="stat-card-value">${msgs.errors || 0}</div>
<div class="stat-card-meta">错误率 ${fmtRate(msgs.errors, msgs.total)}</div>
</div>
<div class="stat-card">
<div class="stat-card-header"><span class="stat-card-label">Token 总量</span></div>
<div class="stat-card-value">${fmtTokens(t.totalTokens)}</div>
<div class="stat-card-meta">${fmtTokens(t.input)} 输入 · ${fmtTokens(t.output)} 输出</div>
</div>
<div class="stat-card">
<div class="stat-card-header"><span class="stat-card-label">费用</span></div>
<div class="stat-card-value">${fmtCost(t.totalCost)}</div>
<div class="stat-card-meta">${fmtCost(t.inputCost)} 输入 · ${fmtCost(t.outputCost)} 输出</div>
</div>
<div class="stat-card">
<div class="stat-card-header"><span class="stat-card-label">会话</span></div>
<div class="stat-card-value">${(data.sessions || []).length}</div>
<div class="stat-card-meta">${data.startDate || ''} ~ ${data.endDate || ''}</div>
</div>
</div>
`
// ── Top 排行 ──
const renderTop = (title, items, keyFn, valueFn, metaFn) => {
if (!items || !items.length) return ''
const rows = items.slice(0, 5).map(item => `
<div style="display:flex;justify-content:space-between;align-items:center;padding:6px 0;border-bottom:1px solid var(--border-primary)">
<span style="font-size:var(--font-size-sm);color:var(--text-primary);font-weight:500">${esc(keyFn(item))}</span>
<span style="font-size:var(--font-size-sm);color:var(--text-secondary);font-family:var(--font-mono)">${valueFn(item)}</span>
</div>
`).join('')
return `
<div class="usage-top-card">
<div class="usage-top-title">${title}</div>
${rows}
</div>
`
}
const topModels = renderTop('热门模型',
a.byModel, m => m.model || '未知', m => fmtCost(m.totals?.totalCost) + ' · ' + fmtTokens(m.totals?.totalTokens))
const topProviders = renderTop('热门服务商',
a.byProvider, p => p.provider || '未知', p => fmtCost(p.totals?.totalCost) + ' · ' + p.count + ' 次')
const topTools = renderTop('热门工具',
(tools.tools || []), t => t.name, t => t.count + ' 次调用')
const topAgents = renderTop('热门 Agent',
a.byAgent, a => a.agentId || 'main', a => fmtCost(a.totals?.totalCost))
const topChannels = renderTop('热门渠道',
a.byChannel, c => c.channel || 'webchat', c => fmtCost(c.totals?.totalCost))
const topsHtml = `<div class="usage-tops-grid">${topModels}${topProviders}${topTools}${topAgents}${topChannels}</div>`
// ── Token 分类 ──
const tokenBreakdownHtml = `
<div class="config-section" style="margin-top:var(--space-lg)">
<div class="config-section-title">Token 分类</div>
<div style="display:flex;gap:var(--space-lg);flex-wrap:wrap;padding:var(--space-md)">
<div><span style="display:inline-block;width:10px;height:10px;background:var(--error);border-radius:2px;margin-right:6px"></span>输出 ${fmtTokens(t.output)}</div>
<div><span style="display:inline-block;width:10px;height:10px;background:var(--accent);border-radius:2px;margin-right:6px"></span>输入 ${fmtTokens(t.input)}</div>
<div><span style="display:inline-block;width:10px;height:10px;background:var(--success);border-radius:2px;margin-right:6px"></span>缓存读取 ${fmtTokens(t.cacheRead)}</div>
<div><span style="display:inline-block;width:10px;height:10px;background:var(--warning);border-radius:2px;margin-right:6px"></span>缓存写入 ${fmtTokens(t.cacheWrite)}</div>
</div>
</div>
`
// ── 每日用量 ──
const daily = a.daily || []
let dailyHtml = ''
if (daily.length > 0) {
const maxTokens = Math.max(...daily.map(d => d.tokens || 0), 1)
const bars = daily.map(d => {
const pct = Math.max(1, Math.round((d.tokens || 0) / maxTokens * 100))
const date = (d.date || '').slice(5) // MM-DD
return `<div class="usage-daily-bar-wrap" title="${d.date}: ${fmtTokens(d.tokens)} tokens · ${d.messages || 0} msgs">
<div class="usage-daily-bar" style="height:${pct}%"></div>
<div class="usage-daily-label">${date}</div>
</div>`
}).join('')
dailyHtml = `
<div class="config-section" style="margin-top:var(--space-lg)">
<div class="config-section-title">每日用量</div>
<div class="usage-daily-chart">${bars}</div>
</div>
`
}
// ── 会话列表 ──
const sessions = (data.sessions || []).slice(0, 10)
let sessionsHtml = ''
if (sessions.length > 0) {
const rows = sessions.map(s => {
const u = s.usage || {}
const key = esc(s.key || '').replace(/^agent:main:/, '')
const model = s.model || u.modelUsage?.[0]?.model || ''
const provider = u.modelUsage?.[0]?.provider || s.modelProvider || ''
return `<div class="session-row">
<div class="session-row-header">
<span class="session-key" title="${esc(s.key || '')}">${key || s.sessionId?.slice(0, 12) || '—'}</span>
${s.agentId ? `<span class="session-flag">${esc(s.agentId)}</span>` : ''}
${model ? `<span class="session-model">${esc(model)}</span>` : ''}
${provider ? `<span class="session-flag">${esc(provider)}</span>` : ''}
</div>
<div class="session-row-meta">${fmtTokens(u.totalTokens)} tokens · ${fmtCost(u.totalCost)} · ${(u.messageCounts?.total || 0)} msgs${u.messageCounts?.errors ? ' · ' + u.messageCounts.errors + ' err' : ''}</div>
</div>`
}).join('')
sessionsHtml = `
<div class="config-section" style="margin-top:var(--space-lg)">
<div class="config-section-title">会话明细 <span style="font-weight:normal;color:var(--text-tertiary);font-size:var(--font-size-xs)">最近 ${sessions.length} 个</span></div>
<div class="session-list">${rows}</div>
</div>
`
}
el.innerHTML = overviewHtml + topsHtml + tokenBreakdownHtml + dailyHtml + sessionsHtml
}
function esc(str) {
return (str || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}

View File

@@ -151,7 +151,13 @@
align-self: flex-start;
}
.msg-system {
.msg.msg-system.compaction-hint {
color: var(--warning);
font-style: italic;
animation: pulse-opacity 1.5s ease-in-out infinite;
}
.msg.msg-system {
align-self: center;
font-size: 12px;
color: var(--text-muted, #999);
@@ -159,6 +165,11 @@
max-width: 100%;
}
@keyframes pulse-opacity {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
@keyframes msg-in {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }

View File

@@ -75,6 +75,121 @@
align-self: center;
}
/* 会话状态 */
.session-list {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.session-row {
padding: var(--space-sm) var(--space-md);
background: var(--bg-card);
border: 1px solid var(--border-primary);
border-radius: var(--radius-md);
}
.session-row-header {
display: flex;
align-items: center;
gap: var(--space-sm);
flex-wrap: wrap;
margin-bottom: 4px;
}
.session-key {
font-size: var(--font-size-sm);
font-weight: 600;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 200px;
}
.session-model {
font-size: var(--font-size-xs);
color: var(--accent);
background: var(--bg-hover);
padding: 1px 6px;
border-radius: var(--radius-sm);
}
.session-flag {
font-size: 10px;
color: var(--text-secondary);
background: var(--bg-tertiary);
padding: 1px 5px;
border-radius: var(--radius-sm);
font-family: var(--font-mono);
}
.session-bar-wrap {
height: 4px;
background: var(--bg-tertiary);
border-radius: 2px;
overflow: hidden;
margin-bottom: 4px;
}
.session-bar {
height: 100%;
border-radius: 2px;
transition: width 0.3s ease;
}
.session-row-meta {
font-size: var(--font-size-xs);
color: var(--text-tertiary);
font-family: var(--font-mono);
}
/* 使用情况页面 */
.usage-empty {
text-align: center;
padding: var(--space-xl);
color: var(--text-tertiary);
}
.usage-tops-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: var(--space-md);
margin-bottom: var(--space-lg);
}
.usage-top-card {
background: var(--bg-card);
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
padding: var(--space-md);
}
.usage-top-title {
font-size: var(--font-size-sm);
font-weight: 700;
color: var(--text-primary);
margin-bottom: var(--space-sm);
}
.usage-daily-chart {
display: flex;
align-items: flex-end;
gap: 2px;
height: 120px;
padding: var(--space-md) var(--space-sm) 0;
}
.usage-daily-bar-wrap {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
height: 100%;
justify-content: flex-end;
}
.usage-daily-bar {
width: 100%;
max-width: 24px;
background: var(--accent);
border-radius: 2px 2px 0 0;
min-height: 2px;
transition: height 0.3s ease;
}
.usage-daily-label {
font-size: 9px;
color: var(--text-tertiary);
margin-top: 4px;
white-space: nowrap;
}
/* 服务卡片 */
.service-card {
display: flex;