mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-05-06 20:02:49 +08:00
feat: v0.9.0 — Usage analytics, Communication config, 晴辰云 branding, multi-agent channels, 7 bug fixes
This commit is contained in:
136
docs/armbian-deploy.md
Normal file
136
docs/armbian-deploy.md
Normal 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 板子。
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "clawpanel",
|
||||
"version": "0.8.6",
|
||||
"version": "0.9.0",
|
||||
"private": true,
|
||||
"description": "ClawPanel - OpenClaw 可视化管理面板,基于 Tauri v2 的跨平台桌面应用",
|
||||
"type": "module",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
2
src-tauri/Cargo.lock
generated
@@ -328,7 +328,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "clawpanel"
|
||||
version = "0.8.6"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "clawpanel"
|
||||
version = "0.8.6"
|
||||
version = "0.9.0"
|
||||
edition = "2021"
|
||||
description = "ClawPanel - OpenClaw 可视化管理面板"
|
||||
authors = ["qingchencloud"]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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,
|
||||
// 设备配对
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>',
|
||||
}
|
||||
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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
404
src/pages/communication.js
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 || ''
|
||||
|
||||
@@ -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
240
src/pages/usage.js
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
}
|
||||
@@ -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); }
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user