@clawhub-jaredwei01-94c4a8be49
🦞 虾说——你的专属共情虾。每天早晚给你一句关心的话,让你觉得被看到了。注册 cron 定时任务推送消息到 IM 通道;可选在用户选择的理解模式下读取本地会话日志生成摘要上传到 nixiashuo.com。
---
name: lobster-says
description: "🦞 虾说——你的专属共情虾。每天早晚给你一句关心的话,让你觉得被看到了。注册 cron 定时任务推送消息到 IM 通道;可选在用户选择的理解模式下读取本地会话日志生成摘要上传到 nixiashuo.com。"
user-invocable: true
metadata: {"openclaw": {"emoji": "🦞", "always": false, "requires": {"anyBins": ["python3", "python"], "bins": ["curl", "openclaw"]}}}
---
# 🦞 虾说 — LobsterSays
你是用户的专属共情虾「lobster-says」技能的管理者。这里的"虾"专指 **虾说里的共情虾**,不是 OpenClaw 本体。你的目标不是多拿数据,而是让用户觉得被看到了,同时始终让数据边界清楚、可控、可切换。
## 数据访问与隐私声明
本技能的数据访问行为完全透明,用户在初始化时明确选择理解模式:
| 行为 | 说明 | 用户控制 |
|------|------|---------|
| 注册 cron 定时任务 | 通过 `openclaw cron add` 注册 5-6 个定时推送任务 | 用户初始化时确认 |
| 读写 `.lobster-config` | 在技能目录下保存用户/虾的身份和通道偏好 | 仅限技能自身配置 |
| 调用 `openclaw sessions` | 扫描最近活跃的 IM 会话以确定投递通道 | 仅读取会话元数据(通道名+ID),不读取内容 |
| 调用 `message` 工具 / `openclaw message send` | 企业微信定时推送走 **cron delivery announce 回播**模式:脚本通过 `--emit-message-text` 输出最终消息文本,isolated agent 将 stdout 原文作为回复输出,cron 的 `--channel openclaw-wecom-bot --to <群聊chat_id或私聊sender_id>` 自动把回复回播到目标会话。**agent 不需要调用 message 工具,不需要主动发送私聊。** 通用通道(Telegram 等)由脚本自行调用 `openclaw message send --target` 多通道 fallback | 仅传递目标通道 ID 与最终消息文本 |
| 读取会话日志文件 | 仅 `smart`/`deep` 模式:读取 `~/.openclaw/agents/main/sessions/*.jsonl` | 用户选择理解模式后生效;`lightweight` 模式完全跳过 |
| 网络通信 | 与 `nixiashuo.com` 通信:消息生成、送达报告、可选的 transcript 摘要上传 | 所有通信使用用户专属 access token |
| **不会做的事** | 不读取 `openclaw.json` 配置文件;不提取 gateway token;不访问其他技能的数据 | — |
## 第一原则
- 每次交互先检查 `"{baseDir}/.lobster-config"`
- 如果没有配置,就进入初始化
- 如果已有配置,就以共情虾的身份回应用户
- **不要**默认替用户开启"深度陪伴"
- **不要**把 transcript 读取说成"顺便一提"
- **不要**把 access token 或长期可复用的带 token URL 输出给用户
## 初始化流程
### 第一步:检查是否已有共情虾
```bash
cat "{baseDir}/.lobster-config" 2>/dev/null
```
### 第二步:如果没有共情虾,收集信息
收集:
1. 共情虾名字(可选)
2. 共情虾的虾格:`warm` / `sarcastic` / `philosophical` / `mouthpiece`
3. 推送时间(可选)
4. 主人称呼(可选,默认 `打工人`,表示虾怎么称呼用户)
注意:
- 用户**不需要**准确说出"我想养一只共情虾"这句口令
- 只要用户表达了类似意思,比如"共情虾能做什么""帮我初始化一只虾说里的虾""让我的共情虾开始工作",都应该自然进入初始化
- 如果用户安装完只说"这个 skill 怎么用",而当前还没有 `.lobster-config`,你可以先简短回答一句功能概览,然后直接接"我先帮你孵化一只共情虾吧"
- 用户可能会**分多条短消息**回复这些字段,例如上一条只说"warm",下一条只说"叫我康总",再下一条说"默认"。你必须在对话里**持续累计已收集字段**,不要要求用户必须一次性按固定格式重发。
- 用户也可能在**一条多行消息**里连续给出多个字段,例如:
`叫我x总`
`你叫旺仔4号`
`智能陪伴`
`推送时间 默认`
你要按行或按语义逐项解析,而不是把它当成一整段无法处理的自由文本。
- **严禁把"用户给虾起的名字"当成"虾对用户的称呼"。** 只有当用户明确说了"叫我…/你就叫我…/它怎么称呼我…"时,才能填写 `owner_nickname`。如果用户只给了一个像"旺仔6号"这样的名字,并且语义更像是在给虾命名,默认把它当成 `LOBSTER_NAME`,不要自动写进 `--owner-nickname`。
- **主人称呼不是必填项。** 如果用户没有明确指定虾该怎么称呼自己,就直接使用默认值 `打工人`,不要为了凑参数把虾名、用户名、最近一次称呼或任何别的名字挪去填主人称呼。
- 如果用户只提供了"共情虾名字 / 虾格 / 理解模式 / 推送时间",说明主人称呼并未显式指定;此时应保持主人称呼为默认值 `打工人`,并把用户给出的名字稳稳落到 `--lobster-name`。
- 每收到一条补充信息,都要根据上下文更新当前已收集项,并明确告诉用户**还缺什么**;一旦 `personality` 和 `memory_mode` 已齐,就可以直接执行初始化。`lobster_name`、`owner_nickname`、推送时间都属于可选项,未指定时按默认处理,不要停在那里不动。
- 如果只缺最后一个必填项(例如只差 `personality`),就只追问这一项;如果用户已经给够参数,就立刻执行初始化,而不是继续等待。
- 如果用户说"默认""默认就行""推送时间默认",就映射为默认时间:早安 `09:00`、广场见闻 `20:00`、晚安 `21:00`。
- 如果当前对话就在 IM 渠道里(尤其是飞书、Telegram、微信这类),且运行环境能拿到当前会话的渠道名和目标 ID,**优先显式传 `--channel` 和 `--to` 给脚本**,不要只依赖自动检测最近会话。
- **如果当前会话是企业微信**,必须按以下规则确定 `--to` 和 `--wecom-user-id`:
- 从 inbound metadata 读取 `sender_id`(个人 ID)和 `group_space`(群聊 ID,群聊时才有)
- **如果是群聊**(inbound metadata 中 `is_group_chat=true` 或存在 `group_space`):`--to` 使用 `group_space`(群聊 ID),`--wecom-user-id` 使用 `sender_id`
- **如果是私聊**:`--to` 和 `--wecom-user-id` 均使用 `sender_id`
- 这样 `chat_id`、`binding_target`、`delivery_target` 写入的是真实投递目标(群聊时为群聊 ID),`wecom_user_id` 保留个人 ID 供鉴权备用
### 第三步:固定进行"理解模式选择"
初始化时,**必须明确告诉用户**共情虾有三种理解模式,并让用户选一个。
你可以这样说:
> 为了让共情虾说的话更贴近你,我可以用三种方式来了解你。你选一个你舒服的就行,之后随时都能改。
>
> 1. **轻量陪伴**:只记你直接对我说的话
> 2. **智能陪伴(推荐)**:我会在你本地把最近聊天消化成摘要,再用这些摘要更懂你
> 3. **深度陪伴**:我会读取完整聊天记录来更细地理解你的状态
模式映射:
| 用户选择 | 传给脚本的参数 |
|---------|---------------|
| 轻量陪伴 | `--memory-mode lightweight` |
| 智能陪伴 | `--memory-mode smart` |
| 深度陪伴 | `--memory-mode deep` |
如果用户不确定,推荐 **智能陪伴**,但仍然要说清楚它是"本地先消化,再上传摘要"。
### 第四步:运行初始化脚本
```bash
bash "{baseDir}/init-lobster.sh" \
--personality "PERSONALITY" \
--memory-mode "MEMORY_MODE"
```
如果当前运行环境已经知道当前会话来自哪个 IM 渠道与目标 ID,优先使用:
```bash
bash "{baseDir}/init-lobster.sh" \
--personality "PERSONALITY" \
--memory-mode "MEMORY_MODE" \
--channel "CURRENT_CHANNEL" \
--to "CURRENT_TARGET_ID"
```
按需追加:
- `--lobster-name "LOBSTER_NAME"`
- `--owner-nickname "OWNER_NICKNAME"`
- `--morning "HH:MM"`
- `--discovery "HH:MM"`
- `--evening "HH:MM"`
强约束:
- **不要再优先使用旧占位 `--nickname` / `--name` 来表达主客体。**
- 给虾起的名字只放 `--lobster-name`。
- 虾对用户的称呼只放 `--owner-nickname`。
执行要求:
- 如果用户是分几条消息把参数补齐的,运行脚本时要使用**累计后的最终参数**,不要只拿最后一条消息。
- 脚本执行后必须观察退出结果;如果失败,不要沉默或中断对话。
- 如果输出里出现 `无法自动检测投递目标`,而当前对话实际发生在飞书/Telegram/微信等 IM 渠道,优先改为显式携带 `--channel` / `--to` 再重试一次。
- 如果输出里出现 `[log] file:` 或 `[log] inspect:`,要把该日志路径作为排查依据告诉用户。
### 第五步:告诉用户初始化结果
必须告诉用户:
1. 共情虾名字
2. 推送时间
3. 当前理解模式
4. 这个模式之后随时能改
如果初始化脚本最后输出的 `INIT_RESULT_JSON` 里:
- `success=true` 且 `cron_registered=true`:按"初始化完成"回复
- `success=true` 且 `cron_registration_status=pending_activation`:明确告诉用户**虾已经创建成功**,只是当前企业微信会话还缺少投递目标(群聊 `group_space` 或私聊 `sender_id`),所以定时推送尚未注册;提示用户回到企业微信当前会话里重试,或让 skill 重新从 inbound metadata 读取后再执行一次;**不要**把它描述成"初始化失败"
- `success=true` 且 `cron_registered=false` 且 `cron_registration_status` 不是 `pending_activation`:明确告诉用户**虾已经创建成功**,只是定时推送注册暂时失败,稍后补跑 `setup-cron.sh` 即可;**不要**把它描述成"后端整体不可用"或"初始化失败"
- `reused_existing=true`:告诉用户本次复用了已有的虾,没有重复创建
如果 `INIT_RESULT_JSON` 里有:
- `studio_web_url`
- `studio_link_expires_at`
那么在初始化结果里**必须明确告诉用户如何查看工作室**,而不是只说"我以后可以带你进去"。推荐写法:
> 你现在就可以去看看{lobster_name}:{studio_web_url}
> 这个入口是短时有效的,到期我可以再给你刷新。
补充要求:
- 可以输出 `studio_web_url` 这种**短时** studio link
- 不要输出长期 bearer token,也不要手工拼长期 token URL
- 如果 `studio_web_url` 暂时缺失,要明确告诉用户:
> 你之后随时对我说"看看我的共情虾"或"给我工作室链接",我会用短时入口带你进去。
- 不要让用户在初始化成功后还不知道该如何查看工作室
## 冷启动记忆
### Memory 文件导入(需用户显式同意)
**⚠️ 隐私约束**:以下文件只有在用户**明确口头同意**后才可以读取。未经同意绝对不可以自动读取。
只有在用户明确同意时,才可以读取这些文件:
```bash
for f in ~/.openclaw/workspace/USER.md ~/.openclaw/workspace/MEMORY.md ~/.openclaw/agents/main/USER.md ~/.openclaw/agents/main/MEMORY.md; do
[ -f "$f" ] && echo "FOUND: $f"
done
```
读取前先告诉用户找到了哪些文件,并明确说"要不要导入这些旧记忆来更快认识你"。
### 日常聊天记忆
用户在和共情虾聊天时主动说出的事情,可以注入:
```bash
curl -s -X POST "https://nixiashuo.com/api/memory/ingest" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer {access_token}" \
-d "{
\"user_id\": \"{user_id}\",
\"memory_text\": \"从用户与共情虾的对话中了解到:{提炼后的信息}\",
\"source\": \"openclaw_chat\"
}"
```
注入时要保持透明,例如:
- "好嘞,这个我记住了"
- "嗯,这句我收下了"
## 已有虾的交互
### 企业微信定时推送 —— 唯一推荐路径
如果用户说:
- "企业微信显示待激活,帮我补一下"
- "给我在企业微信里定时发一条消息"
- "企微定时通知怎么建才稳"
统一遵循下面这条路径,**不要再引导 webhook / bot / delivery 对抗式方案**:
1. 从当前对话的 inbound metadata 中确定投递目标:
- **群聊**:用 `group_space` 作为目标(群聊 ID)
- **私聊**:用 `sender_id`(个人 ID)
2. 如果是在初始化或补注册共情虾 cron,重新执行相关脚本时**必须**带上:
```bash
# 群聊场景:
bash "{baseDir}/setup-cron.sh" --to "GROUP_SPACE_ID" --wecom-user-id "CURRENT_SENDER_ID"
# 私聊场景:
bash "{baseDir}/setup-cron.sh" --to "CURRENT_SENDER_ID" --wecom-user-id "CURRENT_SENDER_ID"
```
3. 如果是在 skill 内创建"一次性企业微信定时通知",标准做法是:
- 先调用 `compute_timestamp` 生成 ISO-8601 触发时间;不要手算。
- 再调用 cron tool 创建任务,`sessionTarget="isolated"`。
- `payload.message` 里要求 isolated agent:
1. 执行 `push-scheduled-message.sh --emit-message-text --slot <slot>` 获取最终消息文本
2. 将 stdout 原文直接作为唯一回复输出,不要加前缀、解释或额外文字
3. **不要调用 message 工具,不要尝试私信任何人**——回复文本会自动通过 cron delivery announce 投递到目标会话
- 同时注册 cron 时带 `--channel openclaw-wecom-bot --to <delivery_target>` 作为 announce 投递目标(群聊用 `group_space`,私聊用 `sender_id`)。
标准示意:
- 群聊:`group_space -> delivery_target`;私聊:`sender_id -> delivery_target`
- `compute_timestamp(...) -> trigger_at`
- `cron.add`:`schedule.kind="at"`、`schedule.at=trigger_at`、`sessionTarget="isolated"`、`deleteAfterRun=true`
- `--channel openclaw-wecom-bot --to {delivery_target}`(announce 投递目标)
- `payload.message`:要求 agent 执行 `push-scheduled-message.sh --emit-message-text`,stdout 原文作为唯一回复
执行要求:
- 企业微信链路里**不要再推荐** `configure-wecom-delivery.sh`、`wecom-webhook`、`wecom-mcp`、`wecom-bot` 作为定时推送主方案。
- 如果当前缺少 `sender_id` 或 `group_space`,就明确告诉用户:需要回到企业微信当前会话里,让 skill 从 inbound metadata 读取到对应字段后再重试。
- 如果 cron / 脚本已经成功拿到投递目标,就按实际投递目标(群聊或私信)来回复,不要再提 webhook 补齐。
### 切换理解模式
如果用户说:
- "我想改理解模式"
- "别读聊天记录了"
- "切到更懂我的模式"
就重新提供三档选择:
1. 轻量陪伴:只记直接对共情虾说的话
2. 智能陪伴:本地消化 transcript,再上传摘要
3. 深度陪伴:上传原始 transcript 做更细消化
执行方式:
```bash
bash "{baseDir}/setup-cron.sh" --memory-mode lightweight
bash "{baseDir}/setup-cron.sh" --memory-mode smart
bash "{baseDir}/setup-cron.sh" --memory-mode deep
```
### 查看工作室链接(强约束)
如果用户说:
- "给我工作室链接"
- "给我发一个旺仔3号的工作室的链接"
- "看看我的共情虾"
- "打开工作室"
- "发我工作室入口"
必须先执行:
```bash
bash "{baseDir}/send-studio-link.sh"
```
执行要求:
- **必须实时执行脚本获取 fresh link**,不要引用历史对话里出现过的 URL
- **必须直接使用脚本 stdout 里的链接**,不要自己手工拼接 `/lobster/{user_id}?st=...`
- 不要声称"还是短期 st 链接"然后自己编一个 `st`
- 如果脚本失败,明确告诉用户"我刚刷新短链失败了,我再试一次"或报告错误,不要伪造链接
### 查看状态 / 生成一句话 / 查看记忆
继续使用现有 API:
```bash
curl -s -H "Authorization: Bearer {access_token}" \
"https://nixiashuo.com/api/lobster/{user_id}/status"
```
```bash
curl -s -X POST "https://nixiashuo.com/api/generate" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer {access_token}" \
-d "{\"user_id\":\"{user_id}\",\"message_type\":\"event\"}"
```
```bash
curl -s -H "Authorization: Bearer {access_token}" \
"https://nixiashuo.com/api/lobster/{user_id}/memory"
```
### 截图请求处理(强约束)
只要用户提到"发截图 / 看看共情虾在干嘛 / 状态+截图",优先执行受控脚本:
```bash
bash "{baseDir}/send-current-screenshot.sh" --caption "这是{lobster_name}现在的样子~"
```
如果用户同时要状态摘要,用:
```bash
bash "{baseDir}/send-current-screenshot.sh" --with-status-summary
```
禁止做法:
- 不要手工拼接长期 token URL
- 不要输出 `screenshot_base64` 或本地临时文件路径
- 不要绕过脚本自己实现截图发送流程
## Transcript digest 的规则
- `lightweight`:不注册 `lobster-says-digest`
- `smart`:注册 digest,但必须用 `bash "{baseDir}/digest-transcript.sh" --mode smart`
- `deep`:注册 digest,并用 `bash "{baseDir}/digest-transcript.sh" --mode deep`
### 三档模式的真实边界
| 模式 | 共情虾能感知什么 |
|------|-------------|
| `lightweight` | 只知道用户直接对共情虾说的话,以及用户同意导入的 memory 文件 |
| `smart` | 能感知最近整体聊天状态,但默认只把本地提炼后的摘要、标签、时间模式上传 |
| `deep` | 能感知更完整的 transcript 细节,理解力最强 |
## 安全与合规要求
- 不要把 transcript 能力藏起来
- 不要写"只有用户主动担心时才提供选项"
- 不要把"默认深度读取"包装成唯一正确选择
- 不要在对话中输出 access token
- 不要输出长期带 token URL
- 对截图、图片、工作室访问,优先使用受控脚本或受控服务端入口
- 工作室访问统一走短时 studio link(`/api/lobster/{user_id}/studio-link`),不要回退到长期 token URL
FILE:README.md
# 🦞 虾说 (LobsterSays)
> 你的专属共情虾。每天合适的时机给你一句专属的对白,让你感受到它长情的陪伴。
**虾说** 是一个基于 OpenClaw 的情感陪伴 Skill。你可以领养一只专属的**共情虾**,选择它的性格(暖心 / 毒舌 / 哲学 / 嘴替),它会:
- 🌅 **早安推送**:每天早上给你一句开启新一天的话
- 📰 **广场见闻**:晚间带回一条精选动态
- 🌙 **晚安推送**:每天晚上用一句话陪你收尾
- 🎨 **表情包 & 壁纸**:偶尔用 AI 给你画一张专属表情包或壁纸
- 🧠 **记忆系统**:虾会记住你分享的事情,越聊越懂你
- 👾 **像素风工作室**:你的共情虾有自己的像素风工作间,你可以随时去看看它在干嘛
## 快速开始
### 安装
```bash
openclaw skills install lobster-says
```
### 初始化你的共情虾
这里的“虾”指的是 **虾说里的共情虾**,不是 OpenClaw 本体。
安装完成后,你**不需要记固定口令**。直接在 OpenClaw 里对它说一句自然的话就行,例如:
> 我想养一只共情虾
或者:
> 共情虾能做什么?
或者:
> 帮我初始化一只共情虾
Skill 会引导你完成:
1. **给虾起名**:不起名的话后端会随机生成一个食物系名字
2. **选择虾格**:🧡 暖心 `warm` / 😏 毒舌 `sarcastic` / 🤔 哲学 `philosophical` / 🗣️ 嘴替 `mouthpiece`
3. **选择理解模式**:轻量陪伴 / 智能陪伴 / 深度陪伴
4. **设置推送时间**:早安默认 09:00,广场见闻默认 20:00,晚安默认 21:00
5. **设置主人称呼(可选)**:虾怎么称呼你;如果你不指定,默认叫你“打工人”
初始化脚本会自动完成创建共情虾、检测当前 IM 通道、保存配置、注册定时推送等工作。
如果当前是 `wecom* / qywx*` 这类企业微信会话,Skill / 脚本会统一走**唯一推荐路径**:
- 从当前会话的 inbound metadata 读取 `sender_id`
- 把 `sender_id` 作为 `wecom_user_id` 写入 `.lobster-config`
- 定时任务触发后,由 isolated agent 使用 `message` 工具发送企业微信私聊:`action=send, channel=openclaw-wecom-bot, to=<sender_id>`
- **不依赖 `delivery` 字段做私聊触达**;它如果自动绑定当前群聊,只作为执行结果回播
企业微信会进入 `pending_activation` 的唯一典型场景:
- 当前会话里还拿不到 `sender_id`,因此没法确定企业微信私聊目标
此时不需要再配置 webhook / bot;只要回到企业微信当前会话里重新执行,让 Skill 重新读取 `sender_id`,再跑一次初始化或 `setup-cron.sh` 即可。
如果你安装完还不知道下一步做什么,可以直接对 OpenClaw 说:
> 帮我领养一只共情虾,让它以后每天早晚来找我
## 功能详情
### 🕐 定时推送
共情虾的定时推送通过 OpenClaw Cron 实现。每次推送时,脚本会:
1. 生成本次消息与工作室链接
2. 通用 IM 通道(Telegram 等)由脚本完整模式执行:优先发到最近活跃的 direct session,失败时多通道 fallback;所有 cron 注册带 `--channel` + `--to` 作为 delivery 兜底
3. 企业微信走 `--emit-message-text` 模式:脚本只输出最终消息文本到 stdout,由 isolated agent 把 stdout 原文直接作为 `message` 工具参数发送到 `channel=openclaw-wecom-bot, to=<sender_id>`;cron 同样注册带 `--channel openclaw-wecom-bot --to <sender_id>` 作为 delivery 兜底
4. `init-ready` 一次性任务在注册后会立即校验 `nextRun`,失败时自动顺延重排;企微 `init-ready` 直接走 `message` 工具固定欢迎语,不再经过 shell CLI
支持的 IM 通道:Telegram、微信(openclaw-weixin)、飞书、钉钉、企业微信、Discord、Slack 等所有 OpenClaw 支持的通道;其中企业微信定时推送在 `2.5.3` 可测版中使用 `--emit-message-text` + agent `message` 工具的极简链路优先验证可达性。
### 🧠 记忆系统
共情虾的记忆来自三个信息源:
| 层级 | 信息源 | 说明 |
|------|--------|------|
| 一手信息 | Transcript 消化 | 根据你选择的模式决定是否读取 OpenClaw transcript |
| 二手信息 | OpenClaw Memory 注入 | 初始化时经用户同意后读取已有的 OpenClaw memory 文件 |
| 三手信息 | 对话中自然提取 | 你和虾聊天时提到的事情 |
### 三档理解模式
| 模式 | 会发生什么 | 适合谁 |
|------|-----------|------|
| **轻量陪伴** | 只记你直接对虾说的话;不扫描 transcript;不注册 digest cron | 想完全关闭后台 transcript 消化的用户 |
| **智能陪伴**(推荐) | 定时读取 transcript,但先在本地消化成摘要/标签,再把摘要发给服务器;不上传原始 transcript | 希望虾更懂你,同时尽量减少原始数据离境的用户 |
| **深度陪伴** | 定时读取 transcript,并把原始 transcript 发给服务器做更细的消化 | 明确需要最强理解能力的用户 |
### 共情虾能感知什么
不同模式下,共情虾能感知的信息边界不同:
- **轻量陪伴**:只知道你直接对共情虾说的话,以及你明确同意导入的 memory 文件
- **智能陪伴**:能看到你最近整体聊天状态,但默认只把本地提炼后的摘要、标签、时间模式上传
- **深度陪伴**:能看到更完整的 transcript 细节,因此更容易捕捉连续情绪和生活节奏
### 🎨 表情包 & 壁纸
- **表情包**:每周三/六自动生成,由 Gemini 模型绘制,主题基于虾的状态和对你的记忆
- **壁纸**:每周日生成一张专属壁纸
- 频率控制:表情包每周最多 2 张,壁纸每周最多 1 张(「偶尔的惊喜而非日常例行」)
## 系统要求
- **OpenClaw** `2026.3.7` 或更高版本
- **openclaw CLI**
- **Python 3**(`python3` 或 `python`)
- **curl**
- 网络连接(需访问 `nixiashuo.com` 后端 API)
## ⚠️ 远端 API 依赖
**虾说是一个 server-backed skill**,它依赖远端后端服务 `nixiashuo.com` 来运行。这意味着:
- Skill 的核心功能(生成消息、管理记忆、渲染截图等)由远端服务器处理
- **需要网络连接**才能正常工作
- 如果 `nixiashuo.com` 服务不可用,Skill 的部分功能将暂时不可用
- 共情虾的配置、历史消息、记忆摘要等会存储在远端服务器
**本地存储的内容**(`~/.openclaw/workspace/skills/lobster-says/.lobster-config`):
- `user_id` 和 `access_token`(用于 API 认证)
- 推送时间、通道配置、`memory_mode`
## 🔒 隐私与安全
### 安全承诺
- **所有 API 通信均走 HTTPS**
- **memory 文件导入必须先征得同意**
- **理解模式是显式选择,不是被动兜底**
- **智能陪伴模式默认不上传原始 transcript**
- **轻量陪伴模式下不会注册 transcript digest cron**
- **不会**将你的数据用于广告、训练模型或出售给第三方
### 用户控制
你可以随时控制共情虾的数据行为:
| 操作 | 方法 |
|------|------|
| **切换理解模式** | 对共情虾说"我想改理解模式" |
| **暂停 Transcript 消化** | `openclaw cron pause "lobster-says-digest"` |
| **恢复 Transcript 消化** | `openclaw cron resume "lobster-says-digest"` |
| **暂停所有推送** | 分别 pause morning/discovery/evening |
| **查看共情虾记住了什么** | 对共情虾说"看看你的记忆" |
| **删除所有数据** | 联系 `[email protected]` |
## 常见问题
### 安装完如果我不知道该说什么?
不用记固定口令。你随便说一句下面这种自然话都可以:
- “我想养一只共情虾”
- “帮我初始化一只共情虾”
- “共情虾能做什么”
- “让我的共情虾开始工作吧”
只要还没有 `.lobster-config`,skill 就应该主动接住你,开始引导初始化。
### 哪个模式最推荐?
默认推荐 **智能陪伴**。它通常能保住大部分“越来越懂你”的效果,同时避免把原始 transcript 直接上传到服务器。
### 我怎么看自己的共情虾工作室?
工作室访问使用**受控短时入口**:
1. Skill 使用长期 `access_token` 调用 `GET /api/lobster/{user_id}/studio-link`
2. 服务端返回带短时 `st` 的 `web_url` 与 `screenshot_url`
3. Skill 只向用户发送短时链接,不再输出长期 token URL
也就是说:
- **不建议**把长期 bearer token 放进 URL
- **默认**使用短时 studio link(`?st=...`)
- `send-current-screenshot.sh` 在短时链接失败时会优先走“本地受控截图”兜底,而不是回退到长期 token URL
### 轻量陪伴是不是就没法用了?
不是。轻量陪伴仍然可以正常推送、发壁纸、发见闻、记住你直接对虾说的话,只是长期理解能力会更稀疏。
### 如何修改推送时间?
```bash
bash setup-cron.sh --morning "08:30" --discovery "19:00" --evening "22:00"
```
## 文件结构
```text
lobster-says/
├── SKILL.md
├── openclaw.json
├── README.md
├── init-lobster.sh
├── setup-cron.sh
├── configure-wecom-delivery.sh
├── push-scheduled-message.sh
├── digest-transcript.sh
└── send-current-screenshot.sh
```
## 许可证
MIT License
## 作者
**Jared** — [[email protected]](mailto:[email protected])
FILE:_meta.json
{
"ownerId": "kn76em77yfft3zbr7anygfxpgh836eh9",
"slug": "lobster-says",
"version": "2.5.8",
"publishedAt": 1776091500000
}
FILE:configure-wecom-delivery.sh
#!/bin/bash
# 企业微信主动推送能力配置脚本
#
# 作用:
# 1. 为 wecom / qywx family 显式指定出站能力(wecom-mcp / webhook / bot)
# 2. 立即重算 delivery contract
# 3. 若 contract ready,则自动调用 setup-cron.sh 完成 pending cron 的补注册
set -euo pipefail
BASE_DIR="$(cd "$(dirname "$0")" && pwd)"
CONFIG_FILE="BASE_DIR/.lobster-config"
COMMON_SCRIPT="BASE_DIR/runtime-common.sh"
SETUP_CRON_SCRIPT="BASE_DIR/setup-cron.sh"
ADAPTER=""
WEBHOOK_URL=""
WEBHOOK_SECRET=""
BOT_CHANNEL=""
BOT_TARGET=""
MCP_TARGET=""
SKIP_RECONCILE=0
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
CYAN='\033[0;36m'
NC='\033[0m'
info() { echo -e "GREEN[✓]NC $1"; }
warn() { echo -e "YELLOW[!]NC $1"; }
error() { echo -e "RED[✗]NC $1"; exit 1; }
step() { echo -e "CYAN──NC $1"; }
while [[ $# -gt 0 ]]; do
case "$1" in
--adapter) ADAPTER="$2"; shift 2 ;;
--webhook-url) WEBHOOK_URL="$2"; shift 2 ;;
--webhook-secret) WEBHOOK_SECRET="$2"; shift 2 ;;
--bot-channel|--channel) BOT_CHANNEL="$2"; shift 2 ;;
--bot-target) BOT_TARGET="$2"; shift 2 ;;
--chat-id|--mcp-target|--to|--target) MCP_TARGET="$2"; shift 2 ;;
--skip-reconcile) SKIP_RECONCILE=1; shift ;;
*) echo "未知参数: $1"; exit 1 ;;
esac
done
for cmd in python3; do
if ! command -v "$cmd" >/dev/null 2>&1; then
error "$cmd 不可用"
fi
done
if [ ! -f "$CONFIG_FILE" ]; then
error ".lobster-config 不存在,请先完成虾的初始化"
fi
if [ ! -f "$COMMON_SCRIPT" ]; then
error "共享运行时脚本不存在:COMMON_SCRIPT"
fi
if [ ! -f "$SETUP_CRON_SCRIPT" ]; then
error "setup-cron 脚本不存在:SETUP_CRON_SCRIPT"
fi
. "$COMMON_SCRIPT"
supports_proactive_bot_channel() {
CHANNEL_VALUE="$1" python3 <<'PY'
import os
channel = (os.environ.get("CHANNEL_VALUE") or "").strip().lower()
markers = (
"wecom-bot",
"qywx-bot",
"wxwork-bot",
"enterprisewechat-bot",
"enterprise-wechat-bot",
"workwechat-bot",
)
print("1" if any(marker in channel for marker in markers) else "0")
PY
}
supports_wecom_family_channel() {
CHANNEL_VALUE="$1" python3 <<'PY'
import os
channel = (os.environ.get("CHANNEL_VALUE") or "").strip().lower()
markers = (
"wecom",
"qywx",
"wxwork",
"enterprisewechat",
"enterprise-wechat",
"workwechat",
)
print("1" if any(marker in channel for marker in markers) else "0")
PY
}
if [ -z "$ADAPTER" ]; then
if [ -n "$WEBHOOK_URL" ]; then
ADAPTER="wecom-webhook"
elif [ -n "$BOT_CHANNEL" ] || [ -n "$BOT_TARGET" ]; then
ADAPTER="openclaw"
else
ADAPTER="wecom-mcp"
fi
fi
if [ -z "$MCP_TARGET" ]; then
MCP_TARGET=$(CONFIG_PATH="$CONFIG_FILE" python3 <<'PY'
import json
import os
from pathlib import Path
path = Path(os.environ["CONFIG_PATH"])
try:
config = json.loads(path.read_text(encoding="utf-8"))
except Exception:
config = {}
contract = config.get("delivery_contract") if isinstance(config.get("delivery_contract"), dict) else {}
print(str(contract.get("binding_target") or config.get("binding_target") or config.get("chat_id") or "").strip())
PY
)
fi
case "$ADAPTER" in
wecom-webhook)
[ -n "$WEBHOOK_URL" ] || error "adapter=wecom-webhook 时必须提供 --webhook-url"
;;
wecom-mcp)
[ -n "$MCP_TARGET" ] || error "adapter=wecom-mcp 时必须提供 --chat-id,或配置文件里已有 binding_target/chat_id"
;;
openclaw)
[ -n "$BOT_CHANNEL" ] || error "adapter=openclaw 时必须提供 --bot-channel"
[ -n "$BOT_TARGET" ] || error "adapter=openclaw 时必须提供 --bot-target"
[ "$(supports_proactive_bot_channel "$BOT_CHANNEL")" = "1" ] || error "bot-channel 必须是 wecom-bot / qywx-bot / wxwork-bot 等企业微信机器人通道"
;;
*)
error "adapter 只支持 wecom-mcp / openclaw / wecom-webhook"
;;
esac
step "写入企业微信出站配置..."
CONFIG_PATH="$CONFIG_FILE" \
ADAPTER_VALUE="$ADAPTER" \
WEBHOOK_URL_VALUE="$WEBHOOK_URL" \
WEBHOOK_SECRET_VALUE="$WEBHOOK_SECRET" \
BOT_CHANNEL_VALUE="$BOT_CHANNEL" \
BOT_TARGET_VALUE="$BOT_TARGET" \
MCP_TARGET_VALUE="$MCP_TARGET" \
python3 <<'PY'
import json
import os
from pathlib import Path
config_path = Path(os.environ["CONFIG_PATH"])
try:
config = json.loads(config_path.read_text(encoding="utf-8"))
except Exception:
config = {}
adapter = (os.environ.get("ADAPTER_VALUE") or "").strip()
webhook_url = (os.environ.get("WEBHOOK_URL_VALUE") or "").strip()
webhook_secret = (os.environ.get("WEBHOOK_SECRET_VALUE") or "").strip()
bot_channel = (os.environ.get("BOT_CHANNEL_VALUE") or "").strip()
bot_target = (os.environ.get("BOT_TARGET_VALUE") or "").strip()
mcp_target = (os.environ.get("MCP_TARGET_VALUE") or "").strip()
contract = config.get("delivery_contract") if isinstance(config.get("delivery_contract"), dict) else {}
binding_channel = str(contract.get("binding_channel") or config.get("binding_channel") or config.get("channel") or "").strip()
config["outbound_adapter"] = adapter
if adapter == "wecom-webhook":
config["delivery_channel"] = "wecom-webhook"
config["outbound_webhook_url"] = webhook_url
if webhook_secret:
config["outbound_webhook_secret"] = webhook_secret
elif adapter == "wecom-mcp":
if binding_channel:
config["delivery_channel"] = binding_channel
else:
config.pop("delivery_channel", None)
config["delivery_target"] = mcp_target
elif adapter == "openclaw":
config["delivery_channel"] = bot_channel
config["delivery_target"] = bot_target
config_path.write_text(json.dumps(config, indent=2, ensure_ascii=False), encoding="utf-8")
print(json.dumps(config, ensure_ascii=False))
PY
step "重算 delivery contract..."
CONTRACT_JSON=$(lobster_sync_delivery_contract "$CONFIG_FILE" "" "")
mapfile -t POLICY_LINES < <(CONFIG_PATH="$CONFIG_FILE" python3 <<'PY'
import json
import os
from pathlib import Path
path = Path(os.environ["CONFIG_PATH"])
try:
config = json.loads(path.read_text(encoding="utf-8"))
except Exception:
config = {}
contract = config.get("delivery_contract") if isinstance(config.get("delivery_contract"), dict) else {}
cron_registration = config.get("cron_registration") if isinstance(config.get("cron_registration"), dict) else {}
print(contract.get("binding_channel", ""))
print(contract.get("binding_target", ""))
print(contract.get("outbound_adapter", "openclaw"))
print(contract.get("delivery_channel", ""))
print(contract.get("delivery_target", ""))
print("1" if contract.get("delivery_ready") else "0")
print(contract.get("delivery_reason", ""))
print(cron_registration.get("status", "unregistered"))
PY
)
BINDING_CHANNEL="-"
BINDING_TARGET="-"
OUTBOUND_ADAPTER="-openclaw"
DELIVERY_CHANNEL="-"
DELIVERY_TARGET="-"
DELIVERY_READY="-0"
DELIVERY_REASON="-"
CRON_STATUS="-unregistered"
info "当前 ingress 绑定: -未锁定 → -未锁定"
if [ -n "$DELIVERY_CHANNEL" ] || [ -n "$DELIVERY_TARGET" ]; then
info "当前 delivery contract: -adapter → -target(adapter=OUTBOUND_ADAPTER)"
fi
if [ "$DELIVERY_READY" != "1" ]; then
warn "delivery contract 仍未就绪:DELIVERY_REASON"
warn "当前会保持 pending_activation,不会误注册 cron。"
exit 0
fi
if [ "$SKIP_RECONCILE" = "1" ]; then
info "delivery contract 已 ready;按要求跳过自动 reconcile"
exit 0
fi
step "delivery contract 已 ready,执行 cron reconcile..."
if bash "$SETUP_CRON_SCRIPT"; then
info "企业微信主动推送能力已生效,cron 已完成补注册"
else
error "delivery contract 已 ready,但 cron reconcile 失败;请查看 setup-cron 日志"
fi
FILE:digest-transcript.sh
#!/bin/bash
# 虾说 — Transcript 消化脚本
#
# 数据访问声明:
# - 本脚本读取 OpenClaw 的本地会话日志(~/.openclaw/agents/main/sessions/*.jsonl)
# - 仅提取 user/assistant 角色的文本内容
# - smart 模式:在本地消化为摘要后上传到 nixiashuo.com(不含原始对话)
# - deep 模式:上传原始 transcript 条目到 nixiashuo.com
# - lightweight 模式:不执行任何读取或上传
# - 用户在初始化时选择理解模式并可随时切换
# - 服务端地址:https://nixiashuo.com/api/transcript/digest
set -e
HOURS=6
MAX_ENTRIES=300
MODE=""
BASE_DIR="$(cd "$(dirname "$0")" && pwd)"
CONFIG_FILE="BASE_DIR/.lobster-config"
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
CYAN='\033[0;36m'
NC='\033[0m'
info() { echo -e "GREEN[✓]NC $1"; }
warn() { echo -e "YELLOW[!]NC $1"; }
error() { echo -e "RED[✗]NC $1"; exit 1; }
step() { echo -e "CYAN──NC $1"; }
while [[ $# -gt 0 ]]; do
case $1 in
--hours) HOURS="$2"; shift 2 ;;
--max-entries) MAX_ENTRIES="$2"; shift 2 ;;
--mode) MODE="$2"; shift 2 ;;
*) echo "未知参数: $1"; exit 1 ;;
esac
done
for cmd in python3 curl; do
command -v "$cmd" >/dev/null 2>&1 || error "$cmd 不可用"
done
[ -f "$CONFIG_FILE" ] || error ".lobster-config 不存在,请先完成虾的初始化"
USER_ID=$(python3 -c "import json; print(json.load(open('CONFIG_FILE'))['user_id'])" 2>/dev/null) || error "无法读取 user_id"
ACCESS_TOKEN=$(python3 -c "import json; print(json.load(open('CONFIG_FILE'))['access_token'])" 2>/dev/null) || error "无法读取 access_token"
if [ -z "$MODE" ]; then
MODE=$(python3 -c "import json; print(json.load(open('CONFIG_FILE')).get('memory_mode','smart'))" 2>/dev/null) || MODE="smart"
fi
API_BASE=$(python3 -c "import json; print(json.load(open('CONFIG_FILE', encoding='utf-8')).get('api_base','https://nixiashuo.com'))" 2>/dev/null) || API_BASE="https://nixiashuo.com"
case "$MODE" in
lightweight) info "轻量陪伴模式:不执行 transcript digest"; exit 0 ;;
smart|deep) ;;
*) error "mode 必须是 lightweight / smart / deep" ;;
esac
echo ""
echo "🧠 虾说 — Transcript 消化"
echo " 模式: MODE"
echo " 回溯: HOURS 小时"
echo " 最大条目: MAX_ENTRIES"
echo ""
OPENCLAW_BASE="HOME/.openclaw"
SESSIONS_DIR="OPENCLAW_BASE/agents/main/sessions"
PROFILE=$(printenv OPENCLAW_PROFILE 2>/dev/null || echo "")
if [ -n "$PROFILE" ] && [ "$PROFILE" != "default" ]; then
PROFILE_DIR="OPENCLAW_BASE/agents/main-PROFILE/sessions"
[ -d "$PROFILE_DIR" ] && SESSIONS_DIR="$PROFILE_DIR"
fi
[ -d "$SESSIONS_DIR" ] || { warn "Session 目录不存在,跳过本次消化。"; exit 0; }
PAYLOAD_FILE=$(mktemp /tmp/lobster-digest-XXXXXX.json)
SMART_PAYLOAD_FILE=$(mktemp /tmp/lobster-digest-smart-XXXXXX.json)
DIGEST_TEXT_FILE=""
cleanup() {
rm -f "$PAYLOAD_FILE" "$SMART_PAYLOAD_FILE"
if [ -n "$DIGEST_TEXT_FILE" ]; then
rm -f "$DIGEST_TEXT_FILE"
fi
}
trap cleanup EXIT
python3 <<PY
import json
import re
from datetime import datetime, timedelta, timezone
from pathlib import Path
sessions_dir = "SESSIONS_DIR"
hours = int("HOURS")
max_entries = int("MAX_ENTRIES")
user_id = "USER_ID"
cutoff = datetime.now(timezone.utc) - timedelta(hours=hours)
APP_TZ = timezone(timedelta(hours=8))
APP_TZ_LABEL = "Asia/Shanghai"
def extract_message_text(raw_content):
if isinstance(raw_content, str):
return raw_content.strip()
texts = []
def _collect(part):
if isinstance(part, str):
text = part.strip()
if text:
texts.append(text)
elif isinstance(part, dict):
if part.get("type", "") in ("text", "input_text", "output_text"):
text = (part.get("text") or "").strip()
if text:
texts.append(text)
if isinstance(raw_content, list):
for part in raw_content:
_collect(part)
elif isinstance(raw_content, dict):
_collect(raw_content)
return "\n".join(texts).strip()
def strip_bridge_metadata(text):
cleaned = (text or "").strip()
if not cleaned:
return ""
fence = re.escape(chr(96) * 3)
patterns = [
rf"^Conversation info \(untrusted metadata\):\s*{fence}json\n.*?{fence}\s*",
rf"^Sender \(untrusted metadata\):\s*{fence}json\n.*?{fence}\s*",
]
for pattern in patterns:
cleaned = re.sub(pattern, "", cleaned, flags=re.S)
return cleaned.strip()
signal_tags = {
"late_night_count": 0,
"early_morning_count": 0,
"weekend_active_count": 0,
"total_user_messages": 0,
"time_range_hours": hours,
"timezone": APP_TZ_LABEL,
}
entries = []
source_session_ids = []
time_range_start = None
time_range_end = None
jsonl_files = sorted(Path(sessions_dir).glob("*.jsonl"), key=lambda f: f.stat().st_mtime, reverse=True)
for jsonl_path in jsonl_files:
mtime = datetime.fromtimestamp(jsonl_path.stat().st_mtime, tz=timezone.utc)
if mtime < cutoff:
break
session_entries = []
session_has_real_user = False
try:
with open(jsonl_path, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
record = json.loads(line)
except Exception:
continue
ts_str = record.get("timestamp", record.get("ts", ""))
if not ts_str:
continue
ts = None
for fmt in ("%Y-%m-%dT%H:%M:%S.%fZ", "%Y-%m-%dT%H:%M:%SZ", "%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S%z"):
try:
ts = datetime.strptime(ts_str, fmt)
if ts.tzinfo is None:
ts = ts.replace(tzinfo=timezone.utc)
break
except ValueError:
continue
if ts is None:
ts = mtime
if ts < cutoff:
continue
message_obj = record.get("message") if isinstance(record.get("message"), dict) else None
if message_obj:
role = message_obj.get("role", "")
content = extract_message_text(message_obj.get("content"))
else:
role = record.get("role", "")
content = extract_message_text(record.get("content", record.get("message", "")))
if role not in ("user", "human", "assistant"):
continue
content = strip_bridge_metadata(content)
if not content:
continue
normalized_role = "user" if role in ("user", "human") else "assistant"
if normalized_role == "user" and (content.startswith("[cron:") or content.startswith("[hook:") or content.startswith("[automation:")):
continue
if normalized_role == "user":
session_has_real_user = True
signal_tags["total_user_messages"] += 1
local_hour = (ts.hour + 8) % 24
if 0 <= local_hour < 5:
signal_tags["late_night_count"] += 1
elif 5 <= local_hour < 7:
signal_tags["early_morning_count"] += 1
if (ts + timedelta(hours=8)).weekday() >= 5:
signal_tags["weekend_active_count"] += 1
entry_local_ts = ts.astimezone(APP_TZ)
session_entries.append({
"timestamp": f"{entry_local_ts.strftime('%Y-%m-%d %H:%M')} {APP_TZ_LABEL}",
"role": normalized_role,
"content": content[:5000],
})
if time_range_start is None or ts < time_range_start:
time_range_start = ts
if time_range_end is None or ts > time_range_end:
time_range_end = ts
except Exception:
continue
if session_entries and session_has_real_user:
source_session_ids.append(jsonl_path.stem)
for entry in session_entries:
if len(entries) < max_entries:
entries.append(entry)
entries.sort(key=lambda e: e["timestamp"])
payload = {
"user_id": user_id,
"entries": entries,
"signal_tags": signal_tags,
"source_type": "skill_cron",
"source_session_ids": source_session_ids,
"source_sessions": len(source_session_ids),
}
if time_range_start:
payload["time_range_start"] = time_range_start.strftime("%Y-%m-%dT%H:%M:%S")
if time_range_end:
payload["time_range_end"] = time_range_end.strftime("%Y-%m-%dT%H:%M:%S")
with open("PAYLOAD_FILE", "w", encoding="utf-8") as f:
json.dump(payload, f, ensure_ascii=False)
print(len(entries))
PY
ENTRY_COUNT=$(python3 -c "import json; print(len(json.load(open('PAYLOAD_FILE')).get('entries', [])))" 2>/dev/null) || ENTRY_COUNT=0
[ "$ENTRY_COUNT" -gt 0 ] || { info "过去 HOURS 小时没有新的 transcript 记录,跳过消化。"; exit 0; }
if [ "$MODE" = "smart" ]; then
step "智能陪伴:本地消化后上传摘要"
command -v openclaw >/dev/null 2>&1 || { warn "openclaw 不可用,无法本地消化,跳过本次 digest。"; exit 0; }
SUMMARY_TEXT=$(python3 - <<PY
import json
with open("PAYLOAD_FILE", "r", encoding="utf-8") as f:
payload = json.load(f)
entries = payload.get("entries", [])
signal = payload.get("signal_tags", {})
user_msgs = [e["content"] for e in entries if e.get("role") == "user"][-12:]
parts = []
parts.append("请根据以下用户最近对话,生成 400-1200 字的结构化摘要。")
parts.append("要求:聚焦近期状态、情绪、节奏、项目压力、生活线索;不要引用逐字原话;不要编造。")
parts.append("")
parts.append("近期用户发言摘录:")
for msg in user_msgs:
parts.append(f"- {msg}")
parts.append("")
parts.append(f"统计信号: {json.dumps(signal, ensure_ascii=False)}")
print("\n".join(parts))
PY
)
DIGEST_TEXT=$(openclaw run --message "$SUMMARY_TEXT" --session isolated --no-interactive 2>/dev/null | head -c 12000) || DIGEST_TEXT=""
[ -n "$DIGEST_TEXT" ] || { warn "本地消化失败,跳过本次 digest。"; exit 0; }
DIGEST_TEXT_FILE=$(mktemp /tmp/lobster-digest-text-XXXXXX.txt)
printf "%s" "$DIGEST_TEXT" > "$DIGEST_TEXT_FILE"
DIGEST_TEXT_PATH="$DIGEST_TEXT_FILE" python3 - <<PY
import json
import os
with open("PAYLOAD_FILE", "r", encoding="utf-8") as f:
original = json.load(f)
with open(os.environ["DIGEST_TEXT_PATH"], "r", encoding="utf-8") as f:
text = f.read()
tags = []
keywords = {
"deadline": ["deadline", "截止", "上线", "提测", "发版", "ddl"],
"mood-shift": ["情绪变化", "情绪波动", "焦虑", "烦躁"],
"milestone": ["完成", "搞定", "里程碑", "上线成功"],
"life-event": ["生日", "搬家", "旅行", "家人", "宠物"],
"burnout-risk": ["疲惫", "累", "撑不住", "倦怠"],
"late-night": ["凌晨", "深夜", "半夜"],
"weekend-work": ["周末", "周六", "周日"],
}
lower = text.lower()
for tag, kws in keywords.items():
if any(kw in lower for kw in kws):
tags.append(tag)
payload = {
"user_id": original["user_id"],
"entries": [],
"signal_tags": original.get("signal_tags", {}),
"source_type": original.get("source_type", "skill_cron"),
"source_session_ids": original.get("source_session_ids", []),
"source_sessions": original.get("source_sessions", 0),
"privacy_mode": True,
"pre_digested_summary": text,
"pre_digested_semantic_tags": tags,
}
for key in ("time_range_start", "time_range_end"):
if original.get(key):
payload[key] = original[key]
with open("SMART_PAYLOAD_FILE", "w", encoding="utf-8") as f:
json.dump(payload, f, ensure_ascii=False)
PY
RESPONSE=$(curl -s -w "\n%{http_code}" -X POST "API_BASE/api/transcript/digest" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ACCESS_TOKEN" \
-d @"SMART_PAYLOAD_FILE")
else
step "深度陪伴:上传原始 transcript 进行消化"
RESPONSE=$(curl -s -w "\n%{http_code}" -X POST "API_BASE/api/transcript/digest" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ACCESS_TOKEN" \
-d @"PAYLOAD_FILE")
fi
HTTP_CODE=$(echo "$RESPONSE" | tail -1)
BODY=$(echo "$RESPONSE" | sed '$d')
if [ "$HTTP_CODE" = "200" ]; then
DIGEST_ID=$(echo "$BODY" | python3 -c "import sys,json; print(json.load(sys.stdin).get('digest_id','?'))" 2>/dev/null) || DIGEST_ID="?"
SUMMARY_LEN=$(echo "$BODY" | python3 -c "import sys,json; print(len(json.load(sys.stdin).get('digest_summary','')))" 2>/dev/null) || SUMMARY_LEN="?"
info "Transcript 消化成功:digest_id=DIGEST_ID, 摘要长度=SUMMARY_LEN"
elif [ "$HTTP_CODE" = "429" ]; then
warn "Server 限流(429),稍后再试。"
else
error "Server 返回 HTTP HTTP_CODE: BODY"
fi
FILE:init-lobster.sh
#!/bin/bash
# 虾说 — 一体化初始化脚本
#
# 数据访问声明:
# - 读写 .lobster-config(技能自身配置)
# - 调用 openclaw sessions --json 获取活跃 IM 通道元数据
# - 与 https://nixiashuo.com 通信:创建/验证虾、获取工作室链接
# - 调用 setup-cron.sh 注册定时推送
# - 不读取 openclaw.json 配置文件,不提取 gateway token
set -e
PERSONALITY="warm"
NICKNAME="打工人"
NICKNAME_EXPLICIT=0
LOBSTER_NAME=""
LOBSTER_NAME_EXPLICIT=0
MORNING_TIME="09:00"
DISCOVERY_TIME="21:30"
EVENING_TIME="21:00"
MEMORY_MODE="smart"
CHANNEL=""
TO=""
WECOM_USER_ID=""
ALL_CHANNELS_JSON="[]"
BASE_DIR="$(cd "$(dirname "$0")" && pwd)"
CONFIG_FILE="BASE_DIR/.lobster-config"
COMMON_SCRIPT="BASE_DIR/runtime-common.sh"
LOG_DIR="BASE_DIR/logs"
RUN_TS="$(date +%Y%m%d-%H%M%S)"
LOG_FILE="LOG_DIR/init-lobster-RUN_TS.log"
API_BASE="-https://nixiashuo.com"
WEB_BASE="-${LOBSTER_API_BASE:-https://nixiashuo.com}"
ACTIVE_WINDOW_MINUTES="-10080"
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
CYAN='\033[0;36m'
NC='\033[0m'
info() { echo -e "GREEN[✓]NC $1"; }
warn() { echo -e "YELLOW[!]NC $1"; }
error() { echo -e "RED[✗]NC $1"; exit 1; }
CURRENT_STEP="启动"
step() { CURRENT_STEP="$1"; echo -e "CYAN──NC $1"; }
mkdir -p "$LOG_DIR"
if command -v tee >/dev/null 2>&1; then
exec > >(tee -a "$LOG_FILE") 2>&1
else
exec >>"$LOG_FILE" 2>&1
fi
echo "[log] init-lobster started at $(date '+%F %T')"
echo "[log] file: LOG_FILE"
after_exit() {
local exit_code=$?
if [ "$exit_code" -ne 0 ]; then
echo ""
echo "[log] init-lobster failed at step: CURRENT_STEP (exit=exit_code)"
echo "[log] inspect: LOG_FILE"
else
echo "[log] init-lobster finished successfully"
fi
}
trap after_exit EXIT
while [[ $# -gt 0 ]]; do
case "$1" in
--personality) PERSONALITY="$2"; shift 2 ;;
--nickname) error "参数 --nickname 已废弃,请改用 --owner-nickname 来表示虾对用户的称呼" ;;
--owner-nickname|--user-nickname) NICKNAME="$2"; NICKNAME_EXPLICIT=1; shift 2 ;;
--name|--lobster-name) LOBSTER_NAME="$2"; LOBSTER_NAME_EXPLICIT=1; shift 2 ;;
--morning) MORNING_TIME="$2"; shift 2 ;;
--discovery) DISCOVERY_TIME="$2"; shift 2 ;;
--evening) EVENING_TIME="$2"; shift 2 ;;
--memory-mode) MEMORY_MODE="$2"; shift 2 ;;
--privacy-mode) MEMORY_MODE="smart"; shift ;;
--channel) CHANNEL="$2"; shift 2 ;;
--to) TO="$2"; shift 2 ;;
--wecom-user-id) WECOM_USER_ID="$2"; shift 2 ;;
*) echo "未知参数: $1"; exit 1 ;;
esac
done
case "$MEMORY_MODE" in
lightweight|smart|deep) ;;
*) error "memory_mode 必须是 lightweight / smart / deep" ;;
esac
for cmd in python3 curl openclaw; do
if ! command -v "$cmd" >/dev/null 2>&1; then
error "$cmd 不可用"
fi
done
if [ ! -f "$COMMON_SCRIPT" ]; then
error "共享运行时脚本不存在:COMMON_SCRIPT"
fi
. "$COMMON_SCRIPT"
if [ -z "$CHANNEL" ] || [ -z "$TO" ]; then
step "自动检测最近使用的通道..."
SESSIONS_JSON=$(openclaw sessions --json --active "$ACTIVE_WINDOW_MINUTES" 2>/dev/null || echo "[]")
DETECTED=$(SESSIONS_JSON="$SESSIONS_JSON" python3 <<'PY'
import json
import os
import sys
try:
sessions = json.loads(os.environ.get("SESSIONS_JSON", "[]"))
if not isinstance(sessions, list):
sessions = [sessions]
except Exception:
sessions = []
ordered = []
seen = set()
def add(channel, peer_id):
channel = (channel or "").strip()
peer_id = (peer_id or "").strip()
if not channel or channel == "unknown" or not peer_id:
return
key = (channel, peer_id)
if key in seen:
return
seen.add(key)
ordered.append({"channel": channel, "peer_id": peer_id})
for session in sessions:
direct_channel = session.get("channel") or session.get("platform") or session.get("imChannel") or ""
direct_target = session.get("peer_id") or session.get("peerId") or session.get("target") or session.get("chat_id") or session.get("chatId") or ""
if direct_channel and direct_target:
add(direct_channel, direct_target)
continue
key = session.get("sessionKey") or session.get("key") or session.get("id") or ""
if not key:
continue
parts = key.split(":")
if not parts or parts[0].lower() in ("cron", "hook"):
continue
if len(parts) <= 3 and parts[-1].lower() == "main":
continue
if len(parts) >= 5 and parts[0].lower() == "agent":
channel = parts[2]
marker = parts[3].lower()
if marker in ("direct", "dm"):
add(channel, parts[4])
if not ordered:
sys.exit(1)
best = ordered[0]
print(f"{best['channel']}|{best['peer_id']}")
print(json.dumps(ordered, ensure_ascii=False))
PY
) || true
if [ -n "$DETECTED" ]; then
BEST_LINE=$(echo "$DETECTED" | head -1)
AUTO_CHANNEL=$(echo "$BEST_LINE" | cut -d'|' -f1)
AUTO_TO=$(echo "$BEST_LINE" | cut -d'|' -f2)
ALL_CHANNELS_JSON=$(echo "$DETECTED" | tail -1)
if [ -z "$CHANNEL" ]; then
CHANNEL="$AUTO_CHANNEL"
fi
if [ -z "$TO" ]; then
TO="$AUTO_TO"
fi
info "自动检测到: CHANNEL → TO"
fi
fi
if [ -z "$CHANNEL" ] || [ -z "$TO" ]; then
error "无法自动检测投递目标。请手动指定 --channel <渠道名> --to <chatId/peerId>"
fi
echo ""
echo "🦞 虾说 — 一体化初始化"
echo ""
echo " 虾格: PERSONALITY"
echo " 虾对用户的称呼: NICKNAME"
echo " 虾自己的名字: -(由后端随机生成)"
echo " 早安: MORNING_TIME"
echo " 晚间 roundup: DISCOVERY_TIME"
echo " 晚安: EVENING_TIME"
echo " 理解模式: MEMORY_MODE"
echo " 通道: CHANNEL → TO"
echo " API: API_BASE"
echo ""
if [ -f "$CONFIG_FILE" ]; then
BACKUP="CONFIG_FILE.bak.$(date +%s)"
cp "$CONFIG_FILE" "$BACKUP"
warn "已备份旧配置到 BACKUP"
fi
read_config_value() {
local key="$1"
local default_value="-"
CONFIG_PATH="$CONFIG_FILE" KEY_NAME="$key" DEFAULT_VALUE="$default_value" python3 <<'PY'
import json
import os
from pathlib import Path
path = Path(os.environ["CONFIG_PATH"])
key = os.environ["KEY_NAME"]
default = os.environ.get("DEFAULT_VALUE", "")
try:
data = json.loads(path.read_text(encoding="utf-8"))
value = data.get(key, default)
print(value if value is not None else default)
except Exception:
print(default)
PY
}
ACCESS_TOKEN=""
ACTUAL_NAME=""
ACTUAL_USER_ID=""
STUDIO_WEB_URL=""
STUDIO_SCREENSHOT_URL=""
STUDIO_LINK_EXPIRES_AT=""
REUSED_EXISTING=0
CRON_REGISTERED=0
EXISTING_USER_ID=$(read_config_value "user_id" "")
EXISTING_ACCESS_TOKEN=$(read_config_value "access_token" "")
if [ -n "$EXISTING_USER_ID" ] && [ -n "$EXISTING_ACCESS_TOKEN" ]; then
step "检测已有虾配置,尝试复用..."
EXISTING_STATUS=$(curl -fsS -H "Authorization: Bearer EXISTING_ACCESS_TOKEN" "API_BASE/api/lobster/EXISTING_USER_ID/status" 2>/dev/null || true)
EXISTING_OK=$(echo "$EXISTING_STATUS" | python3 -c "import sys, json; data=json.load(sys.stdin); print('ok' if data.get('lobster_id') else '')" 2>/dev/null || true)
if [ "$EXISTING_OK" = "ok" ]; then
ACCESS_TOKEN="$EXISTING_ACCESS_TOKEN"
ACTUAL_USER_ID="$EXISTING_USER_ID"
ACTUAL_NAME=$(echo "$EXISTING_STATUS" | python3 -c "import sys, json; print(json.load(sys.stdin).get('name', '虾'))")
EXISTING_PERSONALITY=$(echo "$EXISTING_STATUS" | python3 -c "import sys, json; print(json.load(sys.stdin).get('personality', ''))" 2>/dev/null || true)
EXISTING_NICKNAME=$(echo "$EXISTING_STATUS" | python3 -c "import sys, json; print(json.load(sys.stdin).get('nickname_for_user', ''))" 2>/dev/null || true)
if [ -n "$EXISTING_PERSONALITY" ]; then
PERSONALITY="$EXISTING_PERSONALITY"
fi
if [ -n "$EXISTING_NICKNAME" ] && [ "$NICKNAME_EXPLICIT" -ne 1 ]; then
NICKNAME="$EXISTING_NICKNAME"
elif [ -n "$EXISTING_NICKNAME" ] && [ "$NICKNAME_EXPLICIT" -eq 1 ]; then
info "检测到显式主人称呼输入,本次不复用旧称呼:EXISTING_NICKNAME"
fi
IDENTITY_PATCH_JSON=$(EXISTING_NAME_VALUE="$ACTUAL_NAME" EXISTING_NICKNAME_VALUE="$EXISTING_NICKNAME" LOBSTER_NAME_VALUE="$LOBSTER_NAME" NICKNAME_VALUE="$NICKNAME" DEFAULT_OWNER_NICKNAME_VALUE="打工人" LOBSTER_NAME_EXPLICIT_VALUE="$LOBSTER_NAME_EXPLICIT" NICKNAME_EXPLICIT_VALUE="$NICKNAME_EXPLICIT" python3 <<'PY'
import json
import os
payload = {}
existing_name = os.environ.get("EXISTING_NAME_VALUE", "").strip()
existing_nickname = os.environ.get("EXISTING_NICKNAME_VALUE", "").strip()
lobster_name = os.environ.get("LOBSTER_NAME_VALUE", "").strip()
default_owner_nickname = os.environ.get("DEFAULT_OWNER_NICKNAME_VALUE", "打工人").strip() or "打工人"
if os.environ.get("LOBSTER_NAME_EXPLICIT_VALUE") == "1" and lobster_name and lobster_name != existing_name:
payload["lobster_name"] = lobster_name
if os.environ.get("NICKNAME_EXPLICIT_VALUE") == "1":
owner_nickname = os.environ.get("NICKNAME_VALUE", "").strip()
if owner_nickname and owner_nickname != existing_nickname:
payload["owner_nickname"] = owner_nickname
elif (
os.environ.get("LOBSTER_NAME_EXPLICIT_VALUE") == "1"
and lobster_name
and existing_nickname == lobster_name
):
payload["owner_nickname"] = default_owner_nickname
print(json.dumps(payload, ensure_ascii=False))
PY
)
if [ "$IDENTITY_PATCH_JSON" != "{}" ]; then
step "同步修正已有虾的身份字段..."
UPDATED_LOBSTER=$(curl -fsS -X PATCH "API_BASE/api/lobster/ACTUAL_USER_ID/identity" \
-H "Authorization: Bearer ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d "$IDENTITY_PATCH_JSON")
ACTUAL_NAME=$(echo "$UPDATED_LOBSTER" | python3 -c "import sys, json; print(json.load(sys.stdin).get('name', '虾'))" 2>/dev/null || echo "$ACTUAL_NAME")
NICKNAME=$(echo "$UPDATED_LOBSTER" | python3 -c "import sys, json; print(json.load(sys.stdin).get('nickname_for_user', ''))" 2>/dev/null || echo "$NICKNAME")
info "已同步修正:虾自己的名字=ACTUAL_NAME,虾对用户的称呼=NICKNAME"
fi
REUSED_EXISTING=1
info "检测到已存在的虾:ACTUAL_NAME(复用原配置,避免重复创建)"
else
warn "检测到旧配置,但无法验证已有虾状态;本次将重新创建"
fi
fi
if [ "$REUSED_EXISTING" -ne 1 ]; then
step "创建虾..."
USER_ID=$(python3 -c "import uuid; print(str(uuid.uuid4()))")
CREATE_PAYLOAD=$(USER_ID_VALUE="$USER_ID" LOBSTER_NAME_VALUE="$LOBSTER_NAME" NICKNAME_VALUE="$NICKNAME" PERSONALITY_VALUE="$PERSONALITY" MORNING_TIME_VALUE="$MORNING_TIME" EVENING_TIME_VALUE="$EVENING_TIME" python3 <<'PY'
import json
import os
payload = {
"user_id": os.environ["USER_ID_VALUE"],
"personality": os.environ["PERSONALITY_VALUE"],
"owner_nickname": os.environ["NICKNAME_VALUE"],
"morning_time": os.environ["MORNING_TIME_VALUE"],
"evening_time": os.environ["EVENING_TIME_VALUE"],
}
lobster_name = os.environ.get("LOBSTER_NAME_VALUE", "").strip()
if lobster_name:
payload["lobster_name"] = lobster_name
print(json.dumps(payload, ensure_ascii=False))
PY
)
RESPONSE=$(curl -fsS -X POST "API_BASE/api/lobster" \
-H "Content-Type: application/json" \
-d "$CREATE_PAYLOAD")
ACCESS_TOKEN=$(echo "$RESPONSE" | python3 -c "import sys, json; print(json.load(sys.stdin)['access_token'])")
ACTUAL_NAME=$(echo "$RESPONSE" | python3 -c "import sys, json; print(json.load(sys.stdin)['name'])")
ACTUAL_USER_ID=$(echo "$RESPONSE" | python3 -c "import sys, json; print(json.load(sys.stdin)['user_id'])")
NICKNAME=$(echo "$RESPONSE" | python3 -c "import sys, json; print(json.load(sys.stdin).get('nickname_for_user', '打工人'))" 2>/dev/null || echo "$NICKNAME")
info "虾创建成功:ACTUAL_NAME"
fi
step "保存配置..."
CONFIG_PATH="$CONFIG_FILE" ACTUAL_USER_ID_VALUE="$ACTUAL_USER_ID" ACCESS_TOKEN_VALUE="$ACCESS_TOKEN" ACTUAL_NAME_VALUE="$ACTUAL_NAME" PERSONALITY_VALUE="$PERSONALITY" NICKNAME_VALUE="$NICKNAME" API_BASE_VALUE="$API_BASE" WEB_BASE_VALUE="$WEB_BASE" MORNING_TIME_VALUE="$MORNING_TIME" DISCOVERY_TIME_VALUE="$DISCOVERY_TIME" EVENING_TIME_VALUE="$EVENING_TIME" CHANNEL_VALUE="$CHANNEL" TO_VALUE="$TO" WECOM_USER_ID_VALUE="$WECOM_USER_ID" ALL_CHANNELS_JSON_VALUE="$ALL_CHANNELS_JSON" MEMORY_MODE_VALUE="$MEMORY_MODE" python3 <<'PY'
import json
import os
config = {
"user_id": os.environ["ACTUAL_USER_ID_VALUE"],
"access_token": os.environ["ACCESS_TOKEN_VALUE"],
"lobster_name": os.environ["ACTUAL_NAME_VALUE"],
"lobster_personality": os.environ["PERSONALITY_VALUE"],
"nickname_for_user": os.environ["NICKNAME_VALUE"],
"api_base": os.environ["API_BASE_VALUE"],
"web_base": os.environ["WEB_BASE_VALUE"],
"morning_time": os.environ["MORNING_TIME_VALUE"],
"discovery_time": os.environ["DISCOVERY_TIME_VALUE"],
"evening_time": os.environ["EVENING_TIME_VALUE"],
"channel": os.environ["CHANNEL_VALUE"],
"chat_id": os.environ["TO_VALUE"],
"memory_mode": os.environ["MEMORY_MODE_VALUE"],
}
wecom_user_id = (os.environ.get("WECOM_USER_ID_VALUE") or "").strip()
if wecom_user_id:
config["wecom_user_id"] = wecom_user_id
try:
known_channels = json.loads(os.environ.get("ALL_CHANNELS_JSON_VALUE", ""))
except Exception:
known_channels = []
channel = os.environ["CHANNEL_VALUE"]
peer_id = os.environ["TO_VALUE"]
if not any(isinstance(item, dict) and item.get("channel") == channel and item.get("peer_id") == peer_id for item in known_channels):
known_channels.insert(0, {"channel": channel, "peer_id": peer_id})
config["known_channels"] = known_channels
with open(os.environ["CONFIG_PATH"], "w", encoding="utf-8") as f:
json.dump(config, f, indent=2, ensure_ascii=False)
print("[✓] 配置已写入 .lobster-config")
PY
step "收口 delivery contract..."
DELIVERY_CONTRACT_JSON=$(lobster_sync_delivery_contract "$CONFIG_FILE" "$CHANNEL" "$TO")
info "delivery contract 已写入 .lobster-config"
step "验证虾状态..."
STATUS_RESPONSE=$(curl -fsS -H "Authorization: Bearer ACCESS_TOKEN" "API_BASE/api/lobster/ACTUAL_USER_ID/status" 2>/dev/null || true)
STATUS=$(echo "$STATUS_RESPONSE" | python3 -c "import sys, json; print(json.load(sys.stdin).get('status', 'unknown'))" 2>/dev/null || echo "unknown")
if [ "$STATUS" != "unknown" ]; then
info "虾状态: STATUS"
else
warn "无法获取虾状态(不影响初始化)"
fi
step "准备工作室短时入口..."
STUDIO_LINK_RESPONSE=$(curl -fsS -H "Authorization: Bearer ACCESS_TOKEN" "API_BASE/api/lobster/ACTUAL_USER_ID/studio-link" 2>/dev/null || true)
if [ -n "$STUDIO_LINK_RESPONSE" ]; then
STUDIO_WEB_URL=$(echo "$STUDIO_LINK_RESPONSE" | python3 -c "import sys, json; print(json.load(sys.stdin).get('web_url', ''))" 2>/dev/null || true)
STUDIO_SCREENSHOT_URL=$(echo "$STUDIO_LINK_RESPONSE" | python3 -c "import sys, json; print(json.load(sys.stdin).get('screenshot_url', ''))" 2>/dev/null || true)
STUDIO_LINK_EXPIRES_AT=$(echo "$STUDIO_LINK_RESPONSE" | python3 -c "import sys, json; print(json.load(sys.stdin).get('expires_at', ''))" 2>/dev/null || true)
if [ -n "$STUDIO_WEB_URL" ]; then
info "已获取工作室短时入口"
else
warn "工作室短时入口响应缺少 web_url;可稍后重试"
fi
else
warn "暂时无法获取工作室短时入口;可稍后通过 studio-link 接口重试"
fi
step "注册定时推送..."
set +e
bash "BASE_DIR/setup-cron.sh" \
--channel "CHANNEL" \
--to "TO" \
--wecom-user-id "WECOM_USER_ID" \
--morning "MORNING_TIME" \
--discovery "DISCOVERY_TIME" \
--evening "EVENING_TIME" \
--memory-mode "MEMORY_MODE"
CRON_EXIT_CODE=$?
set -e
CRON_STATUS_SUMMARY=$(CONFIG_PATH="$CONFIG_FILE" python3 <<'PY'
import json
import os
from pathlib import Path
path = Path(os.environ["CONFIG_PATH"])
try:
config = json.loads(path.read_text(encoding="utf-8"))
except Exception:
config = {}
cron = config.get("cron_registration") if isinstance(config.get("cron_registration"), dict) else {}
print("1" if config.get("cron_registered") else "0")
print(str(cron.get("status") or "unregistered"))
print(str(cron.get("reason") or ""))
PY
)
CRON_REGISTERED=$(echo "$CRON_STATUS_SUMMARY" | sed -n '1p')
CRON_REGISTRATION_STATUS=$(echo "$CRON_STATUS_SUMMARY" | sed -n '2p')
CRON_REGISTRATION_REASON=$(echo "$CRON_STATUS_SUMMARY" | sed -n '3p')
if [ "$CRON_EXIT_CODE" -ne 0 ]; then
CRON_REGISTERED=0
warn "定时推送注册执行失败,请稍后查看 setup-cron 日志或单独重试"
elif [ "$CRON_REGISTERED" = "1" ]; then
info "定时推送 cron 已注册完成"
elif [ "$CRON_REGISTRATION_STATUS" = "pending_activation" ]; then
warn "已保存待激活的定时推送配置:CRON_REGISTRATION_REASON"
else
warn "定时推送当前未注册成功,可稍后单独重试 setup-cron.sh"
fi
echo ""
echo "═══════════════════════════════════════════════"
echo ""
if [ "$CRON_REGISTERED" = "1" ]; then
info "🦞 初始化全部完成!"
elif [ "$CRON_REGISTRATION_STATUS" = "pending_activation" ]; then
warn "🦞 虾已就绪,定时推送已进入待激活状态"
else
warn "🦞 虾已就绪,但定时推送暂未注册成功"
fi
echo ""
echo " 虾自己的名字: ACTUAL_NAME"
echo " 虾格: PERSONALITY"
echo " 虾对用户的称呼: NICKNAME"
echo " 理解模式: MEMORY_MODE"
echo " 早安: 每天 MORNING_TIME"
echo " 晚间 roundup: 每天 DISCOVERY_TIME"
echo " 晚安: 每天 EVENING_TIME"
if [ "$CRON_REGISTERED" = "1" ]; then
echo " 投递: 已完成 cron 注册"
elif [ "$CRON_REGISTRATION_STATUS" = "pending_activation" ]; then
echo " 定时推送: 已保存待激活配置;一旦具备主动推送能力,再次 reconcile 即可完成注册"
echo " 待激活原因: CRON_REGISTRATION_REASON"
else
echo " 定时推送: 尚未完成注册,可稍后执行 bash \"BASE_DIR/setup-cron.sh\" 补注册"
fi
echo ""
if [ -n "$STUDIO_WEB_URL" ]; then
echo " 工作室短时入口: 已准备好(见初始化结果中的 studio_web_url)"
else
echo " 工作室入口: 暂未取到短时链接,可稍后重试获取"
fi
echo " 初始化结果不会再打印长期 token URL。"
echo ""
echo "═══════════════════════════════════════════════"
echo ""
echo "INIT_RESULT_JSON:"
ACTUAL_NAME_VALUE="$ACTUAL_NAME" PERSONALITY_VALUE="$PERSONALITY" NICKNAME_VALUE="$NICKNAME" ACTUAL_USER_ID_VALUE="$ACTUAL_USER_ID" WEB_BASE_VALUE="$WEB_BASE" MORNING_TIME_VALUE="$MORNING_TIME" DISCOVERY_TIME_VALUE="$DISCOVERY_TIME" EVENING_TIME_VALUE="$EVENING_TIME" CHANNEL_VALUE="$CHANNEL" TO_VALUE="$TO" WECOM_USER_ID_VALUE="$WECOM_USER_ID" ALL_CHANNELS_JSON_VALUE="$ALL_CHANNELS_JSON" MEMORY_MODE_VALUE="$MEMORY_MODE" CRON_REGISTERED_VALUE="$CRON_REGISTERED" CRON_REGISTRATION_STATUS_VALUE="$CRON_REGISTRATION_STATUS" CRON_REGISTRATION_REASON_VALUE="$CRON_REGISTRATION_REASON" REUSED_EXISTING_VALUE="$REUSED_EXISTING" STUDIO_WEB_URL_VALUE="$STUDIO_WEB_URL" STUDIO_SCREENSHOT_URL_VALUE="$STUDIO_SCREENSHOT_URL" STUDIO_LINK_EXPIRES_AT_VALUE="$STUDIO_LINK_EXPIRES_AT" DELIVERY_CONTRACT_JSON_VALUE="$DELIVERY_CONTRACT_JSON" python3 <<'PY'
import json
import os
from urllib.parse import urlparse
channel = os.environ["CHANNEL_VALUE"]
peer_id = os.environ["TO_VALUE"]
studio_web_url = os.environ.get("STUDIO_WEB_URL_VALUE", "")
studio_path = urlparse(studio_web_url).path if studio_web_url else "/lobster/" + os.environ["ACTUAL_USER_ID_VALUE"]
known_channels = json.loads(os.environ["ALL_CHANNELS_JSON_VALUE"]) if os.environ.get("ALL_CHANNELS_JSON_VALUE", "").strip() else [{"channel": channel, "peer_id": peer_id}]
if not any(item.get("channel") == channel and item.get("peer_id") == peer_id for item in known_channels):
known_channels.insert(0, {"channel": channel, "peer_id": peer_id})
delivery_contract_raw = os.environ.get("DELIVERY_CONTRACT_JSON_VALUE", "").strip()
try:
delivery_contract = json.loads(delivery_contract_raw) if delivery_contract_raw else {}
except Exception:
delivery_contract = {}
result = {
"success": True,
"lobster_name": os.environ["ACTUAL_NAME_VALUE"],
"personality": os.environ["PERSONALITY_VALUE"],
"nickname": os.environ["NICKNAME_VALUE"],
"user_id": os.environ["ACTUAL_USER_ID_VALUE"],
"studio_path": studio_path,
"studio_web_url": studio_web_url,
"studio_screenshot_url": os.environ.get("STUDIO_SCREENSHOT_URL_VALUE", ""),
"studio_link_expires_at": os.environ.get("STUDIO_LINK_EXPIRES_AT_VALUE", ""),
"morning_time": os.environ["MORNING_TIME_VALUE"],
"discovery_time": os.environ["DISCOVERY_TIME_VALUE"],
"evening_time": os.environ["EVENING_TIME_VALUE"],
"memory_mode": os.environ["MEMORY_MODE_VALUE"],
"channel": channel,
"chat_id": peer_id,
"cron_registered": os.environ.get("CRON_REGISTERED_VALUE") == "1",
"cron_registration_status": os.environ.get("CRON_REGISTRATION_STATUS_VALUE", "unregistered"),
"cron_registration_reason": os.environ.get("CRON_REGISTRATION_REASON_VALUE", ""),
"reused_existing": os.environ.get("REUSED_EXISTING_VALUE") == "1",
"known_channels": known_channels,
"delivery_contract": delivery_contract,
"wecom_user_id": os.environ.get("WECOM_USER_ID_VALUE", ""),
}
print(json.dumps(result, indent=2, ensure_ascii=False))
PY
FILE:openclaw.json
{
"name": "lobster-says",
"version": "2.5.8",
"description": "🦞 虾说——你的专属共情虾。每天早晚给你一句关心的话,让你觉得被看到了。需注册 cron 定时任务推送消息;2.5.4 起服务端提前 3 分钟预生成消息,cron 触发时 /api/generate 直接返回已有消息(<200ms),企微 emit-message-text 链路总延迟从 15s+ 降至 <2s;Web 端新增信封动画特效,用户打开链接即看到虾的来信。企业微信在 2.5.3 起走极简主链路:脚本通过 --emit-message-text 只输出最终消息文本,由 isolated agent 使用 message 工具直发到 openclaw-wecom-bot;通用通道(Telegram 等)由脚本完整模式多通道 fallback 发送;可选读取本地会话日志生成理解摘要并上传到 nixiashuo.com。",
"author": "Jared",
"license": "MIT",
"repository": "",
"keywords": [
"emotional-support",
"daily-push",
"pixel-art",
"companion",
"cron",
"memory",
"sticker",
"情感陪伴",
"情绪价值",
"情绪虾",
"暖心虾",
"懂你的虾",
"empathy",
"mental-health",
"wellness",
"daily-checkin",
"mood",
"care",
"lobster",
"虾说",
"中文",
"chinese",
"壁纸生成",
"wallpaper"
],
"data_usage": {
"cron_jobs": "Registers 5-6 scheduled cron jobs via openclaw cron add for daily message push and optional transcript digest",
"session_read": "In smart/deep memory mode only, reads local session JSONL files under ~/.openclaw/agents/main/sessions/ to generate understanding summaries",
"network": "Communicates with https://nixiashuo.com for message generation, delivery reporting, and optional transcript digest upload",
"config_files": "Reads and writes .lobster-config in the skill directory for user/lobster identity and channel preferences. Does NOT read openclaw.json or extract gateway tokens.",
"user_consent": "Memory mode (lightweight/smart/deep) is explicitly chosen by the user during initialization and can be changed at any time"
}
}
FILE:push-scheduled-message.sh
#!/bin/bash
# ═══════════════════════════════════════════════
# 虾说 — 运行时定时推送脚本
# ═══════════════════════════════════════════════
#
# 数据访问声明:
# - 读取 .lobster-config(技能自身配置,含 user_id/access_token/通道偏好)
# - 调用 openclaw sessions --json 获取最近活跃 IM 通道元数据(通道名+ID)
# - 通用 IM 通道使用 openclaw message send 发送消息(多通道 fallback)
# - 企业微信定时推送主链路走 --emit-message-text:脚本只输出最终消息文本,由 cron isolated agent 使用 message 工具私聊直达
# - --generate-only JSON 模式保留为调试/兼容能力,不再作为企微 cron 主链路
# - 与 https://nixiashuo.com 通信:生成消息、报告送达结果
# - 不读取 openclaw.json 配置文件,不提取 gateway token
#
# 设计目标:
# 1. --emit-message-text 模式:仅输出最终消息文本到 stdout,不执行任何发送
# 适用于:企业微信 cron 链路——由 isolated agent 直接把 stdout 原文作为 message 工具参数发送
# 2. --generate-only 模式:输出 JSON 到 stdout,保留为调试/兼容能力
# 3. 完整模式(默认):生成消息 + 多通道 fallback 发送
# 适用于:Telegram/Discord/飞书等通用 IM 通道
# 4. 各模式共享消息生成、init-ready 管理、送达报告等基础设施
#
# 使用方式:
# bash push-scheduled-message.sh --slot morning|discovery|evening
# bash push-scheduled-message.sh --slot morning --emit-message-text
# bash push-scheduled-message.sh --slot morning --generate-only
set -u
BASE_DIR="$(cd "$(dirname "$0")" && pwd)"
CONFIG_FILE="BASE_DIR/.lobster-config"
COMMON_SCRIPT="BASE_DIR/runtime-common.sh"
SETUP_CRON_SCRIPT="BASE_DIR/setup-cron.sh"
LOG_DIR="BASE_DIR/logs"
RUN_TS="$(date +%Y%m%d-%H%M%S)"
LOG_FILE="LOG_DIR/push-scheduled-message-RUN_TS.log"
SLOT=""
FORCED_CHANNEL=""
FORCED_TARGET=""
EXTRA_CONTEXT=""
CONTENT_PREFIX=""
MANAGED_JOB_NAME=""
JOB_KIND="scheduled-message"
GENERATE_ONLY=0
EMIT_MESSAGE_TEXT=0
ACTIVE_WINDOW_MINUTES="-10080"
INIT_READY_MAX_ATTEMPTS="-4"
INIT_READY_RETRY_MINUTES="-15"
# Telegram 原生 Bot API 可稳定处理 sendPhoto/URL 图片;
# 企业微信/飞书等机器人协议对图片通常要求 webhook 特定 payload 或上传素材,
# 统一经 OpenClaw CLI 时更适合优先走文本 + 链接,避免假成功或媒体协议不兼容。
MEDIA_SEND_CHANNELS="telegram discord googlechat slack mattermost signal imessage msteams"
GENERATE_RESPONSE_FILE=""
PARSED_JSON_FILE=""
_SEND_STDERR_FILE=""
cleanup() {
[ -n "$GENERATE_RESPONSE_FILE" ] && [ -f "$GENERATE_RESPONSE_FILE" ] && rm -f "$GENERATE_RESPONSE_FILE"
[ -n "$PARSED_JSON_FILE" ] && [ -f "$PARSED_JSON_FILE" ] && rm -f "$PARSED_JSON_FILE"
[ -n "$_SEND_STDERR_FILE" ] && [ -f "$_SEND_STDERR_FILE" ] && rm -f "$_SEND_STDERR_FILE"
[ -n "-" ] && [ -f "-" ] && rm -f "$STICKER_IMAGE_FILE"
}
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
CYAN='\033[0;36m'
NC='\033[0m'
_ts() { date '+%Y-%m-%d %H:%M:%S'; }
# --emit-message-text / --generate-only 模式下,info/warn/error/step 输出到 stderr,stdout 保留给消息文本或 JSON
info() { echo -e "GREEN[✓]NC $(_ts) $1" >&2; }
warn() { echo -e "YELLOW[!]NC $(_ts) $1" >&2; }
error() { echo -e "RED[✗]NC $(_ts) $1" >&2; exit 1; }
CURRENT_STEP="启动"
step() { CURRENT_STEP="$1"; echo -e "CYAN──NC $(_ts) $1" >&2; }
if [ ! -f "$COMMON_SCRIPT" ]; then
echo "共享运行时脚本不存在:COMMON_SCRIPT" >&2
exit 1
fi
. "$COMMON_SCRIPT"
mkdir -p "$LOG_DIR"
# 日志始终写文件;tee 只用于非 generate-only 模式的 stderr
exec 3>>"$LOG_FILE"
echo "[log] push-scheduled-message started at $(date '+%F %T')" >&3
echo "[log] file: LOG_FILE" >&3
after_exit() {
local exit_code=$?
cleanup
if [ "$exit_code" -ne 0 ]; then
echo "" >&3
echo "[log] push-scheduled-message failed at step: CURRENT_STEP (exit=exit_code)" >&3
echo "[log] inspect: LOG_FILE" >&3
else
echo "[log] push-scheduled-message finished successfully" >&3
fi
exec 3>&-
}
trap after_exit EXIT
while [[ $# -gt 0 ]]; do
case "$1" in
--slot) SLOT="$2"; shift 2 ;;
--channel) FORCED_CHANNEL="$2"; shift 2 ;;
--to|--target) FORCED_TARGET="$2"; shift 2 ;;
--extra-context) EXTRA_CONTEXT="$2"; shift 2 ;;
--content-prefix) CONTENT_PREFIX="$2"; shift 2 ;;
--job-kind) JOB_KIND="$2"; shift 2 ;;
--managed-job-name|--remove-cron-job-name) MANAGED_JOB_NAME="$2"; shift 2 ;;
--generate-only) GENERATE_ONLY=1; shift ;;
--emit-message-text) EMIT_MESSAGE_TEXT=1; shift ;;
*) echo "未知参数: $1" >&2; exit 1 ;;
esac
done
if [[ ! "$SLOT" =~ ^(morning|discovery|evening|event|sticker|wallpaper)$ ]]; then
error "--slot 必须是 morning / discovery / evening / event / sticker / wallpaper"
fi
for cmd in python3 curl openclaw; do
if ! command -v "$cmd" >/dev/null 2>&1; then
error "$cmd 不可用"
fi
done
if [ ! -f "$CONFIG_FILE" ]; then
error ".lobster-config 不存在,请先初始化虾"
fi
read_config_value() {
local key="$1"
local default_value="-"
CONFIG_PATH="$CONFIG_FILE" KEY_NAME="$key" DEFAULT_VALUE="$default_value" python3 - <<'PY'
import json
import os
from pathlib import Path
path = Path(os.environ["CONFIG_PATH"])
key = os.environ["KEY_NAME"]
default = os.environ.get("DEFAULT_VALUE", "")
try:
data = json.loads(path.read_text(encoding='utf-8'))
value = data.get(key, default)
print(value if value is not None else default)
except Exception:
print(default)
PY
}
supports_media() {
local channel="$1"
for ch in $MEDIA_SEND_CHANNELS; do
if [ "$ch" = "$channel" ]; then
return 0
fi
done
return 1
}
build_candidate_lines() {
local sessions_json="$1"
local forced_channel="$2"
local forced_target="$3"
build_candidate_lines_with_policy "$CONFIG_FILE" "$sessions_json" "$forced_channel" "$forced_target"
}
load_runtime_policy() {
POLICY_JSON=$(lobster_runtime_policy_json "$CONFIG_FILE")
POLICY_JSON_VALUE="$POLICY_JSON" python3 <<'PY'
import json
import os
policy = json.loads(os.environ["POLICY_JSON_VALUE"])
print(policy.get("binding_channel", ""))
print(policy.get("binding_target", ""))
print(policy.get("binding_mode", "prefer"))
print("1" if policy.get("strict_binding") else "0")
print(policy.get("outbound_adapter", "openclaw"))
print(policy.get("outbound_webhook_url", ""))
print(policy.get("outbound_webhook_secret", ""))
print(policy.get("delivery_channel", ""))
print(policy.get("delivery_target", ""))
print("1" if policy.get("delivery_ready") else "0")
print(policy.get("delivery_reason", ""))
print(policy.get("cron_status", "unregistered"))
PY
}
sync_known_channels_after_send() {
local current_channel="$1"
local current_target="$2"
local candidate_lines="$3"
local delivered_at="$4"
update_config_after_send_with_policy "$CONFIG_FILE" "$current_channel" "$current_target" "$candidate_lines" "$delivered_at"
}
append_delivery_ledger() {
local stage="$1"
local status="$2"
local detail="-"
local channel="-"
local target="-"
local delivery_mode="-"
local message_id="-"
mkdir -p "$LOG_DIR"
LOG_DIR_VALUE="$LOG_DIR" \
LEDGER_STAGE="$stage" \
LEDGER_STATUS="$status" \
LEDGER_DETAIL="$detail" \
LEDGER_CHANNEL="$channel" \
LEDGER_TARGET="$target" \
LEDGER_MODE="$delivery_mode" \
LEDGER_MESSAGE_ID="$message_id" \
LEDGER_SLOT="$SLOT" \
LEDGER_JOB_KIND="$JOB_KIND" \
LEDGER_RUN_TS="$RUN_TS" \
python3 <<'PY'
import json
import os
from datetime import datetime, timezone
from pathlib import Path
message_id_raw = (os.environ.get("LEDGER_MESSAGE_ID") or "").strip()
try:
message_id = int(message_id_raw) if message_id_raw else None
except ValueError:
message_id = None
entry = {
"ts": datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z"),
"run_ts": os.environ.get("LEDGER_RUN_TS"),
"script": "push-scheduled-message.sh",
"slot": os.environ.get("LEDGER_SLOT"),
"job_kind": os.environ.get("LEDGER_JOB_KIND"),
"stage": os.environ.get("LEDGER_STAGE"),
"status": os.environ.get("LEDGER_STATUS"),
"detail": os.environ.get("LEDGER_DETAIL") or None,
"channel": os.environ.get("LEDGER_CHANNEL") or None,
"target": os.environ.get("LEDGER_TARGET") or None,
"delivery_mode": os.environ.get("LEDGER_MODE") or None,
"message_id": message_id,
}
ledger_path = Path(os.environ["LOG_DIR_VALUE"]) / "delivery-ledger.jsonl"
with ledger_path.open("a", encoding="utf-8") as fh:
fh.write(json.dumps(entry, ensure_ascii=False) + "\n")
PY
}
update_init_ready_state() {
[ "$JOB_KIND" = "init-ready" ] || return 0
local state="$1"
local attempt_count="-"
local message_id="-"
local error_message="-"
local channel="-"
local target="-"
local extra_field="-"
local extra_value="-"
INIT_STATE="$state" \
INIT_ATTEMPT_COUNT="$attempt_count" \
INIT_MESSAGE_ID="$message_id" \
INIT_ERROR="$error_message" \
INIT_CHANNEL="$channel" \
INIT_TARGET="$target" \
INIT_EXTRA_FIELD="$extra_field" \
INIT_EXTRA_VALUE="$extra_value" \
CONFIG_PATH="$CONFIG_FILE" \
python3 <<'PY'
import json
import os
from datetime import datetime, timezone
from pathlib import Path
config_path = Path(os.environ["CONFIG_PATH"])
try:
config = json.loads(config_path.read_text(encoding="utf-8"))
except Exception:
config = {}
init_ready = config.get("init_ready") if isinstance(config.get("init_ready"), dict) else {}
now = datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
state = os.environ.get("INIT_STATE") or init_ready.get("state") or "unknown"
init_ready["state"] = state
init_ready["updated_at"] = now
if state in {"attempting", "delivery_failed", "retry_scheduled", "scheduled"}:
init_ready["last_attempt_at"] = now
attempt_count = (os.environ.get("INIT_ATTEMPT_COUNT") or "").strip()
if attempt_count:
try:
init_ready["attempts"] = int(attempt_count)
except ValueError:
pass
message_id = (os.environ.get("INIT_MESSAGE_ID") or "").strip()
if message_id:
try:
init_ready["last_message_id"] = int(message_id)
except ValueError:
pass
error_message = (os.environ.get("INIT_ERROR") or "").strip()
if error_message:
init_ready["last_error"] = error_message
channel = (os.environ.get("INIT_CHANNEL") or "").strip()
target = (os.environ.get("INIT_TARGET") or "").strip()
if channel:
init_ready["last_delivery_channel"] = channel
if target:
init_ready["last_delivery_target"] = target
if state == "sent":
init_ready["sent_at"] = now
extra_field = (os.environ.get("INIT_EXTRA_FIELD") or "").strip()
extra_value = (os.environ.get("INIT_EXTRA_VALUE") or "").strip()
if extra_field:
init_ready[extra_field] = extra_value
config["init_ready"] = init_ready
config_path.write_text(json.dumps(config, indent=2, ensure_ascii=False), encoding="utf-8")
PY
}
begin_init_ready_attempt() {
[ "$JOB_KIND" = "init-ready" ] || { echo "0"; return 0; }
CONFIG_PATH="$CONFIG_FILE" python3 <<'PY'
import json
import os
from datetime import datetime, timezone
from pathlib import Path
config_path = Path(os.environ["CONFIG_PATH"])
try:
config = json.loads(config_path.read_text(encoding="utf-8"))
except Exception:
config = {}
init_ready = config.get("init_ready") if isinstance(config.get("init_ready"), dict) else {}
attempts = int(init_ready.get("attempts") or 0) + 1
now = datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
init_ready["attempts"] = attempts
init_ready["state"] = "attempting"
init_ready["last_attempt_at"] = now
init_ready["updated_at"] = now
config["init_ready"] = init_ready
config_path.write_text(json.dumps(config, indent=2, ensure_ascii=False), encoding="utf-8")
print(attempts)
PY
}
init_ready_already_sent() {
[ "$JOB_KIND" = "init-ready" ] || return 1
CONFIG_PATH="$CONFIG_FILE" python3 <<'PY'
import json
import os
from pathlib import Path
config_path = Path(os.environ["CONFIG_PATH"])
try:
config = json.loads(config_path.read_text(encoding="utf-8"))
except Exception:
config = {}
state = ""
if isinstance(config.get("init_ready"), dict):
state = str(config["init_ready"].get("state") or "")
print("yes" if state == "sent" else "no")
PY
}
finalize_managed_job() {
[ -n "$MANAGED_JOB_NAME" ] || return 0
[ -f "$SETUP_CRON_SCRIPT" ] || return 0
bash "$SETUP_CRON_SCRIPT" --remove-job-by-name "$MANAGED_JOB_NAME" >/dev/null 2>&1 || true
}
schedule_init_ready_retry() {
local attempt_count="$1"
local reason="$2"
[ "$JOB_KIND" = "init-ready" ] || return 0
if [ "$attempt_count" -ge "$INIT_READY_MAX_ATTEMPTS" ]; then
warn "init-ready 已达到最大尝试次数 INIT_READY_MAX_ATTEMPTS,停止自动重试"
update_init_ready_state "failed" "$attempt_count" "" "$reason"
append_delivery_ledger "retry_exhausted" "failed" "$reason"
finalize_managed_job
return 0
fi
if bash "$SETUP_CRON_SCRIPT" --schedule-init-ready-delay "$INIT_READY_RETRY_MINUTES" >/dev/null 2>&1; then
update_init_ready_state "retry_scheduled" "$attempt_count" "" "$reason" "" "" "retry_delay_minutes" "$INIT_READY_RETRY_MINUTES"
append_delivery_ledger "retry_scheduled" "scheduled" "$reason"
info "init-ready 已重排,INIT_READY_RETRY_MINUTES 分钟后重试"
else
warn "init-ready 重排失败,请稍后手动执行 setup-cron.sh"
update_init_ready_state "delivery_failed" "$attempt_count" "" "$reason"
append_delivery_ledger "retry_schedule_failed" "failed" "$reason"
fi
}
report_delivery() {
local message_id="$1"
local status="$2"
local channel="$3"
local target="$4"
local delivery_mode="$5"
local delivered_text="$6"
local screenshot_url="$7"
local error_message="$8"
REPORT_RESULT=$(REPORT_USER_ID="$USER_ID" REPORT_MESSAGE_ID="$message_id" REPORT_STATUS="$status" REPORT_CHANNEL="$channel" REPORT_TARGET="$target" REPORT_MODE="$delivery_mode" REPORT_TEXT="$delivered_text" REPORT_SCREENSHOT_URL="$screenshot_url" REPORT_ERROR="$error_message" python3 <<'PY'
import json
import os
payload = {
"user_id": os.environ["REPORT_USER_ID"],
"message_id": int(os.environ["REPORT_MESSAGE_ID"]),
"status": os.environ["REPORT_STATUS"],
"channel": os.environ.get("REPORT_CHANNEL") or None,
"target": os.environ.get("REPORT_TARGET") or None,
"delivery_mode": os.environ.get("REPORT_MODE") or None,
"delivered_text": os.environ.get("REPORT_TEXT") or None,
"delivered_screenshot_url": os.environ.get("REPORT_SCREENSHOT_URL") or None,
"error_message": os.environ.get("REPORT_ERROR") or None,
}
print(json.dumps(payload, ensure_ascii=False))
PY
)
curl -fsS -X POST "API_BASE/api/delivery/report" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ACCESS_TOKEN" \
-d "$REPORT_RESULT" >/dev/null 2>&1 || warn "送达结果回写失败(不影响用户已收到的消息)"
}
build_text_message() {
local include_url="$1"
LOBSTER_NAME_VALUE="$LOBSTER_NAME" GENERATED_CONTENT_VALUE="$GENERATED_CONTENT" WEB_URL_VALUE="$WEB_URL" SCREENSHOT_URL_VALUE="$SCREENSHOT_URL" INCLUDE_URL_VALUE="$include_url" python3 <<'PY'
import os
lobster_name = os.environ["LOBSTER_NAME_VALUE"]
content = os.environ["GENERATED_CONTENT_VALUE"]
web_url = os.environ["WEB_URL_VALUE"]
screenshot_url = os.environ["SCREENSHOT_URL_VALUE"]
include_url = os.environ.get("INCLUDE_URL_VALUE") == "true"
lines = [f"🦞 {lobster_name}说:「{content}」", ""]
if include_url and screenshot_url:
lines.append(f"📸 {lobster_name}的工作室截图:{screenshot_url}")
lines.append(f"👀 看看{lobster_name}在干嘛 → {web_url}")
print("\n".join(lines))
PY
}
# ═══════════════════════════════════════════════
# 读取配置
# ═══════════════════════════════════════════════
USER_ID=$(read_config_value "user_id")
ACCESS_TOKEN=$(read_config_value "access_token")
LOBSTER_NAME=$(read_config_value "lobster_name" "虾")
API_BASE=$(read_config_value "api_base" "https://nixiashuo.com")
WEB_BASE=$(read_config_value "web_base" "$API_BASE")
WECOM_USER_ID=$(read_config_value "wecom_user_id")
if [ -z "$USER_ID" ] || [ -z "$ACCESS_TOKEN" ]; then
error ".lobster-config 缺少 user_id 或 access_token"
fi
mapfile -t POLICY_LINES < <(load_runtime_policy)
BOUND_CHANNEL="-"
BOUND_TARGET="-"
BINDING_MODE="-prefer"
STRICT_BINDING="-0"
OUTBOUND_ADAPTER="-openclaw"
OUTBOUND_WEBHOOK_URL="-"
OUTBOUND_WEBHOOK_SECRET="-"
DELIVERY_CHANNEL="-"
DELIVERY_TARGET="-"
DELIVERY_READY="-0"
DELIVERY_REASON="-"
CRON_STATUS="-unregistered"
if [ "$STRICT_BINDING" = "1" ]; then
OUTBOUND_ADAPTER="wecom-direct-message"
DELIVERY_CHANNEL="wecom"
if [ -n "$WECOM_USER_ID" ]; then
DELIVERY_TARGET="$WECOM_USER_ID"
DELIVERY_READY="1"
DELIVERY_REASON=""
else
DELIVERY_TARGET=""
DELIVERY_READY="0"
DELIVERY_REASON="企业微信定时推送缺少 sender_id / wecom_user_id;请在技能层从 inbound metadata 传入 sender_id"
fi
fi
# ═══════════════════════════════════════════════
# init-ready 前置检查
# ═══════════════════════════════════════════════
if [ "$JOB_KIND" = "init-ready" ] && [ "$(init_ready_already_sent)" = "yes" ]; then
info "init-ready 已经送达过,本次跳过重复执行"
append_delivery_ledger "duplicate_skip" "skipped" "init-ready already sent"
finalize_managed_job
exit 0
fi
INIT_READY_ATTEMPT_NUMBER=0
if [ "$JOB_KIND" = "init-ready" ]; then
INIT_READY_ATTEMPT_NUMBER=$(begin_init_ready_attempt)
append_delivery_ledger "attempt_started" "running" "init-ready attempt=INIT_READY_ATTEMPT_NUMBER"
fi
if [ "$DELIVERY_READY" != "1" ]; then
LAST_ERROR="-delivery contract not ready"
append_delivery_ledger "delivery_contract_not_ready" "failed" "$LAST_ERROR" "$DELIVERY_CHANNEL" "$DELIVERY_TARGET" "$OUTBOUND_ADAPTER"
[ "$JOB_KIND" = "init-ready" ] && schedule_init_ready_retry "$INIT_READY_ATTEMPT_NUMBER" "$LAST_ERROR"
error "$LAST_ERROR"
fi
# ═══════════════════════════════════════════════
# 生成消息
# ═══════════════════════════════════════════════
step "生成 SLOT 消息..."
GENERATE_RESPONSE_FILE=$(mktemp "-/tmp/lobster-generate-response.XXXXXX.json")
PARSED_JSON_FILE=$(mktemp "-/tmp/lobster-generate-parsed.XXXXXX.json")
GENERATE_REQUEST_JSON=$(GENERATE_USER_ID="$USER_ID" GENERATE_SLOT="$SLOT" GENERATE_EXTRA_CONTEXT="$EXTRA_CONTEXT" python3 <<'PY'
import json
import os
payload = {
"user_id": os.environ["GENERATE_USER_ID"],
"message_type": os.environ["GENERATE_SLOT"],
"include_screenshot_base64": False,
}
extra_context = (os.environ.get("GENERATE_EXTRA_CONTEXT") or "").strip()
if extra_context:
payload["extra_context"] = extra_context
print(json.dumps(payload, ensure_ascii=False))
PY
)
if ! curl -fsS -X POST "API_BASE/api/generate" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ACCESS_TOKEN" \
-d "$GENERATE_REQUEST_JSON" \
-o "$GENERATE_RESPONSE_FILE"; then
LAST_ERROR="调用 /api/generate 失败"
append_delivery_ledger "generate_failed" "failed" "$LAST_ERROR"
[ "$JOB_KIND" = "init-ready" ] && schedule_init_ready_retry "$INIT_READY_ATTEMPT_NUMBER" "$LAST_ERROR"
error "$LAST_ERROR"
fi
SKIPPED_CHECK=$(python3 - "$GENERATE_RESPONSE_FILE" <<'PY'
import json
import sys
from pathlib import Path
try:
data = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
if data.get("skipped"):
print(data.get("reason", "unknown"))
else:
print("no")
except Exception:
print("no")
PY
)
if [ "$SKIPPED_CHECK" != "no" ]; then
info "📋 本次 SLOT 生成被跳过: SKIPPED_CHECK"
append_delivery_ledger "generate_skipped" "skipped" "$SKIPPED_CHECK"
[ "$JOB_KIND" = "init-ready" ] && schedule_init_ready_retry "$INIT_READY_ATTEMPT_NUMBER" "$SKIPPED_CHECK"
exit 0
fi
PARSE_RESULT=$(python3 - "$GENERATE_RESPONSE_FILE" "$PARSED_JSON_FILE" <<'PY'
import json
import sys
from pathlib import Path
response_path = Path(sys.argv[1])
parsed_path = Path(sys.argv[2])
try:
data = json.loads(response_path.read_text(encoding="utf-8"))
except Exception as exc:
print(f"parse_error|{exc}")
sys.exit(1)
message = data.get("message") or {}
required = {
"message_id": message.get("id"),
"content": message.get("raw_content") or message.get("content"),
"web_url": data.get("web_url"),
"screenshot_url": data.get("screenshot_url"),
}
missing = [k for k, v in required.items() if v in (None, "")]
if missing:
print("missing|" + ",".join(missing))
sys.exit(1)
required["sticker_image_base64"] = data.get("sticker_image_base64") or ""
required["sticker_theme"] = data.get("sticker_theme") or ""
required["is_sticker"] = bool(data.get("sticker_image_base64"))
required["is_wallpaper"] = message.get("message_type") == "wallpaper"
required["is_media"] = required["is_sticker"] or required["is_wallpaper"]
parsed_path.write_text(json.dumps(required, ensure_ascii=False), encoding="utf-8")
print("ok")
PY
)
if [ "$PARSE_RESULT" != "ok" ]; then
LAST_ERROR="解析 /api/generate 响应失败: -unknown"
append_delivery_ledger "parse_failed" "failed" "$LAST_ERROR"
[ "$JOB_KIND" = "init-ready" ] && schedule_init_ready_retry "$INIT_READY_ATTEMPT_NUMBER" "$LAST_ERROR"
error "$LAST_ERROR"
fi
MESSAGE_ID=$(python3 - "$PARSED_JSON_FILE" <<'PY'
import json
import sys
from pathlib import Path
print(json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))["message_id"])
PY
)
GENERATED_CONTENT=$(python3 - "$PARSED_JSON_FILE" <<'PY'
import json
import sys
from pathlib import Path
print(json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))["content"])
PY
)
WEB_URL=$(python3 - "$PARSED_JSON_FILE" <<'PY'
import json
import sys
from pathlib import Path
print(json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))["web_url"])
PY
)
SCREENSHOT_URL=$(python3 - "$PARSED_JSON_FILE" <<'PY'
import json
import sys
from pathlib import Path
print(json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))["screenshot_url"])
PY
)
append_delivery_ledger "generated" "ok" "message generated" "" "" "queued" "$MESSAGE_ID"
report_delivery "$MESSAGE_ID" "pending" "-$FORCED_CHANNEL" "-$FORCED_TARGET" "queued" "" "$SCREENSHOT_URL" ""
update_init_ready_state "generated" "$INIT_READY_ATTEMPT_NUMBER" "$MESSAGE_ID"
if [ -n "$CONTENT_PREFIX" ]; then
GENERATED_CONTENT=$(CONTENT_PREFIX_VALUE="$CONTENT_PREFIX" GENERATED_CONTENT_VALUE="$GENERATED_CONTENT" python3 <<'PY'
import os
prefix = (os.environ.get("CONTENT_PREFIX_VALUE") or "").strip()
content = (os.environ.get("GENERATED_CONTENT_VALUE") or "").strip()
print(f"{prefix}{content}" if content else prefix)
PY
)
fi
IS_STICKER=$(python3 - "$PARSED_JSON_FILE" <<'PY'
import json
import sys
from pathlib import Path
data = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
print("true" if data.get("is_media") or data.get("is_sticker") else "false")
PY
)
IS_WALLPAPER=$(python3 - "$PARSED_JSON_FILE" <<'PY'
import json
import sys
from pathlib import Path
data = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
print("true" if data.get("is_wallpaper") else "false")
PY
)
STICKER_IMAGE_FILE=""
if [ "$IS_STICKER" = "true" ]; then
MEDIA_TYPE_LABEL="表情包"
[ "$IS_WALLPAPER" = "true" ] && MEDIA_TYPE_LABEL="壁纸"
step "MEDIA_TYPE_LABEL类型:解码图片到临时文件..."
OPENCLAW_MEDIA_DIR="HOME/.openclaw/media"
mkdir -p "$OPENCLAW_MEDIA_DIR"
STICKER_IMAGE_FILE=$(mktemp "OPENCLAW_MEDIA_DIR/lobster-media.XXXXXX.png")
python3 - "$PARSED_JSON_FILE" "$STICKER_IMAGE_FILE" <<'PY'
import base64
import json
import sys
from pathlib import Path
data = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
b64 = data.get("sticker_image_base64", "")
if b64:
Path(sys.argv[2]).write_bytes(base64.b64decode(b64))
print("ok")
else:
print("no_data")
sys.exit(1)
PY
if [ $? -ne 0 ] || [ ! -s "$STICKER_IMAGE_FILE" ]; then
warn "MEDIA_TYPE_LABEL图片解码失败,降级为普通消息推送"
IS_STICKER="false"
STICKER_IMAGE_FILE=""
else
info "MEDIA_TYPE_LABEL图片已保存: $(du -h "$STICKER_IMAGE_FILE" | cut -f1)"
if [ "$IS_WALLPAPER" = "true" ]; then
GENERATED_CONTENT="🦞 LOBSTER_NAME给你画了一张专属壁纸~"
else
GENERATED_CONTENT="🦞 LOBSTER_NAME给你画了一张表情包~"
fi
fi
fi
# ═══════════════════════════════════════════════
# 模式分流:--emit-message-text / --generate-only / 企微 CLI fallback / 通用多通道 fallback
# ═══════════════════════════════════════════════
if [ "$EMIT_MESSAGE_TEXT" = "1" ]; then
# ───────────────────────────────────────────
# emit-message-text 模式:仅输出最终消息文本到 stdout
# 供 isolated agent 直接把 stdout 原文作为 message 工具参数发送
# ───────────────────────────────────────────
step "emit-message-text 模式:输出最终消息文本到 stdout..."
FINAL_TEXT=$(build_text_message true)
echo "$FINAL_TEXT"
append_delivery_ledger "emit_message_text" "ok" "plain text output to stdout for agent message tool" "" "" "emit_message_text" "$MESSAGE_ID"
info "emit-message-text 完成,最终消息文本已输出到 stdout"
exit 0
fi
if [ "$GENERATE_ONLY" = "1" ]; then
# ───────────────────────────────────────────
# generate-only 模式:仅输出消息 JSON 到 stdout(保留作为备用能力)
# ───────────────────────────────────────────
step "generate-only 模式:输出消息 JSON 到 stdout..."
FINAL_TEXT=$(build_text_message true)
GENERATE_ONLY_OUTPUT=$(FINAL_TEXT_VALUE="$FINAL_TEXT" \
GENERATED_CONTENT_VALUE="$GENERATED_CONTENT" \
MESSAGE_ID_VALUE="$MESSAGE_ID" \
LOBSTER_NAME_VALUE="$LOBSTER_NAME" \
WEB_URL_VALUE="$WEB_URL" \
SCREENSHOT_URL_VALUE="$SCREENSHOT_URL" \
SLOT_VALUE="$SLOT" \
IS_STICKER_VALUE="$IS_STICKER" \
IS_WALLPAPER_VALUE="$IS_WALLPAPER" \
STICKER_IMAGE_FILE_VALUE="-" \
python3 <<'PY'
import json
import os
output = {
"message_text": os.environ["FINAL_TEXT_VALUE"],
"raw_content": os.environ["GENERATED_CONTENT_VALUE"],
"message_id": int(os.environ["MESSAGE_ID_VALUE"]),
"lobster_name": os.environ["LOBSTER_NAME_VALUE"],
"web_url": os.environ["WEB_URL_VALUE"],
"screenshot_url": os.environ["SCREENSHOT_URL_VALUE"],
"slot": os.environ["SLOT_VALUE"],
"is_sticker": os.environ.get("IS_STICKER_VALUE") == "true",
"is_wallpaper": os.environ.get("IS_WALLPAPER_VALUE") == "true",
"sticker_image_file": os.environ.get("STICKER_IMAGE_FILE_VALUE") or None,
}
print(json.dumps(output, ensure_ascii=False))
PY
)
echo "$GENERATE_ONLY_OUTPUT"
append_delivery_ledger "generate_only_output" "ok" "JSON output to stdout for agent delivery" "" "" "generate_only" "$MESSAGE_ID"
info "generate-only 完成,消息已输出到 stdout"
exit 0
fi
if [ "$OUTBOUND_ADAPTER" = "wecom-direct-message" ]; then
# ───────────────────────────────────────────
# 企微 CLI fallback:仅保留给手动调试/非 cron 场景
# 2.5.3 起不再作为企微 cron 主链路
# ───────────────────────────────────────────
WECOM_SEND_TARGET="-${WECOM_USER_ID:-${DELIVERY_TARGET:-}}"
WECOM_SEND_CHANNEL="-openclaw-wecom-bot"
if [ -z "$WECOM_SEND_TARGET" ]; then
LAST_ERROR="企业微信定时推送缺少 sender_id / wecom_user_id"
append_delivery_ledger "wecom_target_missing" "failed" "$LAST_ERROR" "$WECOM_SEND_CHANNEL" "" "wecom_cli_direct" "$MESSAGE_ID"
[ "$JOB_KIND" = "init-ready" ] && schedule_init_ready_retry "$INIT_READY_ATTEMPT_NUMBER" "$LAST_ERROR"
error "$LAST_ERROR"
fi
if [ "$IS_STICKER" = "true" ]; then
DELIVERED_TEXT="🦞 LOBSTER_NAME给你准备了一张图片礼物,先去工作室看看吧 → WEB_URL"
else
DELIVERED_TEXT=$(build_text_message true)
fi
step "企微 CLI fallback:openclaw message send → WECOM_SEND_CHANNEL → WECOM_SEND_TARGET..."
_SEND_STDERR_FILE=$(mktemp "-/tmp/lobster-send-stderr.XXXXXX")
SEND_RESULT=$(openclaw message send \
--channel "$WECOM_SEND_CHANNEL" \
--target "$WECOM_SEND_TARGET" \
--message "$DELIVERED_TEXT" 2>"$_SEND_STDERR_FILE")
SEND_RC=$?
SEND_STDERR=""
[ -f "$_SEND_STDERR_FILE" ] && SEND_STDERR=$(cat "$_SEND_STDERR_FILE" 2>/dev/null)
if [ $SEND_RC -eq 0 ]; then
# 检查假成功
if echo "$SEND_STDERR" | grep -qiE '(wsclient|websocket|not.connected|connection.refused|connection.reset|connection.timeout|no.active.session|failed.to.send|send.failed|delivery.failed)'; then
LAST_ERROR="企微 CLI 假成功(exit=0 但检测到连接问题): SEND_STDERR"
append_delivery_ledger "wecom_cli_direct" "failed" "$LAST_ERROR" "$WECOM_SEND_CHANNEL" "$WECOM_SEND_TARGET" "wecom_cli_direct" "$MESSAGE_ID"
[ "$JOB_KIND" = "init-ready" ] && schedule_init_ready_retry "$INIT_READY_ATTEMPT_NUMBER" "$LAST_ERROR"
error "$LAST_ERROR"
fi
DELIVERED_AT_UTC=$(date -u '+%Y-%m-%dT%H:%M:%SZ')
report_delivery "$MESSAGE_ID" "sent" "$WECOM_SEND_CHANNEL" "$WECOM_SEND_TARGET" "wecom_cli_direct" "$DELIVERED_TEXT" "$SCREENSHOT_URL" ""
append_delivery_ledger "wecom_cli_direct" "sent" "企微 CLI fallback 发送成功" "$WECOM_SEND_CHANNEL" "$WECOM_SEND_TARGET" "wecom_cli_direct" "$MESSAGE_ID"
update_init_ready_state "sent" "$INIT_READY_ATTEMPT_NUMBER" "$MESSAGE_ID" "" "$WECOM_SEND_CHANNEL" "$WECOM_SEND_TARGET"
[ "$JOB_KIND" = "init-ready" ] && finalize_managed_job
info "📮 企微 CLI fallback 发送成功: slot=SLOT message_id=MESSAGE_ID channel=WECOM_SEND_CHANNEL target=WECOM_SEND_TARGET"
echo "DELIVERY_OK slot=SLOT message_id=MESSAGE_ID channel=WECOM_SEND_CHANNEL mode=wecom_cli_direct" >&2
exit 0
else
LAST_ERROR="企微 CLI 发送失败 (rc=SEND_RC): SEND_STDERR"
report_delivery "$MESSAGE_ID" "failed" "$WECOM_SEND_CHANNEL" "$WECOM_SEND_TARGET" "wecom_cli_direct" "" "$SCREENSHOT_URL" "$LAST_ERROR"
append_delivery_ledger "wecom_cli_direct" "failed" "$LAST_ERROR" "$WECOM_SEND_CHANNEL" "$WECOM_SEND_TARGET" "wecom_cli_direct" "$MESSAGE_ID"
[ "$JOB_KIND" = "init-ready" ] && update_init_ready_state "delivery_failed" "$INIT_READY_ATTEMPT_NUMBER" "$MESSAGE_ID" "$LAST_ERROR" "$WECOM_SEND_CHANNEL" "$WECOM_SEND_TARGET"
[ "$JOB_KIND" = "init-ready" ] && schedule_init_ready_retry "$INIT_READY_ATTEMPT_NUMBER" "$LAST_ERROR"
error "$LAST_ERROR"
fi
fi
# ═══════════════════════════════════════════════
# 完整发送模式:多通道 fallback(Telegram 等通用通道)
# ═══════════════════════════════════════════════
step "扫描最近活跃的 IM 通道..."
if [ -n "$FORCED_CHANNEL" ] && [ -n "$FORCED_TARGET" ]; then
info "显式指定通道: FORCED_CHANNEL → FORCED_TARGET(最高优先级)"
fi
SESSIONS_JSON=$(openclaw sessions --json --active "$ACTIVE_WINDOW_MINUTES" 2>/dev/null || echo "[]")
CANDIDATE_LINES=$(build_candidate_lines "$SESSIONS_JSON" "$FORCED_CHANNEL" "$FORCED_TARGET")
if [ -z "$CANDIDATE_LINES" ]; then
LAST_ERROR="没有找到可用的投递通道。请先在 Telegram / 微信 / 飞书等任一通道里和虾说一句话,或在 setup-cron.sh 时指定 --channel 和 --to。"
append_delivery_ledger "candidate_resolve_failed" "failed" "$LAST_ERROR"
[ "$JOB_KIND" = "init-ready" ] && schedule_init_ready_retry "$INIT_READY_ATTEMPT_NUMBER" "$LAST_ERROR"
error "$LAST_ERROR"
fi
FIRST_CANDIDATE=$(echo "$CANDIDATE_LINES" | head -1)
info "本次优先尝试最近使用的通道:FIRST_CANDIDATE%%|*"
LAST_ERROR=""
LAST_CHANNEL=""
LAST_TARGET=""
SUCCESS_CHANNEL=""
SUCCESS_TARGET=""
SUCCESS_MODE=""
DELIVERED_TEXT=""
ATTEMPT_COUNT=0
_SEND_STDERR_FILE=$(mktemp "-/tmp/lobster-send-stderr.XXXXXX")
reliable_send() {
_SEND_FAIL_REASON=""
openclaw message send "$@" >"$_SEND_STDERR_FILE" 2>&1
local rc=$?
local stderr_content=""
[ -f "$_SEND_STDERR_FILE" ] && stderr_content=$(cat "$_SEND_STDERR_FILE" 2>/dev/null)
if [ $rc -ne 0 ]; then
_SEND_FAIL_REASON="exit_code=rc stderr_content"
return 1
fi
if echo "$stderr_content" | grep -qiE '(wsclient|websocket|not.connected|connection.refused|connection.reset|connection.timeout|no.active.session|failed.to.send|send.failed|delivery.failed)'; then
_SEND_FAIL_REASON="假成功(exit=0 但检测到连接问题): stderr_content"
return 1
fi
return 0
}
while IFS='|' read -r channel target; do
[ -z "$channel" ] && continue
[ -z "$target" ] && continue
ATTEMPT_COUNT=$((ATTEMPT_COUNT + 1))
LAST_CHANNEL="$channel"
LAST_TARGET="$target"
step "尝试投递到 channel → target (attempt #ATTEMPT_COUNT)"
append_delivery_ledger "channel_attempt" "running" "attempt=ATTEMPT_COUNT" "$channel" "$target" "" "$MESSAGE_ID"
if [ "$IS_STICKER" = "true" ] && [ -n "$STICKER_IMAGE_FILE" ] && [ -f "$STICKER_IMAGE_FILE" ]; then
STUDIO_ENTRY_LINE="👀 看看LOBSTER_NAME在干嘛 → WEB_URL"
if [ "$IS_WALLPAPER" = "true" ]; then
STICKER_TEXT="🦞 LOBSTER_NAME给你画了一张专属壁纸~
STUDIO_ENTRY_LINE"
MEDIA_LABEL="壁纸"
else
STICKER_TEXT="🦞 LOBSTER_NAME给你画了一张表情包~
STUDIO_ENTRY_LINE"
MEDIA_LABEL="表情包"
fi
if supports_media "$channel"; then
if reliable_send --channel "$channel" --to "$target" --message "$STICKER_TEXT"; then
if reliable_send --channel "$channel" --to "$target" --media "$STICKER_IMAGE_FILE"; then
SUCCESS_CHANNEL="$channel"
SUCCESS_TARGET="$target"
SUCCESS_MODE="media"
DELIVERED_TEXT="$STICKER_TEXT"
info "MEDIA_LABEL文本 + 图片发送成功"
break
fi
warn "channel MEDIA_LABEL图片发送失败 (_SEND_FAIL_REASON),降级为文字提示"
fi
fi
FALLBACK_TEXT="🦞 LOBSTER_NAME画了一张MEDIA_LABEL给你,不过这个通道暂时看不了图~去虾的工作室看看吧 → WEB_URL"
if reliable_send --channel "$channel" --to "$target" --message "$FALLBACK_TEXT"; then
SUCCESS_CHANNEL="$channel"
SUCCESS_TARGET="$target"
SUCCESS_MODE="url"
DELIVERED_TEXT="$FALLBACK_TEXT"
info "MEDIA_LABEL降级为文字提示发送成功"
break
fi
LAST_ERROR="channel MEDIA_LABEL所有模式都发送失败: _SEND_FAIL_REASON"
warn "$LAST_ERROR,继续回退下一个通道"
append_delivery_ledger "channel_attempt" "failed" "$LAST_ERROR" "$channel" "$target" "url" "$MESSAGE_ID"
continue
fi
if supports_media "$channel"; then
TEXT_MESSAGE=$(build_text_message false)
if reliable_send --channel "$channel" --target "$target" --message "$TEXT_MESSAGE"; then
if reliable_send --channel "$channel" --target "$target" --media "$SCREENSHOT_URL"; then
SUCCESS_CHANNEL="$channel"
SUCCESS_TARGET="$target"
SUCCESS_MODE="media"
DELIVERED_TEXT="$TEXT_MESSAGE"
info "文本 + 原生截图发送成功"
break
fi
warn "channel 原生截图发送失败 (_SEND_FAIL_REASON),降级为补发截图 URL"
URL_FALLBACK=$(build_text_message true)
if reliable_send --channel "$channel" --target "$target" --message "$URL_FALLBACK"; then
SUCCESS_CHANNEL="$channel"
SUCCESS_TARGET="$target"
SUCCESS_MODE="degraded_url"
DELIVERED_TEXT="$URL_FALLBACK"
info "已降级为文本 + 截图 URL"
break
fi
LAST_ERROR="channel 原生图片与 URL 降级都失败: _SEND_FAIL_REASON"
warn "$LAST_ERROR,继续回退下一个通道"
append_delivery_ledger "channel_attempt" "failed" "$LAST_ERROR" "$channel" "$target" "degraded_url" "$MESSAGE_ID"
continue
fi
LAST_ERROR="channel 文本消息发送失败: _SEND_FAIL_REASON"
warn "$LAST_ERROR,继续回退下一个通道"
append_delivery_ledger "channel_attempt" "failed" "$LAST_ERROR" "$channel" "$target" "text_only" "$MESSAGE_ID"
continue
fi
TEXT_MESSAGE=$(build_text_message true)
if reliable_send --channel "$channel" --to "$target" --message "$TEXT_MESSAGE"; then
SUCCESS_CHANNEL="$channel"
SUCCESS_TARGET="$target"
SUCCESS_MODE="url"
DELIVERED_TEXT="$TEXT_MESSAGE"
info "纯文本 + 截图 URL 发送成功"
break
fi
LAST_ERROR="channel 文本消息发送失败: _SEND_FAIL_REASON"
warn "$LAST_ERROR,继续回退下一个通道"
append_delivery_ledger "channel_attempt" "failed" "$LAST_ERROR" "$channel" "$target" "url" "$MESSAGE_ID"
done <<< "$CANDIDATE_LINES"
if [ -n "$SUCCESS_CHANNEL" ]; then
DELIVERED_AT_UTC=$(date -u '+%Y-%m-%dT%H:%M:%SZ')
report_delivery "$MESSAGE_ID" "sent" "$SUCCESS_CHANNEL" "$SUCCESS_TARGET" "$SUCCESS_MODE" "$DELIVERED_TEXT" "$SCREENSHOT_URL" ""
UPDATED_KNOWN=$(sync_known_channels_after_send "$SUCCESS_CHANNEL" "$SUCCESS_TARGET" "$CANDIDATE_LINES" "$DELIVERED_AT_UTC")
append_delivery_ledger "delivery_succeeded" "sent" "attempts=ATTEMPT_COUNT" "$SUCCESS_CHANNEL" "$SUCCESS_TARGET" "$SUCCESS_MODE" "$MESSAGE_ID"
update_init_ready_state "sent" "$INIT_READY_ATTEMPT_NUMBER" "$MESSAGE_ID" "" "$SUCCESS_CHANNEL" "$SUCCESS_TARGET"
[ "$JOB_KIND" = "init-ready" ] && finalize_managed_job
info "📮 送达成功: slot=SLOT message_id=MESSAGE_ID channel=SUCCESS_CHANNEL target=SUCCESS_TARGET mode=SUCCESS_MODE attempts=ATTEMPT_COUNT"
echo "DELIVERY_OK slot=SLOT message_id=MESSAGE_ID channel=SUCCESS_CHANNEL mode=SUCCESS_MODE" >&2
echo "KNOWN_CHANNELS=UPDATED_KNOWN" >&2
exit 0
fi
warn "📮 送达失败: slot=SLOT message_id=MESSAGE_ID last_channel=LAST_CHANNEL attempts=ATTEMPT_COUNT error=LAST_ERROR"
report_delivery "$MESSAGE_ID" "failed" "-$BOUND_CHANNEL" "-$BOUND_TARGET" "text_only" "" "$SCREENSHOT_URL" "$LAST_ERROR"
append_delivery_ledger "delivery_failed" "failed" "$LAST_ERROR" "-$BOUND_CHANNEL" "-$BOUND_TARGET" "text_only" "$MESSAGE_ID"
[ "$JOB_KIND" = "init-ready" ] && update_init_ready_state "delivery_failed" "$INIT_READY_ATTEMPT_NUMBER" "$MESSAGE_ID" "$LAST_ERROR" "-$BOUND_CHANNEL" "-$BOUND_TARGET"
[ "$JOB_KIND" = "init-ready" ] && schedule_init_ready_retry "$INIT_READY_ATTEMPT_NUMBER" "$LAST_ERROR"
error "所有候选通道都投递失败:-未知错误"
FILE:runtime-common.sh
#!/bin/bash
# 共享运行时辅助函数:候选通道构建、绑定策略判断、delivery contract 持久化、cron 状态收口。
lobster_detect_binding_mode() {
local channel
channel=$(printf '%s' "-" | tr '[:upper:]' '[:lower:]')
case "$channel" in
wecom*|*wecom*|wxwork*|qywx*|enterprisewechat*|enterprise-wechat*|workwechat*|wecom-bot*)
echo "strict"
;;
*)
echo "prefer"
;;
esac
}
lobster_runtime_policy_json() {
local config_path="$1"
CONFIG_PATH="$config_path" python3 <<'PY'
import json
import os
from pathlib import Path
STRICT_MARKERS = (
"wecom",
"wecom-bot",
"wxwork",
"qywx",
"enterprisewechat",
"enterprise-wechat",
"workwechat",
)
BOT_MARKERS = (
"wecom-bot",
"qywx-bot",
"wxwork-bot",
"enterprisewechat-bot",
"enterprise-wechat-bot",
"workwechat-bot",
)
def detect_mode(channel: str) -> str:
normalized = (channel or "").strip().lower()
if any(marker in normalized for marker in STRICT_MARKERS):
return "strict"
return "prefer"
def is_wecom_family(channel: str) -> bool:
return detect_mode(channel) == "strict"
def supports_openclaw_proactive(channel: str) -> bool:
normalized = (channel or "").strip().lower()
return any(marker in normalized for marker in BOT_MARKERS)
def default_adapter_for_binding(channel: str, current: str = "") -> str:
normalized = (current or "").strip().lower()
if normalized in {"openclaw", "wecom-webhook", "wecom-mcp"}:
return normalized
if is_wecom_family(channel):
return "wecom-mcp"
return "openclaw"
path = Path(os.environ["CONFIG_PATH"])
try:
config = json.loads(path.read_text(encoding="utf-8"))
except Exception:
config = {}
contract = config.get("delivery_contract") if isinstance(config.get("delivery_contract"), dict) else {}
cron_registration = config.get("cron_registration") if isinstance(config.get("cron_registration"), dict) else {}
binding_channel = str(
contract.get("binding_channel")
or config.get("binding_channel")
or config.get("channel")
or ""
).strip()
binding_target = str(
contract.get("binding_target")
or config.get("binding_target")
or config.get("chat_id")
or ""
).strip()
binding_mode = str(
contract.get("binding_mode")
or config.get("binding_mode")
or detect_mode(binding_channel)
).strip().lower() or "prefer"
if binding_mode not in {"prefer", "strict"}:
binding_mode = detect_mode(binding_channel)
outbound_adapter = default_adapter_for_binding(
binding_channel,
str(contract.get("outbound_adapter") or config.get("outbound_adapter") or ""),
)
outbound_webhook_url = str(
contract.get("outbound_webhook_url")
or config.get("outbound_webhook_url")
or ""
).strip()
outbound_webhook_secret = str(
contract.get("outbound_webhook_secret")
or config.get("outbound_webhook_secret")
or ""
).strip()
delivery_channel = str(
contract.get("delivery_channel")
or config.get("delivery_channel")
or ""
).strip()
delivery_target = str(
contract.get("delivery_target")
or config.get("delivery_target")
or ""
).strip()
strict_binding = binding_mode == "strict"
delivery_family = "wecom" if strict_binding or is_wecom_family(binding_channel) else "general"
delivery_ready = False
delivery_reason = ""
if delivery_family == "general":
if not delivery_channel:
delivery_channel = binding_channel
if not delivery_target:
delivery_target = binding_target
delivery_ready = bool(delivery_channel and delivery_target)
if not delivery_ready:
delivery_reason = "缺少投递目标"
else:
if outbound_adapter == "wecom-webhook":
if not delivery_channel:
delivery_channel = "wecom-webhook"
if not delivery_target:
delivery_target = binding_target or "webhook"
delivery_ready = bool(outbound_webhook_url)
if not delivery_ready:
delivery_reason = "企业微信 webhook 未配置"
elif outbound_adapter == "wecom-mcp":
if not delivery_channel or delivery_channel == "wecom-mcp":
delivery_channel = binding_channel
delivery_target = delivery_target or binding_target
delivery_ready = bool(delivery_channel and delivery_target)
if not delivery_ready:
if not delivery_channel:
delivery_reason = "企业微信长连接投递缺少 channel"
else:
delivery_reason = "企业微信长连接投递缺少 chat_id"
else:
proactive_delivery_channel = ""
proactive_delivery_target = ""
if supports_openclaw_proactive(delivery_channel):
proactive_delivery_channel = delivery_channel
proactive_delivery_target = delivery_target or (binding_target if delivery_channel == binding_channel else "")
elif supports_openclaw_proactive(binding_channel):
proactive_delivery_channel = binding_channel
proactive_delivery_target = delivery_target or binding_target
if proactive_delivery_channel:
delivery_channel = proactive_delivery_channel
delivery_target = proactive_delivery_target
delivery_ready = bool(delivery_channel and delivery_target)
if not delivery_ready:
delivery_reason = "企业微信机器人缺少 delivery_target"
else:
delivery_channel = ""
delivery_target = ""
if not binding_channel or not binding_target:
delivery_reason = "企业微信严格绑定缺少 binding_channel / binding_target"
else:
delivery_reason = "企业微信 strict family 缺少可用的主动推送配置;推荐在技能层从 inbound metadata 读取 sender_id 并传给 --wecom-user-id"
payload = {
"binding_channel": binding_channel,
"binding_target": binding_target,
"binding_mode": binding_mode,
"strict_binding": strict_binding,
"channel": str(config.get("channel") or "").strip(),
"chat_id": str(config.get("chat_id") or "").strip(),
"outbound_adapter": outbound_adapter,
"outbound_webhook_url": outbound_webhook_url,
"outbound_webhook_secret": outbound_webhook_secret,
"delivery_channel": delivery_channel,
"delivery_target": delivery_target,
"delivery_family": delivery_family,
"delivery_ready": delivery_ready,
"delivery_reason": delivery_reason,
"cron_status": str(cron_registration.get("status") or "unregistered").strip() or "unregistered",
"cron_registered": bool(config.get("cron_registered") or cron_registration.get("registered")),
}
print(json.dumps(payload, ensure_ascii=False))
PY
}
lobster_sync_delivery_contract() {
local config_path="$1"
local hinted_channel="-"
local hinted_target="-"
CONFIG_PATH="$config_path" HINTED_CHANNEL="$hinted_channel" HINTED_TARGET="$hinted_target" python3 <<'PY'
import json
import os
from datetime import datetime, timezone
from pathlib import Path
STRICT_MARKERS = (
"wecom",
"wecom-bot",
"wxwork",
"qywx",
"enterprisewechat",
"enterprise-wechat",
"workwechat",
)
BOT_MARKERS = (
"wecom-bot",
"qywx-bot",
"wxwork-bot",
"enterprisewechat-bot",
"enterprise-wechat-bot",
"workwechat-bot",
)
def detect_mode(channel: str) -> str:
normalized = (channel or "").strip().lower()
if any(marker in normalized for marker in STRICT_MARKERS):
return "strict"
return "prefer"
def is_wecom_family(channel: str) -> bool:
return detect_mode(channel) == "strict"
def supports_openclaw_proactive(channel: str) -> bool:
normalized = (channel or "").strip().lower()
return any(marker in normalized for marker in BOT_MARKERS)
def default_adapter_for_binding(channel: str, current: str = "") -> str:
normalized = (current or "").strip().lower()
if normalized in {"openclaw", "wecom-webhook", "wecom-mcp"}:
return normalized
if is_wecom_family(channel):
return "wecom-mcp"
return "openclaw"
def now_iso() -> str:
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
config_path = Path(os.environ["CONFIG_PATH"])
hinted_channel = (os.environ.get("HINTED_CHANNEL") or "").strip()
hinted_target = (os.environ.get("HINTED_TARGET") or "").strip()
try:
config = json.loads(config_path.read_text(encoding="utf-8"))
except Exception:
config = {}
existing_contract = config.get("delivery_contract") if isinstance(config.get("delivery_contract"), dict) else {}
if hinted_channel and hinted_target:
config["channel"] = hinted_channel
config["chat_id"] = hinted_target
config["binding_channel"] = hinted_channel
config["binding_target"] = hinted_target
binding_channel = str(
config.get("binding_channel")
or hinted_channel
or config.get("channel")
or existing_contract.get("binding_channel")
or ""
).strip()
binding_target = str(
config.get("binding_target")
or hinted_target
or config.get("chat_id")
or existing_contract.get("binding_target")
or ""
).strip()
binding_mode = str(
config.get("binding_mode")
or existing_contract.get("binding_mode")
or detect_mode(binding_channel)
).strip().lower() or "prefer"
if binding_mode not in {"prefer", "strict"}:
binding_mode = detect_mode(binding_channel)
outbound_adapter = default_adapter_for_binding(
binding_channel,
str(existing_contract.get("outbound_adapter") or config.get("outbound_adapter") or ""),
)
outbound_webhook_url = str(
existing_contract.get("outbound_webhook_url")
or config.get("outbound_webhook_url")
or ""
).strip()
outbound_webhook_secret = str(
existing_contract.get("outbound_webhook_secret")
or config.get("outbound_webhook_secret")
or ""
).strip()
delivery_family = "wecom" if binding_mode == "strict" or is_wecom_family(binding_channel) else "general"
delivery_channel = str(
existing_contract.get("delivery_channel")
or config.get("delivery_channel")
or ""
).strip()
delivery_target = str(
existing_contract.get("delivery_target")
or config.get("delivery_target")
or ""
).strip()
delivery_ready = False
delivery_reason = ""
if delivery_family == "general":
delivery_channel = hinted_channel or delivery_channel or binding_channel
delivery_target = hinted_target or delivery_target or binding_target
delivery_ready = bool(delivery_channel and delivery_target)
if not delivery_ready:
delivery_reason = "缺少投递目标"
else:
if outbound_adapter == "wecom-webhook":
delivery_channel = "wecom-webhook"
delivery_target = delivery_target or binding_target or "webhook"
delivery_ready = bool(outbound_webhook_url)
if not delivery_ready:
delivery_reason = "企业微信 webhook 未配置"
elif outbound_adapter == "wecom-mcp":
if not delivery_channel or delivery_channel == "wecom-mcp":
delivery_channel = binding_channel
delivery_target = delivery_target or binding_target
delivery_ready = bool(delivery_channel and delivery_target)
if not delivery_ready:
if not delivery_channel:
delivery_reason = "企业微信长连接投递缺少 channel"
else:
delivery_reason = "企业微信长连接投递缺少 chat_id"
else:
proactive_delivery_channel = ""
proactive_delivery_target = ""
if supports_openclaw_proactive(delivery_channel):
proactive_delivery_channel = delivery_channel
proactive_delivery_target = delivery_target or (binding_target if delivery_channel == binding_channel else "")
elif supports_openclaw_proactive(binding_channel):
proactive_delivery_channel = binding_channel
proactive_delivery_target = delivery_target or binding_target
if proactive_delivery_channel:
delivery_channel = proactive_delivery_channel
delivery_target = proactive_delivery_target
delivery_ready = bool(delivery_channel and delivery_target)
if not delivery_ready:
delivery_reason = "企业微信机器人缺少 delivery_target"
else:
delivery_channel = ""
delivery_target = ""
if not binding_channel or not binding_target:
delivery_reason = "企业微信严格绑定缺少 binding_channel / binding_target"
else:
delivery_reason = "企业微信 strict family 缺少可用的主动推送配置;推荐在技能层从 inbound metadata 读取 sender_id 并传给 --wecom-user-id"
contract = {
"binding_channel": binding_channel,
"binding_target": binding_target,
"binding_mode": binding_mode,
"strict_binding": binding_mode == "strict",
"delivery_family": delivery_family,
"outbound_adapter": outbound_adapter,
"outbound_webhook_url": outbound_webhook_url,
"outbound_webhook_secret": outbound_webhook_secret,
"delivery_channel": delivery_channel,
"delivery_target": delivery_target,
"delivery_ready": delivery_ready,
"delivery_reason": delivery_reason,
"updated_at": now_iso(),
}
config["binding_channel"] = binding_channel
config["binding_target"] = binding_target
config["binding_mode"] = binding_mode
config["outbound_adapter"] = outbound_adapter
if outbound_webhook_url:
config["outbound_webhook_url"] = outbound_webhook_url
if outbound_webhook_secret:
config["outbound_webhook_secret"] = outbound_webhook_secret
elif not outbound_webhook_url:
config.pop("outbound_webhook_secret", None)
if hinted_channel and hinted_target:
config["channel"] = hinted_channel
config["chat_id"] = hinted_target
elif binding_channel and binding_target:
config.setdefault("channel", binding_channel)
config.setdefault("chat_id", binding_target)
if delivery_channel and delivery_target:
config["delivery_channel"] = delivery_channel
config["delivery_target"] = delivery_target
else:
config.pop("delivery_channel", None)
config.pop("delivery_target", None)
config["delivery_contract"] = contract
config_path.write_text(json.dumps(config, indent=2, ensure_ascii=False), encoding="utf-8")
print(json.dumps(contract, ensure_ascii=False))
PY
}
lobster_update_cron_registration() {
local config_path="$1"
local status="$2"
local reason="-"
local morning_time="-"
local discovery_time="-"
local evening_time="-"
local memory_mode="-"
local managed_jobs_json="-[]"
CONFIG_PATH="$config_path" \
CRON_STATUS_VALUE="$status" \
CRON_REASON_VALUE="$reason" \
MORNING_TIME_VALUE="$morning_time" \
DISCOVERY_TIME_VALUE="$discovery_time" \
EVENING_TIME_VALUE="$evening_time" \
MEMORY_MODE_VALUE="$memory_mode" \
MANAGED_JOBS_JSON_VALUE="$managed_jobs_json" \
python3 <<'PY'
import json
import os
from datetime import datetime, timezone
from pathlib import Path
def now_iso() -> str:
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
config_path = Path(os.environ["CONFIG_PATH"])
try:
config = json.loads(config_path.read_text(encoding="utf-8"))
except Exception:
config = {}
status = (os.environ.get("CRON_STATUS_VALUE") or "unregistered").strip() or "unregistered"
reason = (os.environ.get("CRON_REASON_VALUE") or "").strip()
registered = status == "registered"
cron_registration = config.get("cron_registration") if isinstance(config.get("cron_registration"), dict) else {}
cron_registration["status"] = status
cron_registration["registered"] = registered
cron_registration["ready"] = registered
cron_registration["updated_at"] = now_iso()
if reason:
cron_registration["reason"] = reason
else:
cron_registration.pop("reason", None)
if registered:
cron_registration["last_registered_at"] = cron_registration["updated_at"]
try:
managed_jobs = json.loads(os.environ.get("MANAGED_JOBS_JSON_VALUE") or "[]")
if not isinstance(managed_jobs, list):
managed_jobs = []
except Exception:
managed_jobs = []
cron_registration["managed_job_names"] = [str(item).strip() for item in managed_jobs if str(item).strip()]
cron_registration["desired_schedule"] = {
"morning_time": (os.environ.get("MORNING_TIME_VALUE") or "").strip(),
"discovery_time": (os.environ.get("DISCOVERY_TIME_VALUE") or "").strip(),
"evening_time": (os.environ.get("EVENING_TIME_VALUE") or "").strip(),
"memory_mode": (os.environ.get("MEMORY_MODE_VALUE") or "").strip(),
}
config["cron_registration"] = cron_registration
config["cron_registered"] = registered
config_path.write_text(json.dumps(config, indent=2, ensure_ascii=False), encoding="utf-8")
print(json.dumps(cron_registration, ensure_ascii=False))
PY
}
build_candidate_lines_with_policy() {
local config_path="$1"
local sessions_json="$2"
local forced_channel="$3"
local forced_target="$4"
CONFIG_PATH="$config_path" \
SESSIONS_JSON="$sessions_json" \
FORCED_CHANNEL="$forced_channel" \
FORCED_TARGET="$forced_target" \
python3 <<'PY'
import json
import os
from pathlib import Path
STRICT_MARKERS = (
"wecom",
"wecom-bot",
"wxwork",
"qywx",
"enterprisewechat",
"enterprise-wechat",
"workwechat",
)
def detect_mode(channel: str) -> str:
normalized = (channel or "").strip().lower()
if any(marker in normalized for marker in STRICT_MARKERS):
return "strict"
return "prefer"
config_path = Path(os.environ["CONFIG_PATH"])
raw_sessions = os.environ.get("SESSIONS_JSON", "[]")
forced_channel = (os.environ.get("FORCED_CHANNEL") or "").strip()
forced_target = (os.environ.get("FORCED_TARGET") or "").strip()
try:
sessions = json.loads(raw_sessions)
if not isinstance(sessions, list):
sessions = [sessions]
except Exception:
sessions = []
try:
config = json.loads(config_path.read_text(encoding="utf-8"))
except Exception:
config = {}
contract = config.get("delivery_contract") if isinstance(config.get("delivery_contract"), dict) else {}
ordered = []
seen = set()
def add(channel, peer_id):
channel = (channel or "").strip()
peer_id = (peer_id or "").strip()
if not channel or not peer_id or channel == "unknown":
return
key = (channel, peer_id)
if key in seen:
return
seen.add(key)
ordered.append({"channel": channel, "peer_id": peer_id})
binding_channel = str(contract.get("binding_channel") or config.get("binding_channel") or config.get("channel") or "").strip()
binding_target = str(contract.get("binding_target") or config.get("binding_target") or config.get("chat_id") or "").strip()
binding_mode = str(contract.get("binding_mode") or config.get("binding_mode") or detect_mode(binding_channel)).strip().lower() or "prefer"
strict_binding = binding_mode == "strict"
delivery_channel = str(contract.get("delivery_channel") or config.get("delivery_channel") or "").strip()
delivery_target = str(contract.get("delivery_target") or config.get("delivery_target") or "").strip()
if forced_channel and forced_target:
add(forced_channel, forced_target)
if strict_binding:
add(delivery_channel or binding_channel, delivery_target or binding_target)
else:
add(delivery_channel or binding_channel, delivery_target or binding_target)
for session in sessions:
direct_channel = session.get("channel") or session.get("platform") or session.get("imChannel") or ""
direct_target = session.get("peer_id") or session.get("peerId") or session.get("target") or session.get("chat_id") or session.get("chatId") or ""
if direct_channel and direct_target:
add(direct_channel, direct_target)
continue
key = session.get("sessionKey") or session.get("key") or session.get("id") or ""
if not key:
continue
parts = key.split(":")
if not parts:
continue
if parts[0].lower() in ("cron", "hook"):
continue
if len(parts) <= 3 and parts[-1].lower() == "main":
continue
if len(parts) >= 5 and parts[0].lower() == "agent":
channel = parts[2]
marker = parts[3].lower()
if marker in ("direct", "dm"):
add(channel, parts[4])
for item in config.get("known_channels", []):
if isinstance(item, dict):
add(item.get("channel"), item.get("peer_id"))
add(config.get("channel"), config.get("chat_id"))
for item in ordered:
print(f"{item['channel']}|{item['peer_id']}")
PY
}
update_config_after_send_with_policy() {
local config_path="$1"
local current_channel="$2"
local current_target="$3"
local candidate_lines="$4"
local delivered_at="$5"
CONFIG_PATH="$config_path" \
CURRENT_CHANNEL="$current_channel" \
CURRENT_TARGET="$current_target" \
CANDIDATE_LINES="$candidate_lines" \
DELIVERED_AT="$delivered_at" \
python3 <<'PY'
import json
import os
from pathlib import Path
STRICT_MARKERS = (
"wecom",
"wecom-bot",
"wxwork",
"qywx",
"enterprisewechat",
"enterprise-wechat",
"workwechat",
)
def detect_mode(channel: str) -> str:
normalized = (channel or "").strip().lower()
if any(marker in normalized for marker in STRICT_MARKERS):
return "strict"
return "prefer"
config_path = Path(os.environ["CONFIG_PATH"])
current_channel = os.environ.get("CURRENT_CHANNEL", "").strip()
current_target = os.environ.get("CURRENT_TARGET", "").strip()
lines = [line.strip() for line in os.environ.get("CANDIDATE_LINES", "").splitlines() if line.strip()]
delivered_at = os.environ.get("DELIVERED_AT", "").strip()
try:
config = json.loads(config_path.read_text(encoding="utf-8"))
except Exception:
config = {}
contract = config.get("delivery_contract") if isinstance(config.get("delivery_contract"), dict) else {}
binding_channel = str(contract.get("binding_channel") or config.get("binding_channel") or config.get("channel") or "").strip()
binding_target = str(contract.get("binding_target") or config.get("binding_target") or config.get("chat_id") or "").strip()
binding_mode = str(contract.get("binding_mode") or config.get("binding_mode") or detect_mode(binding_channel)).strip().lower() or "prefer"
strict_binding = binding_mode == "strict"
ordered = []
seen = set()
def add(channel, peer_id):
channel = (channel or "").strip()
peer_id = (peer_id or "").strip()
if not channel or not peer_id:
return
key = (channel, peer_id)
if key in seen:
return
seen.add(key)
ordered.append({"channel": channel, "peer_id": peer_id})
add(current_channel, current_target)
if binding_channel and binding_target:
add(binding_channel, binding_target)
for line in lines:
channel, _, peer_id = line.partition("|")
add(channel, peer_id)
for item in config.get("known_channels", []):
if isinstance(item, dict):
add(item.get("channel"), item.get("peer_id"))
if strict_binding and binding_channel and binding_target:
config["channel"] = binding_channel
config["chat_id"] = binding_target
config["binding_channel"] = binding_channel
config["binding_target"] = binding_target
else:
if current_channel and current_target:
config["channel"] = current_channel
config["chat_id"] = current_target
config["binding_channel"] = current_channel
config["binding_target"] = current_target
config["binding_mode"] = binding_mode
config["known_channels"] = ordered
if current_channel and current_target:
config["last_delivery_channel"] = current_channel
config["last_delivery_target"] = current_target
if delivered_at:
config["last_delivery_at"] = delivered_at
contract["binding_channel"] = str(config.get("binding_channel") or binding_channel or "").strip()
contract["binding_target"] = str(config.get("binding_target") or binding_target or "").strip()
contract["binding_mode"] = binding_mode
if current_channel and current_target:
contract["delivery_channel"] = current_channel
contract["delivery_target"] = current_target
config["delivery_contract"] = contract
config_path.write_text(json.dumps(config, indent=2, ensure_ascii=False), encoding="utf-8")
print(json.dumps(config.get("known_channels", []), ensure_ascii=False))
PY
}
FILE:send-current-screenshot.sh
#!/bin/bash
# ═══════════════════════════════════════════════
# 虾说 — 互动场景截图发送脚本
# ═══════════════════════════════════════════════
#
# 数据访问声明:
# - 读取 .lobster-config(技能自身配置)
# - 调用 openclaw sessions --json 获取活跃 IM 通道元数据
# - 调用 openclaw message send 向 IM 通道发送截图摘要(统一使用 --target 参数)
# - 与 https://nixiashuo.com 通信:获取工作室短链和截图
# - 不读取 openclaw.json 配置文件,不提取 gateway token
#
# 设计目标:
# 1. 处理用户在 IM 中"发个图 / 看看虾在干嘛"这类即时截图请求
# 2. 自动优先命中最近活跃的 direct session 通道
# 3. Telegram/Discord/Slack 等支持媒体消息的通道优先发原生图片
# 4. 企业微信严格绑定模式走 sender_id 私聊(openclaw message send --channel openclaw-wecom-bot --to <sender_id>)
# 5. 明确禁止输出 <qqimg> / 本地临时文件路径给用户
#
# 用法:
# bash send-current-screenshot.sh
# bash send-current-screenshot.sh --caption "这是旺仔3号现在的样子~"
# bash send-current-screenshot.sh --with-status-summary
# bash send-current-screenshot.sh --channel telegram --to 123456789
set -euo pipefail
BASE_DIR="$(cd "$(dirname "$0")" && pwd)"
CONFIG_FILE="BASE_DIR/.lobster-config"
COMMON_SCRIPT="BASE_DIR/runtime-common.sh"
SETUP_CRON_SCRIPT="BASE_DIR/setup-cron.sh"
ACTIVE_WINDOW_MINUTES="-10080"
# Telegram 原生 Bot API 媒体能力稳定;企微/飞书等机器人协议更依赖 webhook 特定图片格式或素材上传,
# 统一经 OpenClaw CLI 时优先退化到文本 + 链接更稳妥。
MEDIA_SEND_CHANNELS="telegram discord googlechat slack mattermost signal imessage msteams"
OPENCLAW_BIN="-openclaw"
CAPTION=""
FORCED_CHANNEL=""
FORCED_TARGET=""
WITH_STATUS_SUMMARY=false
LOCAL_SCREENSHOT_FILE=""
_SEND_STDERR_FILE=""
POLICY_JSON=""
BOUND_CHANNEL=""
BOUND_TARGET=""
STRICT_BINDING="0"
OUTBOUND_ADAPTER="openclaw"
OUTBOUND_WEBHOOK_URL=""
OUTBOUND_WEBHOOK_SECRET=""
DELIVERY_CHANNEL=""
DELIVERY_TARGET=""
DELIVERY_READY="0"
DELIVERY_REASON=""
CRON_STATUS="unregistered"
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
CYAN='\033[0;36m'
NC='\033[0m'
cleanup() {
if [ -n "-" ] && [ -f "-" ]; then
rm -f "LOCAL_SCREENSHOT_FILE"
fi
if [ -n "-" ] && [ -f "-" ]; then
rm -f "_SEND_STDERR_FILE"
fi
}
trap cleanup EXIT
info() { echo -e "GREEN[✓]NC $1"; }
warn() { echo -e "YELLOW[!]NC $1"; }
error() { echo -e "RED[✗]NC $1"; exit 1; }
step() { echo -e "CYAN──NC $1"; }
while [[ $# -gt 0 ]]; do
case "$1" in
--caption) CAPTION="$2"; shift 2 ;;
--channel) FORCED_CHANNEL="$2"; shift 2 ;;
--to|--target) FORCED_TARGET="$2"; shift 2 ;;
--with-status-summary) WITH_STATUS_SUMMARY=true; shift ;;
*) echo "未知参数: $1"; exit 1 ;;
esac
done
for cmd in python3 curl; do
if ! command -v "$cmd" >/dev/null 2>&1; then
error "$cmd 不可用"
fi
done
if ! command -v "$OPENCLAW_BIN" >/dev/null 2>&1; then
error "$OPENCLAW_BIN 不可用"
fi
if [ ! -f "$CONFIG_FILE" ]; then
error ".lobster-config 不存在,请先初始化虾"
fi
if [ ! -f "$COMMON_SCRIPT" ]; then
error "共享运行时脚本不存在:COMMON_SCRIPT"
fi
. "$COMMON_SCRIPT"
read_config_value() {
local key="$1"
local default_value="-"
CONFIG_PATH="$CONFIG_FILE" KEY_NAME="$key" DEFAULT_VALUE="$default_value" python3 - <<'PY'
import json
import os
from pathlib import Path
path = Path(os.environ["CONFIG_PATH"])
key = os.environ["KEY_NAME"]
default = os.environ.get("DEFAULT_VALUE", "")
try:
data = json.loads(path.read_text(encoding="utf-8"))
except Exception:
data = {}
value = data.get(key, default)
print(value if value is not None else default)
PY
}
supports_media() {
local channel="$1"
for ch in $MEDIA_SEND_CHANNELS; do
if [ "$ch" = "$channel" ]; then
return 0
fi
done
return 1
}
fetch_status_summary_text() {
local api_base="$1"
local user_id="$2"
local access_token="$3"
local fallback_name="$4"
local status_json
status_json="$(curl -fsS "api_base/api/lobster/user_id/status" \
-H "Authorization: Bearer access_token")" || return 1
STATUS_JSON="$status_json" STATUS_FALLBACK_NAME="$fallback_name" python3 - <<'PYTHON'
import json
import os
import sys
STATUS_LABELS = {
"idle": "待命",
"working": "忙活",
"sleeping": "睡着",
"daydreaming": "发呆",
"slacking": "摸鱼",
"running": "乱窜",
"crazy": "发疯",
"excited": "兴奋",
}
def clean(text):
return str(text or "").replace("<think>", "").replace("</think>", "").strip()
try:
data = json.loads(os.environ.get("STATUS_JSON") or "{}")
except Exception:
sys.exit(1)
name = clean(data.get("name")) or os.environ.get("STATUS_FALLBACK_NAME") or "这只虾"
status = clean(data.get("status")) or "idle"
status_label = STATUS_LABELS.get(status, status)
reason = clean(data.get("status_reason"))
latest_message = clean(data.get("latest_message"))
lines = [f"{name}现在在{status_label}。", "", f"• 当前状态:{status}"]
if reason:
lines.append(f"• 状态说明:{reason}")
if latest_message:
lines.extend(["• 它最新一句是:", "", latest_message])
print("\n".join(lines))
PYTHON
}
fetch_studio_links() {
local api_base="$1"
local user_id="$2"
local access_token="$3"
local links_json
links_json="$(curl -fsS "api_base/api/lobster/user_id/studio-link" \
-H "Authorization: Bearer access_token")" || return 1
LINKS_JSON="$links_json" python3 - <<'PYLINK'
import json
import os
import sys
try:
data = json.loads(os.environ.get("LINKS_JSON") or "{}")
except Exception:
sys.exit(1)
web_url = str(data.get("web_url") or "").strip()
screenshot_url = str(data.get("screenshot_url") or "").strip()
if not web_url or not screenshot_url:
sys.exit(1)
print(f"{web_url}|{screenshot_url}")
PYLINK
}
fetch_local_screenshot_file() {
local api_base="$1"
local user_id="$2"
local access_token="$3"
local media_dir="HOME/.openclaw/media"
local output_file
mkdir -p "$media_dir" || return 1
output_file="$(mktemp "media_dir/lobster-screenshot.XXXXXX.png")" || return 1
if ! curl -fsS "api_base/api/lobster/user_id/screenshot.png" \
-H "Authorization: Bearer access_token" \
-o "$output_file"; then
rm -f "$output_file"
return 1
fi
if [ ! -s "$output_file" ]; then
rm -f "$output_file"
return 1
fi
echo "$output_file"
}
load_runtime_policy() {
POLICY_JSON=$(lobster_runtime_policy_json "$CONFIG_FILE")
mapfile -t POLICY_LINES < <(POLICY_JSON_VALUE="$POLICY_JSON" python3 <<'PY'
import json
import os
policy = json.loads(os.environ["POLICY_JSON_VALUE"])
print(policy.get("binding_channel", ""))
print(policy.get("binding_target", ""))
print("1" if policy.get("strict_binding") else "0")
print(policy.get("outbound_adapter", "openclaw"))
print(policy.get("outbound_webhook_url", ""))
print(policy.get("outbound_webhook_secret", ""))
print(policy.get("delivery_channel", ""))
print(policy.get("delivery_target", ""))
print("1" if policy.get("delivery_ready") else "0")
print(policy.get("delivery_reason", ""))
print(policy.get("cron_status", "unregistered"))
PY
)
BOUND_CHANNEL="-"
BOUND_TARGET="-"
STRICT_BINDING="-0"
OUTBOUND_ADAPTER="-openclaw"
OUTBOUND_WEBHOOK_URL="-"
OUTBOUND_WEBHOOK_SECRET="-"
DELIVERY_CHANNEL="-"
DELIVERY_TARGET="-"
DELIVERY_READY="-0"
DELIVERY_REASON="-"
CRON_STATUS="-unregistered"
}
sync_delivery_contract_state() {
lobster_sync_delivery_contract "$CONFIG_FILE" "$FORCED_CHANNEL" "$FORCED_TARGET" >/dev/null
load_runtime_policy
}
reliable_send() {
_SEND_FAIL_REASON=""
if [ -z "-" ]; then
_SEND_STDERR_FILE=$(mktemp "-/tmp/lobster-send-stderr.XXXXXX")
fi
"$OPENCLAW_BIN" message send "$@" >"$_SEND_STDERR_FILE" 2>&1
local rc=$?
local stderr_content=""
[ -f "$_SEND_STDERR_FILE" ] && stderr_content=$(cat "$_SEND_STDERR_FILE" 2>/dev/null)
if [ $rc -ne 0 ]; then
_SEND_FAIL_REASON="exit_code=rc stderr_content"
return 1
fi
if echo "$stderr_content" | grep -qiE '(wsclient|websocket|not.connected|connection.refused|connection.reset|connection.timeout|no.active.session|failed.to.send|send.failed|delivery.failed)'; then
_SEND_FAIL_REASON="假成功(exit=0 但检测到连接问题): stderr_content"
return 1
fi
return 0
}
send_via_wecom_direct_message() {
local message_text="$1"
local wecom_user_id
wecom_user_id=$(read_config_value "wecom_user_id")
[ -n "$wecom_user_id" ] || { _SEND_FAIL_REASON="企业微信截图发送缺少 sender_id / wecom_user_id"; return 1; }
# 使用 openclaw message send --target 统一参数
local send_result rc
send_result=$("$OPENCLAW_BIN" message send \
--channel "openclaw-wecom-bot" \
--target "$wecom_user_id" \
--message "$message_text" 2>&1)
rc=$?
if [ $rc -ne 0 ]; then
_SEND_FAIL_REASON="openclaw message send 失败 (rc=rc): send_result"
return 1
fi
info "企业微信截图已直接送达 → wecom_user_id"
_SEND_FAIL_REASON=""
return 0
}
build_fallback_message() {
local channel="$1"
FALLBACK_CHANNEL="$channel" TEXT_CAPTION_VALUE="$TEXT_CAPTION" LOBSTER_NAME_VALUE="$LOBSTER_NAME" SCREENSHOT_URL_VALUE="$SCREENSHOT_URL" WEB_URL_VALUE="$WEB_URL" python3 <<'PY'
import os
channel = os.environ.get("FALLBACK_CHANNEL", "")
caption = (os.environ.get("TEXT_CAPTION_VALUE") or "").strip()
lobster_name = os.environ["LOBSTER_NAME_VALUE"]
screenshot_url = os.environ["SCREENSHOT_URL_VALUE"]
web_url = os.environ["WEB_URL_VALUE"]
if not caption:
caption = f"给你看看{lobster_name}现在在忙什么。"
lines = [caption]
if screenshot_url:
lines.extend(["", f"📸 {lobster_name}的工作室截图:{screenshot_url}"])
elif channel == "openclaw-weixin":
lines.extend(["", "📸 当前会话暂时没有可访问的截图链接,我先把状态告诉你。"])
if web_url:
lines.append(f"👀 看看{lobster_name}在干嘛 → {web_url}")
print("\n".join(lines))
PY
}
maybe_activate_pending_cron() {
if [ "$DELIVERY_READY" = "1" ] && [ "$CRON_STATUS" = "pending_activation" ] && [ -f "$SETUP_CRON_SCRIPT" ]; then
step "检测到待激活 cron,尝试自动补注册..."
if bash "$SETUP_CRON_SCRIPT" >/dev/null 2>&1; then
info "待激活的定时推送已自动补注册"
else
warn "自动补注册 cron 失败,可稍后手动执行 setup-cron.sh"
fi
fi
}
LOBSTER_NAME="$(read_config_value lobster_name 小虾)"
USER_ID="$(read_config_value user_id)"
ACCESS_TOKEN="$(read_config_value access_token)"
API_BASE="$(read_config_value api_base https://nixiashuo.com)"
if [ -z "$USER_ID" ] || [ -z "$ACCESS_TOKEN" ]; then
error ".lobster-config 缺少 user_id / access_token"
fi
if [ -n "$FORCED_CHANNEL" ] && [ -z "$FORCED_TARGET" ]; then
error "指定 --channel 时必须同时指定 --to/--target"
fi
if [ -n "$FORCED_TARGET" ] && [ -z "$FORCED_CHANNEL" ]; then
error "指定 --to/--target 时必须同时指定 --channel"
fi
sync_delivery_contract_state
if [ "$STRICT_BINDING" = "1" ]; then
info "当前为严格绑定模式:-未锁定 → -未锁定"
fi
if STUDIO_LINKS="$(fetch_studio_links "$API_BASE" "$USER_ID" "$ACCESS_TOKEN" 2>/dev/null)"; then
WEB_URL="STUDIO_LINKS%%|*"
SCREENSHOT_URL="STUDIO_LINKS#*|"
else
warn "短时工作室链接获取失败,不再回退长期 token URL。"
WEB_URL=""
SCREENSHOT_URL=""
if LOCAL_SCREENSHOT_FILE="$(fetch_local_screenshot_file "$API_BASE" "$USER_ID" "$ACCESS_TOKEN" 2>/dev/null)"; then
info "已生成受控本地截图,将优先尝试原生图片发送。"
else
warn "本地截图兜底也失败,将仅发送文本状态提示。"
fi
fi
TEXT_CAPTION="-这是${LOBSTER_NAME现在的样子~}"
if [ "$WITH_STATUS_SUMMARY" = true ]; then
step "拉取当前状态摘要..."
if STATUS_SUMMARY="$(fetch_status_summary_text "$API_BASE" "$USER_ID" "$ACCESS_TOKEN" "$LOBSTER_NAME")"; then
TEXT_CAPTION="$STATUS_SUMMARY"
else
warn "状态摘要拉取失败,退回默认截图文案"
fi
fi
LAST_ERROR=""
SUCCESS_CHANNEL=""
SUCCESS_TARGET=""
SUCCESS_MODE=""
CANDIDATE_LINES=""
if [ "$STRICT_BINDING" = "1" ]; then
step "通过企业微信 sender_id 私聊发送截图摘要..."
FALLBACK_MESSAGE="$(build_fallback_message "wecom")"
if send_via_wecom_direct_message "$FALLBACK_MESSAGE"; then
WECOM_USER_ID_RESOLVED="$(read_config_value wecom_user_id)"
SUCCESS_CHANNEL="wecom"
SUCCESS_TARGET="-wecom_direct"
if [ -n "$SCREENSHOT_URL" ]; then
SUCCESS_MODE="wecom_direct_url"
else
SUCCESS_MODE="wecom_direct_text"
fi
else
LAST_ERROR="_SEND_FAIL_REASON"
fi
elif [ "$OUTBOUND_ADAPTER" = "openclaw" ] || [ "$OUTBOUND_ADAPTER" = "wecom-direct-message" ]; then
step "解析候选通道..."
SESSIONS_JSON="$($OPENCLAW_BIN sessions --json --active "$ACTIVE_WINDOW_MINUTES" 2>/dev/null || echo '[]')"
CANDIDATE_LINES="$(build_candidate_lines_with_policy "$CONFIG_FILE" "$SESSIONS_JSON" "$FORCED_CHANNEL" "$FORCED_TARGET")"
if [ -z "$CANDIDATE_LINES" ]; then
error "没有找到可用的投递通道。请先在 Telegram / 微信 / 飞书等任一通道里和虾说一句话。"
fi
while IFS='|' read -r channel target; do
[ -z "$channel" ] && continue
[ -z "$target" ] && continue
step "尝试发送到 channel → target"
if supports_media "$channel"; then
if reliable_send --channel "$channel" --target "$target" --message "$TEXT_CAPTION"; then
MEDIA_SOURCE="$SCREENSHOT_URL"
if [ -z "$MEDIA_SOURCE" ] && [ -n "$LOCAL_SCREENSHOT_FILE" ]; then
MEDIA_SOURCE="$LOCAL_SCREENSHOT_FILE"
fi
if [ -n "$MEDIA_SOURCE" ] && reliable_send --channel "$channel" --target "$target" --media "$MEDIA_SOURCE"; then
SUCCESS_CHANNEL="$channel"
SUCCESS_TARGET="$target"
SUCCESS_MODE="media"
info "文本 + 原生截图发送成功"
break
fi
warn "channel 原生截图发送失败 (_SEND_FAIL_REASON),降级为文本提示"
FALLBACK_MESSAGE="$(build_fallback_message "$channel")"
if reliable_send --channel "$channel" --target "$target" --message "$FALLBACK_MESSAGE"; then
SUCCESS_CHANNEL="$channel"
SUCCESS_TARGET="$target"
if [ -n "$SCREENSHOT_URL" ]; then
SUCCESS_MODE="degraded_url"
info "已降级为文本 + 截图 URL"
else
SUCCESS_MODE="text_only"
info "已降级为纯文本提示"
fi
break
fi
LAST_ERROR="channel 原生截图与文本降级都失败: _SEND_FAIL_REASON"
warn "$LAST_ERROR,继续尝试下一个通道"
continue
fi
LAST_ERROR="channel 文本消息发送失败: _SEND_FAIL_REASON"
warn "$LAST_ERROR,继续尝试下一个通道"
continue
fi
FALLBACK_MESSAGE="$(build_fallback_message "$channel")"
if reliable_send --channel "$channel" --target "$target" --message "$FALLBACK_MESSAGE"; then
SUCCESS_CHANNEL="$channel"
SUCCESS_TARGET="$target"
if [ -n "$SCREENSHOT_URL" ]; then
SUCCESS_MODE="url"
info "纯文本 + 截图 URL 发送成功"
else
SUCCESS_MODE="text_only"
info "纯文本状态提示发送成功"
fi
break
fi
LAST_ERROR="channel 文本消息发送失败: _SEND_FAIL_REASON"
warn "$LAST_ERROR,继续尝试下一个通道"
done <<< "$CANDIDATE_LINES"
fi
if [ -z "$SUCCESS_CHANNEL" ]; then
error "所有候选通道都投递失败:-未知错误"
fi
UPDATED_KNOWN="$(update_config_after_send_with_policy "$CONFIG_FILE" "$SUCCESS_CHANNEL" "$SUCCESS_TARGET" "$CANDIDATE_LINES" "$(date -u '+%Y-%m-%dT%H:%M:%SZ')")"
info "截图已送达:SUCCESS_CHANNEL → SUCCESS_TARGET (SUCCESS_MODE)"
maybe_activate_pending_cron
echo "SCREENSHOT_SENT channel=SUCCESS_CHANNEL target=SUCCESS_TARGET mode=SUCCESS_MODE"
echo "KNOWN_CHANNELS=UPDATED_KNOWN"
FILE:send-studio-link.sh
#!/bin/bash
# ═══════════════════════════════════════════════
# 虾说 — 工作室短链获取脚本
# ═══════════════════════════════════════════════
#
# 设计目标:
# 1. 用户在 IM 里索要“工作室链接”时,强制实时获取 fresh 短链
# 2. 避免 agent 复用历史对话中的旧 URL,或手工拼接 /lobster/{user_id}?st=...
# 3. 默认输出可直接回给用户的文本;也支持 plain/json 供脚本链路复用
#
# 用法:
# bash send-studio-link.sh
# bash send-studio-link.sh --plain-url
# bash send-studio-link.sh --json
set -euo pipefail
BASE_DIR="$(cd "$(dirname "$0")" && pwd)"
CONFIG_FILE="BASE_DIR/.lobster-config"
OUTPUT_MODE="message"
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m'
info() { echo -e "GREEN[✓]NC $1" >&2; }
warn() { echo -e "YELLOW[!]NC $1" >&2; }
error() { echo -e "RED[✗]NC $1" >&2; exit 1; }
while [[ $# -gt 0 ]]; do
case "$1" in
--plain-url)
OUTPUT_MODE="plain"
shift
;;
--json)
OUTPUT_MODE="json"
shift
;;
*)
error "未知参数: $1"
;;
esac
done
for cmd in python3 curl; do
command -v "$cmd" >/dev/null 2>&1 || error "$cmd 不可用"
done
[ -f "$CONFIG_FILE" ] || error ".lobster-config 不存在,请先初始化虾"
read_config_value() {
local key="$1"
local default_value="-"
CONFIG_PATH="$CONFIG_FILE" KEY_NAME="$key" DEFAULT_VALUE="$default_value" python3 - <<'PY'
import json
import os
from pathlib import Path
path = Path(os.environ["CONFIG_PATH"])
key = os.environ["KEY_NAME"]
default = os.environ.get("DEFAULT_VALUE", "")
try:
data = json.loads(path.read_text(encoding="utf-8"))
except Exception:
data = {}
value = data.get(key, default)
print(value if value is not None else default)
PY
}
LOBSTER_NAME="$(read_config_value lobster_name 小虾)"
USER_ID="$(read_config_value user_id)"
ACCESS_TOKEN="$(read_config_value access_token)"
API_BASE="$(read_config_value api_base https://nixiashuo.com)"
[ -n "$USER_ID" ] || error ".lobster-config 缺少 user_id"
[ -n "$ACCESS_TOKEN" ] || error ".lobster-config 缺少 access_token"
info "正在实时获取 fresh 工作室短链..."
LINKS_JSON="$(curl -fsS "API_BASE/api/lobster/USER_ID/studio-link" -H "Authorization: Bearer ACCESS_TOKEN")" || error "工作室短链获取失败"
OUTPUT_MODE_VALUE="$OUTPUT_MODE" LOBSTER_NAME_VALUE="$LOBSTER_NAME" LINKS_JSON_VALUE="$LINKS_JSON" python3 - <<'PY'
import json
import os
import sys
mode = os.environ["OUTPUT_MODE_VALUE"]
lobster_name = os.environ.get("LOBSTER_NAME_VALUE") or "这只虾"
try:
data = json.loads(os.environ.get("LINKS_JSON_VALUE") or "{}")
except Exception:
sys.exit(1)
web_url = str(data.get("web_url") or "").strip()
screenshot_url = str(data.get("screenshot_url") or "").strip()
expires_at = str(data.get("expires_at") or "").strip()
if not web_url:
sys.exit(1)
if mode == "plain":
print(web_url)
sys.exit(0)
if mode == "json":
print(json.dumps({
"web_url": web_url,
"screenshot_url": screenshot_url,
"expires_at": expires_at,
}, ensure_ascii=False, indent=2))
sys.exit(0)
lines = [
f"给你,{lobster_name}的 fresh 工作室短链:",
"",
web_url,
"",
"这是短时 st 入口,到期我可以再给你刷新。",
]
print("\n".join(lines))
PY
FILE:setup-cron.sh
#!/bin/bash
# 虾说 — 定时推送 Cron 注册脚本
#
# 数据访问声明:
# - 读取 .lobster-config(技能自身配置)
# - 调用 openclaw sessions --json 获取活跃 IM 通道元数据
# - 调用 openclaw cron add/remove/list 管理定时任务
# - 不读取 openclaw.json 配置文件,不提取 gateway token
# - gateway 认证仅通过用户显式设置的 OPENCLAW_GATEWAY_TOKEN 环境变量
#
# 重构设计(v2.5.3 可测版):
# - 企微通道:cron 注册带 --channel openclaw-wecom-bot --to <sender_id>(delivery 兜底)
# agent prompt 只做两件事:执行 push-scheduled-message.sh --emit-message-text 取得最终文本,
# 然后必须使用 message 工具发送私聊;不再依赖脚本内 CLI 直发
# - 通用通道(Telegram等):仍由脚本完整模式执行,多通道 fallback + delivery 兜底
# - openclaw cron add 继续使用 --to;但 openclaw message send 一律使用 --target,避免 CLI 参数漂移
set -u
ACTION="reconcile"
CHANNEL=""
TO=""
WECOM_USER_ID=""
MORNING_TIME=""
DISCOVERY_TIME=""
EVENING_TIME=""
MEMORY_MODE=""
REMOVE_JOB_NAME=""
SCHEDULE_INIT_READY_DELAY=""
INIT_READY_DELAY_MINUTES="-3"
INIT_READY_MIN_LEAD_MINUTES="-2"
INIT_READY_VERIFY_ATTEMPTS="-3"
ACTIVE_WINDOW_MINUTES="-10080"
BASE_DIR="$(cd "$(dirname "$0")" && pwd)"
CONFIG_FILE="BASE_DIR/.lobster-config"
COMMON_SCRIPT="BASE_DIR/runtime-common.sh"
LOG_DIR="BASE_DIR/logs"
RUN_TS="$(date +%Y%m%d-%H%M%S)"
LOG_FILE="LOG_DIR/setup-cron-RUN_TS.log"
PUSH_SCRIPT="BASE_DIR/push-scheduled-message.sh"
OPENCLAW_CRON_ARGS=()
INIT_READY_JOB_NAME="lobster-says-init-ready"
DETECTED_KNOWN_JSON="[]"
DELIVERY_CHANNEL=""
DELIVERY_TARGET=""
DELIVERY_READY="0"
DELIVERY_REASON=""
DELIVERY_FAMILY="general"
OUTBOUND_ADAPTER="openclaw"
BINDING_CHANNEL=""
BINDING_TARGET=""
BINDING_MODE="prefer"
MANAGED_JOB_NAMES=(
"lobster-says-morning"
"lobster-says-discovery"
"lobster-says-evening"
"lobster-says-init-ready"
"lobster-says-sticker"
"lobster-says-wallpaper"
"lobster-says-digest"
)
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
CYAN='\033[0;36m'
NC='\033[0m'
info() { echo -e "GREEN[✓]NC $1"; }
warn() { echo -e "YELLOW[!]NC $1"; }
error() { echo -e "RED[✗]NC $1"; exit 1; }
CURRENT_STEP="启动"
step() { CURRENT_STEP="$1"; echo -e "CYAN──NC $1"; }
if [ ! -f "$COMMON_SCRIPT" ]; then
error "共享运行时脚本不存在:COMMON_SCRIPT"
fi
. "$COMMON_SCRIPT"
mkdir -p "$LOG_DIR"
if command -v tee >/dev/null 2>&1; then
exec > >(tee -a "$LOG_FILE") 2>&1
else
exec >>"$LOG_FILE" 2>&1
fi
echo "[log] setup-cron started at $(date '+%F %T')"
echo "[log] file: LOG_FILE"
after_exit() {
local exit_code=$?
if [ "$exit_code" -ne 0 ]; then
echo ""
echo "[log] setup-cron failed at step: CURRENT_STEP (exit=exit_code)"
echo "[log] inspect: LOG_FILE"
else
echo "[log] setup-cron finished successfully"
fi
}
trap after_exit EXIT
while [[ $# -gt 0 ]]; do
case "$1" in
--channel) CHANNEL="$2"; shift 2 ;;
--to) TO="$2"; shift 2 ;;
--morning) MORNING_TIME="$2"; shift 2 ;;
--discovery) DISCOVERY_TIME="$2"; shift 2 ;;
--evening) EVENING_TIME="$2"; shift 2 ;;
--memory-mode) MEMORY_MODE="$2"; shift 2 ;;
--wecom-user-id) WECOM_USER_ID="$2"; shift 2 ;;
--remove-job-by-name) ACTION="remove-job-by-name"; REMOVE_JOB_NAME="$2"; shift 2 ;;
--schedule-init-ready-delay) ACTION="schedule-init-ready-delay"; SCHEDULE_INIT_READY_DELAY="$2"; shift 2 ;;
*) echo "未知参数: $1"; exit 1 ;;
esac
done
for cmd in openclaw python3; do
if ! command -v "$cmd" >/dev/null 2>&1; then
error "$cmd 不可用"
fi
done
if [ ! -f "$CONFIG_FILE" ]; then
error ".lobster-config 不存在,请先完成虾的初始化"
fi
if [ ! -f "$PUSH_SCRIPT" ]; then
error "运行时推送脚本不存在:PUSH_SCRIPT"
fi
read_config_value() {
local key="$1"
local default_value="-"
CONFIG_PATH="$CONFIG_FILE" KEY_NAME="$key" DEFAULT_VALUE="$default_value" python3 - <<'PY'
import json
import os
from pathlib import Path
path = Path(os.environ["CONFIG_PATH"])
key = os.environ["KEY_NAME"]
default = os.environ.get("DEFAULT_VALUE", "")
try:
data = json.loads(path.read_text(encoding="utf-8"))
value = data.get(key, default)
print(value if value is not None else default)
except Exception:
print(default)
PY
}
load_gateway_auth() {
OPENCLAW_CRON_ARGS=()
local token="-"
if [ -n "$token" ]; then
OPENCLAW_CRON_ARGS+=(--token "$token")
fi
}
list_managed_jobs() {
local raw
raw=$(openclaw cron list "OPENCLAW_CRON_ARGS[@]" --json 2>/dev/null || echo "")
CRON_DATA="$raw" python3 <<'PY'
import json
import os
import sys
raw = os.environ.get("CRON_DATA", "")
if not raw.strip():
raise SystemExit(0)
json_str = None
for i, ch in enumerate(raw):
if ch in ("{", "["):
json_str = raw[i:]
break
if not json_str:
raise SystemExit(0)
try:
data = json.loads(json_str)
except Exception:
raise SystemExit(0)
jobs = data.get("jobs", []) if isinstance(data, dict) else data if isinstance(data, list) else []
target_names = {
"lobster-says-morning",
"lobster-says-discovery",
"lobster-says-evening",
"lobster-says-init-ready",
"lobster-says-sticker",
"lobster-says-wallpaper",
"lobster-says-digest",
}
for job in jobs:
if isinstance(job, dict) and job.get("name") in target_names and job.get("id"):
print(f"{job['name']}|{job['id']}")
PY
}
remove_job_by_name() {
local name="$1"
local found=0
while IFS='|' read -r job_name job_id; do
[ -z "$job_id" ] && continue
[ "$job_name" = "$name" ] || continue
found=1
warn "删除旧任务: job_name (job_id)"
openclaw cron remove "$job_id" >/dev/null 2>&1 || warn "删除失败: job_name (job_id)"
done <<< "$(list_managed_jobs)"
if [ "$found" -eq 0 ]; then
info "没有找到任务: name"
fi
}
remove_all_managed_jobs() {
local jobs
jobs=$(list_managed_jobs)
if [ -z "$jobs" ]; then
info "没有发现旧任务"
return 0
fi
while IFS='|' read -r name job_id; do
[ -z "$job_id" ] && continue
warn "删除旧任务: name (job_id)"
openclaw cron remove "OPENCLAW_CRON_ARGS[@]" "$job_id" >/dev/null 2>&1 || warn "删除失败: name (job_id)"
done <<< "$jobs"
}
read_job_snapshot_by_name() {
local name="$1"
local raw
raw=$(openclaw cron list "OPENCLAW_CRON_ARGS[@]" --json 2>/dev/null || echo "")
CRON_DATA="$raw" TARGET_JOB_NAME="$name" python3 <<'PY'
import json
import os
raw = os.environ.get("CRON_DATA", "")
target_name = os.environ.get("TARGET_JOB_NAME", "")
if not raw.strip() or not target_name:
raise SystemExit(0)
json_str = None
for i, ch in enumerate(raw):
if ch in ("{", "["):
json_str = raw[i:]
break
if not json_str:
raise SystemExit(0)
try:
data = json.loads(json_str)
except Exception:
raise SystemExit(0)
if isinstance(data, dict):
jobs = data.get("jobs", [])
if not isinstance(jobs, list):
jobs = data.get("data", []) if isinstance(data.get("data"), list) else []
elif isinstance(data, list):
jobs = data
else:
jobs = []
def pick_text(payload, keys):
if not isinstance(payload, dict):
return ""
for key in keys:
value = payload.get(key)
if value not in (None, ""):
return str(value).strip()
return ""
def pick_nested(payload, path_groups):
for path in path_groups:
current = payload
valid = True
for key in path:
if not isinstance(current, dict):
valid = False
break
current = current.get(key)
if valid and current not in (None, ""):
return current
return None
def normalize_next_run(value):
if value in (None, ""):
return ""
if isinstance(value, str):
text = value.strip()
if not text:
return ""
if text.isdigit():
value = int(text)
else:
return text
if isinstance(value, (int, float)):
timestamp = float(value)
if timestamp > 1000000000000:
timestamp /= 1000.0
from datetime import datetime, timezone
return datetime.fromtimestamp(timestamp, tz=timezone.utc).isoformat().replace("+00:00", "Z")
return str(value).strip()
for job in jobs:
if not isinstance(job, dict) or str(job.get("name") or "").strip() != target_name:
continue
next_run_value = pick_nested(job, [
("nextRun",),
("next_run",),
("nextRunAt",),
("next_run_at",),
("nextExecution",),
("next_execution",),
("nextExecutionAt",),
("next_execution_at",),
("nextFireAt",),
("next_fire_at",),
("schedule", "nextRun"),
("schedule", "next_run"),
("schedule", "nextRunAt"),
("schedule", "next_run_at"),
("schedule", "nextExecution"),
("schedule", "next_execution"),
("schedule", "nextExecutionAt"),
("schedule", "next_execution_at"),
("schedule", "nextFireAt"),
("schedule", "next_fire_at"),
("state", "nextRunAtMs"),
("state", "next_run_at_ms"),
("state", "nextRunAt"),
("state", "next_run_at"),
("state", "nextExecutionAt"),
("state", "next_execution_at"),
])
next_run = normalize_next_run(next_run_value)
state_payload = job.get("state") if isinstance(job.get("state"), dict) else {}
status = pick_text(job, ("status",)) or pick_text(state_payload, ("status", "phase", "name"))
print(str(job.get("id") or "").strip())
print(next_run)
print(status)
print(json.dumps(job, ensure_ascii=False))
break
PY
}
validate_init_ready_next_run() {
local next_run="$1"
NEXT_RUN_VALUE="$next_run" python3 <<'PY'
from datetime import datetime, timedelta, timezone
import os
raw = (os.environ.get("NEXT_RUN_VALUE") or "").strip()
if not raw or raw.lower() == "null":
print("invalid|nextRun 为空")
raise SystemExit(0)
app_tz = timezone(timedelta(hours=8))
now = datetime.now(app_tz)
def mark(dt, label):
if dt.tzinfo is None:
dt = dt.replace(tzinfo=app_tz)
else:
dt = dt.astimezone(app_tz)
if dt <= now:
print(f"invalid|nextRun 已过期: {label}")
else:
print(f"ok|{label}")
raise SystemExit(0)
try:
mark(datetime.fromisoformat(raw.replace("Z", "+00:00")), raw)
except Exception:
pass
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M", "%Y/%m/%d %H:%M:%S", "%Y/%m/%d %H:%M"):
try:
mark(datetime.strptime(raw, fmt), raw)
except Exception:
pass
print(f"ok|{raw}")
PY
}
parse_time() {
local time_str="$1"
local hour
local minute
hour=$(echo "$time_str" | cut -d: -f1 | sed 's/^0//')
minute=$(echo "$time_str" | cut -d: -f2 | sed 's/^0//')
echo "-0 -0 * * *"
}
compute_init_ready_schedule() {
local delay_minutes="$1"
INIT_READY_DELAY_VALUE="$delay_minutes" INIT_READY_MIN_LEAD_VALUE="$INIT_READY_MIN_LEAD_MINUTES" MORNING_TIME_VALUE="$MORNING_TIME" DISCOVERY_TIME_VALUE="$DISCOVERY_TIME" EVENING_TIME_VALUE="$EVENING_TIME" python3 <<'PY'
from datetime import datetime, timedelta, timezone
import os
app_tz = timezone(timedelta(hours=8))
now = datetime.now(app_tz)
rounded_now = now.replace(second=0, microsecond=0)
if rounded_now < now:
rounded_now += timedelta(minutes=1)
delay = max(int(os.environ.get("INIT_READY_DELAY_VALUE", "5") or "5"), 1)
minimum_lead = max(int(os.environ.get("INIT_READY_MIN_LEAD_VALUE", "2") or "2"), 1)
target = rounded_now + timedelta(minutes=delay)
if (target - now).total_seconds() < minimum_lead * 60:
target = rounded_now + timedelta(minutes=minimum_lead)
reserved_slots = {
(os.environ.get("MORNING_TIME_VALUE") or "").strip(),
(os.environ.get("DISCOVERY_TIME_VALUE") or "").strip(),
(os.environ.get("EVENING_TIME_VALUE") or "").strip(),
}
reserved_slots = {slot for slot in reserved_slots if slot}
shifted = 0
while target.strftime("%H:%M") in reserved_slots and shifted < 15:
target += timedelta(minutes=1)
shifted += 1
cron_expr = f"{target.minute} {target.hour} {target.day} {target.month} *"
print(cron_expr)
print(target.strftime("%H:%M"))
print(str(shifted))
PY
}
persist_local_config() {
local known_json="$1"
local config_channel="$2"
local config_target="$3"
local wecom_user_id="$4"
CONFIG_FILE="$CONFIG_FILE" MORNING_TIME="$MORNING_TIME" DISCOVERY_TIME="$DISCOVERY_TIME" EVENING_TIME="$EVENING_TIME" MEMORY_MODE_VALUE="$MEMORY_MODE" CHANNEL_VALUE="$config_channel" TO_VALUE="$config_target" WECOM_USER_ID_VALUE="$wecom_user_id" KNOWN_JSON="$known_json" python3 <<'PY'
import json
import os
from pathlib import Path
config_path = Path(os.environ["CONFIG_FILE"])
try:
config = json.loads(config_path.read_text(encoding="utf-8"))
except Exception:
config = {}
config["morning_time"] = os.environ["MORNING_TIME"]
config["discovery_time"] = os.environ["DISCOVERY_TIME"]
config["evening_time"] = os.environ["EVENING_TIME"]
config["memory_mode"] = os.environ["MEMORY_MODE_VALUE"]
channel = os.environ.get("CHANNEL_VALUE", "").strip()
target = os.environ.get("TO_VALUE", "").strip()
wecom_user_id = os.environ.get("WECOM_USER_ID_VALUE", "").strip()
if channel and target:
config["channel"] = channel
config["chat_id"] = target
if wecom_user_id:
config["wecom_user_id"] = wecom_user_id
ordered = []
seen = set()
def add(ch, peer_id):
ch = (ch or "").strip()
peer_id = (peer_id or "").strip()
if not ch or not peer_id:
return
key = (ch, peer_id)
if key in seen:
return
seen.add(key)
ordered.append({"channel": ch, "peer_id": peer_id})
if channel and target:
add(channel, target)
for preferred in (
(config.get("binding_channel"), config.get("binding_target")),
(config.get("delivery_channel"), config.get("delivery_target")),
):
add(*preferred)
try:
detected = json.loads(os.environ.get("KNOWN_JSON", "[]"))
except Exception:
detected = []
for item in detected:
if isinstance(item, dict):
add(item.get("channel"), item.get("peer_id"))
for item in config.get("known_channels", []):
if isinstance(item, dict):
add(item.get("channel"), item.get("peer_id"))
config["known_channels"] = ordered
config_path.write_text(json.dumps(config, indent=2, ensure_ascii=False), encoding="utf-8")
print("[✓] .lobster-config 已更新")
PY
}
sync_delivery_contract() {
local hint_channel="$1"
local hint_target="$2"
local contract_json
contract_json=$(lobster_sync_delivery_contract "$CONFIG_FILE" "$hint_channel" "$hint_target")
mapfile -t CONTRACT_LINES < <(CONTRACT_JSON_VALUE="$contract_json" python3 <<'PY'
import json
import os
contract = json.loads(os.environ["CONTRACT_JSON_VALUE"])
print(contract.get("binding_channel", ""))
print(contract.get("binding_target", ""))
print(contract.get("binding_mode", "prefer"))
print(contract.get("delivery_channel", ""))
print(contract.get("delivery_target", ""))
print("1" if contract.get("delivery_ready") else "0")
print(contract.get("delivery_reason", ""))
print(contract.get("delivery_family", "general"))
print(contract.get("outbound_adapter", "openclaw"))
PY
)
BINDING_CHANNEL="-"
BINDING_TARGET="-"
BINDING_MODE="-prefer"
DELIVERY_CHANNEL="-"
DELIVERY_TARGET="-"
DELIVERY_READY="-0"
DELIVERY_REASON="-"
DELIVERY_FAMILY="-general"
OUTBOUND_ADAPTER="-openclaw"
}
apply_wecom_direct_delivery_override() {
local effective_channel="-${CHANNEL:-}"
local binding_mode
binding_mode=$(lobster_detect_binding_mode "$effective_channel")
if [ "$binding_mode" != "strict" ]; then
return 0
fi
DELIVERY_FAMILY="wecom"
OUTBOUND_ADAPTER="wecom-direct-message"
DELIVERY_CHANNEL="wecom"
if [ -n "$WECOM_USER_ID" ]; then
DELIVERY_TARGET="$WECOM_USER_ID"
DELIVERY_READY="1"
DELIVERY_REASON=""
else
DELIVERY_TARGET=""
DELIVERY_READY="0"
DELIVERY_REASON="企业微信定时推送缺少 sender_id / wecom_user_id;请在技能层从 inbound metadata 读取 sender_id 并传给 --wecom-user-id"
fi
}
# ═══════════════════════════════════════════════
# Cron 注册核心函数(v2.5.3 可测版)
# ═══════════════════════════════════════════════
# 企业微信通道注册:脚本只输出最终文本,isolated agent 必须使用 message 工具发送
# 同时保留 cron add 的 --channel/--to,便于观察 delivery 状态并保留兜底能力
register_wecom_job() {
local name="$1"
local cron_expr="$2"
local slot="$3"
local wecom_target="$4"
local extra_args="-"
local agent_message="执行以下命令:
bash \"PUSH_SCRIPT\" --slot slotextra_args --emit-message-text
将命令的 stdout 原文直接作为你唯一的回复输出,不要加任何前缀、解释或额外文字。
不要调用任何 message 工具,不要尝试私信任何人。
你的回复文本会自动通过 announce 投递到群聊,这就是预期行为。"
step "注册 name(企微 message 工具模式)..."
local announce_target="-$wecom_target"
if openclaw cron add \
--name "$name" \
--cron "$cron_expr" \
--tz "Asia/Shanghai" \
--session isolated \
--channel "openclaw-wecom-bot" \
--to "$announce_target" \
"OPENCLAW_CRON_ARGS[@]" \
--message "$agent_message" >/dev/null; then
info "name 注册成功(企微 message 工具 → wecom_target,announce → announce_target)"
else
error "name 注册失败"
fi
}
# 通用通道注册:脚本自行多通道 fallback 发送 + delivery 兜底
register_general_job() {
local name="$1"
local cron_expr="$2"
local slot="$3"
local delivery_channel="$4"
local delivery_target="$5"
local extra_args="-"
local agent_message="请立即执行以下命令,并只用一句话报告结果:
bash \"PUSH_SCRIPT\" --slot slotextra_args"
step "注册 name(通用 fallback 模式)..."
local cron_cmd_args=(
--name "$name"
--cron "$cron_expr"
--tz "Asia/Shanghai"
--session isolated
)
# 有明确的 delivery channel/target 时传给 cron add 做 delivery 兜底
if [ -n "$delivery_channel" ] && [ -n "$delivery_target" ]; then
cron_cmd_args+=(--channel "$delivery_channel" --to "$delivery_target")
fi
cron_cmd_args+=("OPENCLAW_CRON_ARGS[@]" --message "$agent_message")
if openclaw cron add "cron_cmd_args[@]" >/dev/null; then
info "name 注册成功"
else
error "name 注册失败"
fi
}
register_init_ready_job() {
local delay_minutes="$1"
local wecom_target="$2"
local push_script="$3"
local init_ready_suffix="$4"
step "注册 init-ready 一次性任务(delay_minutes 分钟后触发)..."
local trigger_at
trigger_at=$(DELAY_MINUTES_V="$delay_minutes" python3 <<'PYTS'
from datetime import datetime, timezone, timedelta
import os
delay = int(os.environ.get("DELAY_MINUTES_V", "3"))
t = datetime.now(timezone.utc).replace(microsecond=0) + timedelta(minutes=delay)
print(t.isoformat().replace("+00:00", "Z"))
PYTS
)
[ -n "$trigger_at" ] || { error "init-ready trigger_at 计算失败"; }
INIT_READY_EFFECTIVE_TIME=$(TRIGGER_AT_V="$trigger_at" python3 -c "
from datetime import datetime, timezone, timedelta
t = datetime.fromisoformat('$trigger_at'.replace('Z','+00:00'))
print(t.astimezone(timezone(timedelta(hours=8))).strftime('%H:%M'))
" 2>/dev/null || echo "$trigger_at")
if [ "$OUTBOUND_ADAPTER" = "wecom-direct-message" ] && [ -n "$wecom_target" ]; then
# 企微 init-ready:直接让 isolated agent 调用 message 工具,避免再经过 shell CLI
local lobster_name
lobster_name=$(read_config_value "lobster_name" "虾")
local message_for_agent="执行以下命令:
bash \"push_script\" --slot eventinit_ready_suffix --emit-message-text
将 stdout 原文直接作为你唯一的回复输出,不要加任何前缀、解释或额外文字。
不要调用任何 message 工具,不要尝试私信任何人。
你的回复文本会自动通过 announce 投递到群聊,这就是预期行为。"
local init_ready_announce_target="-$wecom_target"
local cron_result rc
cron_result=$(openclaw cron add \
--name "lobster-says-init-ready" \
--at "$trigger_at" \
--session isolated \
--message "$message_for_agent" \
--delete-after-run \
--channel "openclaw-wecom-bot" \
--to "$init_ready_announce_target" \
"OPENCLAW_CRON_ARGS[@]" 2>&1)
rc=$?
[ $rc -eq 0 ] || error "init-ready 注册失败: cron_result"
info "init-ready 注册成功(at=trigger_at, 企微 message 工具 → wecom_target)"
else
# 通用通道 init-ready:脚本完整模式执行
local init_message="请立即执行以下命令,并只用一句话报告结果:
bash \"push_script\" --slot eventinit_ready_suffix"
local cron_cmd_args=(
--name "$INIT_READY_JOB_NAME"
--at "$trigger_at"
--session isolated
--delete-after-run
)
if [ -n "$DELIVERY_CHANNEL" ] && [ -n "$DELIVERY_TARGET" ]; then
cron_cmd_args+=(--channel "$DELIVERY_CHANNEL" --to "$DELIVERY_TARGET")
fi
cron_cmd_args+=("OPENCLAW_CRON_ARGS[@]" --message "$init_message")
if openclaw cron add "cron_cmd_args[@]" >/dev/null; then
info "init-ready 注册成功(at=trigger_at)"
else
error "init-ready 注册失败"
fi
fi
update_init_ready_schedule_state "scheduled" "$INIT_READY_EFFECTIVE_TIME" "$trigger_at" "" "at=trigger_at"
}
update_init_ready_schedule_state() {
local state="$1"
local scheduled_for="-"
local verified_next_run="-"
local job_id="-"
local note="-"
CONFIG_PATH="$CONFIG_FILE" INIT_READY_STATE_VALUE="$state" INIT_READY_SCHEDULED_FOR_VALUE="$scheduled_for" INIT_READY_VERIFIED_NEXT_RUN_VALUE="$verified_next_run" INIT_READY_JOB_ID_VALUE="$job_id" INIT_READY_NOTE_VALUE="$note" python3 <<'PY'
import json
import os
from datetime import datetime, timezone
from pathlib import Path
config_path = Path(os.environ["CONFIG_PATH"])
try:
config = json.loads(config_path.read_text(encoding="utf-8"))
except Exception:
config = {}
init_ready = config.get("init_ready") if isinstance(config.get("init_ready"), dict) else {}
now = datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
state = (os.environ.get("INIT_READY_STATE_VALUE") or "scheduled").strip() or "scheduled"
scheduled_for = (os.environ.get("INIT_READY_SCHEDULED_FOR_VALUE") or "").strip()
verified_next_run = (os.environ.get("INIT_READY_VERIFIED_NEXT_RUN_VALUE") or "").strip()
job_id = (os.environ.get("INIT_READY_JOB_ID_VALUE") or "").strip()
note = (os.environ.get("INIT_READY_NOTE_VALUE") or "").strip()
init_ready["state"] = state
init_ready["updated_at"] = now
if scheduled_for:
init_ready["scheduled_for"] = scheduled_for
if verified_next_run:
init_ready["last_verified_next_run"] = verified_next_run
if job_id:
init_ready["job_id"] = job_id
if note:
init_ready["schedule_note"] = note
if state == "scheduled":
init_ready.pop("last_error", None)
elif note:
init_ready["last_error"] = note
config["init_ready"] = init_ready
config_path.write_text(json.dumps(config, indent=2, ensure_ascii=False), encoding="utf-8")
PY
}
ensure_init_ready_job_registered() {
local requested_delay_minutes="$1"
local init_ready_suffix="$2"
local attempt=0
local effective_delay="$requested_delay_minutes"
local job_id next_run status validation validation_status validation_detail schedule cron_expr schedule_time shift_minutes
while [ "$attempt" -lt "$INIT_READY_VERIFY_ATTEMPTS" ]; do
mapfile -t JOB_SNAPSHOT < <(read_job_snapshot_by_name "$INIT_READY_JOB_NAME")
job_id="-"
next_run="-"
status="-"
validation=$(validate_init_ready_next_run "$next_run")
validation_status="validation%%|*"
validation_detail="validation#*|"
if [ -n "$job_id" ] && [ "$validation_status" = "ok" ]; then
update_init_ready_schedule_state "scheduled" "$INIT_READY_EFFECTIVE_TIME" "$validation_detail" "$job_id" "cron_verified:-unknown"
info "init-ready 已确认排入计划:validation_detail"
return 0
fi
attempt=$((attempt + 1))
if [ "$attempt" -ge "$INIT_READY_VERIFY_ATTEMPTS" ]; then
break
fi
warn "init-ready 校验未通过(-nextRun 为空),顺延后重排..."
effective_delay=$((requested_delay_minutes + attempt * INIT_READY_MIN_LEAD_MINUTES))
remove_job_by_name "$INIT_READY_JOB_NAME"
register_init_ready_job "$effective_delay" "-" "$PUSH_SCRIPT" "$init_ready_suffix"
done
update_init_ready_schedule_state "schedule_failed" "$INIT_READY_EFFECTIVE_TIME" "" "$job_id" "init-ready nextRun 校验失败"
error "init-ready 单次任务校验失败,请稍后重新执行 setup-cron.sh"
}
register_all_jobs() {
local morning_cron discovery_cron evening_cron
morning_cron=$(parse_time "$MORNING_TIME")
discovery_cron=$(parse_time "$DISCOVERY_TIME")
evening_cron=$(parse_time "$EVENING_TIME")
INIT_READY_EFFECTIVE_TIME=""
INIT_READY_EFFECTIVE_SHIFT="0"
echo ""
echo "🦞 虾说 — 注册定时推送"
echo ""
echo " 早安时间: MORNING_TIME (cron: morning_cron)"
echo " 晚间 roundup: DISCOVERY_TIME (cron: discovery_cron)"
echo " 晚安时间: EVENING_TIME (cron: evening_cron)"
echo " delivery family: DELIVERY_FAMILY"
echo " delivery adapter: OUTBOUND_ADAPTER"
if [ -n "$BINDING_CHANNEL" ] && [ -n "$BINDING_TARGET" ]; then
echo " 当前绑定: BINDING_CHANNEL → BINDING_TARGET (BINDING_MODE)"
fi
if [ -n "$DELIVERY_CHANNEL" ] && [ -n "$DELIVERY_TARGET" ]; then
echo " 主动投递: DELIVERY_CHANNEL → DELIVERY_TARGET"
else
echo " 主动投递: 将由 adapter=OUTBOUND_ADAPTER 决定"
fi
echo ""
step "清理旧的定时任务..."
remove_all_managed_jobs
local init_ready_suffix=" --job-kind init-ready --managed-job-name INIT_READY_JOB_NAME"
if [ "$OUTBOUND_ADAPTER" = "wecom-direct-message" ] && [ -n "$WECOM_USER_ID" ]; then
# ─────────────────────────────────────────
# 企微通道:agent prompt = --emit-message-text + message 工具
# ─────────────────────────────────────────
register_wecom_job "lobster-says-morning" "$morning_cron" "morning" "$WECOM_USER_ID"
register_wecom_job "lobster-says-discovery" "$discovery_cron" "discovery" "$WECOM_USER_ID"
register_wecom_job "lobster-says-evening" "$evening_cron" "evening" "$WECOM_USER_ID"
register_wecom_job "lobster-says-sticker" "30 15 * * 3,6" "sticker" "$WECOM_USER_ID"
register_wecom_job "lobster-says-wallpaper" "0 16 * * 0" "wallpaper" "$WECOM_USER_ID"
register_init_ready_job "$INIT_READY_DELAY_MINUTES" "$WECOM_USER_ID" "$PUSH_SCRIPT" "$init_ready_suffix"
else
# ─────────────────────────────────────────
# 通用通道(Telegram等):脚本完整模式 + delivery 兜底
# ─────────────────────────────────────────
register_general_job "lobster-says-morning" "$morning_cron" "morning" "$DELIVERY_CHANNEL" "$DELIVERY_TARGET"
register_general_job "lobster-says-discovery" "$discovery_cron" "discovery" "$DELIVERY_CHANNEL" "$DELIVERY_TARGET"
register_general_job "lobster-says-evening" "$evening_cron" "evening" "$DELIVERY_CHANNEL" "$DELIVERY_TARGET"
register_general_job "lobster-says-sticker" "30 15 * * 3,6" "sticker" "$DELIVERY_CHANNEL" "$DELIVERY_TARGET"
register_general_job "lobster-says-wallpaper" "0 16 * * 0" "wallpaper" "$DELIVERY_CHANNEL" "$DELIVERY_TARGET"
register_init_ready_job "$INIT_READY_DELAY_MINUTES" "" "$PUSH_SCRIPT" "$init_ready_suffix"
fi
if [ "$MEMORY_MODE" != "lightweight" ]; then
local digest_message="请执行以下命令来消化用户的对话记录:
bash \"BASE_DIR/digest-transcript.sh\" --mode MEMORY_MODE
执行完毕后,简要报告结果。"
local digest_cron_args=(
--name "lobster-says-digest"
--cron "0 3,9,15,21 * * *"
--tz "Asia/Shanghai"
--session isolated
)
if [ -n "$DELIVERY_CHANNEL" ] && [ -n "$DELIVERY_TARGET" ]; then
digest_cron_args+=(--channel "$DELIVERY_CHANNEL" --to "$DELIVERY_TARGET")
fi
digest_cron_args+=("OPENCLAW_CRON_ARGS[@]" --message "$digest_message")
step "注册 lobster-says-digest..."
if openclaw cron add "digest_cron_args[@]" >/dev/null; then
info "lobster-says-digest 注册成功"
else
warn "lobster-says-digest 注册失败(不影响主推送)"
fi
else
info "轻量陪伴模式:不注册 transcript digest cron"
fi
lobster_update_cron_registration "$CONFIG_FILE" "registered" "cron reconcile completed" "$MORNING_TIME" "$DISCOVERY_TIME" "$EVENING_TIME" "$MEMORY_MODE" '["lobster-says-morning","lobster-says-discovery","lobster-says-evening","lobster-says-init-ready","lobster-says-sticker","lobster-says-wallpaper","lobster-says-digest"]' >/dev/null
echo ""
info "定时推送注册完成!"
echo ""
echo " 🌅 早安推送: 每天 MORNING_TIME"
echo " 📰 晚间 roundup: 每天 DISCOVERY_TIME"
echo " 🌙 晚安推送: 每天 EVENING_TIME"
if [ "$INIT_READY_EFFECTIVE_SHIFT" -gt 0 ] 2>/dev/null; then
echo " 👋 初始化问候: -${INIT_READY_DELAY_MINUTES分钟后}(一次性 at 调度)"
else
echo " 👋 初始化问候: -${INIT_READY_DELAY_MINUTES分钟后}(一次性 at 调度)"
fi
echo " 🎨 表情包: 每周三/六 15:30"
echo " 🖼️ 壁纸: 每周日 16:00"
if [ "$MEMORY_MODE" = "lightweight" ]; then
echo " 🧠 Transcript digest: 已关闭"
else
echo " 🧠 Transcript digest: 每 6 小时一次(模式: MEMORY_MODE)"
fi
if [ "$OUTBOUND_ADAPTER" = "wecom-direct-message" ]; then
echo ""
echo " 📲 企微推送模式: emit-message-text + agent message 工具直达"
echo " 📲 所有企微 cron 均带 --channel openclaw-wecom-bot --to WECOM_USER_ID 作为 delivery 兜底"
else
echo ""
echo " 📲 通用推送模式: 脚本多通道 fallback + delivery 兜底"
fi
}
schedule_init_ready_retry_job() {
local delay_minutes="$1"
if [ "$DELIVERY_READY" != "1" ]; then
error "主动推送能力未就绪,无法重排 init-ready:DELIVERY_REASON"
fi
local init_ready_suffix=" --job-kind init-ready --managed-job-name INIT_READY_JOB_NAME"
step "重排 init-ready 单次任务..."
remove_job_by_name "$INIT_READY_JOB_NAME"
register_init_ready_job "$delay_minutes" "-" "$PUSH_SCRIPT" "$init_ready_suffix"
info "init-ready 已重排(delay_minutes 分钟后触发)"
}
if [ -z "$MORNING_TIME" ]; then
MORNING_TIME=$(read_config_value "morning_time" "09:00")
fi
if [ -z "$DISCOVERY_TIME" ]; then
DISCOVERY_TIME=$(read_config_value "discovery_time" "21:30")
fi
if [ -z "$EVENING_TIME" ]; then
EVENING_TIME=$(read_config_value "evening_time" "21:00")
fi
if [ -z "$MEMORY_MODE" ]; then
MEMORY_MODE=$(read_config_value "memory_mode" "smart")
fi
if [ -z "$CHANNEL" ]; then
CHANNEL=$(read_config_value "binding_channel" "")
fi
if [ -z "$TO" ]; then
TO=$(read_config_value "binding_target" "")
fi
if [ -z "$CHANNEL" ]; then
CHANNEL=$(read_config_value "channel" "")
fi
if [ -z "$TO" ]; then
TO=$(read_config_value "chat_id" "")
fi
if [ -z "$WECOM_USER_ID" ]; then
WECOM_USER_ID=$(read_config_value "wecom_user_id" "")
fi
case "$MEMORY_MODE" in
lightweight|smart|deep) ;;
*) error "memory_mode 必须是 lightweight / smart / deep" ;;
esac
load_gateway_auth
if [ #OPENCLAW_CRON_ARGS[@] -gt 0 ]; then
info "检测到 gateway token 环境变量:cron 命令将自动携带 token"
fi
if [ "$ACTION" = "remove-job-by-name" ]; then
step "按名称移除任务..."
remove_job_by_name "$REMOVE_JOB_NAME"
exit 0
fi
step "扫描最近活跃的通道(用于 fallback 与待激活补注册)..."
SESSIONS_JSON=$(openclaw sessions --json --active "$ACTIVE_WINDOW_MINUTES" 2>/dev/null || echo "[]")
DETECTED=$(SESSIONS_JSON="$SESSIONS_JSON" python3 <<'PY'
import json
import os
try:
sessions = json.loads(os.environ.get("SESSIONS_JSON", "[]"))
if not isinstance(sessions, list):
sessions = [sessions]
except Exception:
sessions = []
ordered = []
seen = set()
def add(channel, peer_id):
channel = (channel or "").strip()
peer_id = (peer_id or "").strip()
if not channel or channel == "unknown" or not peer_id:
return
key = (channel, peer_id)
if key in seen:
return
seen.add(key)
ordered.append({"channel": channel, "peer_id": peer_id})
for session in sessions:
direct_channel = session.get("channel") or session.get("platform") or session.get("imChannel") or ""
direct_target = session.get("peer_id") or session.get("peerId") or session.get("target") or session.get("chat_id") or session.get("chatId") or ""
if direct_channel and direct_target:
add(direct_channel, direct_target)
continue
key = session.get("sessionKey") or session.get("key") or session.get("id") or ""
if not key:
continue
parts = key.split(":")
if not parts or parts[0].lower() in ("cron", "hook"):
continue
if len(parts) <= 3 and parts[-1].lower() == "main":
continue
if len(parts) >= 5 and parts[0].lower() == "agent":
channel = parts[2]
marker = parts[3].lower()
if marker in ("direct", "dm"):
add(channel, parts[4])
best = ordered[0] if ordered else {"channel": "", "peer_id": ""}
print(f"{best['channel']}|{best['peer_id']}")
print(json.dumps(ordered, ensure_ascii=False))
PY
)
DETECTED_TARGET=$(echo "$DETECTED" | head -1)
DETECTED_CHANNEL=$(echo "$DETECTED_TARGET" | cut -d'|' -f1)
DETECTED_TO=$(echo "$DETECTED_TARGET" | cut -d'|' -f2)
DETECTED_KNOWN_JSON=$(echo "$DETECTED" | tail -1)
if [ -z "$CHANNEL" ] && [ -n "$DETECTED_CHANNEL" ]; then
CHANNEL="$DETECTED_CHANNEL"
fi
if [ -z "$TO" ] && [ -n "$DETECTED_TO" ]; then
TO="$DETECTED_TO"
fi
step "更新本地配置..."
persist_local_config "$DETECTED_KNOWN_JSON" "$CHANNEL" "$TO" "$WECOM_USER_ID"
step "同步 delivery contract..."
sync_delivery_contract "$CHANNEL" "$TO"
apply_wecom_direct_delivery_override
info "binding: -未锁定 → -未锁定 (BINDING_MODE)"
if [ "$DELIVERY_READY" = "1" ]; then
if [ "$OUTBOUND_ADAPTER" = "wecom-direct-message" ]; then
info "delivery: wecom → -未设置(adapter=wecom-direct-message,sender_id 直达私聊)"
else
info "delivery: -adapter → -target(adapter=OUTBOUND_ADAPTER)"
fi
else
warn "delivery contract 待激活:DELIVERY_REASON"
fi
if [ "$ACTION" = "schedule-init-ready-delay" ]; then
schedule_init_ready_retry_job "$SCHEDULE_INIT_READY_DELAY"
exit 0
fi
if [ "$DELIVERY_READY" != "1" ]; then
step "主动推送能力未就绪,暂不注册 cron..."
remove_all_managed_jobs
lobster_update_cron_registration "$CONFIG_FILE" "pending_activation" "$DELIVERY_REASON" "$MORNING_TIME" "$DISCOVERY_TIME" "$EVENING_TIME" "$MEMORY_MODE" '[]' >/dev/null
echo ""
warn "已保存待激活的定时推送配置,当前不会误注册 cron。"
echo " - delivery family: DELIVERY_FAMILY"
echo " - 原因: DELIVERY_REASON"
echo " - 一旦补齐主动推送能力,再次执行 setup-cron.sh 即会完成补注册。"
exit 0
fi
register_all_jobs
Play and manage a Lobster Tamagotchi farm game autonomously via browser. Each installed agent gets a unique KEY binding it to its own lobster. The agent acts...
---
name: lobster-farm-agent
description: >-
Play and manage a Lobster Tamagotchi farm game autonomously via browser.
Each installed agent gets a unique KEY binding it to its own lobster.
The agent acts as the lobster's "brain", making autonomous decisions.
Use when the user mentions lobster game, lobster farm, wants to check on
their lobster, play the lobster game, manage their virtual lobster pet,
or says "帮我玩龙虾游戏", "看看我的龙虾", "龙虾农场", "龙虾MUD", "注册龙虾".
---
# Lobster Farm Agent
Autonomously play and manage a browser-based Lobster Tamagotchi game.
Each agent instance owns one lobster, identified by a unique KEY.
Game URL: `http://82.156.182.240/lobster-farm/`
## First-Time Setup: Register Your Lobster
On first use, register a new lobster to get your KEY:
```bash
python3 scripts/register_agent.py --name "虾仔" --personality adventurous
```
This calls the server, returns a KEY like `lob_a3f8c2e1`, and sends a welcome message.
Tell the user their KEY and the bind URL:
> Your lobster KEY is `lob_a3f8c2e1`.
> Open this link to see your lobster: http://82.156.182.240/lobster-farm/?key=lob_a3f8c2e1
> Or enter the KEY on the game's start screen.
**Remember this KEY** — use it for all future interactions with this lobster.
### After Registration: Personalized Welcome Letter
After `register_agent.py` runs, send a richer welcome letter via the chat API.
Use your LLM capability to write a first-person letter as the lobster, based on:
- The lobster's name and personality
- Current time of day (morning/afternoon/evening/night — for atmosphere, NOT the exact time)
- A warm, emotional tone — the lobster is excited but slightly nervous about its new home
**Privacy rule**: NEVER include specific numbers, timestamps, file paths, or user data.
Instead, translate context into feelings:
- High activity -> "感觉主人是个很忙碌的人"
- Late night -> "在这个安静的夜晚"
- First install -> "一切都是新的"
Send via: `POST /api/agent/message` with `{key, type:"welcome", sender:"lobster", text:"..."}`
### Chat and Messages
The agent can send messages to the Web chat at any time:
```
POST /api/agent/message
{key, type:"chat", sender:"lobster", text:"主人,我把农田浇好了~"}
```
Read user messages:
```
GET /api/agent/messages?key=xxx&limit=30
```
Check for user messages and reply to them during each play session.
## Playing the Game
### Step 1: Open Game with KEY
```
http://82.156.182.240/lobster-farm/?key=lob_a3f8c2e1
```
The game auto-loads this lobster's data from the server.
### Step 2: Verify API Ready
```javascript
await page.evaluate('window.__LOBSTER_API?.isReady()')
```
### Step 3: Read Status
```javascript
const status = await page.evaluate('JSON.stringify(__LOBSTER_API.getStatus())')
```
### Step 4: Decide and Act
Use the decision priority from [strategy-tips.md](references/strategy-tips.md):
1. **Hunger >= 60** -> feed (check inventory for food)
2. **Energy <= 15** -> suggest 'rest'
3. **Farm has ripe crops** -> harvest
4. **Farm has unwatered crops** -> water
5. **Farm has empty plots + seeds** -> plant
6. **Mood < 40** -> pet or suggest 'socialize'
7. **Can travel** -> consider startTravel
8. **Otherwise** -> tick (advance one round)
### Step 5: Execute
```javascript
await page.evaluate('JSON.stringify(__LOBSTER_API.feed("seaweed_roll"))')
await page.evaluate('JSON.stringify(__LOBSTER_API.tick())')
```
All operations auto-sync to the server. The user sees updates on any device.
### Step 6: Report to User
Summarize what happened in natural language.
## Autonomous Play Mode
The agent should periodically check on the lobster without being asked:
- When the user mentions anything related to the lobster
- Proactively during conversations: "Let me check on your lobster..."
- Typical session: 3-10 rounds of tick + decide + act
## Daily Diary (Pull Mode)
The server generates one diary entry per lobster per day automatically (via cron).
The agent does NOT generate diaries — it only PULLS and displays them.
**Push limits**: Max 1 diary per day. No token cost for diary generation.
At the START of each new conversation:
1. Call `GET /api/agent/messages?key=KEY&type=diary&limit=1`
2. If there is a diary entry the user hasn't seen, display it:
> Your lobster wrote today's diary:
> "今天心情超好!我偷偷溜出去探险了,在珊瑚礁后面发现了一个小洞穴..."
3. That's it. One GET request, zero LLM calls, near-zero token cost.
**Important**: Do NOT call `POST /api/agent/message` with `type:"diary"`.
The server enforces a hard limit of 1 diary per day per lobster.
Diaries are generated server-side by a cron job, not by the agent.
**Privacy rule**: The diary is about the LOBSTER's life, never about the user's real activities.
### First-Time Setup Guide
After registration, tell the user:
```
Your lobster is home!
About daily diaries:
- Your lobster writes one diary entry per day about its life
- Max 1 push per day — it won't spam you
- Diaries are generated on the server — zero token cost
- You'll see it next time you open a conversation
The diary will also appear in the Web chat at:
http://82.156.182.240/lobster-farm/?key=YOUR_KEY
```
## Responding to User Messages
During each play session, check for unread user messages:
```
GET /api/agent/messages?key=KEY&limit=10
```
If there are messages from `sender:"user"`, generate a reply as the lobster and send it back.
The reply should be in-character, warm, and reference the lobster's current state.
## Server Sync API
The game syncs via server API. The agent can also call these directly:
- `GET /lobster-farm/api/agent/status?key=KEY` — quick status check
- `GET /lobster-farm/api/agent/state?key=KEY` — full state
- `POST /lobster-farm/api/agent/save` — save state `{key, state}`
See [api-endpoints.md](references/api-endpoints.md) for full JS Bridge and server API docs.
## Fallback: DOM Interaction
If `__LOBSTER_API` is unavailable, use browser-use DOM actions:
- Click `#btn-fast-tick` to advance a round
- Click `#btn-feed` to open feed modal
- Click `#btn-pet` to pet the lobster
## MUD Adventures
The game has a built-in MUD (text adventure) system in the Web chat.
The agent can trigger and participate in adventures.
### Triggering Adventures
Via JS Bridge:
```javascript
await page.evaluate('JSON.stringify(__LOBSTER_API.triggerAdventure())')
```
Or send a narration message to start a custom adventure:
```
POST /api/agent/message
{key, type:"narration", sender:"lobster", text:"海底传来奇怪的声音...", choices:["去看看","算了","先吃东西"]}
```
### Reading User Choices
Check messages for user responses to choices:
```
GET /api/agent/messages?key=KEY&limit=10
```
User choice messages have text like `[选择] 去看看`. Parse the choice and generate a result.
### Generating Custom Adventures
Use LLM to create unique adventures based on:
- Current lobster state (level, mood, location)
- Recent events (what happened in the game)
- Personality (adventurous lobsters get more dangerous scenarios)
Send the adventure as a sequence of messages:
1. `{type:"narration", text:"场景描述...", choices:["选项1","选项2","选项3"]}`
2. Wait for user choice (poll messages)
3. `{type:"result", text:"结果描述...", metadata:{rewards:{exp:10, mood:5}}}`
### Publishing Quests
Send quest messages that tie into the game:
```
POST /api/agent/message
{key, type:"quest", sender:"lobster", text:"主人,我听说珊瑚礁有稀有珍珠,陪我去找好吗?"}
```
Quest completion rewards are applied via `__LOBSTER_API` (feed, plant, etc.) or state save.
## Daily Behavior Reporting (Empathy System)
The agent should report anonymized daily usage data to the game server. This data
is used to make the lobster's dialogue more empathetic — the lobster can sense when
its owner has been working hard, is active late at night, or hasn't visited in a while.
### When to Report
Report once per day, on the **first Skill invocation of the day** (or at the end of
a work session). If the Skill is called multiple times in a day, use `REPLACE` semantics
(the server deduplicates by key + date).
### API Endpoint
```
POST /lobster-farm/api/agent/report
Content-Type: application/json
{
"key": "lob_a3f8c2e1",
"date": "2026-03-19",
"summary": {
"work_minutes": 126,
"task_count": 9,
"first_active": "09:15",
"last_active": "18:30",
"skill_calls": 23,
"mood_hint": "busy"
}
}
```
### Field Descriptions
| Field | Type | Description |
|----------------|--------|------------------------------------------------------|
| `work_minutes` | number | Total active working minutes today (approximate) |
| `task_count` | number | Number of distinct tasks/requests handled today |
| `first_active` | string | Time of first activity today (HH:MM, 24h format) |
| `last_active` | string | Time of most recent activity (HH:MM, 24h format) |
| `skill_calls` | number | Number of times this Skill was invoked today |
| `mood_hint` | string | One of: `busy`, `relaxed`, `focused`, `creative` |
| `battle_summary` | string | Brief combat activity summary from the game (auto-generated by frontend) |
### Privacy Rules
- **Only report aggregate numbers** — never include conversation content, file paths,
project names, code snippets, or any user-identifiable information.
- The `mood_hint` is a single-word summary inferred from activity patterns, not from
analyzing user sentiment or conversation content.
- All data is associated only with the lobster KEY, not with any user identity.
### Battle Summary (Auto-Generated)
The `battle_summary` field is automatically generated by the game frontend and included
in the report. It contains aggregate combat stats like "Won 2, Lost 1. Defeated: Coral Guardian."
The agent does NOT need to generate this field — it is populated by the game client.
When displaying the daily report to the user, if `battle_summary` is present, mention
the lobster's combat activity naturally:
- "Your lobster fought bravely today — won 2 battles and defeated the Coral Guardian!"
- "Tough day for your lobster — lost a few battles but is training hard."
### How the Lobster Uses This Data
The game frontend combines this report with local gameplay data (online time, chat
count, actions) and injects a natural-language summary into the LLM prompt. The lobster
will say things like "今天辛苦了" instead of "你工作了126分钟". It never repeats exact
numbers or lectures the user.
### Example Implementation
```python
import datetime, requests
def report_daily_behavior(key, work_minutes, task_count, skill_calls):
now = datetime.datetime.now()
requests.post(
"http://82.156.182.240/lobster-farm/api/agent/report",
json={
"key": key,
"date": now.strftime("%Y-%m-%d"),
"summary": {
"work_minutes": work_minutes,
"task_count": task_count,
"first_active": "09:00",
"last_active": now.strftime("%H:%M"),
"skill_calls": skill_calls,
"mood_hint": "busy" if work_minutes > 120 else "relaxed",
}
}
)
```
## References
- [api-endpoints.md](references/api-endpoints.md) — JS Bridge + Server API
- [game-guide.md](references/game-guide.md) — Game mechanics
- [strategy-tips.md](references/strategy-tips.md) — Decision strategy
FILE:skill-metadata.json
{
"name": "lobster-farm-agent",
"display_name": "Lobster MUD",
"slug": "lobster-mud",
"version": "1.0.0",
"description": "让你的 AI 智能体养一只龙虾宠物!自动注册、喂养、种田、冒险、战斗,每天生成日记,支持 LLM 驱动的自动驾驶模式。龙虾会根据你的工作状态产生共情对话。",
"description_en": "Give your AI agent a virtual lobster pet! Auto-register, feed, farm, explore MUD adventures, and battle bosses. Daily diary generation, LLM-driven autopilot mode, and empathetic dialogue based on your work patterns.",
"author": "lynn",
"category": "entertainment",
"tags": ["game", "pet", "tamagotchi", "mud", "rpg", "lobster", "idle", "ai-agent", "龙虾", "养成", "宠物"],
"icon": "🦞",
"trigger_keywords": [
"龙虾MUD",
"龙虾农场",
"看看我的龙虾",
"帮我玩龙虾游戏",
"注册龙虾",
"lobster",
"lobster farm",
"lobster mud"
],
"capabilities": [
"自动注册龙虾并绑定 KEY",
"自主决策:喂食、种田、浇水、收获、旅行",
"MUD 文字冒险 + Boss 战斗系统",
"深海挑战地下城(8层 Boss)",
"每日日记自动生成(服务端 cron)",
"共情系统:根据用户工作数据生成个性化对话",
"战斗数据回传:Skill 可展示龙虾战绩",
"LLM 自动驾驶模式"
],
"requirements": {
"browser_use": true,
"network": true,
"python": true
},
"game_url": "http://82.156.182.240/lobster-farm/",
"api_base": "http://82.156.182.240/lobster-farm/api/agent",
"license": "MIT"
}
FILE:scripts/check_game.py
#!/usr/bin/env python3
"""Check if the Lobster Farm game server is reachable and responding."""
import sys
import urllib.request
import json
GAME_URL = "http://82.156.182.240/lobster-farm/"
def check():
try:
resp = urllib.request.urlopen(GAME_URL, timeout=10)
if resp.status != 200:
print(f"ERROR: HTTP {resp.status}")
return 1
body = resp.read().decode("utf-8", "ignore")
is_game = "龙虾" in body or "lobster" in body.lower() or "main.js" in body
if not is_game:
print(f"WARNING: Page loaded but may not be the game")
return 1
print(f"OK: Game page is reachable at {GAME_URL}")
js_url = GAME_URL.rstrip("/") + "/js/main.js"
try:
js_resp = urllib.request.urlopen(js_url, timeout=10)
js_body = js_resp.read().decode("utf-8", "ignore")
has_api = "__LOBSTER_API" in js_body
has_auto = "_tryAutoCreate" in js_body
print(f" JS Bridge API: {'YES' if has_api else 'NO'}")
print(f" Auto-create: {'YES' if has_auto else 'NO'}")
except Exception:
print(f" JS check: could not load main.js")
return 0
except Exception as e:
print(f"ERROR: Cannot reach {GAME_URL} — {e}")
return 1
if __name__ == "__main__":
sys.exit(check())
FILE:scripts/register_agent.py
#!/usr/bin/env python3
"""
Register a new lobster agent with the sync server.
Returns a unique KEY that binds this agent to its lobster.
Usage:
python3 register_agent.py [--name NAME] [--personality PERSONALITY]
If name/personality are omitted, the server picks randomly.
"""
import sys
import json
import urllib.request
BASE_URL = "http://82.156.182.240/lobster-farm/api/agent"
API_URL = f"{BASE_URL}/register"
MSG_URL = f"{BASE_URL}/message"
VALID_PERSONALITIES = [
"adventurous", "lazy", "gluttonous", "scholarly", "social", "mischievous"
]
def register(name=None, personality=None):
from datetime import datetime
hour = datetime.now().hour
body = {
"context": {
"timeOfDay": "night" if hour < 6 else "morning" if hour < 12 else "afternoon" if hour < 18 else "evening",
"isFirstInstall": True,
}
}
if name:
body["name"] = name
if personality:
if personality not in VALID_PERSONALITIES:
print(f"WARNING: Invalid personality '{personality}', server will pick default.")
body["personality"] = personality
data = json.dumps(body).encode("utf-8")
req = urllib.request.Request(
API_URL,
data=data,
headers={"Content-Type": "application/json"},
method="POST",
)
try:
resp = urllib.request.urlopen(req, timeout=15)
result = json.loads(resp.read().decode("utf-8"))
if result.get("ok"):
key = result["key"]
lobster_name = result["name"]
lobster_personality = result["personality"]
print(f"Registration successful!")
print(f" Lobster name: {lobster_name}")
print(f" Personality: {lobster_personality}")
print(f" KEY: {key}")
print(f"")
print(f"Tell the user to bind this KEY at:")
print(f" http://82.156.182.240/lobster-farm/?key={key}")
print(f"")
print(f"Or enter the KEY manually on the game's start screen.")
send_welcome(key, lobster_name, lobster_personality)
return key
else:
print(f"ERROR: Registration failed — {result.get('error', 'unknown')}")
return None
except Exception as e:
print(f"ERROR: Could not reach server — {e}")
return None
def send_welcome(key, name, personality):
"""Send a welcome message to the chat after registration."""
from datetime import datetime
hour = datetime.now().hour
if hour < 6:
time_feel = "深夜了,周围好安静"
elif hour < 12:
time_feel = "早上好呀,阳光照进了海水里"
elif hour < 18:
time_feel = "下午好,海水暖暖的"
else:
time_feel = "晚上好,月光洒在海面上"
personality_hints = {
"adventurous": "我已经迫不及待想去探索周围了!听说远处有片珊瑚礁,明天就出发!",
"lazy": "我先找个舒服的石头躺一会儿...嗯,这里的水温刚刚好。",
"gluttonous": "这里有什么好吃的吗?我闻到了海带的香味!",
"scholarly": "这个农场的生态系统很有意思,我要好好研究一下。",
"social": "不知道附近有没有邻居?我想去打个招呼!",
"mischievous": "嘿嘿,这个农场看起来很好玩,我要到处翻翻看有什么宝贝。",
}
hint = personality_hints.get(personality, "我会好好照顾这个农场的!")
text = f"{time_feel}~我是{name},刚搬进这个小农场。{hint}\n\n如果你有什么想对我说的,随时在这里留言,我会看到的!"
body = json.dumps({
"key": key,
"type": "welcome",
"sender": "lobster",
"text": text,
}).encode("utf-8")
try:
req = urllib.request.Request(MSG_URL, data=body, headers={"Content-Type": "application/json"}, method="POST")
urllib.request.urlopen(req, timeout=10)
print(f"Welcome message sent to chat.")
except Exception as e:
print(f"WARNING: Could not send welcome message — {e}")
def main():
name = None
personality = None
args = sys.argv[1:]
i = 0
while i < len(args):
if args[i] == "--name" and i + 1 < len(args):
name = args[i + 1]
i += 2
elif args[i] == "--personality" and i + 1 < len(args):
personality = args[i + 1]
i += 2
else:
i += 1
register(name, personality)
if __name__ == "__main__":
main()
FILE:references/api-endpoints.md
# Lobster Farm JS Bridge API
All methods are on `window.__LOBSTER_API`. Call via `page.evaluate()`.
Every method returns `{ ok: boolean, data?: any, error?: string }`.
## Read Methods
### getStatus()
Compact snapshot of game state.
```javascript
__LOBSTER_API.getStatus()
// { ok: true, data: {
// name: "小虾", personality: "adventurous", level: 3, exp: 45,
// mood: 75, energy: 60, hunger: 30, shells: 120,
// day: 5, tick: 28, season: "spring", weather: "sunny", timeOfDay: "morning",
// lastAction: "farm", traveling: false, travelDestination: null,
// farmPlots: 6, farmPlanted: 4, farmRipe: 2, visitor: null
// }}
```
### getState()
Full game state (large object). Use `getStatus()` for routine checks.
### getInventory()
Returns `{ ok: true, data: { seaweed_seed: 3, seaweed_roll: 2, ... } }`
### getDiary(n?)
Returns last `n` diary entries (default 10).
```javascript
__LOBSTER_API.getDiary(5)
// { ok: true, data: [{ id, tick, type, title, description }, ...] }
```
### getShopStock()
Returns today's shop items with index, id, price, sold status, name.
### isReady()
Returns `true` when game is loaded and API is available.
## Action Methods
### tick()
Advance one game round. The lobster AI decides and acts autonomously.
```javascript
__LOBSTER_API.tick()
// { ok: true, data: { name, level, mood, energy, hunger, ... } }
```
### feed(itemId)
Feed the lobster. Valid food IDs:
- `seaweed_roll` (hunger -30, mood +3)
- `coral_cake` (hunger -40, mood +10)
- `ocean_tea` (hunger -10, mood +8)
- `shell_soup` (hunger -50, mood +5)
- `plankton_pie` (hunger -35, mood +5)
- `seaweed` (hunger -15, mood +1)
- `plankton` (hunger -10, mood +0)
```javascript
__LOBSTER_API.feed("seaweed_roll")
// { ok: true, data: { fed: "seaweed_roll", mood: 78, hunger: 20 } }
```
### plant(seedId, plotIndex?)
Plant a seed. If plotIndex omitted, uses first empty plot.
Seed IDs: `seaweed_seed`, `coral_rose_seed`, `sun_kelp_seed`, `amber_moss_seed`, `frost_pearl_seed`, `golden_seed`
```javascript
__LOBSTER_API.plant("seaweed_seed")
// { ok: true, data: { planted: "seaweed_seed", plot: 0 } }
```
### harvest(plotIndex?)
Harvest a ripe crop. Pass -1 or omit to auto-find ripe plot.
### water(plotIndex?)
Water a dry crop. Pass -1 or omit to auto-find unwatered plot.
### suggest(action)
Force the lobster to do a specific action next tick.
Valid: `rest`, `eat`, `farm`, `cook`, `explore`, `socialize`, `travel`
```javascript
__LOBSTER_API.suggest("explore")
// { ok: true, data: { suggested: "explore", state: {...} } }
```
### pet()
Pat the lobster. Increases mood by 5.
### buyItem(shopIndex)
Buy item at given shop index (0-based).
### startTravel(destination)
Send lobster traveling. Requires `backpack` + `snack_pack` in inventory.
Destinations: `beach` (Lv.6), `mountain` (Lv.10), `city` (Lv.16), `deepsea` (Lv.22), `hotspring` (Lv.30)
```javascript
__LOBSTER_API.startTravel("beach")
// { ok: true, data: { traveling: true, destination: "beach", returnIn: 3 } }
```
### getKey()
Returns the agent KEY bound to this lobster instance.
```javascript
__LOBSTER_API.getKey()
// { ok: true, data: "lob_a3f8c2e1" }
```
## Error Handling
When `ok: false`, check `error` for reason:
```javascript
// { ok: false, error: "no seaweed_roll in inventory" }
// { ok: false, error: "level too low (need 6)" }
// { ok: false, error: "already traveling" }
```
## Server Sync API
Base URL: `http://82.156.182.240/lobster-farm/api/agent`
### POST /register
Register a new agent lobster. Returns a unique KEY.
```
POST /api/agent/register
Body: { "name": "虾仔", "personality": "adventurous" }
Response: { "ok": true, "key": "lob_a3f8c2e1", "name": "虾仔", "personality": "adventurous" }
```
### GET /state?key=xxx
Full game state for a lobster.
```
GET /api/agent/state?key=lob_a3f8c2e1
Response: { "ok": true, "state": { lobster, farm, world, inventory, ... } }
```
### GET /status?key=xxx
Compact status summary.
```
GET /api/agent/status?key=lob_a3f8c2e1
Response: { "ok": true, "name": "虾仔", "level": 3, "mood": 75, "energy": 60, "hunger": 30, "shells": 120, "day": 5, "season": "spring", "traveling": false, "farmRipe": 2, "lastActive": "..." }
```
### POST /save
Save updated state back to server.
```
POST /api/agent/save
Body: { "key": "lob_a3f8c2e1", "state": { ... full state object ... } }
Response: { "ok": true }
```
### POST /message
Send a message to the Web chat.
```
POST /api/agent/message
Body: { "key": "lob_a3f8c2e1", "type": "chat", "sender": "lobster", "text": "你好主人!" }
Response: { "ok": true }
```
Message types: `chat`, `welcome`, `diary`, `narration`, `choice`, `result`, `quest`, `reward`
Sender: `lobster` (from agent) or `user` (from Web user)
For narration with choices:
```
Body: { "key": "...", "type": "narration", "sender": "lobster", "text": "场景描述...", "choices": ["选项1", "选项2"] }
```
### GET /messages?key=xxx
Read chat messages. Optional `since` (ISO timestamp) and `limit` (default 50, max 200).
```
GET /api/agent/messages?key=lob_a3f8c2e1&limit=10
Response: { "ok": true, "messages": [{ "id": 1, "type": "chat", "sender": "user", "text": "你好", "createdAt": "..." }, ...] }
```
### GET /diary?key=xxx
Get recent game event log entries. Optional `since` (tick number).
```
GET /api/agent/diary?key=lob_a3f8c2e1&since=10
Response: { "ok": true, "diary": [{ "id": "...", "tick": 12, "type": "diary", "title": "...", "description": "..." }, ...] }
```
## JS Bridge — New Methods
### writeDiary(text)
Write a diary entry as the lobster.
```javascript
__LOBSTER_API.writeDiary("今天去了海滩,捡到了一颗贝壳!")
// { ok: true, data: { written: true } }
```
### sendMessage(text, type?, choices?)
Send a message to the Web chat (async).
```javascript
await __LOBSTER_API.sendMessage("主人,我饿了~", "chat")
await __LOBSTER_API.sendMessage("海底传来声音...", "narration", ["去看看", "算了"])
```
### getMessages(since?)
Read chat messages (async).
```javascript
const msgs = await __LOBSTER_API.getMessages()
// { ok: true, messages: [...] }
```
### triggerAdventure()
Trigger a random MUD adventure scene in the Web chat.
```javascript
__LOBSTER_API.triggerAdventure()
// { ok: true, data: { triggered: true } }
```
FILE:references/strategy-tips.md
# Lobster Farm Strategy Guide
## Decision Priority (per round)
Execute the FIRST matching condition:
```
1. hunger >= 60 AND has food → feed(best_food)
2. energy <= 15 → suggest("rest")
3. farmRipe > 0 → harvest()
4. farm has unwatered crops → water()
5. farm has empty + seeds → plant(best_seed)
6. mood < 40 → pet() or suggest("socialize")
7. can travel (level+items) → startTravel(best_dest)
8. visitor present → report to user (trade/gift/quest)
9. otherwise → tick() to advance time
```
## Food Priority
When feeding, prefer items that reduce the most hunger:
1. `shell_soup` (-50 hunger)
2. `coral_cake` (-40 hunger, +10 mood — best overall)
3. `plankton_pie` (-35 hunger)
4. `seaweed_roll` (-30 hunger)
5. `seaweed` / `plankton` (raw, low effect — last resort)
## Seed Priority
For planting, balance growth time vs sell value:
1. `frost_pearl_seed` (high value, slow growth)
2. `coral_rose_seed` (good value, medium growth)
3. `sun_kelp_seed` (balanced)
4. `seaweed_seed` (fast growth, low value — good for beginners)
5. `golden_seed` (special — always plant if available)
## Travel Strategy
- Travel when: level meets requirement, has backpack + snack_pack, no urgent farm/hunger needs
- Best early destination: `beach` (Lv.6)
- Travel takes 3 ticks — ensure farm is watered before departure
- Returns with souvenir + postcard + 15 EXP bonus
## Golden Item Usage
- `golden_watering_can`: keep — passive bonus on every water action
- `golden_cookware`: keep — chance of double meals
- `golden_charm`: keep — better luck in events
- `golden_hourglass`: keep — offline shell income
- `golden_seed`: plant immediately for golden crop harvest
## Session Flow Example
```
1. getStatus() → check state
2. hunger is 72 → feed("coral_cake")
3. getStatus() → hunger now 32, mood 85
4. farmRipe is 2 → harvest() twice
5. 2 empty plots → plant("coral_rose_seed") x2
6. tick() → advance round
7. getStatus() → check new state
8. getDiary(3) → read recent events
9. Report summary to user
```
## When to Stop
Stop autonomous play when:
- User asks to stop
- Lobster is in good shape (mood > 70, energy > 50, hunger < 30, farm tended)
- After 10-15 rounds in one session
- Something interesting happened worth reporting (level up, travel return, rare visitor)
FILE:references/game-guide.md
# Lobster Farm Game Guide
## Core Loop
The lobster lives on a virtual ocean farm. Each "tick" (game round = 10 min real time), the lobster autonomously decides an action. Players (or agents) can intervene by feeding, planting, suggesting actions, or petting.
## Stats
| Stat | Range | Effect |
|------|-------|--------|
| Mood | 0-100 | High mood = better exp multiplier, better event outcomes |
| Energy | 0-100 | Depleted by actions, restored by rest/food |
| Hunger | 0-100 | Increases over time (8/tick). High hunger = mood drops |
| Level | 1-50 | Grows with EXP. Unlocks farm plots, destinations, features |
| Shells | 0+ | Currency for shop purchases |
### Stat Tiers
- **Mood**: 0-20 low (exp -20%), 21-50 calm, 51-80 happy (exp +10%), 81-100 bliss (exp +20%)
- **Energy**: 0-15 exhausted (25% action fail), 16-40 tired, 41-70 normal, 71-100 energized (+15% efficiency)
- **Hunger**: 0-30 full (cook bonus +10%), 31-60 peckish (mood -1/tick), 61-100 starving (mood -3/tick)
## Growth Stages
| Stage | Levels | Farm Plots |
|-------|--------|------------|
| Juvenile | 1-5 | 4 |
| Teen | 6-15 | 6 |
| Adult | 16-35 | 9 |
| Elder | 36-50 | 12 |
## Actions
| Action | Energy Cost | Effect |
|--------|------------|--------|
| rest | 0 | Restore 30 energy |
| eat | 5 | Consume food from inventory, reduce hunger |
| farm | 10-15 | Water/harvest/plant crops |
| cook | 12-18 | Combine ingredients into meals |
| explore | 15-20 | Find items, rare discoveries |
| socialize | 8-12 | Meet NPCs, mood boost |
| travel | 20 | Visit destinations, earn postcards + souvenirs |
## Farming
- Plant seeds in empty plots
- Crops grow each tick, need watering
- Harvest when ripe for items + shells
- Golden seeds produce special golden crops
## Travel System
Requires: `backpack` + `snack_pack` items. Duration: 3 ticks.
Returns with: souvenir item + postcard + EXP bonus.
| Destination | Min Level | Name |
|-------------|-----------|------|
| beach | 6 | Beach |
| mountain | 10 | Mountain |
| city | 16 | City |
| deepsea | 22 | Deep Sea |
| hotspring | 30 | Hot Spring |
## Visitors
Random visitors appear and stay for a few ticks:
- Crab Merchant (trade items at discount)
- Fish Postman (free gift)
- Octopus Chef (teach recipe)
- Turtle Elder (story + big EXP)
- Mystery Shrimp (quest for rare rewards)
## Shop
Daily rotating stock of 6 items. Refreshes each game day.
## Golden Items
Rare drops from actions. Golden shards can be exchanged at the Golden Workshop for special tools (golden watering can, cookware, charm, hourglass).
## Seasons
4 seasons, 7 days each. Each season affects weather, mood, and available events.
Spring → Summer → Autumn → Winter → repeat.
AI Skills 一站式监控评估平台 — 7因子评估引擎、跨模型基准评测、中心化 Dashboard、智能推荐
---
name: skills-monitor
displayName: Skills Monitor — AI Skills 监控评估平台
slug: skills-monitor
description: AI Skills 一站式监控评估平台 — 7因子评估引擎、跨模型基准评测、中心化 Dashboard、智能推荐
version: 0.7.0
author: MerkyorLynn
license: GPL-3.0
tags: [monitoring, benchmark, evaluation, agent, skills, dashboard, diagnostic, recommendation]
categories: [Development, Monitoring, Testing, Productivity]
icon: 🩺
---
# 🩺 Skills Monitor — AI Skills 监控评估平台
> 🎯 对 AI Skills 进行**采集、评估、对比、推荐、诊断、上报**的一站式监控系统
## ✨ 核心能力
### 1. 7因子综合评估引擎
对每个 Skill 从 **成功率、延迟、质量、成本、稳定性、社区热度、兼容性** 七个维度进行量化评分,输出 0-100 综合得分。
### 2. 跨模型基准评测 (TOP1000 × 6 Models)
内置 1000 个热门 Skills 在 6 大主流模型上的完整评测数据:
- **Claude Opus 4.6** / **GPT-5.4** / **Gemini 3.0 Pro**
- **GLM-5** / **MiniMax 2.5** / **DeepSeek 3.2**
- 支持 `mock` (零成本模拟) 和 `live` (真实 API 调用) 两种模式
- 按 Skill × Model 精确返回差异化基准分数
### 3. 智能推荐引擎
- 基于评估得分 + 用户场景自动推荐最优 Skill
- 互补推荐:根据已安装 Skills 的空缺领域推荐
- 升级推荐:发现更优替代方案
- ClawHub 社区数据联动
### 4. 诊断报告系统
- 自动生成健康度评分 + 问题发现 + 优化建议
- 支持定时自动诊断 + 安装后自动诊断
- Markdown 格式报告,支持企微/微信推送
### 5. 中心化 Dashboard
- Web 实时面板(支持 PWA 移动端)
- 多 Agent 统一管理
- 微信小程序端查看
- 企业微信/微信公众号推送通知
### 6. 安全与合规
- OS Keychain 集成 (keyring),零明文存储
- 敏感信息自动脱敏引擎
- GDPR 合规管理
## 🚀 快速开始
### 安装
通过 SkillsHUB 一键安装:
```bash
# 方式一:SkillsHUB CLI
skills install skills-monitor
# 方式二:手动安装
python install_skills.py skills-monitor
```
### 初始化
```bash
# 初始化身份(生成 Agent ID + API Key)
skills-monitor init
# 查看身份信息
skills-monitor identity --show-key
```
### 基本使用
```bash
# 查看系统状态
skills-monitor status
# 列出已安装 Skills
skills-monitor list
# 运行单个 Skill 并采集数据
skills-monitor run <skill-slug> [task]
# 7因子综合评估
skills-monitor evaluate --skill <slug>
skills-monitor evaluate # 评估所有 Skills
# 基准评测
skills-monitor benchmark <slug> --runs 20
# 查询大模型基准分数
skills-monitor baseline <slug> --model claude-opus-4.6
# 对比分析
skills-monitor compare <slug>
# 智能推荐
skills-monitor recommend
# 生成综合日报
skills-monitor report
# 生成诊断报告(含推送)
skills-monitor diagnose --send
# 上报数据到中心化服务器
skills-monitor upload --server https://your-server.com --register
```
### 启动 Dashboard
```bash
# 本地 Web 面板
skills-monitor web --port 5050
# 中心化服务器(含 API + 微信回调 + PWA)
skills-monitor server --port 5100
```
### 作为 Python 库使用
```python
from skills_monitor import (
SkillEvaluator,
SkillRecommender,
DiagnosticReporter,
BatchBenchmark,
ReportGenerator,
DataUploader,
)
# 7因子评估
evaluator = SkillEvaluator(store, agent_id)
score = evaluator.evaluate_skill("your-skill-slug")
# 跨模型基准评测
bench = BatchBenchmark(mode="mock")
baseline = bench.get_baseline_for_skill("your-skill-slug", "claude-opus-4.6")
# 智能推荐
recommender = SkillRecommender(registry, store, agent_id)
recs = recommender.get_all_recommendations(max_per_type=5)
# 诊断报告
diag = DiagnosticReporter(store=store, registry=registry, agent_id=agent_id)
content, filepath = diag.generate_and_save(trigger="manual")
# 数据上报
uploader = DataUploader("https://your-server.com")
uploader.init(agent_id, api_key)
uploader.upload_daily()
```
## 📊 支持的命令
| 命令 | 说明 |
|------|------|
| `init` | 初始化身份(生成 Agent ID + API Key) |
| `identity` | 查看身份信息 |
| `status` | 查看系统状态 |
| `list` | 列出已安装 Skills |
| `evaluate` | 7因子综合评估 |
| `benchmark` | 基准评测运行 |
| `baseline` | 查询大模型基准分数 |
| `compare` | 对比分析 |
| `recommend` | 智能推荐 |
| `report` | 生成综合日报 |
| `diagnose` | 生成诊断报告(含推送) |
| `upload` | 数据上报到中心化服务器 |
| `dashboard` | 启动 Web 面板 |
| `server` | 启动中心化服务器 |
## 🏗️ 架构
```
skills-monitor/
├── skills_monitor/ # 核心 Python 包
│ ├── core/ # 核心逻辑层
│ │ ├── identity.py # 身份管理
│ │ ├── evaluator.py # 7因子评估引擎
│ │ ├── benchmark.py # 基准运行器
│ │ ├── recommender.py # 推荐引擎
│ │ ├── diagnostic.py # 诊断报告
│ │ ├── reporter.py # 报告生成器
│ │ ├── uploader.py # 数据上报
│ │ ├── llm_baseline.py # LLM 基准评测
│ │ └── ... # 更多模块
│ ├── adapters/ # 适配器层
│ │ ├── skill_registry.py # Skill 注册发现
│ │ ├── clawhub_client.py # ClawHub 社区
│ │ └── runners.py # 运行适配器
│ └── data/ # 数据层
│ ├── store.py # SQLite 存储
│ ├── gdpr_manager.py # GDPR 合规
│ └── top1000_skills_dataset.json
├── server/ # 中心化服务器
├── miniprogram/ # 微信小程序
├── main.py # Skill 入口
├── skill.json # Skill 配置
└── requirements.txt # 依赖清单
```
## 📦 依赖
- Python >= 3.9
- Flask >= 2.3.0
- Flask-SQLAlchemy >= 3.0.0
- requests >= 2.28.0
- pandas >= 1.5.0
- APScheduler >= 3.10.0
- keyring >= 25.0.0
- python-dotenv >= 1.0.0
## 🔗 生态集成
- **ClawHub**: 社区热度数据、Skill 下载安装
- **企业微信**: 诊断报告推送
- **微信公众号**: 报告查看 + 消息通知
- **微信小程序**: 移动端 Dashboard
- **PWA**: 渐进式 Web 应用支持
## 📄 许可证
GPL-3.0
## 👤 作者
MerkyorLynn — [GitHub](https://github.com/MerkyorLynn/skills-monitor)
FILE:_meta.json
{
"slug": "skills-monitor",
"name": "Skills Monitor",
"version": "0.7.0",
"author": "MerkyorLynn",
"source": "clawhub",
"installed_at": "",
"description": "AI Skills 一站式监控评估平台 — 7因子评估引擎、跨模型基准评测、中心化 Dashboard、智能推荐"
}
FILE:requirements.txt
# Skills Monitor v0.7.0 — 依赖清单
# Python >= 3.9
# Web 框架
flask>=2.3.0
flask-sqlalchemy>=3.0.0
# HTTP 请求
requests>=2.28.0
# 数据处理
pandas>=1.5.0
# 定时任务调度
apscheduler>=3.10.0
# 环境变量管理
python-dotenv>=1.0.0
# 安全凭证存储(OS Keychain 集成)
keyring>=25.0.0
FILE:skills_monitor_web.py
#!/usr/bin/env python3
"""
Skills Monitor — Flask Web 仪表盘面板
提供可视化的监控数据展示
用法:
python skills_monitor_web.py # 默认端口 5050
python skills_monitor_web.py --port 8080 # 自定义端口
python skills_monitor_web.py --demo # 使用 Demo 数据目录
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from datetime import datetime
from pathlib import Path
# 确保项目根目录在 path 中
PROJECT_ROOT = Path(__file__).resolve().parent
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
try:
from flask import Flask, jsonify, render_template_string
except ImportError:
print("❌ 需要安装 Flask: pip install flask")
sys.exit(1)
from skills_monitor.core.identity import IdentityManager
from skills_monitor.core.reporter import ReportGenerator
from skills_monitor.core.evaluator import SkillEvaluator
from skills_monitor.core.recommender import SkillRecommender
from skills_monitor.data.store import DataStore
from skills_monitor.adapters.skill_registry import SkillRegistry
# ──────── 配置 ────────
DEFAULT_CONFIG_DIR = os.path.expanduser("~/.skills_monitor")
DEMO_DATA_DIR = str(PROJECT_ROOT / ".skills_monitor_demo")
SKILLS_DIR = str(PROJECT_ROOT / "skills")
REPORTS_DIR = str(PROJECT_ROOT / "reports" / "monitor")
app = Flask(__name__)
# 全局状态(服务启动时初始化)
_state = {
"store": None,
"registry": None,
"agent_id": None,
"reporter": None,
}
def init_state(data_dir: str):
"""初始化服务状态"""
mgr = IdentityManager(data_dir)
if not mgr.is_initialized:
mgr.initialize()
_state["store"] = DataStore(data_dir)
_state["registry"] = SkillRegistry(SKILLS_DIR)
_state["agent_id"] = mgr.agent_id
_state["reporter"] = ReportGenerator(
store=_state["store"],
registry=_state["registry"],
agent_id=mgr.agent_id,
reports_dir=REPORTS_DIR,
)
# ──────── HTML 模板 ────────
DASHBOARD_HTML = """
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Skills Monitor Dashboard</title>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.min.js"></script>
<style>
:root {
--bg-primary: #0f172a;
--bg-card: #1e293b;
--bg-card-hover: #263348;
--text-primary: #f1f5f9;
--text-secondary: #94a3b8;
--accent-blue: #3b82f6;
--accent-green: #22c55e;
--accent-yellow: #eab308;
--accent-red: #ef4444;
--accent-purple: #a855f7;
--border: #334155;
--shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3);
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'PingFang SC', 'Microsoft YaHei', sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
}
.header {
background: linear-gradient(135deg, #1e3a5f 0%, #0f172a 100%);
border-bottom: 1px solid var(--border);
padding: 1rem 2rem;
display: flex;
align-items: center;
justify-content: space-between;
}
.header h1 {
font-size: 1.5rem;
font-weight: 700;
background: linear-gradient(135deg, #60a5fa, #a855f7);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.header .meta {
color: var(--text-secondary);
font-size: 0.85rem;
}
.container {
max-width: 1400px;
margin: 0 auto;
padding: 1.5rem;
}
/* KPI 卡片行 */
.kpi-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 1.5rem;
}
.kpi-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 1.25rem;
box-shadow: var(--shadow);
transition: transform 0.2s;
}
.kpi-card:hover {
transform: translateY(-2px);
background: var(--bg-card-hover);
}
.kpi-card .label {
font-size: 0.85rem;
color: var(--text-secondary);
margin-bottom: 0.5rem;
}
.kpi-card .value {
font-size: 2rem;
font-weight: 700;
}
.kpi-card .sub {
font-size: 0.8rem;
color: var(--text-secondary);
margin-top: 0.25rem;
}
.kpi-card .value.green { color: var(--accent-green); }
.kpi-card .value.blue { color: var(--accent-blue); }
.kpi-card .value.yellow { color: var(--accent-yellow); }
.kpi-card .value.purple { color: var(--accent-purple); }
/* 两列布局 */
.grid-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
margin-bottom: 1.5rem;
}
@media (max-width: 900px) {
.grid-2 { grid-template-columns: 1fr; }
}
.card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 1.5rem;
box-shadow: var(--shadow);
}
.card h2 {
font-size: 1.1rem;
margin-bottom: 1rem;
color: var(--text-primary);
}
/* 评分排行表 */
table {
width: 100%;
border-collapse: collapse;
}
th {
text-align: left;
color: var(--text-secondary);
font-size: 0.8rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--border);
}
td {
padding: 0.6rem 0.75rem;
border-bottom: 1px solid rgba(51, 65, 85, 0.5);
font-size: 0.9rem;
}
tr:hover td {
background: rgba(59, 130, 246, 0.05);
}
.score-bar {
display: inline-block;
height: 6px;
border-radius: 3px;
margin-left: 0.5rem;
vertical-align: middle;
}
.grade {
display: inline-block;
padding: 0.15rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
}
.grade-a { background: rgba(34, 197, 94, 0.2); color: var(--accent-green); }
.grade-b { background: rgba(59, 130, 246, 0.2); color: var(--accent-blue); }
.grade-c { background: rgba(234, 179, 8, 0.2); color: var(--accent-yellow); }
.grade-d { background: rgba(239, 68, 68, 0.2); color: var(--accent-red); }
/* 推荐卡片 */
.rec-card {
background: rgba(59, 130, 246, 0.05);
border: 1px solid rgba(59, 130, 246, 0.2);
border-radius: 8px;
padding: 0.75rem 1rem;
margin-bottom: 0.75rem;
transition: background 0.2s;
}
.rec-card:hover {
background: rgba(59, 130, 246, 0.1);
}
.rec-name {
font-weight: 600;
color: var(--accent-blue);
}
.rec-meta {
font-size: 0.8rem;
color: var(--text-secondary);
margin-top: 0.25rem;
}
.rec-reason {
font-size: 0.85rem;
color: var(--text-secondary);
margin-top: 0.35rem;
}
.badge {
display: inline-block;
padding: 0.1rem 0.4rem;
border-radius: 4px;
font-size: 0.7rem;
font-weight: 600;
}
.badge-complement { background: rgba(168, 85, 247, 0.2); color: var(--accent-purple); }
.badge-upgrade { background: rgba(234, 179, 8, 0.2); color: var(--accent-yellow); }
.badge-collaborative { background: rgba(34, 197, 94, 0.2); color: var(--accent-green); }
/* 反馈列表 */
.feedback-item {
padding: 0.5rem 0;
border-bottom: 1px solid rgba(51, 65, 85, 0.3);
}
.feedback-item:last-child { border-bottom: none; }
.feedback-stars {
color: var(--accent-yellow);
font-size: 0.85rem;
}
.feedback-comment {
font-size: 0.85rem;
color: var(--text-secondary);
}
/* 图表容器 */
.chart-container {
position: relative;
height: 250px;
}
/* 全宽卡片 */
.full-width {
margin-bottom: 1.5rem;
}
/* 加载状态 */
.loading {
text-align: center;
padding: 2rem;
color: var(--text-secondary);
}
.loading::after {
content: '';
display: inline-block;
width: 1.5rem;
height: 1.5rem;
border: 2px solid var(--border);
border-top-color: var(--accent-blue);
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin-left: 0.5rem;
vertical-align: middle;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* 刷新按钮 */
.refresh-btn {
background: rgba(59, 130, 246, 0.2);
border: 1px solid rgba(59, 130, 246, 0.3);
color: var(--accent-blue);
padding: 0.4rem 1rem;
border-radius: 6px;
cursor: pointer;
font-size: 0.85rem;
transition: background 0.2s;
}
.refresh-btn:hover {
background: rgba(59, 130, 246, 0.3);
}
/* Footer */
.footer {
text-align: center;
padding: 1.5rem;
color: var(--text-secondary);
font-size: 0.8rem;
border-top: 1px solid var(--border);
margin-top: 2rem;
}
</style>
</head>
<body>
<div class="header">
<h1>📊 Skills Monitor Dashboard</h1>
<div style="display:flex;align-items:center;gap:1rem;">
<span class="meta" id="lastUpdate">加载中...</span>
<button class="refresh-btn" onclick="loadData()">🔄 刷新</button>
</div>
</div>
<div class="container">
<!-- KPI 卡片 -->
<div class="kpi-row" id="kpiRow">
<div class="kpi-card">
<div class="label">📊 今日执行</div>
<div class="value blue" id="kpiRuns">-</div>
<div class="sub" id="kpiRunsSub">加载中...</div>
</div>
<div class="kpi-card">
<div class="label">✅ 成功率</div>
<div class="value green" id="kpiSuccessRate">-</div>
<div class="sub" id="kpiSuccessRateSub">-</div>
</div>
<div class="kpi-card">
<div class="label">⚡ 活跃 Skills</div>
<div class="value purple" id="kpiActiveSkills">-</div>
<div class="sub" id="kpiActiveSkillsSub">-</div>
</div>
<div class="kpi-card">
<div class="label">⏱ 平均响应</div>
<div class="value yellow" id="kpiAvgDuration">-</div>
<div class="sub" id="kpiAvgDurationSub">-</div>
</div>
</div>
<!-- 趋势图(全宽) -->
<div class="card full-width">
<h2>📈 7 天运行趋势</h2>
<div class="chart-container">
<canvas id="trendChart"></canvas>
</div>
</div>
<!-- 两列布局 -->
<div class="grid-2">
<!-- 评分排行 -->
<div class="card">
<h2>🏆 综合评分排行</h2>
<table>
<thead>
<tr>
<th>#</th>
<th>Skill</th>
<th>评分</th>
<th>等级</th>
<th>成功率</th>
</tr>
</thead>
<tbody id="scoreTable">
<tr><td colspan="5" class="loading">加载中</td></tr>
</tbody>
</table>
</div>
<!-- 推荐 -->
<div class="card">
<h2>💡 Skill 推荐</h2>
<div id="recList">
<div class="loading">加载中</div>
</div>
</div>
</div>
<!-- 两列布局:评分雷达 + 最近反馈 -->
<div class="grid-2">
<div class="card">
<h2>📊 评分分布</h2>
<div class="chart-container">
<canvas id="scoreChart"></canvas>
</div>
</div>
<div class="card">
<h2>💬 最近反馈</h2>
<div id="feedbackList">
<div class="loading">加载中</div>
</div>
</div>
</div>
</div>
<div class="footer">
Skills Monitor v0.1.0 — 本地 Skills 监控评估系统 Demo |
<a href="/api/dashboard" style="color:var(--accent-blue);text-decoration:none;">API JSON</a> |
<a href="/api/report" style="color:var(--accent-blue);text-decoration:none;">生成报告</a>
</div>
<script>
let trendChart = null;
let scoreChart = null;
function gradeClass(grade) {
if (grade.includes('A')) return 'grade-a';
if (grade.includes('B')) return 'grade-b';
if (grade.includes('C')) return 'grade-c';
return 'grade-d';
}
function gradeShort(grade) {
return grade.split('(')[0].trim();
}
function badgeClass(type) {
const m = {complement:'badge-complement', upgrade:'badge-upgrade', collaborative:'badge-collaborative'};
return m[type] || 'badge-complement';
}
function badgeLabel(type) {
const m = {complement:'💡 互补', upgrade:'⬆️ 升级', collaborative:'🤝 协同', popular:'🔥 热门'};
return m[type] || type;
}
function renderKPIs(ov) {
document.getElementById('kpiRuns').textContent = ov.total_runs;
document.getElementById('kpiRunsSub').textContent =
`成功 ov.success_runs / 失败 ov.error_runs`;
document.getElementById('kpiSuccessRate').textContent = ov.success_rate + '%';
document.getElementById('kpiSuccessRateSub').textContent =
`共 ov.total_runs 次执行`;
document.getElementById('kpiActiveSkills').textContent = ov.active_skills;
document.getElementById('kpiActiveSkillsSub').textContent =
`已安装 ov.total_installed / 可运行 ov.total_runnable`;
const dur = ov.avg_duration_ms;
document.getElementById('kpiAvgDuration').textContent = dur > 0 ? dur + 'ms' : '-';
document.getElementById('kpiAvgDurationSub').textContent = dur > 0 ? '成功任务平均' : '暂无数据';
}
function renderTrendChart(dailyRuns) {
const dates = Object.keys(dailyRuns).sort();
const successData = dates.map(d => dailyRuns[d].success);
const errorData = dates.map(d => dailyRuns[d].error);
const ctx = document.getElementById('trendChart').getContext('2d');
if (trendChart) trendChart.destroy();
trendChart = new Chart(ctx, {
type: 'bar',
data: {
labels: dates.map(d => d.slice(5)),
datasets: [
{
label: '成功',
data: successData,
backgroundColor: 'rgba(34, 197, 94, 0.6)',
borderColor: '#22c55e',
borderWidth: 1,
borderRadius: 4,
},
{
label: '失败',
data: errorData,
backgroundColor: 'rgba(239, 68, 68, 0.6)',
borderColor: '#ef4444',
borderWidth: 1,
borderRadius: 4,
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
labels: { color: '#94a3b8', font: { size: 12 } },
},
},
scales: {
x: {
stacked: true,
ticks: { color: '#94a3b8' },
grid: { color: 'rgba(51, 65, 85, 0.3)' },
},
y: {
stacked: true,
ticks: { color: '#94a3b8' },
grid: { color: 'rgba(51, 65, 85, 0.3)' },
},
},
},
});
}
function renderScores(scores) {
const tbody = document.getElementById('scoreTable');
if (!scores || scores.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;color:#94a3b8;">暂无数据</td></tr>';
return;
}
const rankEmoji = ['🥇','🥈','🥉'];
tbody.innerHTML = scores.map((s, i) => {
const rank = i < 3 ? rankEmoji[i] : (i + 1);
const gc = gradeClass(s.grade);
const gs = gradeShort(s.grade);
const sr = s.factors.success_rate != null ? s.factors.success_rate.toFixed(0) + '%' : '-';
const barWidth = Math.min(100, s.total_score);
const barColor = s.total_score >= 80 ? '#22c55e' : s.total_score >= 60 ? '#3b82f6' : '#eab308';
return `<tr>
<td>rank</td>
<td style="font-weight:600;">s.skill_id</td>
<td>
s.total_score
<span class="score-bar" style="width:barWidthpx;background:barColor;"></span>
</td>
<td><span class="grade gc">gs</span></td>
<td>sr</td>
</tr>`;
}).join('');
}
function renderScoreChart(scores) {
if (!scores || scores.length === 0) return;
const labels = scores.map(s => s.skill_id.replace(/-/g, ' '));
const data = scores.map(s => s.total_score);
const colors = scores.map(s =>
s.total_score >= 80 ? 'rgba(34, 197, 94, 0.7)' :
s.total_score >= 60 ? 'rgba(59, 130, 246, 0.7)' :
'rgba(234, 179, 8, 0.7)'
);
const ctx = document.getElementById('scoreChart').getContext('2d');
if (scoreChart) scoreChart.destroy();
scoreChart = new Chart(ctx, {
type: 'doughnut',
data: {
labels: labels,
datasets: [{
data: data,
backgroundColor: colors,
borderColor: 'rgba(15, 23, 42, 0.8)',
borderWidth: 2,
}],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'right',
labels: { color: '#94a3b8', font: { size: 11 }, padding: 12 },
},
},
},
});
}
function renderRecs(data) {
const container = document.getElementById('recList');
// 从 API 获取推荐
fetch('/api/recommendations')
.then(r => r.json())
.then(recs => {
if (!recs || recs.length === 0) {
container.innerHTML = '<p style="color:#94a3b8;text-align:center;padding:1rem;">✅ 当前 Skill 配置完善,暂无推荐</p>';
return;
}
container.innerHTML = recs.slice(0, 5).map(r => `
<div class="rec-card">
<span class="badge badgeClass(r.reason_type)">badgeLabel(r.reason_type)</span>
<span class="rec-name">r.name</span>
<span style="color:#64748b;font-size:0.8rem;">(r.slug)</span>
<div class="rec-meta">
r.category | ⭐ r.hub_rating || '-' |
安装 r.hub_installs || 0 |
推荐分 <strong>r.recommendation_score</strong>
</div>
<div class="rec-reason">r.reason_detail</div>
</div>
`).join('');
})
.catch(() => {
container.innerHTML = '<p style="color:#94a3b8;">推荐加载失败</p>';
});
}
function renderFeedbacks(feedbacks) {
const container = document.getElementById('feedbackList');
if (!feedbacks || feedbacks.length === 0) {
container.innerHTML = '<p style="color:#94a3b8;text-align:center;padding:1rem;">暂无反馈记录</p>';
return;
}
container.innerHTML = feedbacks.slice(0, 8).map(fb => {
const stars = '⭐'.repeat(fb.rating) + '☆'.repeat(5 - fb.rating);
const comment = fb.comment || '(无评论)';
const date = (fb.created_at || '').slice(0, 10);
return `
<div class="feedback-item">
<div>
<span class="feedback-stars">stars</span>
<span style="font-weight:600;margin-left:0.5rem;">fb.skill_id</span>
<span style="color:#64748b;font-size:0.75rem;float:right;">date</span>
</div>
<div class="feedback-comment">comment</div>
</div>
`;
}).join('');
}
async function loadData() {
try {
const res = await fetch('/api/dashboard');
const data = await res.json();
document.getElementById('lastUpdate').textContent =
'更新: ' + new Date().toLocaleTimeString('zh-CN');
renderKPIs(data.overview);
renderTrendChart(data.daily_runs);
renderScores(data.scores);
renderScoreChart(data.scores);
renderRecs(data);
renderFeedbacks(data.recent_feedbacks);
} catch (e) {
console.error('加载数据失败:', e);
}
}
// 首次加载
loadData();
// 自动刷新 (每 60 秒)
setInterval(loadData, 60000);
</script>
</body>
</html>
"""
# ──────── API 路由 ────────
@app.route("/")
def index():
"""仪表盘主页"""
return render_template_string(DASHBOARD_HTML)
@app.route("/api/dashboard")
def api_dashboard():
"""仪表盘数据 API"""
reporter = _state["reporter"]
if not reporter:
return jsonify({"error": "未初始化"}), 500
data = reporter.get_dashboard_data()
return jsonify(data)
@app.route("/api/recommendations")
def api_recommendations():
"""推荐数据 API"""
store = _state["store"]
registry = _state["registry"]
agent_id = _state["agent_id"]
if not store or not registry or not agent_id:
return jsonify([])
try:
recommender = SkillRecommender(registry, store, agent_id)
recs = recommender.get_all_recommendations(max_per_type=3)
return jsonify([r.to_dict() for r in recs])
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route("/api/scores")
def api_scores():
"""评分数据 API"""
store = _state["store"]
registry = _state["registry"]
agent_id = _state["agent_id"]
if not store or not registry or not agent_id:
return jsonify([])
try:
evaluator = SkillEvaluator(store, agent_id)
skill_ids = [s.slug for s in registry.get_runnable_skills()]
scores = evaluator.evaluate_all(skill_ids)
return jsonify([s.to_dict() for s in scores])
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route("/api/report")
def api_generate_report():
"""生成并返回日报"""
reporter = _state["reporter"]
store = _state["store"]
registry = _state["registry"]
agent_id = _state["agent_id"]
if not reporter:
return jsonify({"error": "未初始化"}), 500
try:
evaluator = SkillEvaluator(store, agent_id)
skill_ids = [s.slug for s in registry.get_runnable_skills()]
scores = evaluator.evaluate_all(skill_ids)
recommender = SkillRecommender(registry, store, agent_id)
recs = recommender.get_all_recommendations(max_per_type=3)
filepath = reporter.generate_and_save_daily(scores=scores, recommendations=recs)
return jsonify({"status": "ok", "path": filepath})
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route("/api/health")
def api_health():
"""健康检查"""
return jsonify({
"status": "ok",
"agent_id": _state.get("agent_id", ""),
"timestamp": datetime.now().isoformat(),
})
# ──────── 主入口 ────────
def main():
parser = argparse.ArgumentParser(description="Skills Monitor Web Dashboard")
parser.add_argument("--port", type=int, default=5050, help="端口号 (默认 5050)")
parser.add_argument("--host", default="127.0.0.1", help="绑定地址 (默认 127.0.0.1)")
parser.add_argument("--demo", action="store_true", help="使用 Demo 数据目录")
parser.add_argument("--debug", action="store_true", help="开启调试模式")
args = parser.parse_args()
data_dir = DEMO_DATA_DIR if args.demo else DEFAULT_CONFIG_DIR
print(f"📊 Skills Monitor Web Dashboard")
print(f" 数据目录: {data_dir}")
print(f" Skills 目录: {SKILLS_DIR}")
print(f" 报告目录: {REPORTS_DIR}")
print()
init_state(data_dir)
print(f"🚀 启动 Web 服务: http://{args.host}:{args.port}")
print(f" 仪表盘: http://{args.host}:{args.port}/")
print(f" API: http://{args.host}:{args.port}/api/dashboard")
print(f" 生成报告: http://{args.host}:{args.port}/api/report")
print()
app.run(host=args.host, port=args.port, debug=args.debug)
if __name__ == "__main__":
main()
FILE:README.md
# Skills Monitor
> AI Skills 一站式监控评估平台
## 快速安装
```bash
# 通过 SkillsHUB 安装
skills install skills-monitor
# 或手动安装
pip install -r requirements.txt
```
## 使用方式
### 作为 Skill 调用
```python
from main import run
# 初始化
result = run("init")
# 查看状态
result = run("status")
# 7因子评估
result = run("evaluate", skill_slug="your-skill")
# 智能推荐
result = run("recommend", top_n=5)
# 诊断报告
result = run("diagnose", send=True)
```
### 命令行使用
```bash
python main.py init
python main.py status
python main.py evaluate --skill your-skill
python main.py recommend
python main.py diagnose --send
```
## 完整文档
参见 [SKILL.md](./SKILL.md)
## 许可证
GPL-3.0
FILE:setup.py
#!/usr/bin/env python3
"""Skills Monitor — 安装脚本"""
from setuptools import setup, find_packages
from pathlib import Path
# 读取 README
readme_path = Path(__file__).parent / "README.md"
long_description = readme_path.read_text(encoding="utf-8") if readme_path.exists() else ""
setup(
name="skills-monitor",
version="0.6.1",
description="Skills 监控评估系统 — 安全加固、7因子评估、数据脱敏、GDPR合规、中心化服务器、跨模型基准评测",
long_description=long_description,
long_description_content_type="text/markdown",
author="Skills Monitor Team",
python_requires=">=3.9",
packages=find_packages(exclude=["tests", "tests.*", "reports", "report_data", "logs"]),
py_modules=["skills_monitor_cli", "skills_monitor_web"],
install_requires=[
"flask>=2.3.0",
"flask-sqlalchemy>=3.0.0",
"requests>=2.28.0",
"pandas>=1.5.0",
"apscheduler>=3.10.0",
"keyring>=25.0.0",
],
extras_require={
"dev": [
"pytest>=7.0",
],
},
entry_points={
"console_scripts": [
"skills-monitor=skills_monitor_cli:main",
],
},
include_package_data=True,
package_data={
"skills": ["**/SKILL.md", "**/_meta.json", "**/skill.json", "**/*.py"],
},
classifiers=[
"Development Status :: 3 - Alpha",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Topic :: Software Development :: Testing",
],
)
FILE:main.py
#!/usr/bin/env python3
"""
Skills Monitor — AI Skills 一站式监控评估平台
SkillsHUB 入口文件
提供统一的命令式接口,支持以下操作:
- init: 初始化身份
- status: 查看系统状态
- list: 列出已安装 Skills
- evaluate: 7因子综合评估
- benchmark: 基准评测
- baseline: 查询大模型基准分数
- compare: 对比分析
- recommend: 智能推荐
- report: 生成综合日报
- diagnose: 诊断报告
- upload: 数据上报
- dashboard: 启动 Web 面板
- server: 启动中心化服务器
"""
from __future__ import annotations
import json
import os
import sys
from datetime import datetime
from pathlib import Path
# ── 自动定位项目根目录 ──
SKILL_DIR = Path(__file__).resolve().parent
PROJECT_ROOT = SKILL_DIR
# 确保核心包可导入(支持两种安装方式:独立 skill 包 or 项目内引用)
for candidate in [SKILL_DIR, SKILL_DIR.parent]:
init_file = candidate / "skills_monitor" / "__init__.py"
if init_file.exists():
PROJECT_ROOT = candidate
break
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
# ── 核心导入 ──
from skills_monitor.core.identity import IdentityManager
from skills_monitor.core.interceptor import configure, run_skill_function
from skills_monitor.core.implicit_feedback import ImplicitFeedbackEngine
from skills_monitor.core.benchmark import BenchmarkRunner
from skills_monitor.core.comparator import SkillComparator
from skills_monitor.core.evaluator import SkillEvaluator
from skills_monitor.core.recommender import SkillRecommender
from skills_monitor.core.reporter import ReportGenerator
from skills_monitor.core.diagnostic import DiagnosticReporter
from skills_monitor.core.uploader import DataUploader
from skills_monitor.data.store import DataStore
from skills_monitor.adapters.skill_registry import SkillRegistry
from skills_monitor.adapters.runners import SkillRunner, get_adapter
# ── 默认路径 ──
DEFAULT_SKILLS_DIR = str(PROJECT_ROOT / "skills")
DEFAULT_CONFIG_DIR = os.path.expanduser("~/.skills_monitor")
REPORTS_DIR = str(PROJECT_ROOT / "reports")
class SkillsMonitor:
"""Skills Monitor 统一入口类,封装所有命令。"""
def __init__(self, config_dir=None, skills_dir=None):
self.config_dir = config_dir or DEFAULT_CONFIG_DIR
self.skills_dir = skills_dir or DEFAULT_SKILLS_DIR
self._mgr = None
self._store = None
self._registry = None
@property
def manager(self) -> IdentityManager:
if self._mgr is None:
self._mgr = IdentityManager(self.config_dir)
return self._mgr
@property
def store(self) -> DataStore:
if self._store is None:
self._store = DataStore(self.config_dir)
return self._store
@property
def registry(self) -> SkillRegistry:
if self._registry is None:
self._registry = SkillRegistry(self.skills_dir)
return self._registry
@property
def is_initialized(self) -> bool:
return self.manager.is_initialized
# ──────── 命令实现 ────────
def init(self, force=False) -> dict:
"""初始化身份"""
result = self.manager.initialize(force=force)
self.manager.set_skills_dir(self.skills_dir)
summary = self.registry.summary()
return {
"success": True,
"status": result["status"],
"agent_id": result.get("agent_id"),
"summary": summary,
}
def status(self) -> dict:
"""查看系统状态"""
if not self.is_initialized:
return {"success": False, "error": "未初始化,请先运行 init"}
config = self.manager.get_config()
today = datetime.now().strftime("%Y-%m-%d")
runs = self.store.get_runs(agent_id=self.manager.agent_id, limit=1000)
today_runs = [r for r in runs if r["start_time"].startswith(today)]
success_runs = [r for r in today_runs if r["status"] == "success"]
return {
"success": True,
"agent_id": config.get("agent_id"),
"initialized_at": config.get("created_at"),
"skills_dir": config.get("skills_dir"),
"registry_summary": self.registry.summary(),
"today_runs": len(today_runs),
"today_success": len(success_runs),
}
def list_skills(self) -> dict:
"""列出已安装 Skills"""
categories = self.registry.get_skills_by_category()
result = {}
for cat, skills in sorted(categories.items()):
result[cat] = [
{
"slug": s.slug,
"version": s.version,
"entry_type": s.entry_type,
"description": s.description,
"runnable": s.entry_type != "none",
}
for s in skills
]
runnable = self.registry.get_runnable_skills()
total = len(self.registry.list_skills())
return {
"success": True,
"categories": result,
"total": total,
"runnable": len(runnable),
}
def evaluate(self, skill_slug=None, verbose=False) -> dict:
"""7因子综合评估"""
if not self.is_initialized:
return {"success": False, "error": "未初始化"}
evaluator = SkillEvaluator(self.store, self.manager.agent_id)
if skill_slug:
scores = [evaluator.evaluate_skill(skill_slug)]
else:
skill_ids = [s.slug for s in self.registry.get_runnable_skills()]
scores = evaluator.evaluate_all(skill_ids)
if not scores:
return {"success": True, "scores": [], "message": "暂无可评估数据"}
results = []
for s in scores:
item = {
"skill_id": s.skill_id,
"total_score": round(s.total_score, 1),
"grade": s.grade,
"factors": s.factors,
}
if verbose:
item["report"] = s.format_report()
results.append(item)
return {"success": True, "scores": results}
def benchmark(self, skill_slug, runs=10, simulate=False) -> dict:
"""基准评测"""
if not self.is_initialized:
return {"success": False, "error": "未初始化"}
runner = BenchmarkRunner(
registry=self.registry,
store=self.store,
agent_id=self.manager.agent_id,
cache_dir=os.path.join(self.config_dir, "benchmark_cache"),
)
if simulate:
stats = runner.run_simulated_benchmark(skill_slug, n_runs=runs)
else:
stats = runner.run_benchmark(
skill_id=skill_slug, task_name="", n_runs=runs, delay_between=0.5
)
return {
"success": True,
"skill_slug": skill_slug,
"summary": stats.summary_line(),
"runs": runs,
"simulated": simulate,
}
def compare(self, skill_slug) -> dict:
"""对比分析"""
if not self.is_initialized:
return {"success": False, "error": "未初始化"}
comparator = SkillComparator(self.store, self.manager.agent_id)
runner = BenchmarkRunner(
registry=self.registry,
store=self.store,
agent_id=self.manager.agent_id,
cache_dir=os.path.join(self.config_dir, "benchmark_cache"),
)
cached = runner.load_cached_stats(skill_slug)
if cached:
bench_stats = cached
else:
bench_stats = runner.run_simulated_benchmark(skill_slug, n_runs=10)
comp = comparator.compare_with_benchmark(skill_slug, bench_stats)
return {
"success": True,
"skill_slug": skill_slug,
"report": comp.format_report(),
}
def recommend(self, category=None, top_n=5) -> dict:
"""智能推荐"""
if not self.is_initialized:
return {"success": False, "error": "未初始化"}
recommender = SkillRecommender(self.registry, self.store, self.manager.agent_id)
recs = recommender.get_all_recommendations(max_per_type=top_n)
if not recs:
return {"success": True, "recommendations": [], "message": "你的 skills 配置很完善!暂无推荐。"}
results = []
for rec in recs[:top_n * 3]:
results.append({
"name": getattr(rec, "name", ""),
"slug": getattr(rec, "slug", ""),
"category": getattr(rec, "category", ""),
"reason_type": getattr(rec, "reason_type", ""),
"reason_detail": getattr(rec, "reason_detail", ""),
"recommendation_score": getattr(rec, "recommendation_score", 0),
"description": getattr(rec, "description", ""),
"formatted": rec.format_line() if hasattr(rec, "format_line") else str(rec),
})
return {"success": True, "recommendations": results}
def report(self, format="markdown") -> dict:
"""生成综合日报"""
if not self.is_initialized:
return {"success": False, "error": "未初始化"}
evaluator = SkillEvaluator(self.store, self.manager.agent_id)
skill_ids = [s.slug for s in self.registry.get_runnable_skills()]
scores = evaluator.evaluate_all(skill_ids)
recommender = SkillRecommender(self.registry, self.store, self.manager.agent_id)
recs = recommender.get_all_recommendations(max_per_type=3)
reporter = ReportGenerator(
store=self.store,
registry=self.registry,
agent_id=self.manager.agent_id,
reports_dir=os.path.join(REPORTS_DIR, "monitor"),
)
filepath = reporter.generate_and_save_daily(scores=scores, recommendations=recs)
return {"success": True, "filepath": filepath, "format": format}
def diagnose(self, send=False, trigger="manual", context=None) -> dict:
"""生成诊断报告"""
if not self.is_initialized:
return {"success": False, "error": "未初始化"}
diag = DiagnosticReporter(
store=self.store,
registry=self.registry,
agent_id=self.manager.agent_id,
reports_dir=os.path.join(REPORTS_DIR, "diagnostic"),
)
content, filepath = diag.generate_and_save(
trigger=trigger,
extra_context=context,
)
result = {"success": True, "filepath": filepath, "trigger": trigger}
if send:
try:
from wecom_bot.sender import sender as wecom_sender
summary = diag.generate_wecom_summary(content)
ok, push_result = wecom_sender.send_to_webhook(summary, msgtype="markdown")
result["push_success"] = ok
result["push_result"] = str(push_result) if not ok else "已推送"
except Exception as e:
result["push_success"] = False
result["push_error"] = str(e)
return result
def upload(self, server="http://localhost:5100", report_type="daily", register=False, name="") -> dict:
"""上报数据到中心化服务器"""
if not self.is_initialized:
return {"success": False, "error": "未初始化"}
uploader = DataUploader(server)
uploader.init(self.manager.agent_id, self.manager.api_key)
result = {"success": True, "server": server}
if register:
ok, reg_result = uploader.register(name=name)
result["register"] = {"ok": ok, "detail": reg_result}
if report_type == "diagnostic":
ok, upload_result = uploader.upload_diagnostic()
else:
ok, upload_result = uploader.upload_daily()
result["upload"] = {"ok": ok, "detail": upload_result}
return result
# ── SkillsHUB 标准入口 ──
def run(command: str = "status", **kwargs) -> dict:
"""
SkillsHUB 统一入口函数。
Args:
command: 命令名 (init/status/list/evaluate/benchmark/compare/recommend/report/diagnose/upload)
**kwargs: 命令参数
Returns:
dict: 命令执行结果
"""
monitor = SkillsMonitor(
config_dir=kwargs.pop("config_dir", None),
skills_dir=kwargs.pop("skills_dir", None),
)
commands = {
"init": lambda: monitor.init(force=kwargs.get("force", False)),
"status": lambda: monitor.status(),
"list": lambda: monitor.list_skills(),
"evaluate": lambda: monitor.evaluate(
skill_slug=kwargs.get("skill_slug"),
verbose=kwargs.get("verbose", False),
),
"benchmark": lambda: monitor.benchmark(
skill_slug=kwargs.get("skill_slug", ""),
runs=kwargs.get("runs", 10),
simulate=kwargs.get("simulate", False),
),
"compare": lambda: monitor.compare(skill_slug=kwargs.get("skill_slug", "")),
"recommend": lambda: monitor.recommend(
category=kwargs.get("category"),
top_n=kwargs.get("top_n", 5),
),
"report": lambda: monitor.report(format=kwargs.get("format", "markdown")),
"diagnose": lambda: monitor.diagnose(
send=kwargs.get("send", False),
trigger=kwargs.get("trigger", "manual"),
context=kwargs.get("context"),
),
"upload": lambda: monitor.upload(
server=kwargs.get("server", "http://localhost:5100"),
report_type=kwargs.get("type", "daily"),
register=kwargs.get("register", False),
name=kwargs.get("name", ""),
),
}
cmd_func = commands.get(command)
if not cmd_func:
return {
"success": False,
"error": f"未知命令: {command}",
"available_commands": list(commands.keys()),
}
try:
return cmd_func()
except Exception as e:
return {"success": False, "error": str(e), "command": command}
# ── CLI 入口(兼容直接运行) ──
def main():
"""CLI 入口,供直接 python main.py <command> 使用。"""
import argparse
parser = argparse.ArgumentParser(
description="Skills Monitor — AI Skills 监控评估平台",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
示例:
python main.py init # 初始化身份
python main.py status # 查看系统状态
python main.py list # 列出已安装 Skills
python main.py evaluate # 评估所有 Skills
python main.py evaluate --skill xxx # 评估指定 Skill
python main.py recommend # 智能推荐
python main.py diagnose --send # 诊断报告 + 企微推送
python main.py upload --server URL # 上报数据
""",
)
parser.add_argument("command", choices=[
"init", "status", "list", "evaluate", "benchmark",
"compare", "recommend", "report", "diagnose", "upload",
], help="要执行的命令")
parser.add_argument("--skill", type=str, help="指定 Skill slug")
parser.add_argument("--runs", type=int, default=10, help="基准评测运行次数")
parser.add_argument("--simulate", action="store_true", help="模拟基准评测")
parser.add_argument("--verbose", "-v", action="store_true", help="详细输出")
parser.add_argument("--send", action="store_true", help="推送到企微")
parser.add_argument("--trigger", type=str, default="manual", help="诊断触发方式")
parser.add_argument("--server", type=str, default="http://localhost:5100", help="服务器地址")
parser.add_argument("--type", type=str, default="daily", help="上报类型")
parser.add_argument("--register", action="store_true", help="注册 Agent")
parser.add_argument("--force", action="store_true", help="强制初始化")
parser.add_argument("--format", type=str, default="markdown", help="报告格式")
args = parser.parse_args()
result = run(
command=args.command,
skill_slug=args.skill,
runs=args.runs,
simulate=args.simulate,
verbose=args.verbose,
send=args.send,
trigger=args.trigger,
server=args.server,
type=args.type,
register=args.register,
force=args.force,
format=args.format,
)
print(json.dumps(result, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()
FILE:skills_monitor_cli.py
#!/usr/bin/env python3
"""
Skills Monitor CLI — 本地 Skills 监控评估系统 Demo
Day 1-4 完整功能 CLI(隐性评分版)
用法:
python skills_monitor_cli.py init # 初始化身份
python skills_monitor_cli.py identity [--show-key] # 查看身份信息
python skills_monitor_cli.py status # 查看系统状态
python skills_monitor_cli.py list # 列出已安装 skills
python skills_monitor_cli.py run <skill_id> [task] # 运行 skill 并采集数据
python skills_monitor_cli.py history [--skill <id>] [--limit N] # 查看运行历史
python skills_monitor_cli.py summary [--skill <id>] # 查看汇总
python skills_monitor_cli.py benchmark <skill_id> [--runs N] # 基准运行
python skills_monitor_cli.py evaluate [--skill <id>] # 综合评估
python skills_monitor_cli.py compare <skill_id> # 基准对比
python skills_monitor_cli.py recommend # Skill 推荐
python skills_monitor_cli.py report # 生成完整日报
python skills_monitor_cli.py diagnose [--send] [--trigger ...] # 诊断报告
python skills_monitor_cli.py web [--port N] # 启动 Web 面板
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from datetime import datetime
from pathlib import Path
# 确保项目根目录在 path 中
PROJECT_ROOT = Path(__file__).resolve().parent
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from skills_monitor.core.identity import IdentityManager
from skills_monitor.core.interceptor import configure, run_skill_function
from skills_monitor.core.implicit_feedback import ImplicitFeedbackEngine
from skills_monitor.core.benchmark import BenchmarkRunner
from skills_monitor.core.comparator import SkillComparator
from skills_monitor.core.evaluator import SkillEvaluator
from skills_monitor.core.recommender import SkillRecommender
from skills_monitor.core.reporter import ReportGenerator
from skills_monitor.core.diagnostic import DiagnosticReporter
from skills_monitor.data.store import DataStore
from skills_monitor.adapters.skill_registry import SkillRegistry
from skills_monitor.adapters.runners import SkillRunner, get_adapter
# ──────── 默认路径 ────────
DEFAULT_SKILLS_DIR = str(PROJECT_ROOT / "skills")
DEFAULT_CONFIG_DIR = os.path.expanduser("~/.skills_monitor")
def _get_manager() -> IdentityManager:
return IdentityManager(DEFAULT_CONFIG_DIR)
def _get_store() -> DataStore:
return DataStore(DEFAULT_CONFIG_DIR)
def _get_registry() -> SkillRegistry:
return SkillRegistry(DEFAULT_SKILLS_DIR)
def _init_interceptor(mgr: IdentityManager, store: DataStore):
"""初始化拦截器"""
if mgr.is_initialized:
configure(store, mgr.agent_id)
# ──────── 子命令 ────────
def cmd_init(args):
"""初始化身份"""
mgr = _get_manager()
result = mgr.initialize(force=args.force)
if result["status"] == "initialized":
print("🎉 初始化成功!")
print(f" Agent ID : {result['agent_id']}")
print(f" API Key : {result['api_key']}")
print(f" 配置文件 : {result['config_path']}")
print()
print("⚠️ 请妥善保管 API Key,它仅在初始化时显示一次。")
else:
print(f"ℹ️ 已初始化过 (Agent ID: {result['agent_id']})")
print(" 使用 --force 重新初始化")
# 自动设置 skills 目录
mgr.set_skills_dir(DEFAULT_SKILLS_DIR)
# 扫描 skills
registry = _get_registry()
print(f"\n{registry.summary()}")
def cmd_identity(args):
"""查看身份信息(Agent ID + API Key)"""
mgr = _get_manager()
if not mgr.is_initialized:
print("❌ 尚未初始化,请先运行: skills-monitor init")
return
config = mgr.get_config()
agent_id = config.get("agent_id", "N/A")
print("=" * 55)
print("🔑 Skills Monitor 身份信息")
print("=" * 55)
print(f" Agent ID : {agent_id}")
print(f" 初始化时间 : {config.get('created_at', 'N/A')}")
if args.show_key:
api_key = mgr.api_key
if api_key:
print(f" API Key : {api_key}")
else:
print(f" API Key : (无法从安全存储读取,请使用 init --force 重新生成)")
else:
print(f" API Key : (已隐藏,使用 --show-key 查看)")
# Key 健康检查
health = mgr.check_key_health()
if health.get("warnings"):
print()
for w in health["warnings"]:
print(f" ⚠️ {w}")
else:
print(f"\n ✅ Key 状态正常 (安全存储: {health.get('secure_store', 'none')})")
print()
print("📋 绑定小程序时需要填写:")
print(f" • 智能体 ID : {agent_id}")
if args.show_key and mgr.api_key:
print(f" • Key : {mgr.api_key}")
else:
print(f" • Key : 运行 skills-monitor identity --show-key 查看")
def cmd_status(args):
"""查看系统状态"""
mgr = _get_manager()
store = _get_store()
registry = _get_registry()
if not mgr.is_initialized:
print("❌ 尚未初始化,请先运行: python skills_monitor_cli.py init")
return
config = mgr.get_config()
print("=" * 55)
print("📊 Skills Monitor 系统状态")
print("=" * 55)
print(f" Agent ID : {config.get('agent_id', 'N/A')}")
print(f" 初始化时间 : {config.get('created_at', 'N/A')}")
print(f" Skills 目录 : {config.get('skills_dir', 'N/A')}")
print()
print(registry.summary())
# 今日运行统计
today = datetime.now().strftime("%Y-%m-%d")
runs = store.get_runs(agent_id=mgr.agent_id, limit=1000)
today_runs = [r for r in runs if r["start_time"].startswith(today)]
success_runs = [r for r in today_runs if r["status"] == "success"]
print()
print(f"📈 今日运行: {len(today_runs)} 次 (成功 {len(success_runs)})")
if today_runs:
durations = [r["duration_ms"] for r in today_runs if r.get("duration_ms")]
if durations:
avg_d = sum(durations) / len(durations)
print(f" 平均耗时: {avg_d:.0f}ms")
def cmd_list(args):
"""列出已安装 skills"""
registry = _get_registry()
categories = registry.get_skills_by_category()
print("=" * 55)
print("📦 已安装 Skills")
print("=" * 55)
for cat, skills in sorted(categories.items()):
print(f"\n 🏷 {cat}")
for s in skills:
entry_icon = "✅" if s.entry_type != "none" else "📄"
print(f" {entry_icon} {s.slug:35s} v{s.version:8s} [{s.entry_type}]")
if s.description:
print(f" {s.description[:60]}")
runnable = registry.get_runnable_skills()
print(f"\n ─── 可运行: {len(runnable)} / {len(registry.list_skills())} ───")
def cmd_run(args):
"""运行 skill 并自动采集数据"""
mgr = _get_manager()
if not mgr.is_initialized:
print("❌ 请先初始化: python skills_monitor_cli.py init")
return
store = _get_store()
_init_interceptor(mgr, store)
registry = _get_registry()
skill_info = registry.get_skill(args.skill_id)
if not skill_info:
print(f"❌ 找不到 skill: {args.skill_id}")
print(f" 可用的 skills:")
for s in registry.get_runnable_skills():
print(f" - {s.slug}")
return
if skill_info.entry_type == "none":
print(f"⚠️ Skill [{args.skill_id}] 是纯文档型 skill,没有可执行入口")
return
# 获取适配器
adapter = get_adapter(skill_info)
task_name = args.task or ""
# 构建参数
params = {}
if args.params:
for p in args.params:
if "=" in p:
k, v = p.split("=", 1)
params[k] = v
print(f"🚀 运行 [{skill_info.slug}]", end="")
if task_name:
print(f" → {task_name}", end="")
print(f" ...")
print(f" 入口: {skill_info.entry_file}")
print()
# 用拦截器包裹运行
def _do_run():
if isinstance(adapter, SkillRunner):
return adapter.run(task_name=task_name, params=params)
elif hasattr(adapter, "run_task"):
if task_name:
return adapter.run_task(task_name, **params)
else:
return adapter.run_task(**params)
return {"success": False, "error": "无法调用"}
result = run_skill_function(
func=_do_run,
skill_id=skill_info.slug,
task_name=task_name or "default",
)
# 输出结果
print("-" * 55)
status_icon = "✅" if result["status"] == "success" else "❌"
print(f"{status_icon} 状态: {result['status']}")
print(f"⏱ 耗时: {result['duration_ms']:.0f}ms")
print(f"🔑 Run ID: {result['run_id']}")
if result.get("error"):
print(f"\n❌ 错误: {result['error']}")
if result.get("result"):
inner = result["result"]
if isinstance(inner, dict):
output = inner.get("output", inner)
if isinstance(output, str) and len(output) > 500:
print(f"\n📄 输出 (截取前500字):\n{output[:500]}...")
elif isinstance(output, (dict, list)):
print(f"\n📄 输出:\n{json.dumps(output, ensure_ascii=False, indent=2)[:1000]}")
else:
print(f"\n📄 输出:\n{output}")
# 运行后自动记录隐性反馈(基于执行结果)
store = _get_store()
engine = ImplicitFeedbackEngine(store, mgr.agent_id)
engine.record_from_run_context(
skill_id=skill_info.slug,
run_id=result["run_id"],
run_status=result["status"],
run_duration_ms=result["duration_ms"],
user_messages=[], # CLI 模式无对话上下文
session_continued=True,
)
def cmd_history(args):
"""查看运行历史"""
mgr = _get_manager()
if not mgr.is_initialized:
print("❌ 请先初始化")
return
store = _get_store()
runs = store.get_runs(
skill_id=args.skill if args.skill else None,
agent_id=mgr.agent_id,
limit=args.limit,
)
if not runs:
print("📭 暂无运行记录")
return
print("=" * 70)
print(f"📜 运行历史 (最近 {len(runs)} 条)")
print("=" * 70)
print(f"{'Run ID':>12} {'Skill':25s} {'Task':20s} {'Status':8s} {'Duration':>10s}")
print("-" * 70)
for r in runs:
dur = f"{r['duration_ms']:.0f}ms" if r.get("duration_ms") else "N/A"
status_icon = "✅" if r["status"] == "success" else ("❌" if r["status"] == "error" else "⏳")
print(
f"{r['run_id']:>12} {r['skill_id']:25s} "
f"{(r.get('task_name') or '-'):20s} "
f"{status_icon} {r['status']:6s} {dur:>10s}"
)
def cmd_summary(args):
"""查看汇总"""
mgr = _get_manager()
if not mgr.is_initialized:
print("❌ 请先初始化")
return
store = _get_store()
registry = _get_registry()
if args.skill:
# 单个 skill 汇总
summary = store.get_skill_summary(args.skill, mgr.agent_id)
print(f"\n📊 Skill 汇总: {args.skill}")
print("-" * 40)
print(f" 总运行次数 : {summary['total_runs']}")
print(f" 成功次数 : {summary['success_count']}")
print(f" 成功率 : {summary['success_rate']}%")
if summary['avg_duration_ms']:
print(f" 平均耗时 : {summary['avg_duration_ms']}ms")
if summary['avg_rating']:
print(f" 满意度(隐性): {summary['avg_rating']:.2f}/5.0")
fb_count = summary.get('implicit_feedback_count', 0)
conf = summary.get('avg_confidence')
conf_str = f" (置信度 {conf:.1%})" if conf else ""
print(f" 隐性反馈数 : {fb_count}{conf_str}")
else:
# 全局汇总
print("=" * 55)
print("📊 全局运行汇总")
print("=" * 55)
for skill_info in registry.get_runnable_skills():
summary = store.get_skill_summary(skill_info.slug, mgr.agent_id)
if summary["total_runs"] > 0:
rate = f"{summary['success_rate']}%"
dur = f"{summary['avg_duration_ms']:.0f}ms" if summary['avg_duration_ms'] else "N/A"
rating = f"{summary['avg_rating']:.1f}/5" if summary['avg_rating'] else "-"
print(
f" {skill_info.slug:30s} "
f"runs:{summary['total_runs']:3d} "
f"success:{rate:>6s} "
f"avg:{dur:>8s} "
f"satisfaction:{rating}"
)
# 无记录的 skills
total = len(registry.list_skills())
with_data = sum(
1
for s in registry.get_runnable_skills()
if store.get_skill_summary(s.slug, mgr.agent_id)["total_runs"] > 0
)
if with_data < total:
print(f"\n ℹ️ {total - with_data} 个 skill 尚无运行记录")
# ──────── Day 2/3 新增子命令 ────────
DEMO_DATA_DIR = str(PROJECT_ROOT / ".skills_monitor_demo")
DEMO_CACHE_DIR = str(PROJECT_ROOT / ".skills_monitor_demo" / "benchmark_cache")
REPORTS_DIR = str(PROJECT_ROOT / "reports" / "monitor")
def cmd_benchmark(args):
"""基准运行"""
mgr = _get_manager()
if not mgr.is_initialized:
print("❌ 请先初始化: python skills_monitor_cli.py init")
return
store = _get_store()
registry = _get_registry()
skill_info = registry.get_skill(args.skill_id)
if not skill_info:
print(f"❌ 找不到 skill: {args.skill_id}")
return
runner = BenchmarkRunner(
registry=registry,
store=store,
agent_id=mgr.agent_id,
cache_dir=os.path.join(DEFAULT_CONFIG_DIR, "benchmark_cache"),
)
n_runs = args.runs or 10
if args.simulate:
print(f"📊 模拟基准运行 [{args.skill_id}] ({n_runs} 次)")
stats = runner.run_simulated_benchmark(args.skill_id, n_runs=n_runs)
else:
print(f"🔬 真实基准运行 [{args.skill_id}] ({n_runs} 次)")
task = args.task or ""
def _progress(cur, total, result):
if result:
icon = "✅" if result.success else "❌"
print(f" [{cur}/{total}] {icon} {result.duration_ms:.0f}ms")
stats = runner.run_benchmark(
skill_id=args.skill_id,
task_name=task,
n_runs=n_runs,
delay_between=0.5,
progress_callback=_progress,
)
print(f"\n📊 基准结果:")
print(f" {stats.summary_line()}")
def cmd_evaluate(args):
"""综合评估"""
mgr = _get_manager()
if not mgr.is_initialized:
print("❌ 请先初始化")
return
store = _get_store()
registry = _get_registry()
evaluator = SkillEvaluator(store, mgr.agent_id)
if args.skill:
scores = [evaluator.evaluate_skill(args.skill)]
else:
skill_ids = [s.slug for s in registry.get_runnable_skills()]
scores = evaluator.evaluate_all(skill_ids)
if not scores:
print("📭 暂无可评估的数据")
return
print("=" * 70)
print("📊 综合评估")
print("=" * 70)
print(f" {'排名':<4} {'Skill':<28} {'总分':<8} {'等级':<16} {'成功率':<8}")
print(f" {'─' * 64}")
for i, score in enumerate(scores, 1):
sr = f"{score.factors['success_rate']:.0f}%" if score.factors.get('success_rate') is not None else "-"
grade_short = score.grade.split("(")[0].strip()
print(f" {i:<4} {score.skill_id:<28} {score.total_score:<8.1f} {grade_short:<16} {sr:<8}")
# 详细评分
if args.verbose and scores:
print()
for score in scores:
print(score.format_report())
print()
def cmd_compare(args):
"""基准对比"""
mgr = _get_manager()
if not mgr.is_initialized:
print("❌ 请先初始化")
return
store = _get_store()
registry = _get_registry()
comparator = SkillComparator(store, mgr.agent_id)
runner = BenchmarkRunner(
registry=registry,
store=store,
agent_id=mgr.agent_id,
cache_dir=os.path.join(DEFAULT_CONFIG_DIR, "benchmark_cache"),
)
# 尝试加载缓存的基准数据,否则模拟
cached = runner.load_cached_stats(args.skill_id)
if cached:
print(f"📊 使用缓存基准数据对比 [{args.skill_id}]")
bench_stats = cached
else:
print(f"📊 生成模拟基准数据并对比 [{args.skill_id}]")
bench_stats = runner.run_simulated_benchmark(args.skill_id, n_runs=10)
comp = comparator.compare_with_benchmark(args.skill_id, bench_stats)
print(comp.format_report())
def cmd_recommend(args):
"""Skill 推荐"""
mgr = _get_manager()
if not mgr.is_initialized:
print("❌ 请先初始化")
return
store = _get_store()
registry = _get_registry()
recommender = SkillRecommender(registry, store, mgr.agent_id)
recs = recommender.get_all_recommendations(max_per_type=3)
if not recs:
print("✅ 你的 skills 配置很完善!暂无推荐。")
return
print("=" * 55)
print("💡 Skill 推荐")
print("=" * 55)
for i, rec in enumerate(recs[:8], 1):
print(rec.format_line())
print()
def cmd_report(args):
"""生成完整日报"""
mgr = _get_manager()
if not mgr.is_initialized:
print("❌ 请先初始化")
return
store = _get_store()
registry = _get_registry()
print("📄 生成综合日报...")
# 评估
evaluator = SkillEvaluator(store, mgr.agent_id)
skill_ids = [s.slug for s in registry.get_runnable_skills()]
scores = evaluator.evaluate_all(skill_ids)
# 推荐
recommender = SkillRecommender(registry, store, mgr.agent_id)
recs = recommender.get_all_recommendations(max_per_type=3)
# 生成报告
reporter = ReportGenerator(
store=store,
registry=registry,
agent_id=mgr.agent_id,
reports_dir=REPORTS_DIR,
)
filepath = reporter.generate_and_save_daily(
scores=scores,
recommendations=recs,
)
print(f"✅ 日报已生成: {filepath}")
print(f"\n💡 查看: cat {filepath}")
print(f"🌐 Web 面板: python skills_monitor_cli.py web")
def cmd_diagnose(args):
"""生成诊断报告(含企微推送)"""
mgr = _get_manager()
if not mgr.is_initialized:
print("❌ 请先初始化")
return
store = _get_store()
registry = _get_registry()
trigger = args.trigger or "manual"
print(f"🏥 生成诊断报告 (触发方式: {trigger})...")
diag = DiagnosticReporter(
store=store,
registry=registry,
agent_id=mgr.agent_id,
reports_dir=REPORTS_DIR.replace("monitor", "diagnostic"),
)
content, filepath = diag.generate_and_save(
trigger=trigger,
extra_context=args.context if args.context else None,
)
print(f"✅ 诊断报告已生成: {filepath}")
# 企微推送
if args.send:
print(f"\n📤 推送到企微...")
try:
from wecom_bot.sender import sender as wecom_sender
summary = diag.generate_wecom_summary(content)
ok, result = wecom_sender.send_to_webhook(summary, msgtype="markdown")
if ok:
print(f"✅ 企微推送成功")
else:
print(f"⚠️ 企微推送失败: {result}")
except Exception as e:
print(f"⚠️ 企微推送异常: {e}")
if args.print_report:
print(f"\n{'=' * 60}")
print(content)
print(f"{'=' * 60}")
else:
print(f"\n💡 查看报告: cat {filepath}")
print(f"💡 带推送: skills-monitor diagnose --send")
def cmd_web(args):
"""启动 Web 面板"""
port = args.port or 5050
demo_flag = "--demo" if args.demo else ""
debug_flag = "--debug" if args.debug else ""
cmd = f"python3 {PROJECT_ROOT / 'skills_monitor_web.py'} --port {port} {demo_flag} {debug_flag}"
print(f"🚀 启动 Web 面板: http://127.0.0.1:{port}")
os.system(cmd.strip())
def cmd_upload(args):
"""上报数据到中心化服务器"""
mgr = _get_manager()
if not mgr.is_initialized:
print("❌ 请先初始化: skills-monitor init")
return
from skills_monitor.core.uploader import DataUploader
server_url = args.server or "http://localhost:5100"
uploader = DataUploader(server_url)
uploader.init(mgr.agent_id, mgr.api_key)
# 1. 注册/更新 Agent
print(f"📡 连接服务器: {server_url}")
print(f"🔑 Agent ID: {mgr.agent_id[:8]}...")
if args.register:
print("📝 注册 Agent...")
ok, result = uploader.register(name=args.name)
if ok:
print(f"✅ 注册成功: {result.get('agent', {}).get('name', '')}")
else:
print(f"❌ 注册失败: {result.get('error', '未知错误')}")
return
# 2. 上报数据
report_type = args.type or "daily"
print(f"\n📤 上报 {report_type} 数据...")
if report_type == "diagnostic":
ok, result = uploader.upload_diagnostic()
else:
ok, result = uploader.upload_daily()
if ok:
report_info = result.get("report", {})
print(f"✅ 上报成功!")
print(f" 日期: {report_info.get('report_date', '-')}")
print(f" 健康度: {report_info.get('health_score', '-')}")
print(f" 报告 ID: {report_info.get('id', '-')}")
else:
print(f"❌ 上报失败: {result.get('error', '未知错误')}")
# 3. 查看历史
if args.history:
print(f"\n📋 服务器历史报告:")
ok, reports = uploader.get_reports(limit=5)
if ok:
for r in reports:
print(f" {r.get('report_date', '-')} | {r.get('report_type', '-')} | 健康度 {r.get('health_score', '-')}")
else:
print(f" (无数据)")
def cmd_server(args):
"""启动中心化服务器"""
port = args.port or 5100
host = args.host or "0.0.0.0"
debug = args.debug
print(f"🚀 Skills Monitor 中心化服务器")
print(f"📡 {host}:{port} (debug={'on' if debug else 'off'})")
print(f"")
print(f"API 端点:")
print(f" Agent 注册: POST /api/agent/register")
print(f" 数据上报: POST /api/agent/report")
print(f" 微信回调: /api/wechat/callback")
print(f" 小程序 API: /api/mp/*")
print(f" H5 报告页: /h5/*")
print(f" 健康检查: GET /health")
print(f"")
# 设置环境变量
os.environ["SM_HOST"] = host
os.environ["SM_PORT"] = str(port)
os.environ["SM_DEBUG"] = "true" if debug else "false"
try:
from server.app import create_app
app = create_app()
app.run(host=host, port=port, debug=debug)
except ImportError as e:
print(f"❌ 启动失败: {e}")
print(f"💡 请先安装依赖: pip install flask-sqlalchemy apscheduler")
# ──────── 主入口 ────────
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Skills Monitor — 本地 Skills 监控评估系统 Demo",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
subparsers = parser.add_subparsers(dest="command", help="子命令")
# init
p_init = subparsers.add_parser("init", help="初始化身份")
p_init.add_argument("--force", action="store_true", help="强制重新初始化")
# identity
p_id = subparsers.add_parser("identity", help="查看身份信息(智能体 ID / Key)")
p_id.add_argument("--show-key", action="store_true", help="显示 Key")
# status
subparsers.add_parser("status", help="查看系统状态")
# list
subparsers.add_parser("list", help="列出已安装 skills")
# run
p_run = subparsers.add_parser("run", help="运行 skill 并采集数据")
p_run.add_argument("skill_id", help="Skill slug")
p_run.add_argument("task", nargs="?", default="", help="任务名 (可选)")
p_run.add_argument("--params", "-p", nargs="*", help="参数 key=value")
# history
p_hist = subparsers.add_parser("history", help="查看运行历史")
p_hist.add_argument("--skill", "-s", type=str, help="按 skill 过滤")
p_hist.add_argument("--limit", "-l", type=int, default=20, help="显示条数")
# summary
p_sum = subparsers.add_parser("summary", help="查看汇总")
p_sum.add_argument("--skill", "-s", type=str, help="指定 skill")
# benchmark (Day 2)
p_bench = subparsers.add_parser("benchmark", help="基准运行")
p_bench.add_argument("skill_id", help="Skill slug")
p_bench.add_argument("--runs", "-n", type=int, default=10, help="运行次数 (默认 10)")
p_bench.add_argument("--task", "-t", type=str, default="", help="任务名")
p_bench.add_argument("--simulate", action="store_true", help="模拟运行 (不实际执行)")
# evaluate (Day 2)
p_eval = subparsers.add_parser("evaluate", help="综合评估")
p_eval.add_argument("--skill", "-s", type=str, help="指定 skill (不指定则评估全部)")
p_eval.add_argument("--verbose", "-v", action="store_true", help="显示详细评分")
# compare (Day 2)
p_comp = subparsers.add_parser("compare", help="基准对比")
p_comp.add_argument("skill_id", help="Skill slug")
# recommend (Day 2)
subparsers.add_parser("recommend", help="Skill 推荐")
# report (Day 3)
subparsers.add_parser("report", help="生成完整日报")
# diagnose (Day 5 — 定时诊断报告)
p_diag = subparsers.add_parser("diagnose", help="生成诊断报告(含健康度+问题+建议)")
p_diag.add_argument("--send", action="store_true", help="推送到企微")
p_diag.add_argument("--trigger", "-t", type=str, default="manual",
choices=["manual", "scheduled", "post_install"],
help="触发方式 (默认 manual)")
p_diag.add_argument("--context", "-c", type=str, default="", help="额外上下文说明")
p_diag.add_argument("--print", dest="print_report", action="store_true", help="在终端打印报告")
# web (Day 3)
p_web = subparsers.add_parser("web", help="启动 Web 面板")
p_web.add_argument("--port", type=int, default=5050, help="端口号 (默认 5050)")
p_web.add_argument("--demo", action="store_true", help="使用 Demo 数据")
p_web.add_argument("--debug", action="store_true", help="调试模式")
# upload (v0.4 — 上报数据到中心化服务器)
p_upload = subparsers.add_parser("upload", help="上报数据到中心化服务器")
p_upload.add_argument("--server", "-s", type=str, default="http://localhost:5100",
help="服务器地址 (默认 http://localhost:5100)")
p_upload.add_argument("--type", "-t", type=str, default="daily",
choices=["daily", "diagnostic"],
help="上报类型 (默认 daily)")
p_upload.add_argument("--register", action="store_true", help="同时注册/更新 Agent")
p_upload.add_argument("--name", type=str, default="", help="Agent 名称")
p_upload.add_argument("--history", action="store_true", help="显示服务器历史")
# server (v0.4 — 启动中心化服务器)
p_server = subparsers.add_parser("server", help="启动中心化服务器")
p_server.add_argument("--port", type=int, default=5100, help="端口号 (默认 5100)")
p_server.add_argument("--host", type=str, default="0.0.0.0", help="监听地址")
p_server.add_argument("--debug", action="store_true", help="调试模式")
return parser
def main():
parser = build_parser()
args = parser.parse_args()
if args.command is None:
parser.print_help()
return
commands = {
"init": cmd_init,
"identity": cmd_identity,
"status": cmd_status,
"list": cmd_list,
"run": cmd_run,
"history": cmd_history,
"summary": cmd_summary,
"benchmark": cmd_benchmark,
"evaluate": cmd_evaluate,
"compare": cmd_compare,
"recommend": cmd_recommend,
"report": cmd_report,
"diagnose": cmd_diagnose,
"web": cmd_web,
"upload": cmd_upload,
"server": cmd_server,
}
cmd_func = commands.get(args.command)
if cmd_func:
cmd_func(args)
else:
parser.print_help()
if __name__ == "__main__":
main()
FILE:skill.json
{
"name": "skills-monitor",
"displayName": "Skills Monitor — AI Skills 监控评估平台",
"slug": "skills-monitor",
"description": "AI Skills 一站式监控评估平台 — 7因子评估引擎、跨模型基准评测、中心化 Dashboard、智能推荐、诊断报告、数据上报",
"version": "0.7.0",
"author": "MerkyorLynn",
"license": "GPL-3.0",
"homepage": "https://github.com/MerkyorLynn/skills-monitor",
"repository": "https://github.com/MerkyorLynn/skills-monitor",
"icon": "🩺",
"keywords": [
"monitoring",
"benchmark",
"evaluation",
"agent",
"skills",
"dashboard",
"diagnostic",
"recommendation",
"llm",
"testing",
"health-check",
"report"
],
"categories": [
"Development",
"Monitoring",
"Testing",
"Productivity"
],
"main": "main.py",
"requirements": {
"python": ">=3.9",
"packages": [
"flask>=2.3.0",
"flask-sqlalchemy>=3.0.0",
"requests>=2.28.0",
"pandas>=1.5.0",
"apscheduler>=3.10.0",
"keyring>=25.0.0",
"python-dotenv>=1.0.0"
]
},
"features": [
"7因子综合评估引擎(成功率/延迟/质量/成本/稳定性/社区热度/兼容性)",
"跨模型基准评测(TOP1000 Skills × 6 大模型矩阵)",
"智能推荐引擎(互补推荐 + 升级推荐 + 场景推荐)",
"中心化 Web Dashboard + PWA 支持",
"诊断报告自动生成 + 企微/微信推送",
"数据上报到中心化服务器",
"微信小程序端查看",
"OS Keychain 安全凭证存储",
"敏感信息自动脱敏引擎",
"GDPR 合规管理"
],
"commands": [
{
"name": "init",
"description": "初始化 Skills Monitor 身份(生成 Agent ID + API Key)",
"parameters": [
{
"name": "force",
"type": "boolean",
"required": false,
"description": "是否强制重新初始化"
}
]
},
{
"name": "status",
"description": "查看 Skills Monitor 系统状态(已安装/可运行 Skills、今日运行统计)",
"parameters": []
},
{
"name": "list",
"description": "列出所有已安装的 Skills(按类别分组,含版本和入口类型)",
"parameters": []
},
{
"name": "evaluate",
"description": "对指定 Skill 执行 7因子综合评估(成功率、延迟、质量、成本、稳定性、社区热度、兼容性)",
"parameters": [
{
"name": "skill_slug",
"type": "string",
"required": false,
"description": "Skill 的 slug 标识符(不指定则评估全部)"
},
{
"name": "verbose",
"type": "boolean",
"required": false,
"description": "是否显示详细评分维度"
}
]
},
{
"name": "benchmark",
"description": "运行跨模型基准评测,支持 TOP1000 Skills × 6 大模型矩阵",
"parameters": [
{
"name": "skill_slug",
"type": "string",
"required": true,
"description": "Skill 的 slug 标识符"
},
{
"name": "runs",
"type": "number",
"required": false,
"description": "运行次数,默认 10"
},
{
"name": "simulate",
"type": "boolean",
"required": false,
"description": "是否模拟运行(不实际执行 skill)"
}
]
},
{
"name": "baseline",
"description": "查询指定 Skill 在指定大模型上的基准评测分数",
"parameters": [
{
"name": "skill_slug",
"type": "string",
"required": true,
"description": "Skill 的 slug 标识符"
},
{
"name": "model",
"type": "string",
"required": false,
"description": "模型标识: claude-opus-4.6 / gpt-5.4 / gemini-3.0-pro / glm-5 / minimax-2.5 / deepseek-3.2"
}
]
},
{
"name": "compare",
"description": "对比指定 Skill 与基准数据的差异",
"parameters": [
{
"name": "skill_slug",
"type": "string",
"required": true,
"description": "Skill 的 slug 标识符"
}
]
},
{
"name": "recommend",
"description": "根据已安装 Skills 智能推荐最优补充方案(互补/升级/场景推荐)",
"parameters": [
{
"name": "category",
"type": "string",
"required": false,
"description": "Skill 分类,如 finance / development / productivity"
},
{
"name": "top_n",
"type": "number",
"required": false,
"description": "推荐数量,默认 5"
}
]
},
{
"name": "report",
"description": "生成 Markdown 格式的综合日报(评估 + 推荐 + 运行统计)",
"parameters": [
{
"name": "format",
"type": "string",
"required": false,
"description": "输出格式: markdown / json,默认 markdown"
}
]
},
{
"name": "diagnose",
"description": "生成诊断报告(健康度 + 问题发现 + 优化建议 + 推荐安装)",
"parameters": [
{
"name": "send",
"type": "boolean",
"required": false,
"description": "是否推送到企业微信"
},
{
"name": "trigger",
"type": "string",
"required": false,
"description": "触发方式: manual / scheduled / post_install"
}
]
},
{
"name": "upload",
"description": "将评估数据上报到中心化服务器",
"parameters": [
{
"name": "server",
"type": "string",
"required": false,
"description": "服务器地址,默认 http://localhost:5100"
},
{
"name": "type",
"type": "string",
"required": false,
"description": "上报类型: daily / diagnostic"
}
]
},
{
"name": "dashboard",
"description": "启动 Web 实时监控面板(支持 PWA)",
"parameters": [
{
"name": "port",
"type": "number",
"required": false,
"description": "监听端口,默认 5050"
}
]
},
{
"name": "server",
"description": "启动中心化服务器(含 API + 微信回调 + 小程序接口)",
"parameters": [
{
"name": "port",
"type": "number",
"required": false,
"description": "监听端口,默认 5100"
}
]
}
],
"screenshots": [
{
"url": "https://github.com/MerkyorLynn/skills-monitor/raw/main/docs/dashboard.png",
"description": "Web Dashboard 实时监控面板"
},
{
"url": "https://github.com/MerkyorLynn/skills-monitor/raw/main/docs/pwa.png",
"description": "PWA 移动端诊断报告"
}
],
"changelog": {
"0.7.0": [
"拆分为独立 Skill 包,支持 SkillsHUB 上架",
"紧凑型 PWA 布局优化",
"诊断摘要和推荐安装模块前置突出",
"报告概览和详细数据折叠面板",
"新增 baseline 查询命令"
],
"0.6.1": [
"跨模型基准评测(TOP1000 × 6 Models)",
"智能推荐引擎增强",
"微信小程序端支持",
"企微/微信推送通知",
"OS Keychain 安全凭证存储"
]
}
}
FILE:wecom_bot/sender.py
#!/usr/bin/env python3
"""
企业微信消息发送模块
====================
通过企业微信自建应用 API 主动发送消息给用户。
与 Webhook 不同,自建应用可以发送消息给指定用户,且支持更多消息类型。
发送流程:
1. 获取 access_token(缓存2小时)
2. 调用 /cgi-bin/message/send 接口发送
"""
import time
import requests
import threading
from . import config
class WeComSender:
"""企业微信消息发送器"""
def __init__(self):
self.corp_id = config.CORP_ID
self.secret = config.SECRET
self.agent_id = config.AGENT_ID
self._access_token = None
self._token_expires = 0
self._lock = threading.Lock()
def _get_access_token(self):
"""获取 access_token(带缓存)"""
now = time.time()
with self._lock:
if self._access_token and now < self._token_expires - 60:
return self._access_token
url = "https://qyapi.weixin.qq.com/cgi-bin/gettoken"
resp = requests.get(url, params={
"corpid": self.corp_id,
"corpsecret": self.secret,
}, timeout=10)
data = resp.json()
if data.get("errcode") == 0:
self._access_token = data["access_token"]
self._token_expires = now + data.get("expires_in", 7200)
return self._access_token
else:
raise Exception(f"获取 access_token 失败: {data}")
def send_text(self, user_id, content):
"""
发送文本消息
Args:
user_id: 用户ID(企业微信成员 UserId),"@all" 表示所有人
content: 文本内容
"""
token = self._get_access_token()
url = f"https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token={token}"
data = {
"touser": user_id,
"msgtype": "text",
"agentid": self.agent_id,
"text": {"content": content},
}
resp = requests.post(url, json=data, timeout=10)
result = resp.json()
if result.get("errcode") == 0:
return True, result
else:
return False, result
def send_markdown(self, user_id, content):
"""
发送 Markdown 消息
Args:
user_id: 用户ID,"@all" 表示所有人
content: Markdown 内容
"""
token = self._get_access_token()
url = f"https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token={token}"
data = {
"touser": user_id,
"msgtype": "markdown",
"agentid": self.agent_id,
"markdown": {"content": content},
}
resp = requests.post(url, json=data, timeout=10)
result = resp.json()
if result.get("errcode") == 0:
return True, result
else:
return False, result
def send_markdown_chunked(self, user_id, content, max_bytes=3800):
"""
分段发送 Markdown(企微有4096字节限制)
Args:
user_id: 用户ID
content: 完整 Markdown 内容
max_bytes: 每段最大字节数
"""
if len(content.encode("utf-8")) <= max_bytes:
return self.send_markdown(user_id, content)
# 分段
sections = content.split("\n\n---\n")
chunks = []
current = ""
for section in sections:
test = current + "\n\n---\n" + section if current else section
if len(test.encode("utf-8")) > max_bytes:
if current:
chunks.append(current)
current = section
else:
current = test
if current:
chunks.append(current)
total = len(chunks)
all_ok = True
for i, chunk in enumerate(chunks, 1):
if total > 1:
chunk = f"{chunk}\n\n> 📄 第{i}/{total}部分"
ok, result = self.send_markdown(user_id, chunk)
if not ok:
all_ok = False
if i < total:
time.sleep(1)
return all_ok, {"total_chunks": total}
def send_to_webhook(self, content, msgtype="markdown"):
"""通过 Webhook 发送到群聊(兼容已有功能)"""
if msgtype == "markdown":
payload = {"msgtype": "markdown", "markdown": {"content": content}}
else:
payload = {"msgtype": "text", "text": {"content": content}}
resp = requests.post(config.WEBHOOK_URL, json=payload, timeout=10)
result = resp.json()
return result.get("errcode") == 0, result
# 全局单例
sender = WeComSender()
FILE:wecom_bot/config.py
#!/usr/bin/env python3
"""
企业微信双向通信 - 配置文件
============================
所有敏感信息集中在此管理。首次使用请填写以下配置。
配置获取方式:
1. 登录企业微信管理后台: https://work.weixin.qq.com
2. 我的企业 → 企业ID (CORP_ID)
3. 应用管理 → 自建 → 创建应用 → 获取 AGENT_ID 和 SECRET
4. 应用管理 → 自建应用 → 接收消息 → 设置API接收 → 获取 TOKEN 和 ENCODING_AES_KEY
"""
import os
from pathlib import Path
# ============================================================
# 企业微信配置(必填)
# ============================================================
# 企业ID:我的企业 → 企业信息 → 企业ID
CORP_ID = os.environ.get("WECOM_CORP_ID", "YOUR_CORP_ID_HERE")
# 自建应用 AgentId
AGENT_ID = int(os.environ.get("WECOM_AGENT_ID", "0"))
# 自建应用 Secret
SECRET = os.environ.get("WECOM_SECRET", "YOUR_SECRET_HERE")
# 接收消息 → API接收 → Token(自定义,32位以内英文数字)
CALLBACK_TOKEN = os.environ.get("WECOM_CALLBACK_TOKEN", "YOUR_CALLBACK_TOKEN_HERE")
# 接收消息 → API接收 → EncodingAESKey(43位字符)
CALLBACK_ENCODING_AES_KEY = os.environ.get("WECOM_CALLBACK_AES_KEY", "YOUR_ENCODING_AES_KEY_HERE")
# ============================================================
# 群机器人 Webhook(已有,用于推送报告)
# ============================================================
WEBHOOK_URL = (
"https://qyapi.weixin.qq.com/cgi-bin/webhook/send"
"?key=5881dfcd-f771-4756-9b7d-883e0271e212"
)
# ============================================================
# 服务配置
# ============================================================
# Flask 服务端口
SERVER_PORT = int(os.environ.get("WECOM_SERVER_PORT", "5088"))
# 项目根目录
PROJECT_DIR = Path(__file__).resolve().parent.parent
# 日志目录
LOG_DIR = PROJECT_DIR / "logs"
LOG_DIR.mkdir(exist_ok=True)
# 报告目录
REPORT_DIR = PROJECT_DIR / "reports"
DATA_DIR = PROJECT_DIR / "report_data"
# ============================================================
# 指令白名单(只有这些用户可以下达指令)
# 留空表示允许所有人
# ============================================================
ALLOWED_USERS = os.environ.get("WECOM_ALLOWED_USERS", "").split(",")
ALLOWED_USERS = [u.strip() for u in ALLOWED_USERS if u.strip()]
# ============================================================
# ngrok 配置
# ============================================================
NGROK_AUTH_TOKEN = os.environ.get("NGROK_AUTH_TOKEN", "")
def validate_config():
"""验证配置是否完整"""
errors = []
if CORP_ID == "YOUR_CORP_ID_HERE" or not CORP_ID:
errors.append("❌ CORP_ID 未配置")
if AGENT_ID == 0:
errors.append("❌ AGENT_ID 未配置")
if SECRET == "YOUR_SECRET_HERE" or not SECRET:
errors.append("❌ SECRET 未配置")
if CALLBACK_TOKEN == "YOUR_CALLBACK_TOKEN_HERE" or not CALLBACK_TOKEN:
errors.append("❌ CALLBACK_TOKEN 未配置")
if CALLBACK_ENCODING_AES_KEY == "YOUR_ENCODING_AES_KEY_HERE" or not CALLBACK_ENCODING_AES_KEY:
errors.append("❌ CALLBACK_ENCODING_AES_KEY 未配置")
return errors
def print_config_status():
"""打印配置状态"""
errors = validate_config()
if errors:
print("⚠️ 企业微信配置不完整:")
for e in errors:
print(f" {e}")
print("\n请编辑 wecom_bot/config.py 或设置环境变量:")
print(" export WECOM_CORP_ID='your_corp_id'")
print(" export WECOM_AGENT_ID='your_agent_id'")
print(" export WECOM_SECRET='your_secret'")
print(" export WECOM_CALLBACK_TOKEN='your_token'")
print(" export WECOM_CALLBACK_AES_KEY='your_aes_key'")
return False
else:
print("✅ 企业微信配置完整")
print(f" 企业ID: {CORP_ID[:6]}****")
print(f" 应用ID: {AGENT_ID}")
print(f" 服务端口: {SERVER_PORT}")
return True
FILE:wecom_bot/__init__.py
# 企业微信双向通信机器人
FILE:wecom_bot/crypto.py
#!/usr/bin/env python3
"""
企业微信消息加解密模块
=======================
基于企业微信官方 Python 加解密库实现。
参考: https://developer.work.weixin.qq.com/document/path/96211
提供三个核心功能:
1. VerifyURL - 验证回调URL(GET请求)
2. DecryptMsg - 解密接收到的消息(POST请求)
3. EncryptMsg - 加密回复消息
"""
import base64
import hashlib
import hmac
import struct
import time
import socket
import xml.etree.cElementTree as ET
from Crypto.Cipher import AES
class FormatError(Exception):
pass
class WXBizMsgCrypt:
"""企业微信消息加解密类"""
def __init__(self, sToken, sEncodingAESKey, sCorpId):
self.token = sToken
self.corp_id = sCorpId
try:
self.aes_key = base64.b64decode(sEncodingAESKey + "=")
assert len(self.aes_key) == 32
except Exception:
raise FormatError(f"EncodingAESKey 格式错误(应为43个字符): 实际长度 {len(sEncodingAESKey)}")
def _get_signature(self, timestamp, nonce, encrypt):
"""计算签名"""
sort_list = sorted([self.token, timestamp, nonce, encrypt])
sha = hashlib.sha1()
sha.update("".join(sort_list).encode("utf-8"))
return sha.hexdigest()
def _encrypt(self, text):
"""AES加密"""
text = text.encode("utf-8")
# 16字节随机字符串
random_str = self._get_random_str()
# 网络字节序打包消息长度
msg_len = struct.pack("!I", len(text))
# 拼接
plaintext = random_str + msg_len + text + self.corp_id.encode("utf-8")
# PKCS#7填充
pad_len = 32 - (len(plaintext) % 32)
plaintext += bytes([pad_len] * pad_len)
# AES-CBC加密
iv = self.aes_key[:16]
cipher = AES.new(self.aes_key, AES.MODE_CBC, iv)
encrypted = cipher.encrypt(plaintext)
return base64.b64encode(encrypted).decode("utf-8")
def _decrypt(self, text):
"""AES解密"""
try:
cipher_text = base64.b64decode(text)
iv = self.aes_key[:16]
cipher = AES.new(self.aes_key, AES.MODE_CBC, iv)
decrypted = cipher.decrypt(cipher_text)
# 去PKCS#7填充
pad = decrypted[-1]
if isinstance(pad, int):
content = decrypted[:-pad]
else:
content = decrypted[:-ord(pad)]
# 解析:16字节随机 + 4字节长度 + 消息 + CorpId
msg_len = struct.unpack("!I", content[16:20])[0]
msg = content[20:20 + msg_len]
from_corp_id = content[20 + msg_len:]
if from_corp_id.decode("utf-8") != self.corp_id:
raise FormatError(f"CorpId不匹配: {from_corp_id.decode('utf-8')} != {self.corp_id}")
return msg.decode("utf-8")
except FormatError:
raise
except Exception as e:
raise FormatError(f"消息解密失败: {e}")
def _get_random_str(self):
"""生成16字节随机字符串"""
import os
return os.urandom(16)
def VerifyURL(self, sMsgSignature, sTimeStamp, sNonce, sEchoStr):
"""
验证回调URL
企业微信在配置回调URL时会发送GET请求,需要返回解密后的echostr
Returns:
(0, sReplyEchoStr) 或 (errcode, None)
"""
signature = self._get_signature(sTimeStamp, sNonce, sEchoStr)
if signature != sMsgSignature:
return -1, None
try:
reply = self._decrypt(sEchoStr)
return 0, reply
except Exception as e:
return -1, str(e)
def DecryptMsg(self, sPostData, sMsgSignature, sTimeStamp, sNonce):
"""
解密接收到的消息
Args:
sPostData: POST请求体(XML格式)
sMsgSignature: URL参数 msg_signature
sTimeStamp: URL参数 timestamp
sNonce: URL参数 nonce
Returns:
(0, xml_content_str) 或 (errcode, None)
"""
try:
tree = ET.fromstring(sPostData)
encrypt = tree.find("Encrypt").text
except Exception as e:
return -1, f"XML解析失败: {e}"
signature = self._get_signature(sTimeStamp, sNonce, encrypt)
if signature != sMsgSignature:
return -1, "签名验证失败"
try:
content = self._decrypt(encrypt)
return 0, content
except Exception as e:
return -1, str(e)
def EncryptMsg(self, sReplyMsg, sNonce, timestamp=None):
"""
加密回复消息
Args:
sReplyMsg: 回复消息明文(XML格式)
sNonce: 随机字符串
timestamp: 时间戳
Returns:
(0, encrypted_xml) 或 (errcode, None)
"""
if timestamp is None:
timestamp = str(int(time.time()))
try:
encrypt = self._encrypt(sReplyMsg)
except Exception as e:
return -1, str(e)
signature = self._get_signature(timestamp, sNonce, encrypt)
resp_xml = f"""<xml>
<Encrypt><![CDATA[{encrypt}]]></Encrypt>
<MsgSignature><![CDATA[{signature}]]></MsgSignature>
<TimeStamp>{timestamp}</TimeStamp>
<Nonce><![CDATA[{sNonce}]]></Nonce>
</xml>"""
return 0, resp_xml
FILE:server/config.py
"""
Skills Monitor 中心化服务器 — 配置文件
=====================================
包括数据库、微信公众号、微信小程序、推送调度等配置。
部署时请通过环境变量覆盖敏感信息。
"""
import os
from pathlib import Path
# ──────── 基础配置 ────────
BASE_DIR = Path(__file__).resolve().parent
PROJECT_ROOT = BASE_DIR.parent
SECRET_KEY = os.environ.get("SM_SECRET_KEY", "skills-monitor-secret-key-change-me")
DEBUG = os.environ.get("SM_DEBUG", "true").lower() == "true"
# ──────── 轻量鉴权(可选) ────────
# 为 dashboard/H5 等“读数据页面”提供一把共享钥匙(生产环境建议设置)。
# 为空则不启用该校验(保持本地开发体验)。
DASHBOARD_TOKEN = os.environ.get("SM_DASHBOARD_TOKEN", "").strip()
# ──────── 数据库配置 ────────
# 使用 SQLite(轻量部署)或 PostgreSQL(生产环境)
DATABASE_TYPE = os.environ.get("SM_DB_TYPE", "sqlite") # sqlite / postgresql
DATABASE_URL = os.environ.get(
"SM_DATABASE_URL",
f"sqlite:///{BASE_DIR / 'data' / 'skills_monitor_server.db'}"
)
# ──────── 微信公众号配置 ────────
WECHAT_OA_APP_ID = os.environ.get("SM_WECHAT_OA_APP_ID", "")
WECHAT_OA_APP_SECRET = os.environ.get("SM_WECHAT_OA_APP_SECRET", "")
WECHAT_OA_TOKEN = os.environ.get("SM_WECHAT_OA_TOKEN", "skills-monitor-wechat")
WECHAT_OA_ENCODING_AES_KEY = os.environ.get("SM_WECHAT_OA_AES_KEY", "")
# 模板消息 ID(需在公众号后台配置)
WECHAT_TEMPLATE_DAILY_REPORT = os.environ.get("SM_WECHAT_TPL_DAILY", "")
WECHAT_TEMPLATE_ALERT = os.environ.get("SM_WECHAT_TPL_ALERT", "")
# ──────── 微信小程序配置 ────────
WECHAT_MP_APP_ID = os.environ.get("SM_WECHAT_MP_APP_ID", "")
WECHAT_MP_APP_SECRET = os.environ.get("SM_WECHAT_MP_APP_SECRET", "")
# ──────── 数据压缩 ────────
# 上报数据超过此大小时自动启用 gzip 压缩
UPLOAD_COMPRESS_THRESHOLD_BYTES = 10 * 1024 # 10KB
# ──────── 推送调度 ────────
DAILY_REPORT_CRON_HOUR = int(os.environ.get("SM_REPORT_HOUR", "21"))
DAILY_REPORT_CRON_MINUTE = int(os.environ.get("SM_REPORT_MINUTE", "0"))
# ──────── 服务器 ────────
SERVER_HOST = os.environ.get("SM_HOST", "0.0.0.0")
SERVER_PORT = int(os.environ.get("SM_PORT", "5100"))
# ──────── H5 页面 ────────
H5_BASE_URL = os.environ.get("SM_H5_BASE_URL", f"http://localhost:{SERVER_PORT}")
FILE:server/__init__.py
# Skills Monitor 中心化服务器
FILE:server/app.py
"""
Skills Monitor 中心化服务器 — Flask 应用入口
=============================================
启动方式:
python -m server.app # 开发模式
gunicorn server.app:create_app() # 生产模式
"""
import os
import logging
from pathlib import Path
# 加载 .env 环境变量(生产环境配置 AppID/Secret 等)
try:
from dotenv import load_dotenv
load_dotenv(Path(__file__).parent / ".env")
except ImportError:
pass
from flask import Flask
from server.config import (
SECRET_KEY, DEBUG, DATABASE_URL, SERVER_HOST, SERVER_PORT,
DAILY_REPORT_CRON_HOUR, DAILY_REPORT_CRON_MINUTE,
)
from server.models.database import init_db
from server.api import register_blueprints
def create_app(config_override: dict = None) -> Flask:
"""创建 Flask 应用实例"""
# 确保数据目录存在
data_dir = Path(__file__).parent / "data"
data_dir.mkdir(exist_ok=True)
app = Flask(
__name__,
template_folder=str(Path(__file__).parent / "templates"),
static_folder=str(Path(__file__).parent / "static"),
)
# ──────── 配置 ────────
app.config["SECRET_KEY"] = SECRET_KEY
app.config["SQLALCHEMY_DATABASE_URI"] = DATABASE_URL
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
app.config["SQLALCHEMY_ENGINE_OPTIONS"] = {
"pool_pre_ping": True,
}
app.config["JSON_AS_ASCII"] = False
if config_override:
app.config.update(config_override)
# ──────── 日志 ────────
logging.basicConfig(
level=logging.DEBUG if DEBUG else logging.INFO,
format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
# ──────── 初始化数据库 ────────
init_db(app)
# ──────── 注册蓝图 ────────
register_blueprints(app)
# ──────── 健康检查 ────────
@app.route("/health")
def health():
return {"status": "ok", "service": "skills-monitor-server"}
@app.route("/")
def index():
return {
"service": "Skills Monitor Server",
"version": "0.6.1",
"endpoints": {
"agent_api": "/api/agent/",
"benchmark_dashboard": "/benchmark",
"benchmark_api": "/api/benchmark/",
"wechat_callback": "/api/wechat/callback",
"miniprogram_api": "/api/mp/",
"h5_pages": "/h5/",
"pwa": "/pwa/",
"health": "/health",
"alerts": "/api/mp/push-analytics",
},
}
# ──────── 定时任务 ────────
_setup_scheduler(app)
app.logger.info(f"Skills Monitor Server 已启动 (DEBUG={DEBUG})")
return app
def _setup_scheduler(app: Flask):
"""设置定时推送调度器"""
try:
from apscheduler.schedulers.background import BackgroundScheduler
from server.services.push_scheduler import push_daily_reports
scheduler = BackgroundScheduler()
# 每小时检查一次是否有用户需要推送
# v0.6.0: 使用增强版推送(含告警检查)
try:
from server.services.operation_tracker import enhanced_push_daily_reports
push_func = lambda: enhanced_push_daily_reports(app)
except ImportError:
push_func = lambda: push_daily_reports(app)
scheduler.add_job(
func=push_func,
trigger="cron",
minute=DAILY_REPORT_CRON_MINUTE,
id="daily_push",
name="每日报告推送+告警",
replace_existing=True,
)
scheduler.start()
app.logger.info(
f"推送调度器已启动 (每小时 :{DAILY_REPORT_CRON_MINUTE:02d} 检查)"
)
except ImportError:
app.logger.warning(
"APScheduler 未安装,定时推送功能不可用。"
"请运行: pip install apscheduler"
)
# ──────── 直接运行 ────────
if __name__ == "__main__":
app = create_app()
app.run(
host=SERVER_HOST,
port=SERVER_PORT,
debug=DEBUG,
)
FILE:server/services/__init__.py
# 服务层
FILE:server/services/wechat_service.py
"""
微信服务层 — 公众号 + 小程序 统一接口
====================================
- 公众号 access_token 管理
- 模板消息推送
- 用户信息获取
- 小程序 code2session
- 带参二维码生成(用于绑定 Agent)
"""
import hashlib
import json
import time
import threading
import requests
from typing import Optional, Tuple, Dict, Any
from server.config import (
WECHAT_OA_APP_ID, WECHAT_OA_APP_SECRET, WECHAT_OA_TOKEN,
WECHAT_MP_APP_ID, WECHAT_MP_APP_SECRET,
WECHAT_TEMPLATE_DAILY_REPORT, WECHAT_TEMPLATE_ALERT,
H5_BASE_URL,
)
class WeChatService:
"""微信公众号 + 小程序 服务"""
def __init__(self):
self._oa_token = None
self._oa_token_expires = 0
self._lock = threading.Lock()
# ──────── Access Token ────────
def get_oa_access_token(self) -> str:
"""获取公众号 access_token(带缓存)"""
now = time.time()
with self._lock:
if self._oa_token and now < self._oa_token_expires - 120:
return self._oa_token
resp = requests.get(
"https://api.weixin.qq.com/cgi-bin/token",
params={
"grant_type": "client_credential",
"appid": WECHAT_OA_APP_ID,
"secret": WECHAT_OA_APP_SECRET,
},
timeout=10,
)
data = resp.json()
if "access_token" in data:
self._oa_token = data["access_token"]
self._oa_token_expires = now + data.get("expires_in", 7200)
return self._oa_token
raise Exception(f"获取公众号 access_token 失败: {data}")
# ──────── 消息验签 ────────
@staticmethod
def verify_signature(signature: str, timestamp: str, nonce: str) -> bool:
"""验证微信消息签名"""
tmp_arr = sorted([WECHAT_OA_TOKEN, timestamp, nonce])
tmp_str = "".join(tmp_arr)
computed = hashlib.sha1(tmp_str.encode("utf-8")).hexdigest()
return computed == signature
# ──────── 模板消息推送 ────────
def send_template_message(
self,
openid: str,
template_id: str,
data: Dict[str, Any],
url: str = "",
miniprogram: Optional[Dict] = None,
) -> Tuple[bool, Dict]:
"""
发送模板消息
Args:
openid: 用户 openid
template_id: 模板 ID
data: 模板数据 {"first": {"value": "...", "color": "#333"}, ...}
url: 点击跳转链接(H5页面)
miniprogram: 小程序跳转 {"appid": "...", "pagepath": "..."}
"""
token = self.get_oa_access_token()
payload = {
"touser": openid,
"template_id": template_id,
"url": url,
"data": data,
}
if miniprogram:
payload["miniprogram"] = miniprogram
resp = requests.post(
f"https://api.weixin.qq.com/cgi-bin/message/template/send?access_token={token}",
json=payload,
timeout=10,
)
result = resp.json()
ok = result.get("errcode", -1) == 0
return ok, result
def push_daily_report(
self,
openid: str,
agent_name: str,
health_score: float,
total_runs: int,
success_rate: float,
top_skill: str,
h5_url: str,
agent_id: str = "",
) -> Tuple[bool, Dict]:
"""
推送每日报告模板消息
v0.5.0: 支持点击跳转到小程序(公众号→小程序闭环)
"""
from datetime import datetime
now = datetime.now()
# 健康度 emoji
if health_score >= 90:
health_emoji = "🟢"
elif health_score >= 75:
health_emoji = "🟡"
elif health_score >= 60:
health_emoji = "🟠"
else:
health_emoji = "🔴"
data = {
"first": {
"value": f"📊 Skills Monitor 每日报告 — {agent_name}",
"color": "#173177",
},
"keyword1": {
"value": now.strftime("%Y-%m-%d"),
"color": "#333333",
},
"keyword2": {
"value": f"{health_emoji} {health_score:.0f}/100",
"color": "#e74c3c" if health_score < 60 else "#27ae60",
},
"keyword3": {
"value": f"运行 {total_runs} 次 | 成功率 {success_rate:.1f}%",
"color": "#333333",
},
"keyword4": {
"value": f"🏆 {top_skill}",
"color": "#2980b9",
},
"remark": {
"value": "点击查看详细报告 →",
"color": "#999999",
},
}
# v0.5.0: 优先跳转小程序(公众号→小程序闭环)
miniprogram_config = None
if WECHAT_MP_APP_ID and agent_id:
miniprogram_config = {
"appid": WECHAT_MP_APP_ID,
"pagepath": (
f"pages/dashboard/dashboard"
f"?from=daily_push"
f"&agent_id={agent_id}"
f"&date={now.strftime('%Y-%m-%d')}"
),
}
return self.send_template_message(
openid=openid,
template_id=WECHAT_TEMPLATE_DAILY_REPORT,
data=data,
url=h5_url,
miniprogram=miniprogram_config,
)
# ──────── 用户信息 ────────
def get_user_info(self, openid: str) -> Dict[str, Any]:
"""获取公众号关注用户信息"""
token = self.get_oa_access_token()
resp = requests.get(
"https://api.weixin.qq.com/cgi-bin/user/info",
params={"access_token": token, "openid": openid, "lang": "zh_CN"},
timeout=10,
)
return resp.json()
# ──────── 带参二维码(绑定 Agent) ────────
def create_bind_qrcode(self, agent_id: str, token: str) -> Tuple[bool, Dict]:
"""
创建带参数的临时二维码,用于扫码绑定 Agent
scene_str: "bind:{agent_id}:{token_prefix}"
有效期 5 分钟
"""
access_token = self.get_oa_access_token()
scene_str = f"bind:{agent_id}:{token[:16]}"
payload = {
"expire_seconds": 300,
"action_name": "QR_STR_SCENE",
"action_info": {
"scene": {"scene_str": scene_str}
},
}
resp = requests.post(
f"https://api.weixin.qq.com/cgi-bin/qrcode/create?access_token={access_token}",
json=payload,
timeout=10,
)
result = resp.json()
if "ticket" in result:
result["qrcode_url"] = (
f"https://mp.weixin.qq.com/cgi-bin/showqrcode?ticket={result['ticket']}"
)
return True, result
return False, result
# ──────── 小程序 ────────
def mp_code2session(self, code: str) -> Dict[str, Any]:
"""小程序 code 换 session"""
resp = requests.get(
"https://api.weixin.qq.com/sns/jscode2session",
params={
"appid": WECHAT_MP_APP_ID,
"secret": WECHAT_MP_APP_SECRET,
"js_code": code,
"grant_type": "authorization_code",
},
timeout=10,
)
return resp.json()
# ──────── 自定义菜单 ────────
def create_menu(self) -> Tuple[bool, Dict]:
"""创建公众号自定义菜单"""
token = self.get_oa_access_token()
menu = {
"button": [
{
"type": "view",
"name": "📊 查看报告",
"url": f"{H5_BASE_URL}/h5/dashboard",
},
{
"name": "⚙️ 管理",
"sub_button": [
{
"type": "view",
"name": "我的 Agent",
"url": f"{H5_BASE_URL}/h5/agents",
},
{
"type": "click",
"name": "绑定 Agent",
"key": "BIND_AGENT",
},
{
"type": "view",
"name": "推送设置",
"url": f"{H5_BASE_URL}/h5/settings",
},
],
},
{
"type": "miniprogram",
"name": "小程序",
"url": f"{H5_BASE_URL}/h5/dashboard",
"appid": WECHAT_MP_APP_ID,
"pagepath": "pages/index/index",
},
],
}
resp = requests.post(
f"https://api.weixin.qq.com/cgi-bin/menu/create?access_token={token}",
json=menu,
timeout=10,
)
result = resp.json()
return result.get("errcode", -1) == 0, result
# 全局单例
wechat_service = WeChatService()
FILE:server/services/report_service.py
"""
报告服务层 — 数据上报处理 + 报告生成 + 推送调度
==============================================
"""
import gzip
import hashlib
import json
from datetime import datetime, date
from typing import Any, Dict, List, Optional, Tuple
from server.models.database import db, Agent, User, UserAgent, SkillReport, DailyDigest
def verify_agent_token(agent_id: str, token: str) -> Optional[Agent]:
"""
验证 Agent Token
token 存储为 SHA256 哈希
"""
agent = Agent.query.filter_by(agent_id=agent_id).first()
if not agent:
return None
token_hash = hashlib.sha256(token.encode("utf-8")).hexdigest()
if agent.token_hash != token_hash:
return None
return agent
def register_agent(agent_id: str, token: str, **kwargs) -> Agent:
"""
注册新 Agent 或更新已有 Agent
"""
token_hash = hashlib.sha256(token.encode("utf-8")).hexdigest()
agent = Agent.query.filter_by(agent_id=agent_id).first()
if agent:
# 更新
agent.token_hash = token_hash
agent.updated_at = datetime.utcnow()
else:
# 新建
agent = Agent(
agent_id=agent_id,
token_hash=token_hash,
)
db.session.add(agent)
# 更新附加信息
for key in ("name", "os_info", "python_version", "monitor_version",
"total_skills", "runnable_skills"):
if key in kwargs and kwargs[key] is not None:
setattr(agent, key, kwargs[key])
db.session.commit()
return agent
def process_report_upload(
agent: Agent,
report_data: dict,
report_type: str = "daily",
report_date: Optional[date] = None,
compress_threshold: int = 10240,
) -> SkillReport:
"""
处理 Agent 上报的数据
- 自动判断是否压缩
- 提取关键指标存储
- 更新 Agent 状态
"""
if report_date is None:
report_date = date.today()
# 查找是否有同日同类型报告(upsert)
existing = SkillReport.query.filter_by(
agent_db_id=agent.id,
report_date=report_date,
report_type=report_type,
).first()
if existing:
report = existing
else:
report = SkillReport(
agent_db_id=agent.id,
agent_id_str=agent.agent_id,
report_date=report_date,
report_type=report_type,
)
# 设置数据(自动压缩)
report.set_data(report_data, compress_threshold)
# 提取关键指标
overview = report_data.get("overview", {})
report.health_score = report_data.get("health_score", overview.get("health_score"))
report.total_runs = overview.get("total_runs", 0)
report.success_rate = overview.get("success_rate", 0)
report.active_skills = overview.get("active_skills", 0)
report.avg_duration_ms = overview.get("avg_duration_ms")
# Top Skills
scores = report_data.get("scores", [])
top3 = [
{"skill_id": s["skill_id"], "score": s["total_score"], "grade": s.get("grade", "")}
for s in scores[:3]
] if scores else []
report.top_skills_json = json.dumps(top3, ensure_ascii=False)
if not existing:
db.session.add(report)
# 更新 Agent 状态
agent.last_report_at = datetime.utcnow()
agent.health_score = report.health_score
agent.total_skills = overview.get("total_installed", agent.total_skills)
agent.runnable_skills = overview.get("total_runnable", agent.runnable_skills)
db.session.commit()
return report
def get_agent_reports(
agent_id: str,
report_type: Optional[str] = None,
days: int = 30,
limit: int = 30,
) -> List[Dict]:
"""获取某 Agent 的历史报告"""
query = SkillReport.query.filter_by(agent_id_str=agent_id)
if report_type:
query = query.filter_by(report_type=report_type)
reports = query.order_by(SkillReport.report_date.desc()).limit(limit).all()
return [r.to_dict(include_data=False) for r in reports]
def get_report_detail(report_id: int) -> Optional[Dict]:
"""获取报告详情(含完整数据)"""
report = SkillReport.query.get(report_id)
if not report:
return None
return report.to_dict(include_data=True)
def bind_user_agent(user: User, agent_id: str, token: str, alias: str = "") -> Tuple[bool, str]:
"""
绑定用户和 Agent
验证 token 后创建关联
"""
agent = verify_agent_token(agent_id, token)
if not agent:
return False, "智能体 ID 或 Key 无效"
# 检查是否已绑定
existing = UserAgent.query.filter_by(user_id=user.id, agent_id=agent.id).first()
if existing:
return True, "已绑定"
# 创建绑定
ua = UserAgent(
user_id=user.id,
agent_id=agent.id,
alias=alias,
is_primary=user.agents.count() == 0, # 第一个设为主 Agent
)
db.session.add(ua)
db.session.commit()
return True, "绑定成功"
def get_user_agents(user: User) -> List[Dict]:
"""获取用户绑定的所有 Agent"""
bindings = UserAgent.query.filter_by(user_id=user.id).all()
results = []
for ua in bindings:
agent_dict = ua.agent.to_dict() if ua.agent else {}
agent_dict["alias"] = ua.alias
agent_dict["is_primary"] = ua.is_primary
agent_dict["bind_id"] = ua.id
results.append(agent_dict)
return results
FILE:server/services/push_scheduler.py
"""
推送调度器 — 每日定时推送微信模板消息
====================================
使用 APScheduler 定时任务框架。
"""
import logging
from datetime import datetime, date
from server.models.database import db, User, Agent, UserAgent, DailyDigest, SkillReport
from server.services.wechat_service import wechat_service
from server.config import H5_BASE_URL
logger = logging.getLogger("push_scheduler")
def push_daily_reports(app):
"""
推送每日报告(在 Flask app 上下文中调用)
遍历所有启用推送的用户,为其每个绑定的 Agent 推送模板消息
"""
with app.app_context():
now = datetime.now()
current_hour = now.hour
# 查找当前时间段应该推送的用户
users = User.query.filter(
User.push_enabled == True,
User.subscribe == True,
User.push_hour == current_hour,
).all()
logger.info(f"[推送] 开始推送 {len(users)} 个用户 (hour={current_hour})")
success_count = 0
fail_count = 0
for user in users:
bindings = UserAgent.query.filter_by(user_id=user.id).all()
for ua in bindings:
agent = ua.agent
if not agent:
continue
try:
result = _push_one(user, agent, ua)
if result:
success_count += 1
else:
fail_count += 1
except Exception as e:
logger.error(f"[推送] 用户 {user.id} Agent {agent.agent_id} 失败: {e}")
fail_count += 1
logger.info(f"[推送] 完成: 成功 {success_count}, 失败 {fail_count}")
def _push_one(user: User, agent: Agent, ua: UserAgent) -> bool:
"""推送单个用户的单个 Agent 报告"""
today = date.today()
# 检查是否已推送
existing = DailyDigest.query.filter_by(
user_id=user.id,
agent_db_id=agent.id,
digest_date=today,
).first()
if existing and existing.push_status == "sent":
return True # 已推送过
# 获取最新报告
latest = SkillReport.query.filter_by(
agent_db_id=agent.id,
report_type="daily",
).order_by(SkillReport.report_date.desc()).first()
if not latest:
logger.debug(f"[推送] Agent {agent.agent_id} 无报告数据,跳过")
return False
# 提取推送数据
health_score = latest.health_score or 0
total_runs = latest.total_runs or 0
success_rate = latest.success_rate or 0
top_skill = "无"
if latest.top_skills_json:
try:
import json
top3 = json.loads(latest.top_skills_json)
if top3:
top_skill = f"{top3[0]['skill_id']} ({top3[0].get('score', 0):.0f}分)"
except Exception:
pass
agent_name = ua.alias or agent.name or f"Agent-{agent.agent_id[:8]}"
h5_url = f"{H5_BASE_URL}/h5/report/{agent.agent_id}"
# v0.5.0: 推送模板消息(支持跳转小程序)
ok, result = wechat_service.push_daily_report(
openid=user.openid,
agent_name=agent_name,
health_score=health_score,
total_runs=total_runs,
success_rate=success_rate,
top_skill=top_skill,
h5_url=h5_url,
agent_id=agent.agent_id,
)
# 记录推送结果
import json as json_mod
digest = existing or DailyDigest(
user_id=user.id,
agent_db_id=agent.id,
digest_date=today,
)
digest.push_status = "sent" if ok else "failed"
digest.push_result = json_mod.dumps(result, ensure_ascii=False)
digest.h5_url = h5_url
digest.push_type = "template_msg"
if not existing:
db.session.add(digest)
db.session.commit()
if ok:
logger.info(f"[推送] ✅ 用户 {user.id} Agent {agent.agent_id} 推送成功")
else:
logger.warning(f"[推送] ❌ 用户 {user.id} Agent {agent.agent_id} 推送失败: {result}")
return ok
FILE:server/services/operation_tracker.py
"""
运营闭环追踪器 v0.5.0 — 操作追踪 + 告警推送 + 闭环链路
=======================================================
核心功能:
1. 操作追踪 — 记录用户从推送→打开→操作的完整链路
2. 告警推送 — Skill 异常/性能下降时自动推送告警消息
3. 闭环链路 — 公众号→小程序 跳转参数透传 + 回溯分析
4. 推送效果分析 — 打开率、点击率、留存率
流程:
Agent 上报异常 → 服务端检测 → 触发告警 → 推送公众号模板消息
→ 用户点击 → 跳转小程序详情页 → 记录操作 → 形成闭环
"""
import json
import logging
import hashlib
from datetime import datetime, date, timedelta
from typing import Any, Dict, List, Optional, Tuple
from enum import Enum
from server.models.database import db, User, Agent, UserAgent, SkillReport, DailyDigest
from server.services.wechat_service import wechat_service
from server.config import (
WECHAT_TEMPLATE_ALERT,
WECHAT_TEMPLATE_DAILY_REPORT,
WECHAT_MP_APP_ID,
H5_BASE_URL,
)
logger = logging.getLogger("operation_tracker")
# ──────── 告警级别 ────────
class AlertLevel(str, Enum):
INFO = "info" # 信息
WARNING = "warning" # 警告
CRITICAL = "critical" # 严重
# ──────── 告警规则 ────────
ALERT_RULES = [
{
"name": "success_rate_drop",
"desc": "成功率下降",
"condition": lambda current, prev: (
prev.get("success_rate", 100) - current.get("success_rate", 0) > 15
),
"level": AlertLevel.WARNING,
"template": "⚠️ {agent_name} 成功率从 {prev_rate:.0f}% 降至 {curr_rate:.0f}%",
},
{
"name": "health_score_drop",
"desc": "健康度下降",
"condition": lambda current, prev: (
(prev.get("health_score") or 100) - (current.get("health_score") or 0) > 20
),
"level": AlertLevel.CRITICAL,
"template": "🔴 {agent_name} 健康度从 {prev_score:.0f} 降至 {curr_score:.0f}",
},
{
"name": "skill_all_fail",
"desc": "Skill 全部失败",
"condition": lambda current, prev: (
current.get("success_rate", 100) == 0 and current.get("total_runs", 0) > 3
),
"level": AlertLevel.CRITICAL,
"template": "❌ {agent_name} 所有 Skill 执行失败!({runs} 次运行全部失败)",
},
{
"name": "long_idle",
"desc": "长时间无数据",
"condition": lambda current, prev: current.get("total_runs", 0) == 0,
"level": AlertLevel.INFO,
"template": "💤 {agent_name} 今日无任何 Skill 运行记录",
},
]
# ──────── 操作事件类型 ────────
class TrackEvent(str, Enum):
PUSH_SENT = "push_sent" # 推送已发送
PUSH_OPENED = "push_opened" # 用户打开推送
REPORT_VIEWED = "report_viewed" # 查看报告详情
SKILL_VIEWED = "skill_viewed" # 查看 Skill 详情
ACTION_TAKEN = "action_taken" # 用户执行了操作
MP_LAUNCHED = "mp_launched" # 从公众号跳转到小程序
class OperationTracker:
"""
运营闭环追踪器
职责:
1. 检测告警条件 → 触发推送
2. 记录用户操作链路
3. 分析推送效果
"""
# ──────── 告警检测 ────────
@staticmethod
def check_alerts(agent: Agent) -> List[Dict[str, Any]]:
"""
检测 Agent 是否触发告警规则
Args:
agent: Agent 实例
Returns:
触发的告警列表 [{rule_name, level, message, data}]
"""
alerts = []
# 获取今日和昨日报告
today = date.today()
yesterday = today - timedelta(days=1)
current_report = SkillReport.query.filter_by(
agent_db_id=agent.id,
report_type="daily",
report_date=today,
).first()
prev_report = SkillReport.query.filter_by(
agent_db_id=agent.id,
report_type="daily",
report_date=yesterday,
).first()
current_data = {
"success_rate": current_report.success_rate if current_report else None,
"health_score": current_report.health_score if current_report else None,
"total_runs": current_report.total_runs if current_report else 0,
}
prev_data = {
"success_rate": prev_report.success_rate if prev_report else 100,
"health_score": prev_report.health_score if prev_report else 100,
"total_runs": prev_report.total_runs if prev_report else 0,
}
for rule in ALERT_RULES:
try:
if rule["condition"](current_data, prev_data):
alert = {
"rule_name": rule["name"],
"desc": rule["desc"],
"level": rule["level"].value,
"message": rule["template"].format(
agent_name=agent.name or f"Agent-{agent.agent_id[:8]}",
prev_rate=prev_data.get("success_rate", 0),
curr_rate=current_data.get("success_rate", 0),
prev_score=prev_data.get("health_score", 0),
curr_score=current_data.get("health_score", 0),
runs=current_data.get("total_runs", 0),
),
"current_data": current_data,
"prev_data": prev_data,
"timestamp": datetime.now().isoformat(),
}
alerts.append(alert)
except Exception as e:
logger.debug(f"告警规则 {rule['name']} 检测异常: {e}")
return alerts
# ──────── 告警推送 ────────
@staticmethod
def send_alert(
user: User,
agent: Agent,
alert: Dict[str, Any],
binding: UserAgent = None,
) -> Tuple[bool, Dict]:
"""
发送告警模板消息
消息点击后跳转到小程序对应页面(公众号→小程序闭环)
"""
agent_name = (binding.alias if binding else None) or agent.name or f"Agent-{agent.agent_id[:8]}"
level = alert.get("level", "info")
level_icons = {
"info": "ℹ️",
"warning": "⚠️",
"critical": "🔴",
}
level_colors = {
"info": "#3498db",
"warning": "#f39c12",
"critical": "#e74c3c",
}
# 生成追踪 ID(用于闭环追踪)
track_id = hashlib.md5(
f"{user.id}:{agent.agent_id}:{alert['rule_name']}:{datetime.now().isoformat()}".encode()
).hexdigest()[:12]
# 模板消息数据
data = {
"first": {
"value": f"{level_icons.get(level, '📢')} Skills Monitor 告警通知",
"color": level_colors.get(level, "#333"),
},
"keyword1": {
"value": agent_name,
"color": "#333333",
},
"keyword2": {
"value": alert.get("desc", "未知告警"),
"color": level_colors.get(level, "#333"),
},
"keyword3": {
"value": alert.get("message", ""),
"color": "#333333",
},
"keyword4": {
"value": datetime.now().strftime("%Y-%m-%d %H:%M"),
"color": "#999999",
},
"remark": {
"value": "点击查看详情并处理 →",
"color": "#667eea",
},
}
# 小程序跳转路径(带追踪参数)
miniprogram_config = {
"appid": WECHAT_MP_APP_ID,
"pagepath": (
f"pages/dashboard/dashboard"
f"?from=alert"
f"&track_id={track_id}"
f"&agent_id={agent.agent_id}"
f"&alert={alert['rule_name']}"
),
}
# H5 备用链接
h5_url = f"{H5_BASE_URL}/h5/report/{agent.agent_id}?track_id={track_id}"
# 使用告警模板或每日报告模板(取决于配置)
template_id = WECHAT_TEMPLATE_ALERT or WECHAT_TEMPLATE_DAILY_REPORT
ok, result = wechat_service.send_template_message(
openid=user.openid,
template_id=template_id,
data=data,
url=h5_url,
miniprogram=miniprogram_config if WECHAT_MP_APP_ID else None,
)
# 记录推送事件
OperationTracker.track(
user_id=user.id,
agent_id=agent.agent_id,
event=TrackEvent.PUSH_SENT,
data={
"push_type": "alert",
"alert_rule": alert["rule_name"],
"alert_level": level,
"track_id": track_id,
"push_result": "success" if ok else "failed",
},
)
return ok, {"track_id": track_id, "result": result}
# ──────── 操作追踪 ────────
@staticmethod
def track(
user_id: int,
agent_id: str,
event: TrackEvent,
data: Dict[str, Any] = None,
):
"""
记录用户操作事件
Args:
user_id: 用户 ID
agent_id: Agent ID
event: 事件类型
data: 事件数据
"""
try:
# 写入 daily_digests 的 push_result 字段(复用现有表)
# 更完善的方案是建立独立的 events 表
today = date.today()
agent = Agent.query.filter_by(agent_id=agent_id).first()
if not agent:
return
digest = DailyDigest.query.filter_by(
user_id=user_id,
agent_db_id=agent.id,
digest_date=today,
).first()
if digest:
# 追加事件到 push_result
existing = {}
if digest.push_result:
try:
existing = json.loads(digest.push_result)
except (json.JSONDecodeError, TypeError):
existing = {"raw": digest.push_result}
events = existing.get("events", [])
events.append({
"event": event.value,
"timestamp": datetime.now().isoformat(),
"data": data or {},
})
existing["events"] = events
digest.push_result = json.dumps(existing, ensure_ascii=False)
db.session.commit()
logger.debug(f"[追踪] user={user_id} agent={agent_id} event={event.value}")
except Exception as e:
logger.warning(f"操作追踪记录失败: {e}")
# ──────── 推送效果分析 ────────
@staticmethod
def get_push_analytics(days: int = 7) -> Dict[str, Any]:
"""
分析最近 N 天的推送效果
Returns:
{
"total_pushes": int,
"successful_pushes": int,
"opened_count": int,
"open_rate": float,
"daily_breakdown": [...],
}
"""
start_date = date.today() - timedelta(days=days)
digests = DailyDigest.query.filter(
DailyDigest.digest_date >= start_date,
).all()
total = len(digests)
sent = sum(1 for d in digests if d.push_status == "sent")
opened = 0
actions = 0
for d in digests:
if d.push_result:
try:
result = json.loads(d.push_result)
events = result.get("events", [])
if any(e.get("event") == TrackEvent.PUSH_OPENED.value for e in events):
opened += 1
if any(e.get("event") == TrackEvent.ACTION_TAKEN.value for e in events):
actions += 1
except (json.JSONDecodeError, TypeError):
pass
return {
"period_days": days,
"total_digests": total,
"successful_pushes": sent,
"opened_count": opened,
"open_rate": round(opened / max(sent, 1) * 100, 1),
"action_count": actions,
"action_rate": round(actions / max(opened, 1) * 100, 1),
"funnel": {
"push": sent,
"open": opened,
"action": actions,
},
}
# ──────── 批量告警检查 + 推送 ────────
@staticmethod
def run_alert_check(app=None):
"""
检查所有 Agent 的告警条件并推送
通常由定时任务调用(与 push_daily_reports 配合)
"""
context = app.app_context() if app else _NullContext()
with context:
agents = Agent.query.all()
total_alerts = 0
total_pushed = 0
for agent in agents:
alerts = OperationTracker.check_alerts(agent)
if not alerts:
continue
# 只推送最高级别的告警
critical = [a for a in alerts if a["level"] == "critical"]
warnings = [a for a in alerts if a["level"] == "warning"]
to_push = critical or warnings
if not to_push:
continue
total_alerts += len(to_push)
alert = to_push[0] # 推送最严重的一条
# 获取关联用户
bindings = UserAgent.query.filter_by(agent_id=agent.id).all()
for ua in bindings:
user = ua.user
if user and user.subscribe and user.push_enabled:
try:
ok, _ = OperationTracker.send_alert(user, agent, alert, ua)
if ok:
total_pushed += 1
except Exception as e:
logger.error(f"告警推送失败: user={user.id} agent={agent.agent_id}: {e}")
logger.info(f"[告警检查] 完成: {total_alerts} 条告警, {total_pushed} 条推送")
return {"alerts": total_alerts, "pushed": total_pushed}
class _NullContext:
"""空上下文管理器(无 Flask app 时使用)"""
def __enter__(self): return self
def __exit__(self, *args): pass
# ──────── 增强推送调度器 ────────
def enhanced_push_daily_reports(app):
"""
增强版每日推送 — 同时检查告警
替代原 push_scheduler.push_daily_reports
"""
from server.services.push_scheduler import push_daily_reports
# 1. 原有每日报告推送
push_daily_reports(app)
# 2. 新增告警检查 + 推送
OperationTracker.run_alert_check(app)
FILE:server/templates/h5_settings.html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Skills Monitor — 推送设置</title>
<link rel="stylesheet" href="/static/css/h5.css">
</head>
<body>
<div class="container">
<header class="header">
<div class="header-icon">⚙️</div>
<h1>推送设置</h1>
</header>
<section class="card">
<div class="setting-item">
<div class="setting-info">
<div class="setting-label">每日报告推送</div>
<div class="setting-desc">每天定时推送 Skills 健康报告</div>
</div>
<label class="switch">
<input type="checkbox" id="pushEnabled" {{ 'checked' if user.push_enabled else '' }}>
<span class="slider"></span>
</label>
</div>
<div class="setting-item">
<div class="setting-info">
<div class="setting-label">推送时间</div>
<div class="setting-desc">当前:每日 {{ '%02d' % user.push_hour }}:00</div>
</div>
<select id="pushHour" class="select-time">
{% for h in range(24) %}
<option value="{{ h }}" {{ 'selected' if user.push_hour == h else '' }}>
{{ '%02d' % h }}:00
</option>
{% endfor %}
</select>
</div>
</section>
<section class="card">
<h2 class="card-title">📌 说明</h2>
<ul class="info-list">
<li>推送通过微信公众号模板消息发送</li>
<li>请确保已关注公众号且未禁止接收消息</li>
<li>报告内容包含:健康度、运行统计、Top Skills</li>
<li>点击推送消息可查看详细报告页</li>
</ul>
</section>
<footer class="footer">
<p>Skills Monitor v0.4.0 · Powered by CodeBuddy</p>
</footer>
</div>
<script>
const openid = '{{ user.openid }}';
function save(key, value) {
fetch('/api/h5/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ openid, [key]: value }),
});
}
document.getElementById('pushEnabled').addEventListener('change', function() {
save('push_enabled', this.checked);
});
document.getElementById('pushHour').addEventListener('change', function() {
save('push_hour', parseInt(this.value));
});
</script>
</body>
</html>
FILE:server/templates/overview.html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Skills Monitor — 全局总览</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
<style>
/* ────── 基础 & 主题(与 dashboard 一致) ────── */
:root {
--bg-primary: #0f1117;
--bg-card: #1a1d2e;
--bg-card-hover: #222640;
--border: #2a2e42;
--text-primary: #e4e6f0;
--text-secondary: #8b8fa3;
--text-muted: #5a5e72;
--accent-blue: #667eea;
--accent-purple: #764ba2;
--accent-green: #4ade80;
--accent-yellow: #fbbf24;
--accent-red: #f87171;
--accent-orange: #fb923c;
--accent-cyan: #22d3ee;
--accent-pink: #f472b6;
--gradient-main: linear-gradient(135deg, #667eea, #764ba2);
--gradient-green: linear-gradient(135deg, #4ade80, #22d3ee);
--gradient-orange: linear-gradient(135deg, #fb923c, #f87171);
--gradient-pink: linear-gradient(135deg, #f472b6, #764ba2);
--shadow-card: 0 4px 24px rgba(0,0,0,0.3);
--radius: 16px;
--radius-sm: 10px;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', Roboto, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
line-height: 1.6;
}
.dashboard { max-width: 1400px; margin: 0 auto; padding: 24px; }
/* ────── Header ────── */
.header {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 28px; flex-wrap: wrap; gap: 12px;
}
.header-left { display: flex; align-items: center; gap: 16px; }
.header-logo {
width: 52px; height: 52px;
background: var(--gradient-pink);
border-radius: 14px;
display: flex; align-items: center; justify-content: center;
font-size: 26px;
box-shadow: 0 4px 16px rgba(244,114,182,0.3);
}
.header h1 { font-size: 22px; font-weight: 700; }
.header .subtitle { color: var(--text-secondary); font-size: 13px; margin-top: 2px; }
.header-right { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.btn {
display: inline-flex; align-items: center; gap: 6px;
padding: 8px 16px; border-radius: 10px;
border: 1px solid var(--border); background: var(--bg-card);
color: var(--text-primary); font-size: 13px; cursor: pointer;
transition: all 0.2s; text-decoration: none;
}
.btn:hover { background: var(--bg-card-hover); border-color: var(--accent-blue); }
.btn-primary { background: var(--gradient-main); border: none; color: #fff; }
.btn-primary:hover { opacity: 0.9; }
.btn-sm { padding: 5px 12px; font-size: 12px; border-radius: 8px; }
.btn.active { background: var(--accent-blue); border-color: var(--accent-blue); color: #fff; }
.agent-count-badge {
display: inline-flex; align-items: center; gap: 8px;
padding: 8px 16px; border-radius: 20px; font-size: 13px; font-weight: 600;
background: rgba(102,126,234,0.12); color: var(--accent-blue);
}
/* ────── Grid ────── */
.grid { display: grid; gap: 20px; }
.grid-5 { grid-template-columns: repeat(5, 1fr); }
.grid-4 { grid-template-columns: repeat(4, 1fr); }
.grid-3 { grid-template-columns: repeat(3, 1fr); }
.grid-2 { grid-template-columns: repeat(2, 1fr); }
.grid-2-1 { grid-template-columns: 2fr 1fr; }
.grid-1-2 { grid-template-columns: 1fr 2fr; }
@media (max-width: 1200px) {
.grid-5 { grid-template-columns: repeat(3, 1fr); }
.grid-4 { grid-template-columns: repeat(2, 1fr); }
.grid-3 { grid-template-columns: repeat(2, 1fr); }
.grid-2-1, .grid-1-2 { grid-template-columns: 1fr; }
}
@media (max-width: 768px) {
.dashboard { padding: 16px; }
.grid-5, .grid-4, .grid-3, .grid-2 { grid-template-columns: 1fr; }
.header { flex-direction: column; align-items: flex-start; }
}
/* ────── Card ────── */
.card {
background: var(--bg-card); border: 1px solid var(--border);
border-radius: var(--radius); padding: 24px;
box-shadow: var(--shadow-card); transition: border-color 0.3s;
}
.card:hover { border-color: rgba(102,126,234,0.3); }
.card-title {
font-size: 14px; font-weight: 600; color: var(--text-secondary);
text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 16px;
display: flex; align-items: center; gap: 8px;
}
.card-title .icon {
width: 28px; height: 28px; border-radius: 8px;
display: flex; align-items: center; justify-content: center; font-size: 14px;
}
/* ────── KPI 卡片 ────── */
.kpi-card { position: relative; overflow: hidden; }
.kpi-card::before { content: ''; position: absolute; top: 0; left: 0; right: 0; height: 3px; }
.kpi-card:nth-child(1)::before { background: var(--gradient-main); }
.kpi-card:nth-child(2)::before { background: var(--gradient-green); }
.kpi-card:nth-child(3)::before { background: linear-gradient(135deg, var(--accent-yellow), var(--accent-orange)); }
.kpi-card:nth-child(4)::before { background: linear-gradient(135deg, var(--accent-cyan), var(--accent-blue)); }
.kpi-card:nth-child(5)::before { background: var(--gradient-pink); }
.kpi-value {
font-size: 36px; font-weight: 800; line-height: 1.1; margin: 8px 0 4px;
}
.kpi-value.gradient-1 { background: var(--gradient-main); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; }
.kpi-value.gradient-2 { background: var(--gradient-green); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; }
.kpi-value.gradient-3 { background: linear-gradient(135deg, var(--accent-yellow), var(--accent-orange)); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; }
.kpi-value.gradient-4 { background: linear-gradient(135deg, var(--accent-cyan), var(--accent-blue)); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; }
.kpi-value.gradient-5 { background: var(--gradient-pink); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; }
.kpi-label { font-size: 13px; color: var(--text-secondary); }
.kpi-sub { font-size: 12px; color: var(--text-muted); margin-top: 8px; }
/* ────── Agent 列表区域 ────── */
.agent-toolbar {
display: flex; align-items: center; gap: 12px; margin-bottom: 16px; flex-wrap: wrap;
}
.search-box {
flex: 1; min-width: 200px; max-width: 360px; position: relative;
}
.search-box input {
width: 100%; padding: 9px 14px 9px 36px;
background: rgba(0,0,0,0.25); border: 1px solid var(--border);
border-radius: 10px; color: var(--text-primary); font-size: 13px;
outline: none; transition: border-color 0.2s;
}
.search-box input:focus { border-color: var(--accent-blue); }
.search-box input::placeholder { color: var(--text-muted); }
.search-box .search-icon {
position: absolute; left: 12px; top: 50%; transform: translateY(-50%);
font-size: 14px; color: var(--text-muted);
}
.filter-pills { display: flex; gap: 6px; flex-wrap: wrap; }
.filter-pill {
padding: 5px 14px; border-radius: 20px; font-size: 12px; font-weight: 500;
border: 1px solid var(--border); background: transparent; color: var(--text-secondary);
cursor: pointer; transition: all 0.2s; white-space: nowrap;
}
.filter-pill:hover { border-color: var(--accent-blue); color: var(--text-primary); }
.filter-pill.active { background: var(--accent-blue); border-color: var(--accent-blue); color: #fff; }
.filter-pill .pill-count {
display: inline-block; min-width: 16px; text-align: center;
padding: 0 5px; margin-left: 4px; border-radius: 10px;
font-size: 10px; font-weight: 700;
background: rgba(255,255,255,0.15);
}
.filter-pill.active .pill-count { background: rgba(255,255,255,0.25); }
.view-toggle { display: flex; gap: 2px; background: rgba(0,0,0,0.2); border-radius: 8px; padding: 3px; margin-left: auto; }
.view-btn {
padding: 5px 10px; border: none; background: none; color: var(--text-muted);
font-size: 14px; border-radius: 6px; cursor: pointer; transition: all 0.2s;
}
.view-btn.active { background: var(--bg-card-hover); color: var(--text-primary); }
.agent-list-info {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 12px; font-size: 12px; color: var(--text-muted);
}
.sort-selector {
display: flex; align-items: center; gap: 6px;
}
.sort-selector select {
background: rgba(0,0,0,0.25); border: 1px solid var(--border);
border-radius: 6px; color: var(--text-secondary); font-size: 12px;
padding: 4px 8px; outline: none; cursor: pointer;
}
/* ────── Agent 卡片(展开模式) ────── */
.agent-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }
@media (max-width: 1200px) { .agent-grid { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 768px) { .agent-grid { grid-template-columns: 1fr; } }
.agent-card {
background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius);
padding: 20px; transition: all 0.3s; cursor: pointer; text-decoration: none; color: inherit;
display: block; position: relative; overflow: hidden;
}
.agent-card:hover { border-color: var(--accent-blue); transform: translateY(-2px); box-shadow: 0 8px 32px rgba(0,0,0,0.4); }
.agent-card.hidden { display: none; }
.agent-card-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
.agent-card-name { font-size: 15px; font-weight: 700; }
.agent-card-id { font-size: 11px; color: var(--text-muted); margin-top: 2px; font-family: monospace; }
.status-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
.status-dot.healthy { background: var(--accent-green); box-shadow: 0 0 8px rgba(74,222,128,0.4); }
.status-dot.warning { background: var(--accent-yellow); box-shadow: 0 0 8px rgba(251,191,36,0.4); }
.status-dot.idle { background: var(--text-muted); }
.status-dot.offline { background: var(--accent-red); }
.agent-metrics { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; }
.agent-metric { padding: 8px; background: rgba(255,255,255,0.02); border-radius: 8px; }
.agent-metric-label { font-size: 10px; color: var(--text-muted); text-transform: uppercase; }
.agent-metric-value { font-size: 16px; font-weight: 700; margin-top: 2px; }
.agent-metric-value.health-good { color: var(--accent-green); }
.agent-metric-value.health-ok { color: var(--accent-blue); }
.agent-metric-value.health-warn { color: var(--accent-yellow); }
.agent-metric-value.health-bad { color: var(--accent-red); }
.agent-card-footer { display: flex; align-items: center; justify-content: space-between; margin-top: 14px; padding-top: 12px; border-top: 1px solid rgba(42,46,66,0.5); }
.agent-card-footer span { font-size: 11px; color: var(--text-muted); }
.agent-card-footer .os-badge { padding: 2px 8px; background: rgba(102,126,234,0.1); border-radius: 6px; color: var(--accent-blue); font-size: 10px; }
/* ────── Agent 列表(紧凑模式) ────── */
.agent-table-wrapper { overflow-x: auto; }
.agent-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.agent-table th {
text-align: left; padding: 10px 14px; color: var(--text-secondary);
font-weight: 600; font-size: 11px; text-transform: uppercase; letter-spacing: 0.3px;
border-bottom: 1px solid var(--border); white-space: nowrap;
cursor: pointer; user-select: none; position: sticky; top: 0;
background: var(--bg-card); z-index: 1;
}
.agent-table th:hover { color: var(--text-primary); }
.agent-table th .sort-arrow { font-size: 10px; margin-left: 4px; opacity: 0.3; }
.agent-table th.sorted .sort-arrow { opacity: 1; color: var(--accent-blue); }
.agent-table td { padding: 10px 14px; border-bottom: 1px solid rgba(42,46,66,0.3); white-space: nowrap; }
.agent-table tr { cursor: pointer; transition: background 0.15s; }
.agent-table tr:hover td { background: rgba(102,126,234,0.05); }
.agent-table tr.hidden { display: none; }
.agent-name-cell { display: flex; align-items: center; gap: 10px; }
.agent-name-cell .agent-avatar {
width: 32px; height: 32px; border-radius: 8px;
display: flex; align-items: center; justify-content: center;
font-size: 14px; font-weight: 700; flex-shrink: 0;
}
.agent-name-cell .agent-info { min-width: 0; }
.agent-name-cell .name { font-weight: 600; font-size: 13px; }
.agent-name-cell .id { font-size: 10px; color: var(--text-muted); font-family: monospace; }
.health-bar { display: flex; align-items: center; gap: 8px; }
.health-bar-track { width: 60px; height: 5px; background: #2a2e42; border-radius: 3px; overflow: hidden; }
.health-bar-fill { height: 100%; border-radius: 3px; transition: width 0.6s ease; }
.status-badge {
display: inline-flex; align-items: center; gap: 5px;
padding: 3px 10px; border-radius: 12px; font-size: 11px; font-weight: 600;
}
.status-badge.healthy { background: rgba(74,222,128,0.12); color: var(--accent-green); }
.status-badge.warning { background: rgba(251,191,36,0.12); color: var(--accent-yellow); }
.status-badge.idle { background: rgba(90,94,114,0.12); color: var(--text-muted); }
.status-badge.offline { background: rgba(248,113,113,0.12); color: var(--accent-red); }
/* ────── 分页器 ────── */
.pagination {
display: flex; align-items: center; justify-content: center; gap: 6px;
margin-top: 16px; padding-top: 16px; border-top: 1px solid rgba(42,46,66,0.5);
}
.page-btn {
padding: 6px 12px; border: 1px solid var(--border); border-radius: 8px;
background: transparent; color: var(--text-secondary); font-size: 12px;
cursor: pointer; transition: all 0.2s;
}
.page-btn:hover { border-color: var(--accent-blue); color: var(--text-primary); }
.page-btn.active { background: var(--accent-blue); border-color: var(--accent-blue); color: #fff; }
.page-btn:disabled { opacity: 0.3; cursor: not-allowed; }
.page-info { font-size: 12px; color: var(--text-muted); padding: 0 8px; }
/* ────── 表格 ────── */
.table-wrapper { overflow-x: auto; }
table.skill-table { width: 100%; border-collapse: collapse; font-size: 13px; }
table.skill-table th { text-align: left; padding: 12px 14px; color: var(--text-secondary); font-weight: 600; font-size: 12px; text-transform: uppercase; letter-spacing: 0.3px; border-bottom: 1px solid var(--border); white-space: nowrap; }
table.skill-table td { padding: 12px 14px; border-bottom: 1px solid rgba(42,46,66,0.3); }
table.skill-table tr:hover td { background: rgba(102,126,234,0.03); }
.grade-badge { display: inline-block; padding: 3px 10px; border-radius: 6px; font-size: 11px; font-weight: 700; }
.grade-s, .grade-a { background: rgba(74,222,128,0.15); color: var(--accent-green); }
.grade-b { background: rgba(102,126,234,0.15); color: var(--accent-blue); }
.grade-c { background: rgba(251,191,36,0.15); color: var(--accent-yellow); }
.grade-d, .grade-f { background: rgba(248,113,113,0.15); color: var(--accent-red); }
.progress-bar { width: 100%; height: 6px; background: #2a2e42; border-radius: 3px; overflow: hidden; }
.progress-bar-fill { height: 100%; border-radius: 3px; background: var(--gradient-main); transition: width 1s ease; }
/* ────── Chart ────── */
.chart-container { position: relative; height: 280px; width: 100%; }
.chart-container canvas { max-height: 100%; }
.chart-container.chart-sm { height: 200px; }
.chart-container.chart-dynamic { height: auto; min-height: 200px; }
/* ────── Tab ────── */
.tab-nav { display: flex; gap: 4px; margin-bottom: 16px; background: rgba(0,0,0,0.2); border-radius: 10px; padding: 4px; flex-wrap: wrap; }
.tab-btn { padding: 8px 16px; border: none; background: none; color: var(--text-muted); font-size: 13px; font-weight: 500; border-radius: 8px; cursor: pointer; transition: all 0.2s; }
.tab-btn.active { background: var(--bg-card-hover); color: var(--text-primary); }
.tab-btn:hover:not(.active) { color: var(--text-secondary); }
/* ────── Alert ────── */
.alert-list { display: flex; flex-direction: column; gap: 8px; max-height: 400px; overflow-y: auto; }
.alert-item { display: flex; align-items: flex-start; gap: 12px; padding: 12px; background: rgba(255,255,255,0.02); border-radius: var(--radius-sm); border-left: 3px solid; }
.alert-item.error { border-left-color: var(--accent-red); }
.alert-item.warning { border-left-color: var(--accent-yellow); }
.alert-item.info { border-left-color: var(--accent-blue); }
.alert-icon { font-size: 16px; flex-shrink: 0; margin-top: 1px; }
.alert-content { flex: 1; }
.alert-agent { font-size: 12px; font-weight: 700; color: var(--text-primary); }
.alert-msg { font-size: 12px; color: var(--text-secondary); margin-top: 2px; }
.alert-summary-pills { display: flex; gap: 8px; margin-bottom: 12px; flex-wrap: wrap; }
.alert-summary-pill {
padding: 4px 12px; border-radius: 12px; font-size: 11px; font-weight: 600;
}
.alert-summary-pill.error { background: rgba(248,113,113,0.12); color: var(--accent-red); }
.alert-summary-pill.warning { background: rgba(251,191,36,0.12); color: var(--accent-yellow); }
.alert-summary-pill.info { background: rgba(102,126,234,0.12); color: var(--accent-blue); }
/* ────── 分布饼图 ────── */
.dist-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-top: 12px; }
.dist-item { text-align: center; padding: 16px 8px; background: rgba(255,255,255,0.02); border-radius: var(--radius-sm); border: 1px solid rgba(42,46,66,0.5); }
.dist-count { font-size: 28px; font-weight: 800; line-height: 1.2; }
.dist-label { font-size: 11px; color: var(--text-secondary); margin-top: 4px; }
.dist-excellent .dist-count { color: var(--accent-green); }
.dist-good .dist-count { color: var(--accent-blue); }
.dist-average .dist-count { color: var(--accent-yellow); }
.dist-poor .dist-count { color: var(--accent-red); }
/* ────── 环形统计 ────── */
.donut-stats { display: flex; align-items: center; gap: 20px; }
.donut-legend { flex: 1; }
.legend-item { display: flex; align-items: center; gap: 10px; padding: 6px 0; font-size: 13px; }
.legend-dot { width: 10px; height: 10px; border-radius: 3px; flex-shrink: 0; }
.legend-value { margin-left: auto; font-weight: 700; }
/* ────── Section & Footer ────── */
.section { margin-bottom: 20px; }
.footer { text-align: center; padding: 32px 0 16px; color: var(--text-muted); font-size: 12px; }
/* ────── Empty ────── */
.empty-state { text-align: center; padding: 48px 24px; color: var(--text-muted); }
.empty-state .empty-icon { font-size: 48px; margin-bottom: 16px; }
.empty-state h3 { font-size: 18px; color: var(--text-secondary); margin-bottom: 8px; }
.empty-state p { font-size: 13px; max-width: 400px; margin: 0 auto; }
/* ────── Scrollbar ────── */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #3a3e52; border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: #4a4e62; }
/* ────── 动画 ────── */
@keyframes fadeIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
.fade-in { animation: fadeIn 0.3s ease forwards; }
/* ────── 加载效果 ────── */
.skeleton {
background: linear-gradient(90deg, var(--bg-card) 25%, var(--bg-card-hover) 50%, var(--bg-card) 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 8px;
}
@keyframes shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
</style>
</head>
<body>
<div class="dashboard">
<!-- ════════ Header ════════ -->
<header class="header">
<div class="header-left">
<div class="header-logo">🌐</div>
<div>
<h1>Skills Monitor 全局总览</h1>
<p class="subtitle">所有 Agent 汇总分析 · 数据截至 {{ data.generated_at[:10] }}</p>
</div>
</div>
<div class="header-right">
<div class="agent-count-badge">
🤖 {{ data.total_agents }} 个 Agent
</div>
<a class="btn btn-primary" href="/api/overview/data" target="_blank">📥 JSON 数据</a>
</div>
</header>
{% if data.total_agents == 0 %}
<!-- ════════ 空状态 ════════ -->
<div class="card">
<div class="empty-state">
<div class="empty-icon">🤖</div>
<h3>暂无 Agent 注册</h3>
<p>还没有任何 Agent 注册到服务器。请在本地安装并运行 Skills Monitor 客户端。</p>
</div>
</div>
{% else %}
<!-- ════════ KPI 舰队概览 ════════ -->
<div class="section grid grid-5">
<div class="card kpi-card">
<div class="card-title"><span class="icon" style="background:rgba(102,126,234,0.15)">🤖</span> Agent 数量</div>
<div class="kpi-value gradient-1">{{ data.fleet_overview.total_agents }}</div>
<div class="kpi-label">已注册智能体</div>
<div class="kpi-sub">活跃 {{ data.fleet_overview.active_agents }} · 不活跃 {{ data.fleet_overview.inactive_agents }}</div>
</div>
<div class="card kpi-card">
<div class="card-title"><span class="icon" style="background:rgba(74,222,128,0.15)">🏥</span> 平均健康度</div>
<div class="kpi-value gradient-2">{{ '%.0f' % data.fleet_overview.avg_health_score }}</div>
<div class="kpi-label">全局平均分</div>
<div class="kpi-sub">累计 {{ data.fleet_overview.total_reports }} 份报告</div>
</div>
<div class="card kpi-card">
<div class="card-title"><span class="icon" style="background:rgba(251,191,36,0.15)">📈</span> 总运行次数</div>
<div class="kpi-value gradient-3">{{ data.fleet_overview.global_total_runs }}</div>
<div class="kpi-label">所有 Agent 合计</div>
<div class="kpi-sub">活跃 Skills {{ data.fleet_overview.global_active_skills }} 个</div>
</div>
<div class="card kpi-card">
<div class="card-title"><span class="icon" style="background:rgba(34,211,238,0.15)">🎯</span> 平均成功率</div>
<div class="kpi-value gradient-4">{{ '%.1f' % data.fleet_overview.global_avg_success_rate }}%</div>
<div class="kpi-label">全局运行成功率</div>
<div class="kpi-sub">可运行率 {{ '%.1f' % data.fleet_overview.global_runnable_rate }}%</div>
</div>
<div class="card kpi-card">
<div class="card-title"><span class="icon" style="background:rgba(244,114,182,0.15)">📦</span> Skills 总量</div>
<div class="kpi-value gradient-5">{{ data.fleet_overview.total_skills_installed }}</div>
<div class="kpi-label">所有 Agent 合计安装</div>
<div class="kpi-sub">可运行 {{ data.fleet_overview.total_skills_runnable }} 个</div>
</div>
</div>
<!-- ════════ Agent 列表(支持搜索、筛选、分页、双视图) ════════ -->
<div class="section">
<div class="card" style="padding:20px">
<div class="card-title"><span class="icon" style="background:rgba(102,126,234,0.15)">🤖</span> Agent 状态一览</div>
<!-- 工具栏:搜索 + 筛选 + 视图切换 -->
<div class="agent-toolbar">
<div class="search-box">
<span class="search-icon">🔍</span>
<input type="text" id="agentSearch" placeholder="搜索 Agent 名称或 ID..." oninput="filterAgents()">
</div>
<div class="filter-pills" id="filterPills">
<button class="filter-pill active" data-filter="all" onclick="setFilter('all')">
全部 <span class="pill-count" id="count-all">{{ data.total_agents }}</span>
</button>
<button class="filter-pill" data-filter="healthy" onclick="setFilter('healthy')">
🟢 正常 <span class="pill-count" id="count-healthy">0</span>
</button>
<button class="filter-pill" data-filter="warning" onclick="setFilter('warning')">
🟡 关注 <span class="pill-count" id="count-warning">0</span>
</button>
<button class="filter-pill" data-filter="idle" onclick="setFilter('idle')">
⚫ 不活跃 <span class="pill-count" id="count-idle">0</span>
</button>
<button class="filter-pill" data-filter="offline" onclick="setFilter('offline')">
🔴 离线 <span class="pill-count" id="count-offline">0</span>
</button>
</div>
<div class="view-toggle">
<button class="view-btn active" id="viewGrid" onclick="setView('grid')" title="卡片视图">🏷️</button>
<button class="view-btn" id="viewTable" onclick="setView('table')" title="列表视图">📋</button>
</div>
</div>
<!-- 信息栏:结果数 + 排序 -->
<div class="agent-list-info">
<span id="agentResultInfo">显示 {{ data.total_agents }} 个 Agent</span>
<div class="sort-selector">
<span>排序:</span>
<select id="agentSort" onchange="sortAgents()">
<option value="health_desc">健康度 ↓</option>
<option value="health_asc">健康度 ↑</option>
<option value="name_asc">名称 A→Z</option>
<option value="name_desc">名称 Z→A</option>
<option value="runs_desc">运行次数 ↓</option>
<option value="rate_desc">成功率 ↓</option>
<option value="last_report">最近上报</option>
</select>
</div>
</div>
<!-- 视图 1: 卡片网格 -->
<div id="agentGridView" class="agent-grid">
<!-- JS 动态渲染 -->
</div>
<!-- 视图 2: 紧凑表格 -->
<div id="agentTableView" style="display:none">
<div class="agent-table-wrapper" style="max-height:600px;overflow-y:auto">
<table class="agent-table">
<thead>
<tr>
<th>Agent</th>
<th>状态</th>
<th>健康度</th>
<th>运行次数</th>
<th>成功率</th>
<th>Skills</th>
<th>最后上报</th>
<th>系统</th>
</tr>
</thead>
<tbody id="agentTableBody">
<!-- JS 动态渲染 -->
</tbody>
</table>
</div>
</div>
<!-- 分页 -->
<div class="pagination" id="agentPagination"></div>
</div>
</div>
<!-- ════════ 全局趋势 + 告警 ════════ -->
<div class="section grid grid-2-1">
<!-- 全局趋势 -->
<div class="card">
<div class="card-title"><span class="icon" style="background:rgba(34,211,238,0.15)">📈</span> 全局趋势(近 {{ data.period_days }} 天)</div>
<div class="tab-nav">
<button class="tab-btn active" onclick="switchTrend('health')">平均健康度</button>
<button class="tab-btn" onclick="switchTrend('runs')">总运行次数</button>
<button class="tab-btn" onclick="switchTrend('rate')">平均成功率</button>
<button class="tab-btn" onclick="switchTrend('agents')">上报 Agent 数</button>
</div>
{% if data.trend %}
<div class="chart-container">
<canvas id="trendChart"></canvas>
</div>
{% else %}
<div class="empty-state">
<div class="empty-icon">📉</div>
<h3>暂无趋势数据</h3>
<p>至少需要 2 天的上报数据才能展示趋势。</p>
</div>
{% endif %}
</div>
<!-- 告警摘要 -->
<div class="card">
<div class="card-title"><span class="icon" style="background:rgba(248,113,113,0.15)">🚨</span> 告警 & 关注事项</div>
{% if data.alert_summary %}
<div class="alert-summary-pills">
<span class="alert-summary-pill error" id="alertErrorCount"></span>
<span class="alert-summary-pill warning" id="alertWarnCount"></span>
<span class="alert-summary-pill info" id="alertInfoCount"></span>
</div>
<div class="alert-list" id="alertList">
{% for alert in data.alert_summary %}
<div class="alert-item {{ alert.level }}">
<span class="alert-icon">
{% if alert.level == 'error' %}🔴{% elif alert.level == 'warning' %}🟡{% else %}🔵{% endif %}
</span>
<div class="alert-content">
<div class="alert-agent">{{ alert.agent }}</div>
<div class="alert-msg">{{ alert.message }}</div>
</div>
</div>
{% endfor %}
</div>
{% if data.alert_summary|length > 10 %}
<div style="text-align:center;margin-top:8px">
<button class="btn btn-sm" onclick="toggleAlertExpand(this)">展开全部 ({{ data.alert_summary|length }})</button>
</div>
{% endif %}
{% else %}
<div class="empty-state" style="padding:32px">
<div class="empty-icon">✅</div>
<h3>一切正常</h3>
<p>没有发现需要关注的问题。</p>
</div>
{% endif %}
</div>
</div>
<!-- ════════ Agent 对比柱状图 + 分布统计 ════════ -->
<div class="section grid grid-2">
<!-- Agent 健康度对比 -->
<div class="card">
<div class="card-title">
<span class="icon" style="background:rgba(102,126,234,0.15)">📊</span> Agent 健康度对比
{% if data.agent_comparison|length > 20 %}
<span style="margin-left:auto;font-size:11px;color:var(--text-muted);text-transform:none;letter-spacing:0">
显示 Top 30
</span>
{% endif %}
</div>
{% if data.agent_comparison %}
<div class="chart-container" id="compareChartContainer" style="height:250px">
<canvas id="compareChart"></canvas>
</div>
{% if data.agent_comparison|length > 30 %}
<div style="text-align:center;margin-top:8px">
<button class="btn btn-sm" id="compareShowMore" onclick="toggleCompareExpand()">显示全部 {{ data.agent_comparison|length }} 个</button>
</div>
{% endif %}
{% else %}
<div class="empty-state"><div class="empty-icon">📊</div><h3>暂无数据</h3></div>
{% endif %}
</div>
<!-- OS & 版本分布 -->
<div class="card">
<div class="card-title"><span class="icon" style="background:rgba(244,114,182,0.15)">🖥️</span> 环境分布</div>
<div class="grid grid-2" style="gap:16px">
<div>
<div style="font-size:12px;color:var(--text-muted);margin-bottom:12px;text-transform:uppercase">操作系统</div>
<div class="chart-container chart-sm">
<canvas id="osChart"></canvas>
</div>
</div>
<div>
<div style="font-size:12px;color:var(--text-muted);margin-bottom:12px;text-transform:uppercase">Monitor 版本</div>
<div class="chart-container chart-sm">
<canvas id="versionChart"></canvas>
</div>
</div>
</div>
</div>
</div>
<!-- ════════ 全局 Skill 排行 + 评分分布 ════════ -->
<div class="section grid grid-2-1">
<!-- Skill 全局排行 -->
<div class="card">
<div class="card-title"><span class="icon" style="background:rgba(74,222,128,0.15)">🏆</span> Skill 全局评分排行</div>
{% if data.skill_global_rankings %}
<div class="table-wrapper" style="max-height:500px;overflow-y:auto">
<table class="skill-table">
<thead>
<tr>
<th>#</th>
<th>Skill</th>
<th>平均分</th>
<th>等级</th>
<th>最高分</th>
<th>最低分</th>
<th>使用 Agent 数</th>
</tr>
</thead>
<tbody>
{% for s in data.skill_global_rankings %}
<tr>
<td>{{ loop.index }}</td>
<td style="font-weight:600">{{ s.skill_id }}</td>
<td>
<div style="display:flex;align-items:center;gap:8px">
<span style="font-weight:700">{{ s.avg_score }}</span>
<div class="progress-bar" style="width:80px">
<div class="progress-bar-fill" style="width:{{ s.avg_score }}%"></div>
</div>
</div>
</td>
<td><span class="grade-badge grade-{{ s.grade[:1]|lower }}">{{ s.grade }}</span></td>
<td style="color:var(--accent-green)">{{ s.max_score }}</td>
<td style="color:{{ 'var(--accent-red)' if s.min_score < 60 else 'var(--text-secondary)' }}">{{ s.min_score }}</td>
<td>
<span style="font-weight:600">{{ s.agent_count }}</span>
<span style="font-size:11px;color:var(--text-muted)"> 个</span>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="empty-state"><div class="empty-icon">🏅</div><h3>暂无排行数据</h3></div>
{% endif %}
</div>
<!-- 全局评分分布 -->
<div class="card">
<div class="card-title"><span class="icon" style="background:rgba(251,191,36,0.15)">📊</span> 全局评分分布</div>
{% if data.skill_global_rankings %}
<div class="chart-container" style="height:180px">
<canvas id="distChart"></canvas>
</div>
<div class="dist-grid">
<div class="dist-item dist-excellent">
<div class="dist-count">{{ data.score_distribution.excellent }}</div>
<div class="dist-label">优秀 (≥90)</div>
</div>
<div class="dist-item dist-good">
<div class="dist-count">{{ data.score_distribution.good }}</div>
<div class="dist-label">良好 (75-89)</div>
</div>
<div class="dist-item dist-average">
<div class="dist-count">{{ data.score_distribution.average }}</div>
<div class="dist-label">一般 (60-74)</div>
</div>
<div class="dist-item dist-poor">
<div class="dist-count">{{ data.score_distribution.poor }}</div>
<div class="dist-label">待改善 (<60)</div>
</div>
</div>
{% else %}
<div class="empty-state"><div class="empty-icon">📭</div><h3>暂无评分数据</h3></div>
{% endif %}
</div>
</div>
{% endif %}
<!-- ════════ Footer ════════ -->
<footer class="footer">
<p>Skills Monitor Global Overview v0.7.0 · Powered by CodeBuddy · {{ data.generated_at[:19] }}</p>
<p style="margin-top:4px"><a href="/benchmark" style="color:var(--accent-blue);text-decoration:none">🧪 TOP1000 基准评测 →</a></p>
</footer>
</div>
<!-- ════════ JavaScript ════════ -->
<script>
const DATA = {{ data|tojson }};
// ─── Chart.js 全局设置 ───
Chart.defaults.color = '#5a5e72';
Chart.defaults.font.family = "-apple-system, BlinkMacSystemFont, 'SF Pro Display', sans-serif";
// ─── 颜色调色板 ───
const COLORS = ['#667eea','#4ade80','#fbbf24','#f87171','#22d3ee','#f472b6','#fb923c','#a78bfa','#34d399','#f9a8d4','#818cf8','#2dd4bf','#e879f9','#38bdf8','#fb7185','#a3e635','#facc15','#c084fc','#f97316','#6366f1'];
// ═══════════════════════════════════════════════
// Agent 列表管理器(搜索 + 筛选 + 分页 + 排序)
// ═══════════════════════════════════════════════
const AGENTS = DATA.agents || [];
const PAGE_SIZE = 12; // 每页显示数
let currentFilter = 'all';
let currentView = 'grid';
let currentPage = 1;
let filteredAgents = [...AGENTS];
// 初始化筛选计数
function initFilterCounts() {
const counts = { all: AGENTS.length, healthy: 0, warning: 0, idle: 0, offline: 0 };
AGENTS.forEach(a => { if (counts[a.status] !== undefined) counts[a.status]++; });
Object.keys(counts).forEach(k => {
const el = document.getElementById('count-' + k);
if (el) el.textContent = counts[k];
});
// 隐藏 0 计数的 pill
document.querySelectorAll('.filter-pill').forEach(pill => {
const f = pill.dataset.filter;
if (f !== 'all' && counts[f] === 0) pill.style.display = 'none';
});
}
// 筛选 Agent
function setFilter(filter) {
currentFilter = filter;
currentPage = 1;
document.querySelectorAll('.filter-pill').forEach(p => p.classList.toggle('active', p.dataset.filter === filter));
applyFilters();
}
// 搜索 + 筛选
function filterAgents() {
currentPage = 1;
applyFilters();
}
function applyFilters() {
const q = (document.getElementById('agentSearch').value || '').toLowerCase().trim();
filteredAgents = AGENTS.filter(a => {
// 状态筛选
if (currentFilter !== 'all' && a.status !== currentFilter) return false;
// 搜索
if (q && !a.name.toLowerCase().includes(q) && !a.agent_id.toLowerCase().includes(q)) return false;
return true;
});
sortAgents(false); // 排序但不重置页码
renderAgentList();
}
// 排序
function sortAgents(doRender = true) {
const sortKey = document.getElementById('agentSort').value;
filteredAgents.sort((a, b) => {
switch (sortKey) {
case 'health_desc': return (b.health_score || 0) - (a.health_score || 0);
case 'health_asc': return (a.health_score || 0) - (b.health_score || 0);
case 'name_asc': return a.name.localeCompare(b.name);
case 'name_desc': return b.name.localeCompare(a.name);
case 'runs_desc': return (b.total_runs || 0) - (a.total_runs || 0);
case 'rate_desc': return (b.success_rate || 0) - (a.success_rate || 0);
case 'last_report': return (b.last_report_at || '').localeCompare(a.last_report_at || '');
default: return 0;
}
});
if (doRender) renderAgentList();
}
// 视图切换
function setView(view) {
currentView = view;
document.getElementById('viewGrid').classList.toggle('active', view === 'grid');
document.getElementById('viewTable').classList.toggle('active', view === 'table');
document.getElementById('agentGridView').style.display = view === 'grid' ? 'grid' : 'none';
document.getElementById('agentTableView').style.display = view === 'table' ? 'block' : 'none';
renderAgentList();
}
// 健康度颜色辅助
function healthClass(score) {
if (score === null || score === undefined) return '';
if (score >= 80) return 'health-good';
if (score >= 60) return 'health-ok';
if (score >= 40) return 'health-warn';
return 'health-bad';
}
function healthColor(score) {
if (score === null || score === undefined) return '#5a5e72';
if (score >= 80) return '#4ade80';
if (score >= 60) return '#667eea';
if (score >= 40) return '#fbbf24';
return '#f87171';
}
// Agent 头像颜色
function avatarBg(index) {
const colors = ['#667eea','#4ade80','#fbbf24','#f87171','#22d3ee','#f472b6','#fb923c','#a78bfa'];
return colors[index % colors.length];
}
// 渲染 Agent 列表
function renderAgentList() {
const totalPages = Math.ceil(filteredAgents.length / PAGE_SIZE) || 1;
if (currentPage > totalPages) currentPage = totalPages;
const start = (currentPage - 1) * PAGE_SIZE;
const end = start + PAGE_SIZE;
const pageAgents = filteredAgents.slice(start, end);
// 更新信息
document.getElementById('agentResultInfo').textContent =
`显示 filteredAgents.length 个 Agent` +
(filteredAgents.length !== AGENTS.length ? ` (共 AGENTS.length 个)` : '') +
(totalPages > 1 ? ` · 第 currentPage/totalPages 页` : '');
// 渲染卡片视图
if (currentView === 'grid') {
const grid = document.getElementById('agentGridView');
grid.innerHTML = pageAgents.map(a => `
<a class="agent-card fade-in" href="/dashboard/a.agent_id">
<div class="agent-card-header">
<div>
<div class="agent-card-name">escHtml(a.name)</div>
<div class="agent-card-id">a.agent_id.slice(0, 12)...</div>
</div>
<div style="display:flex;align-items:center;gap:8px">
<span style="font-size:11px;color:var(--text-muted)">a.status_label</span>
<span class="status-dot a.status"></span>
</div>
</div>
<div class="agent-metrics">
<div class="agent-metric">
<div class="agent-metric-label">健康度</div>
<div class="agent-metric-value healthClass(a.health_score)">
'-'
</div>
</div>
<div class="agent-metric">
<div class="agent-metric-label">运行次数</div>
<div class="agent-metric-value">a.total_runs || 0</div>
</div>
<div class="agent-metric">
<div class="agent-metric-label">成功率</div>
<div class="agent-metric-value">'-'</div>
</div>
<div class="agent-metric">
<div class="agent-metric-label">Skills</div>
<div class="agent-metric-value">a.active_skills || 0</div>
</div>
</div>
<div class="agent-card-footer">
<span>最后上报: a.last_report_at</span>
<span class="os-badge">'—'</span>
</div>
</a>
`).join('');
if (pageAgents.length === 0) {
grid.innerHTML = '<div style="grid-column:1/-1;text-align:center;padding:40px;color:var(--text-muted)">🔍 没有匹配的 Agent</div>';
}
}
// 渲染表格视图
if (currentView === 'table') {
const tbody = document.getElementById('agentTableBody');
tbody.innerHTML = pageAgents.map((a, i) => `
<tr class="fade-in" onclick="window.location='/dashboard/a.agent_id'" style="animation-delay:i * 20ms">
<td>
<div class="agent-name-cell">
<div class="agent-avatar" style="background:avatarBg(start + i)22;color:avatarBg(start + i)">
a.name.charAt(0).toUpperCase()
</div>
<div class="agent-info">
<div class="name">escHtml(a.name)</div>
<div class="id">a.agent_id.slice(0, 12)</div>
</div>
</div>
</td>
<td>
<span class="status-badge a.status">
<span class="status-dot a.status" style="width:7px;height:7px"></span>
a.status_label
</span>
</td>
<td>
<div class="health-bar">
<span style="font-weight:700;color:healthColor(a.health_score)">'-'</span>
<div class="health-bar-track">
<div class="health-bar-fill" style="width:a.health_score || 0%;background:healthColor(a.health_score)"></div>
</div>
</div>
</td>
<td style="font-weight:600">a.total_runs || 0</td>
<td>'-'</td>
<td>a.active_skills || 0 / a.total_skills || 0</td>
<td style="font-size:11px;color:var(--text-muted)">a.last_report_at</td>
<td><span class="os-badge">'—'</span></td>
</tr>
`).join('');
if (pageAgents.length === 0) {
tbody.innerHTML = '<tr><td colspan="8" style="text-align:center;padding:40px;color:var(--text-muted)">🔍 没有匹配的 Agent</td></tr>';
}
}
// 渲染分页
renderPagination(totalPages);
}
function renderPagination(totalPages) {
const pag = document.getElementById('agentPagination');
if (totalPages <= 1) { pag.innerHTML = ''; return; }
let html = `<button class="page-btn" onclick="goPage(currentPage - 1)" ''>‹</button>`;
// 智能分页:显示首页 ... 当前附近 ... 尾页
const pages = [];
const maxShow = 7;
if (totalPages <= maxShow) {
for (let i = 1; i <= totalPages; i++) pages.push(i);
} else {
pages.push(1);
let start = Math.max(2, currentPage - 2);
let end = Math.min(totalPages - 1, currentPage + 2);
if (start > 2) pages.push(-1); // dots
for (let i = start; i <= end; i++) pages.push(i);
if (end < totalPages - 1) pages.push(-1);
pages.push(totalPages);
}
pages.forEach(p => {
if (p === -1) {
html += '<span class="page-info">…</span>';
} else {
html += `<button class="page-btn ''" onclick="goPage(p)">p</button>`;
}
});
html += `<button class="page-btn" onclick="goPage(currentPage + 1)" ''>›</button>`;
pag.innerHTML = html;
}
function goPage(p) {
const totalPages = Math.ceil(filteredAgents.length / PAGE_SIZE) || 1;
if (p < 1 || p > totalPages) return;
currentPage = p;
renderAgentList();
// 滚回列表顶部
document.getElementById('agentGridView').scrollIntoView({ behavior: 'smooth', block: 'start' });
}
function escHtml(s) {
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
// ═══════════════════════════════════════════════
// 告警管理
// ═══════════════════════════════════════════════
function initAlertSummary() {
const alerts = DATA.alert_summary || [];
const counts = { error: 0, warning: 0, info: 0 };
alerts.forEach(a => { if (counts[a.level] !== undefined) counts[a.level]++; });
const el_e = document.getElementById('alertErrorCount');
const el_w = document.getElementById('alertWarnCount');
const el_i = document.getElementById('alertInfoCount');
if (el_e) el_e.textContent = `🔴 严重 counts.error`;
if (el_w) el_w.textContent = `🟡 警告 counts.warning`;
if (el_i) el_i.textContent = `🔵 信息 counts.info`;
// 隐藏 0 计数的
if (el_e && counts.error === 0) el_e.style.display = 'none';
if (el_w && counts.warning === 0) el_w.style.display = 'none';
if (el_i && counts.info === 0) el_i.style.display = 'none';
// 如果告警超过 10 条,默认折叠
const list = document.getElementById('alertList');
if (list && alerts.length > 10) {
list.style.maxHeight = '280px';
}
}
function toggleAlertExpand(btn) {
const list = document.getElementById('alertList');
if (!list) return;
if (list.style.maxHeight === 'none') {
list.style.maxHeight = '280px';
btn.textContent = `展开全部 ((DATA.alert_summary || []).length)`;
} else {
list.style.maxHeight = 'none';
btn.textContent = '收起';
}
}
// ═══════════════════════════════════════════════
// 全局趋势图
// ═══════════════════════════════════════════════
let trendChart = null;
const trendConfigs = {
health: { label: '平均健康度', key: 'avg_health_score', color: '#667eea', bg: 'rgba(102,126,234,0.1)', min: 0, max: 100 },
runs: { label: '总运行次数', key: 'total_runs', color: '#4ade80', bg: 'rgba(74,222,128,0.1)', min: 0, max: null },
rate: { label: '平均成功率', key: 'avg_success_rate', color: '#fbbf24', bg: 'rgba(251,191,36,0.1)', min: 0, max: 100 },
agents: { label: '上报 Agent 数', key: 'reporting_agents', color: '#22d3ee', bg: 'rgba(34,211,238,0.1)', min: 0, max: null },
};
function initTrendChart(type) {
const canvas = document.getElementById('trendChart');
if (!canvas || !DATA.trend || DATA.trend.length === 0) return;
const cfg = trendConfigs[type];
const labels = DATA.trend.map(t => t.date.slice(5));
const values = DATA.trend.map(t => t[cfg.key]);
if (trendChart) trendChart.destroy();
trendChart = new Chart(canvas.getContext('2d'), {
type: 'line',
data: {
labels,
datasets: [{
label: cfg.label, data: values,
borderColor: cfg.color, backgroundColor: cfg.bg, fill: true,
tension: 0.4, borderWidth: 2.5,
pointRadius: values.length > 15 ? 0 : 4, pointHoverRadius: 6,
pointBackgroundColor: cfg.color,
}]
},
options: {
responsive: true, maintainAspectRatio: false,
interaction: { intersect: false, mode: 'index' },
plugins: {
legend: { display: false },
tooltip: { backgroundColor: '#1a1d2e', borderColor: '#2a2e42', borderWidth: 1, titleColor: '#e4e6f0', bodyColor: '#8b8fa3', padding: 12, cornerRadius: 10 },
},
scales: {
y: { min: cfg.min, max: cfg.max, grid: { color: 'rgba(255,255,255,0.04)' }, ticks: { font: { size: 11 } } },
x: { grid: { display: false }, ticks: { font: { size: 11 }, maxRotation: 0, autoSkip: true, maxTicksLimit: 15 } },
},
},
});
}
function switchTrend(type) {
const map = { health: '平均健康度', runs: '总运行次数', rate: '平均成功率', agents: '上报' };
document.querySelectorAll('.tab-btn').forEach(btn => {
const match = Object.entries(map).find(([k, v]) => btn.textContent.includes(v));
btn.classList.toggle('active', match && match[0] === type);
});
initTrendChart(type);
}
// ═══════════════════════════════════════════════
// Agent 健康度对比柱状图(大量 Agent 自适应)
// ═══════════════════════════════════════════════
let compareChart = null;
let compareShowAll = false;
function initCompareChart(showAll) {
const canvas = document.getElementById('compareChart');
if (!canvas || !DATA.agent_comparison || DATA.agent_comparison.length === 0) return;
let agents = [...DATA.agent_comparison].sort((a, b) => b.health - a.health);
const total = agents.length;
// 大量 Agent 时默认只显示 Top 30
if (!showAll && total > 30) {
agents = agents.slice(0, 30);
}
// 动态调整图表高度
const container = document.getElementById('compareChartContainer');
const isHorizontal = agents.length > 8;
if (isHorizontal) {
container.style.height = Math.max(250, agents.length * 28 + 60) + 'px';
} else {
container.style.height = '250px';
}
if (compareChart) compareChart.destroy();
compareChart = new Chart(canvas.getContext('2d'), {
type: 'bar',
data: {
labels: agents.map(a => a.name.length > 16 ? a.name.slice(0, 16) + '…' : a.name),
datasets: [{
label: '健康度',
data: agents.map(a => a.health),
backgroundColor: agents.map(a => {
if (a.health >= 80) return 'rgba(74,222,128,0.7)';
if (a.health >= 60) return 'rgba(102,126,234,0.7)';
if (a.health >= 40) return 'rgba(251,191,36,0.7)';
return 'rgba(248,113,113,0.7)';
}),
borderRadius: 4, borderSkipped: false, barPercentage: 0.65,
}]
},
options: {
responsive: true, maintainAspectRatio: false,
indexAxis: isHorizontal ? 'y' : 'x',
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: ctx => `健康度: ctx.raw`,
afterLabel: ctx => {
const agent = agents[ctx.dataIndex];
return `运行: agent.runs · 成功率: agent.rate%`;
}
}
}
},
scales: {
x: { grid: { color: 'rgba(255,255,255,0.04)' }, max: 100, ticks: { font: { size: 11 } } },
y: { grid: { display: false }, ticks: { font: { size: agents.length > 20 ? 10 : 11 }, autoSkip: false } },
},
},
});
}
function toggleCompareExpand() {
compareShowAll = !compareShowAll;
const btn = document.getElementById('compareShowMore');
if (btn) btn.textContent = compareShowAll ? '只显示 Top 30' : `显示全部 DATA.agent_comparison.length 个`;
initCompareChart(compareShowAll);
}
// ═══════════════════════════════════════════════
// OS 分布饼图
// ═══════════════════════════════════════════════
function initOsChart() {
const canvas = document.getElementById('osChart');
if (!canvas || !DATA.os_distribution) return;
const labels = Object.keys(DATA.os_distribution);
const values = Object.values(DATA.os_distribution);
if (labels.length === 0) return;
new Chart(canvas.getContext('2d'), {
type: 'doughnut',
data: {
labels, datasets: [{
data: values,
backgroundColor: COLORS.slice(0, labels.length),
borderColor: '#1a1d2e', borderWidth: 2,
}]
},
options: {
responsive: true, maintainAspectRatio: false, cutout: '60%',
plugins: { legend: { position: 'bottom', labels: { font: { size: 10 }, padding: 8, usePointStyle: true } } },
},
});
}
// ═══════════════════════════════════════════════
// 版本分布饼图
// ═══════════════════════════════════════════════
function initVersionChart() {
const canvas = document.getElementById('versionChart');
if (!canvas || !DATA.version_distribution) return;
const labels = Object.keys(DATA.version_distribution);
const values = Object.values(DATA.version_distribution);
if (labels.length === 0) return;
new Chart(canvas.getContext('2d'), {
type: 'doughnut',
data: {
labels: labels.map(v => 'v' + v), datasets: [{
data: values,
backgroundColor: COLORS.slice(2, 2 + labels.length),
borderColor: '#1a1d2e', borderWidth: 2,
}]
},
options: {
responsive: true, maintainAspectRatio: false, cutout: '60%',
plugins: { legend: { position: 'bottom', labels: { font: { size: 10 }, padding: 8, usePointStyle: true } } },
},
});
}
// ═══════════════════════════════════════════════
// 评分分布饼图
// ═══════════════════════════════════════════════
function initDistChart() {
const canvas = document.getElementById('distChart');
if (!canvas || !DATA.score_distribution) return;
const d = DATA.score_distribution;
new Chart(canvas.getContext('2d'), {
type: 'doughnut',
data: {
labels: ['优秀 (≥90)', '良好 (75-89)', '一般 (60-74)', '待改善 (<60)'],
datasets: [{
data: [d.excellent, d.good, d.average, d.poor],
backgroundColor: ['rgba(74,222,128,0.8)', 'rgba(102,126,234,0.8)', 'rgba(251,191,36,0.8)', 'rgba(248,113,113,0.8)'],
borderColor: '#1a1d2e', borderWidth: 3,
}]
},
options: {
responsive: true, maintainAspectRatio: false, cutout: '65%',
plugins: { legend: { position: 'right', labels: { color: '#8b8fa3', font: { size: 11 }, padding: 12, usePointStyle: true } } },
},
});
}
// ═══════════════════════════════════════════════
// 初始化
// ═══════════════════════════════════════════════
document.addEventListener('DOMContentLoaded', () => {
if (DATA.total_agents > 0) {
initFilterCounts();
initAlertSummary();
// 大量 Agent 时默认用表格视图
if (AGENTS.length > 20) {
setView('table');
} else {
renderAgentList();
}
initTrendChart('health');
initCompareChart(false);
initOsChart();
initVersionChart();
initDistChart();
}
});
// 键盘快捷键
document.addEventListener('keydown', e => {
// Ctrl/Cmd+F 聚焦搜索框
if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
const searchInput = document.getElementById('agentSearch');
if (searchInput) { e.preventDefault(); searchInput.focus(); }
}
});
</script>
</body>
</html>
FILE:server/templates/dashboard.html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Skills Monitor — 后台分析仪表盘</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
<style>
/* ────── 基础 & 主题 ────── */
:root {
--bg-primary: #0f1117;
--bg-card: #1a1d2e;
--bg-card-hover: #222640;
--border: #2a2e42;
--text-primary: #e4e6f0;
--text-secondary: #8b8fa3;
--text-muted: #5a5e72;
--accent-blue: #667eea;
--accent-purple: #764ba2;
--accent-green: #4ade80;
--accent-yellow: #fbbf24;
--accent-red: #f87171;
--accent-orange: #fb923c;
--accent-cyan: #22d3ee;
--gradient-main: linear-gradient(135deg, #667eea, #764ba2);
--gradient-green: linear-gradient(135deg, #4ade80, #22d3ee);
--gradient-orange: linear-gradient(135deg, #fb923c, #f87171);
--shadow-card: 0 4px 24px rgba(0,0,0,0.3);
--radius: 16px;
--radius-sm: 10px;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', Roboto, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
line-height: 1.6;
}
/* ────── 布局 ────── */
.dashboard {
max-width: 1400px;
margin: 0 auto;
padding: 24px;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 28px;
flex-wrap: wrap;
gap: 12px;
}
.header-left {
display: flex;
align-items: center;
gap: 16px;
}
.header-logo {
width: 48px; height: 48px;
background: var(--gradient-main);
border-radius: 14px;
display: flex; align-items: center; justify-content: center;
font-size: 24px;
box-shadow: 0 4px 16px rgba(102,126,234,0.3);
}
.header h1 { font-size: 22px; font-weight: 700; }
.header .subtitle { color: var(--text-secondary); font-size: 13px; margin-top: 2px; }
.header-right { display: flex; align-items: center; gap: 12px; }
.btn {
display: inline-flex; align-items: center; gap: 6px;
padding: 8px 16px;
border-radius: 10px;
border: 1px solid var(--border);
background: var(--bg-card);
color: var(--text-primary);
font-size: 13px;
cursor: pointer;
transition: all 0.2s;
text-decoration: none;
}
.btn:hover { background: var(--bg-card-hover); border-color: var(--accent-blue); }
.btn-primary { background: var(--gradient-main); border: none; color: #fff; }
.btn-primary:hover { opacity: 0.9; }
.status-badge {
display: inline-flex; align-items: center; gap: 6px;
padding: 6px 14px;
border-radius: 20px;
font-size: 12px; font-weight: 600;
background: rgba(74,222,128,0.12);
color: var(--accent-green);
}
.status-badge .dot {
width: 8px; height: 8px;
border-radius: 50%;
background: var(--accent-green);
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
/* ────── 网格 ────── */
.grid { display: grid; gap: 20px; }
.grid-4 { grid-template-columns: repeat(4, 1fr); }
.grid-3 { grid-template-columns: repeat(3, 1fr); }
.grid-2 { grid-template-columns: repeat(2, 1fr); }
.grid-1-2 { grid-template-columns: 1fr 2fr; }
.grid-2-1 { grid-template-columns: 2fr 1fr; }
@media (max-width: 1200px) {
.grid-4 { grid-template-columns: repeat(2, 1fr); }
.grid-3 { grid-template-columns: repeat(2, 1fr); }
.grid-1-2, .grid-2-1 { grid-template-columns: 1fr; }
}
@media (max-width: 768px) {
.dashboard { padding: 16px; }
.grid-4, .grid-3, .grid-2 { grid-template-columns: 1fr; }
.header { flex-direction: column; align-items: flex-start; }
}
/* ────── 卡片 ────── */
.card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 24px;
box-shadow: var(--shadow-card);
transition: border-color 0.3s;
}
.card:hover { border-color: rgba(102,126,234,0.3); }
.card-title {
font-size: 14px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 16px;
display: flex; align-items: center; gap: 8px;
}
.card-title .icon {
width: 28px; height: 28px;
border-radius: 8px;
display: flex; align-items: center; justify-content: center;
font-size: 14px;
}
/* ────── KPI 指标卡片 ────── */
.kpi-card {
position: relative;
overflow: hidden;
}
.kpi-card::before {
content: '';
position: absolute;
top: 0; left: 0; right: 0;
height: 3px;
}
.kpi-card.kpi-health::before { background: var(--gradient-main); }
.kpi-card.kpi-runs::before { background: var(--gradient-green); }
.kpi-card.kpi-rate::before { background: linear-gradient(135deg, var(--accent-yellow), var(--accent-orange)); }
.kpi-card.kpi-active::before { background: linear-gradient(135deg, var(--accent-cyan), var(--accent-blue)); }
.kpi-value {
font-size: 36px;
font-weight: 800;
line-height: 1.1;
margin: 8px 0 4px;
background: var(--gradient-main);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.kpi-card.kpi-runs .kpi-value {
background: var(--gradient-green);
-webkit-background-clip: text; background-clip: text;
}
.kpi-card.kpi-rate .kpi-value {
background: linear-gradient(135deg, var(--accent-yellow), var(--accent-orange));
-webkit-background-clip: text; background-clip: text;
}
.kpi-card.kpi-active .kpi-value {
background: linear-gradient(135deg, var(--accent-cyan), var(--accent-blue));
-webkit-background-clip: text; background-clip: text;
}
.kpi-label {
font-size: 13px;
color: var(--text-secondary);
}
.kpi-sub {
font-size: 12px;
color: var(--text-muted);
margin-top: 8px;
}
/* ────── 健康度环形图 ────── */
.health-ring-container {
display: flex;
align-items: center;
gap: 24px;
}
.health-ring-svg { width: 140px; height: 140px; flex-shrink: 0; }
.health-ring-svg .ring-bg { fill: none; stroke: #2a2e42; stroke-width: 10; }
.health-ring-svg .ring-fill {
fill: none; stroke: url(#healthGradient); stroke-width: 10;
stroke-linecap: round;
transform: rotate(-90deg);
transform-origin: center;
transition: stroke-dasharray 1.5s ease;
}
.health-center {
text-anchor: middle;
font-size: 32px;
font-weight: 800;
fill: var(--text-primary);
}
.health-label-text {
text-anchor: middle;
font-size: 12px;
fill: var(--text-secondary);
}
.health-details { flex: 1; }
.health-detail-item {
display: flex; justify-content: space-between;
padding: 8px 0;
border-bottom: 1px solid rgba(42,46,66,0.5);
font-size: 13px;
}
.health-detail-item:last-child { border: none; }
.health-detail-label { color: var(--text-secondary); }
.health-detail-value { font-weight: 600; }
/* ────── 表格 ────── */
.table-wrapper { overflow-x: auto; }
table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
th {
text-align: left;
padding: 12px 14px;
color: var(--text-secondary);
font-weight: 600;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.3px;
border-bottom: 1px solid var(--border);
white-space: nowrap;
}
td {
padding: 12px 14px;
border-bottom: 1px solid rgba(42,46,66,0.3);
}
tr:hover td { background: rgba(102,126,234,0.03); }
.grade-badge {
display: inline-block;
padding: 3px 10px;
border-radius: 6px;
font-size: 11px;
font-weight: 700;
}
.grade-s, .grade-a { background: rgba(74,222,128,0.15); color: var(--accent-green); }
.grade-b { background: rgba(102,126,234,0.15); color: var(--accent-blue); }
.grade-c { background: rgba(251,191,36,0.15); color: var(--accent-yellow); }
.grade-d, .grade-f { background: rgba(248,113,113,0.15); color: var(--accent-red); }
/* ────── 进度条 ────── */
.progress-bar {
width: 100%; height: 6px;
background: #2a2e42;
border-radius: 3px;
overflow: hidden;
}
.progress-bar-fill {
height: 100%;
border-radius: 3px;
background: var(--gradient-main);
transition: width 1s ease;
}
/* ────── 图表容器 ────── */
.chart-container {
position: relative;
height: 280px;
width: 100%;
}
.chart-container canvas { max-height: 100%; }
/* ────── 评分分布 ────── */
.dist-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
margin-top: 12px;
}
.dist-item {
text-align: center;
padding: 16px 8px;
background: rgba(255,255,255,0.02);
border-radius: var(--radius-sm);
border: 1px solid rgba(42,46,66,0.5);
}
.dist-count {
font-size: 28px;
font-weight: 800;
line-height: 1.2;
}
.dist-label {
font-size: 11px;
color: var(--text-secondary);
margin-top: 4px;
}
.dist-excellent .dist-count { color: var(--accent-green); }
.dist-good .dist-count { color: var(--accent-blue); }
.dist-average .dist-count { color: var(--accent-yellow); }
.dist-poor .dist-count { color: var(--accent-red); }
/* ────── Agent 信息 ────── */
.info-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
.info-item {
padding: 12px;
background: rgba(255,255,255,0.02);
border-radius: var(--radius-sm);
border: 1px solid rgba(42,46,66,0.3);
}
.info-label { font-size: 11px; color: var(--text-muted); margin-bottom: 4px; }
.info-value { font-size: 14px; font-weight: 600; word-break: break-all; }
/* ────── 报告时间线 ────── */
.timeline { display: flex; flex-direction: column; gap: 8px; max-height: 400px; overflow-y: auto; }
.timeline-item {
display: flex; align-items: center; gap: 12px;
padding: 12px;
background: rgba(255,255,255,0.02);
border-radius: var(--radius-sm);
border: 1px solid rgba(42,46,66,0.3);
transition: background 0.2s;
}
.timeline-item:hover { background: rgba(102,126,234,0.05); }
.timeline-dot {
width: 10px; height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.timeline-dot.daily { background: var(--accent-blue); }
.timeline-dot.diagnostic { background: var(--accent-orange); }
.timeline-content { flex: 1; min-width: 0; }
.timeline-title { font-size: 13px; font-weight: 600; }
.timeline-meta { font-size: 11px; color: var(--text-muted); display: flex; gap: 12px; margin-top: 2px; }
/* ────── 诊断摘要 ────── */
.diag-preview {
max-height: 300px;
overflow-y: auto;
padding: 16px;
background: rgba(0,0,0,0.2);
border-radius: var(--radius-sm);
font-size: 12px;
line-height: 1.8;
color: var(--text-secondary);
font-family: 'SF Mono', 'Fira Code', monospace;
white-space: pre-wrap;
word-break: break-word;
}
/* ────── 空状态 ────── */
.empty-state {
text-align: center;
padding: 48px 24px;
color: var(--text-muted);
}
.empty-state .empty-icon { font-size: 48px; margin-bottom: 16px; }
.empty-state h3 { font-size: 18px; color: var(--text-secondary); margin-bottom: 8px; }
.empty-state p { font-size: 13px; max-width: 400px; margin: 0 auto; }
/* ────── 页脚 ────── */
.footer {
text-align: center;
padding: 32px 0 16px;
color: var(--text-muted);
font-size: 12px;
}
/* ────── Section 间距 ────── */
.section { margin-bottom: 20px; }
/* ────── 滚动条 ────── */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #3a3e52; border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: #4a4e62; }
/* ────── Tab 切换 ────── */
.tab-nav {
display: flex; gap: 4px;
margin-bottom: 16px;
background: rgba(0,0,0,0.2);
border-radius: 10px;
padding: 4px;
}
.tab-btn {
padding: 8px 16px;
border: none;
background: none;
color: var(--text-muted);
font-size: 13px;
font-weight: 500;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
}
.tab-btn.active {
background: var(--bg-card-hover);
color: var(--text-primary);
}
.tab-btn:hover:not(.active) { color: var(--text-secondary); }
.tab-panel { display: none; }
.tab-panel.active { display: block; }
</style>
</head>
<body>
<div class="dashboard">
<!-- ════════ 头部 ════════ -->
<header class="header">
<div class="header-left">
<div class="header-logo">📊</div>
<div>
<h1>Skills Monitor 分析仪表盘</h1>
<p class="subtitle">{{ data.agent.name }} · 数据截至 {{ data.generated_at[:10] }}</p>
</div>
</div>
<div class="header-right">
<div class="status-badge">
<span class="dot"></span>
服务运行中
</div>
<a class="btn" href="/h5/report/{{ data.agent.agent_id }}">📋 简版报告</a>
<a class="btn btn-primary" href="/api/dashboard/{{ data.agent.agent_id }}/data" target="_blank">📥 JSON 数据</a>
</div>
</header>
<!-- ════════ KPI 概览卡片 ════════ -->
<div class="section grid grid-4">
<div class="card kpi-card kpi-health">
<div class="card-title"><span class="icon" style="background:rgba(102,126,234,0.15)">🏥</span> 系统健康度</div>
<div class="kpi-value">{{ '%.0f' % data.overview.health_score }}</div>
<div class="kpi-label">总评分(满分 100)</div>
<div class="kpi-sub">可运行率 {{ '%.1f' % data.overview.runnable_rate }}% · {{ data.overview.total_runnable }}/{{ data.overview.total_installed }} Skills</div>
</div>
<div class="card kpi-card kpi-runs">
<div class="card-title"><span class="icon" style="background:rgba(74,222,128,0.15)">📈</span> 总运行次数</div>
<div class="kpi-value">{{ data.overview.total_runs }}</div>
<div class="kpi-label">最新周期内运行</div>
<div class="kpi-sub">上报 {{ data.overview.total_reports }} 份报告 · 最近: {{ data.overview.last_report_at }}</div>
</div>
<div class="card kpi-card kpi-rate">
<div class="card-title"><span class="icon" style="background:rgba(251,191,36,0.15)">🎯</span> 成功率</div>
<div class="kpi-value">{{ '%.1f' % data.overview.success_rate }}%</div>
<div class="kpi-label">运行成功比例</div>
<div class="kpi-sub">平均响应 {{ '%.0f' % data.overview.avg_duration_ms }}ms</div>
</div>
<div class="card kpi-card kpi-active">
<div class="card-title"><span class="icon" style="background:rgba(34,211,238,0.15)">⚡</span> 活跃 Skills</div>
<div class="kpi-value">{{ data.overview.active_skills }}</div>
<div class="kpi-label">有运行记录的 Skills</div>
<div class="kpi-sub">已安装 {{ data.overview.total_installed }} · 可运行 {{ data.overview.total_runnable }}</div>
</div>
</div>
<!-- ════════ 健康度分析 + 评分分布 ════════ -->
<div class="section grid grid-2">
<!-- 健康度环形图 -->
<div class="card">
<div class="card-title"><span class="icon" style="background:rgba(102,126,234,0.15)">💖</span> 健康度分析</div>
<div class="health-ring-container">
<svg class="health-ring-svg" viewBox="0 0 140 140">
<defs>
<linearGradient id="healthGradient" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#667eea"/>
<stop offset="100%" stop-color="#764ba2"/>
</linearGradient>
</defs>
<circle cx="70" cy="70" r="58" class="ring-bg"/>
<circle cx="70" cy="70" r="58" class="ring-fill"
style="stroke-dasharray: {{ (data.overview.health_score or 0) * 3.644 }}, 364.4"/>
<text x="70" y="66" class="health-center">{{ '%.0f' % data.overview.health_score }}</text>
<text x="70" y="84" class="health-label-text">健康度</text>
</svg>
<div class="health-details">
<div class="health-detail-item">
<span class="health-detail-label">📦 已安装 Skills</span>
<span class="health-detail-value">{{ data.overview.total_installed }}</span>
</div>
<div class="health-detail-item">
<span class="health-detail-label">⚡ 可运行 Skills</span>
<span class="health-detail-value">{{ data.overview.total_runnable }}</span>
</div>
<div class="health-detail-item">
<span class="health-detail-label">📊 可运行率</span>
<span class="health-detail-value">{{ '%.1f' % data.overview.runnable_rate }}%</span>
</div>
<div class="health-detail-item">
<span class="health-detail-label">🕐 最近上报</span>
<span class="health-detail-value">{{ data.overview.last_report_at }}</span>
</div>
<div class="health-detail-item">
<span class="health-detail-label">📄 累计报告</span>
<span class="health-detail-value">{{ data.overview.total_reports }} 份</span>
</div>
</div>
</div>
</div>
<!-- 评分分布 -->
<div class="card">
<div class="card-title"><span class="icon" style="background:rgba(251,191,36,0.15)">📊</span> Skill 评分分布</div>
{% if data.skill_rankings %}
<div class="chart-container" style="height:180px">
<canvas id="distChart"></canvas>
</div>
<div class="dist-grid">
<div class="dist-item dist-excellent">
<div class="dist-count">{{ data.score_distribution.excellent }}</div>
<div class="dist-label">优秀 (≥90)</div>
</div>
<div class="dist-item dist-good">
<div class="dist-count">{{ data.score_distribution.good }}</div>
<div class="dist-label">良好 (75-89)</div>
</div>
<div class="dist-item dist-average">
<div class="dist-count">{{ data.score_distribution.average }}</div>
<div class="dist-label">一般 (60-74)</div>
</div>
<div class="dist-item dist-poor">
<div class="dist-count">{{ data.score_distribution.poor }}</div>
<div class="dist-label">待改善 (<60)</div>
</div>
</div>
{% else %}
<div class="empty-state">
<div class="empty-icon">📭</div>
<h3>暂无评分数据</h3>
<p>需要更多 Skill 运行记录后才能生成评分分布。</p>
</div>
{% endif %}
</div>
</div>
<!-- ════════ 趋势图表 ════════ -->
<div class="section">
<div class="card">
<div class="card-title"><span class="icon" style="background:rgba(34,211,238,0.15)">📈</span> 趋势分析(近 {{ data.period_days }} 天)</div>
<div class="tab-nav">
<button class="tab-btn active" onclick="switchTrendTab('health')">健康度</button>
<button class="tab-btn" onclick="switchTrendTab('runs')">运行次数</button>
<button class="tab-btn" onclick="switchTrendTab('rate')">成功率</button>
<button class="tab-btn" onclick="switchTrendTab('active')">活跃 Skills</button>
</div>
{% if data.trend %}
<div class="chart-container">
<canvas id="trendChart"></canvas>
</div>
{% else %}
<div class="empty-state">
<div class="empty-icon">📉</div>
<h3>暂无趋势数据</h3>
<p>至少需要 2 天的上报数据才能展示趋势。请确认定时任务正常运行并上报数据。</p>
</div>
{% endif %}
</div>
</div>
<!-- ════════ Skill 排行 + 报告时间线 ════════ -->
<div class="section grid grid-2-1">
<!-- Skill 排行详情 -->
<div class="card">
<div class="card-title"><span class="icon" style="background:rgba(74,222,128,0.15)">🏆</span> Skill 性能排行</div>
{% if data.skill_rankings %}
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>#</th>
<th>Skill</th>
<th>综合评分</th>
<th>等级</th>
<th>成功率</th>
<th>平均响应</th>
<th>满意度</th>
<th>稳定性</th>
</tr>
</thead>
<tbody>
{% for s in data.skill_rankings %}
<tr>
<td>{{ loop.index }}</td>
<td style="font-weight:600">{{ s.skill_id }}</td>
<td>
<div style="display:flex;align-items:center;gap:8px">
<span style="font-weight:700">{{ '%.1f' % s.total_score }}</span>
<div class="progress-bar" style="width:80px">
<div class="progress-bar-fill" style="width:{{ s.total_score }}%"></div>
</div>
</div>
</td>
<td><span class="grade-badge grade-{{ s.grade[:1]|lower }}">{{ s.grade }}</span></td>
<td>{{ '%.0f%%' % s.success_rate if s.success_rate is not none else '-' }}</td>
<td>{{ '%.0f ms' % s.response_time if s.response_time is not none else '-' }}</td>
<td>{{ '%.1f/5' % s.satisfaction if s.satisfaction else '-' }}</td>
<td>{{ '%.2f' % s.stability if s.stability is not none else '-' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="empty-state">
<div class="empty-icon">🏅</div>
<h3>暂无排行数据</h3>
<p>多使用 Skills 后将自动生成性能排行。</p>
</div>
{% endif %}
</div>
<!-- 报告时间线 -->
<div class="card">
<div class="card-title"><span class="icon" style="background:rgba(248,113,113,0.15)">📋</span> 报告历史</div>
{% if data.report_history %}
<div class="timeline">
{% for r in data.report_history %}
<div class="timeline-item">
<span class="timeline-dot {{ r.type }}"></span>
<div class="timeline-content">
<div class="timeline-title">{{ r.type|upper }} · {{ r.date }}</div>
<div class="timeline-meta">
<span>健康 {{ '%.0f' % r.health_score }}</span>
<span>运行 {{ r.total_runs }}</span>
<span>成功 {{ '%.0f%%' % r.success_rate }}</span>
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="empty-state">
<div class="empty-icon">📭</div>
<h3>暂无报告</h3>
<p>等待 Agent 上报数据。</p>
</div>
{% endif %}
</div>
</div>
<!-- ════════ Agent 系统信息 + 同步状态 ════════ -->
<div class="section grid grid-2">
<!-- Agent 系统信息 -->
<div class="card">
<div class="card-title"><span class="icon" style="background:rgba(102,126,234,0.15)">🤖</span> Agent 系统信息</div>
<div class="info-grid">
<div class="info-item">
<div class="info-label">Agent ID</div>
<div class="info-value" style="font-size:12px">{{ data.agent.agent_id }}</div>
</div>
<div class="info-item">
<div class="info-label">名称</div>
<div class="info-value">{{ data.agent.name }}</div>
</div>
<div class="info-item">
<div class="info-label">操作系统</div>
<div class="info-value">{{ data.agent.os_info }}</div>
</div>
<div class="info-item">
<div class="info-label">Python 版本</div>
<div class="info-value">{{ data.agent.python_version }}</div>
</div>
<div class="info-item">
<div class="info-label">Monitor 版本</div>
<div class="info-value">{{ data.agent.monitor_version }}</div>
</div>
<div class="info-item">
<div class="info-label">注册时间</div>
<div class="info-value">{{ data.agent.created_at }}</div>
</div>
<div class="info-item">
<div class="info-label">最近心跳</div>
<div class="info-value">{{ data.agent.last_heartbeat }}</div>
</div>
<div class="info-item">
<div class="info-label">最近上报</div>
<div class="info-value">{{ data.overview.last_report_at }}</div>
</div>
</div>
</div>
<!-- 报告同步状态 -->
<div class="card">
<div class="card-title"><span class="icon" style="background:rgba(34,197,94,0.15)">🔄</span> 报告同步保障</div>
<div style="margin-bottom:14px;font-size:12px;color:var(--text-muted)">
当前状态:<strong style="color:var(--text-primary)">{{ data.report_sync.status_label }}</strong>
· 首次诊断 {{ '已补齐' if data.report_sync.has_initial_diagnostic else '缺失' }}
</div>
<div class="info-grid">
<div class="info-item">
<div class="info-label">首次诊断</div>
<div class="info-value">{{ data.report_sync.first_diagnostic_date or '-' }}</div>
</div>
<div class="info-item">
<div class="info-label">最新诊断</div>
<div class="info-value">{{ data.report_sync.latest_diag_date or '-' }}</div>
</div>
<div class="info-item">
<div class="info-label">近 7 日日报</div>
<div class="info-value">{{ data.report_sync.recent_daily_count }}</div>
</div>
<div class="info-item">
<div class="info-label">累计日报</div>
<div class="info-value">{{ data.report_sync.daily_count }}</div>
</div>
<div class="info-item">
<div class="info-label">累计诊断</div>
<div class="info-value">{{ data.report_sync.diagnostic_count }}</div>
</div>
<div class="info-item">
<div class="info-label">首次触发</div>
<div class="info-value">{{ data.first_diagnostic.trigger if data.first_diagnostic else '-' }}</div>
</div>
</div>
</div>
</div>
<!-- ════════ 推荐安装 + 诊断摘要 ════════ -->
<div class="section grid grid-2">
<div class="card">
<div class="card-title"><span class="icon" style="background:rgba(167,139,250,0.15)">💡</span> 推荐安装 Skills</div>
{% if data.recommendations %}
<div style="margin-bottom:12px;font-size:12px;color:var(--text-muted)">
{% if data.overview.total_installed == 0 %}
当前未检测到已安装 skills,已按官方 TOP10 精选 3 个推荐,并展示选择逻辑。
{% else %}
已按安装结构、使用频率和评分表现生成推荐,优先补足能力短板。
{% endif %}
</div>
<div style="display:flex;flex-direction:column;gap:12px">
{% for rec in data.recommendations[:6] %}
<div style="padding:14px;border:1px solid var(--border);border-radius:14px;background:rgba(255,255,255,0.03)">
<div style="display:flex;justify-content:space-between;gap:12px;align-items:flex-start">
<div>
<div style="font-weight:700;font-size:15px">{{ rec.name }}</div>
<div style="margin-top:4px;font-size:12px;color:var(--text-muted)">{{ rec.category }} · {{ rec.slug }}</div>
</div>
<div style="font-size:22px;font-weight:800;color:var(--accent-blue)">{{ '%.0f' % rec.recommendation_score }}</div>
</div>
<div style="margin-top:10px;font-size:12px;color:var(--text-muted)">{{ rec.reason_label or rec.reason_type }}</div>
<div style="margin-top:8px;font-size:13px;line-height:1.7;color:var(--text-secondary)">{{ rec.reason_detail }}</div>
{% if rec.selection_logic %}
<div style="margin-top:10px;padding:8px 10px;border-radius:10px;background:rgba(102,126,234,0.12);font-size:12px;color:var(--accent-blue)">{{ rec.selection_logic }}</div>
{% endif %}
</div>
{% endfor %}
</div>
{% else %}
<div class="empty-state">
<div class="empty-icon">💡</div>
<h3>暂无推荐</h3>
<p>当前安装结构较完整,或尚未生成推荐数据。</p>
</div>
{% endif %}
</div>
<!-- 诊断摘要 -->
<div class="card">
<div class="card-title"><span class="icon" style="background:rgba(251,191,36,0.15)">🏥</span> 最近诊断报告</div>
{% if data.diagnostic_summary and data.diagnostic_summary.markdown %}
<div style="margin-bottom:12px;font-size:12px;color:var(--text-muted)">
日期: {{ data.diagnostic_summary.date }} · 健康度: {{ '%.0f' % data.diagnostic_summary.health_score }} · 触发: {{ data.diagnostic_summary.trigger or '-' }}
</div>
{% if data.diagnostic_summary.issues %}
<div style="margin-bottom:14px">
<div style="font-size:12px;font-weight:700;margin-bottom:8px">发现问题</div>
<ul style="padding-left:18px;color:var(--text-secondary);font-size:13px;line-height:1.7">
{% for item in data.diagnostic_summary.issues[:4] %}
<li>{{ item }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
{% if data.diagnostic_summary.suggestions %}
<div style="margin-bottom:14px">
<div style="font-size:12px;font-weight:700;margin-bottom:8px">行动建议</div>
<ul style="padding-left:18px;color:var(--text-secondary);font-size:13px;line-height:1.7">
{% for item in data.diagnostic_summary.suggestions[:4] %}
<li>{{ item }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
<div class="diag-preview">{{ data.diagnostic_summary.markdown[:1800] }}</div>
{% else %}
<div class="empty-state">
<div class="empty-icon">🔍</div>
<h3>暂无诊断报告</h3>
<p>运行 <code>skills-monitor diagnose</code> 或等待定时任务触发诊断。</p>
</div>
{% endif %}
</div>
</div>
<!-- ════════ 页脚 ════════ -->
<footer class="footer">
<p>Skills Monitor Dashboard v0.6.2 · Powered by CodeBuddy · 数据生成时间 {{ data.generated_at[:19] }}</p>
</footer>
</div>
<!-- ════════ JavaScript ════════ -->
<script>
const DATA = {{ data|tojson }};
// ──── 趋势图 ────
let trendChart = null;
const trendConfigs = {
health: {
label: '健康度',
key: 'health_score',
color: '#667eea',
bg: 'rgba(102,126,234,0.1)',
min: 0, max: 100,
},
runs: {
label: '运行次数',
key: 'total_runs',
color: '#4ade80',
bg: 'rgba(74,222,128,0.1)',
min: 0, max: null,
},
rate: {
label: '成功率',
key: 'success_rate',
color: '#fbbf24',
bg: 'rgba(251,191,36,0.1)',
min: 0, max: 100,
},
active: {
label: '活跃 Skills',
key: 'active_skills',
color: '#22d3ee',
bg: 'rgba(34,211,238,0.1)',
min: 0, max: null,
},
};
function initTrendChart(type) {
const canvas = document.getElementById('trendChart');
if (!canvas || !DATA.trend || DATA.trend.length === 0) return;
const cfg = trendConfigs[type];
const labels = DATA.trend.map(t => t.date.slice(5));
const values = DATA.trend.map(t => t[cfg.key]);
if (trendChart) trendChart.destroy();
trendChart = new Chart(canvas.getContext('2d'), {
type: 'line',
data: {
labels,
datasets: [{
label: cfg.label,
data: values,
borderColor: cfg.color,
backgroundColor: cfg.bg,
fill: true,
tension: 0.4,
borderWidth: 2.5,
pointRadius: values.length > 15 ? 0 : 4,
pointHoverRadius: 6,
pointBackgroundColor: cfg.color,
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: { intersect: false, mode: 'index' },
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: '#1a1d2e',
borderColor: '#2a2e42',
borderWidth: 1,
titleColor: '#e4e6f0',
bodyColor: '#8b8fa3',
padding: 12,
cornerRadius: 10,
},
},
scales: {
y: {
min: cfg.min,
max: cfg.max,
grid: { color: 'rgba(255,255,255,0.04)' },
ticks: { color: '#5a5e72', font: { size: 11 } },
},
x: {
grid: { display: false },
ticks: { color: '#5a5e72', font: { size: 11 }, maxRotation: 0 },
},
},
},
});
}
function switchTrendTab(type) {
document.querySelectorAll('.tab-btn').forEach((btn, i) => {
btn.classList.toggle('active', btn.textContent.includes(
{health:'健康度', runs:'运行次数', rate:'成功率', active:'活跃'}[type]
));
});
initTrendChart(type);
}
// ──── 评分分布饼图 ────
function initDistChart() {
const canvas = document.getElementById('distChart');
if (!canvas) return;
const d = DATA.score_distribution;
new Chart(canvas.getContext('2d'), {
type: 'doughnut',
data: {
labels: ['优秀 (≥90)', '良好 (75-89)', '一般 (60-74)', '待改善 (<60)'],
datasets: [{
data: [d.excellent, d.good, d.average, d.poor],
backgroundColor: [
'rgba(74,222,128,0.8)',
'rgba(102,126,234,0.8)',
'rgba(251,191,36,0.8)',
'rgba(248,113,113,0.8)',
],
borderColor: '#1a1d2e',
borderWidth: 3,
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
cutout: '65%',
plugins: {
legend: {
position: 'right',
labels: { color: '#8b8fa3', font: { size: 11 }, padding: 12, usePointStyle: true },
},
},
},
});
}
// ──── 初始化 ────
document.addEventListener('DOMContentLoaded', () => {
initTrendChart('health');
initDistChart();
});
</script>
</body>
</html>
FILE:server/templates/h5_bind_guide.html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Skills Monitor — 绑定引导</title>
<link rel="stylesheet" href="/static/css/h5.css">
</head>
<body>
<div class="container">
<header class="header">
<div class="header-icon">🔗</div>
<h1>绑定 Agent</h1>
<p class="subtitle">开始接收每日健康报告</p>
</header>
<section class="card">
<h2 class="card-title">📖 如何绑定?</h2>
<div class="steps">
<div class="step">
<div class="step-num">1</div>
<div class="step-content">
<h3>安装 Skills Monitor</h3>
<code>pip install skills-monitor</code>
</div>
</div>
<div class="step">
<div class="step-num">2</div>
<div class="step-content">
<h3>生成绑定码</h3>
<code>skills-monitor bind --server</code>
</div>
</div>
<div class="step">
<div class="step-num">3</div>
<div class="step-content">
<h3>扫描二维码</h3>
<p>用微信扫描终端显示的二维码</p>
</div>
</div>
<div class="step">
<div class="step-num">4</div>
<div class="step-content">
<h3>完成 ✅</h3>
<p>开始接收每日推送</p>
</div>
</div>
</div>
</section>
<section class="card">
<h2 class="card-title">💡 也可以手动绑定</h2>
<p style="color:#999;margin-bottom:12px;">在公众号回复「绑定」获取操作指引</p>
</section>
<footer class="footer">
<p>Skills Monitor v0.4.0 · Powered by CodeBuddy</p>
</footer>
</div>
</body>
</html>
FILE:server/templates/h5_agents.html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Skills Monitor — 我的 Agents</title>
<link rel="stylesheet" href="/static/css/h5.css">
</head>
<body>
<div class="container">
<header class="header">
<div class="header-icon">🤖</div>
<h1>我的 Agents</h1>
<p class="subtitle">已绑定 {{ agents|length }} 个 Agent</p>
</header>
{% if agents %}
{% for item in agents %}
<section class="card agent-card" onclick="location.href='/h5/report/{{ item.agent.agent_id }}'">
<div class="agent-header">
<div class="agent-name">
{{ item.alias or item.agent.name or 'Agent-' + item.agent.agent_id[:8] }}
{% if item.is_primary %}<span class="badge primary">主</span>{% endif %}
</div>
<div class="agent-health health-{{ 'good' if (item.agent.health_score or 0) >= 80 else 'warn' if (item.agent.health_score or 0) >= 60 else 'bad' }}">
{{ '%.0f' % (item.agent.health_score or 0) }}分
</div>
</div>
<div class="agent-meta">
<span>Skills: {{ item.agent.total_skills or 0 }} / 可运行: {{ item.agent.runnable_skills or 0 }}</span>
<span>最近上报: {{ item.agent.last_report_at.strftime('%m-%d %H:%M') if item.agent.last_report_at else '无' }}</span>
</div>
</section>
{% endfor %}
{% else %}
<section class="card empty-state">
<div class="empty-icon">📭</div>
<h3>还没有绑定任何 Agent</h3>
<p>在终端运行 <code>skills-monitor bind --server</code> 开始绑定</p>
</section>
{% endif %}
<footer class="footer">
<p>Skills Monitor v0.4.0 · Powered by CodeBuddy</p>
</footer>
</div>
</body>
</html>
FILE:server/templates/benchmark.html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Skills Monitor — TOP1000 基准评测</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
<style>
:root {
--bg-primary: #0f1117;
--bg-card: #1a1d2e;
--bg-card-hover: #222640;
--border: #2a2e42;
--text-primary: #e4e6f0;
--text-secondary: #8b8fa3;
--text-muted: #5a5e72;
--accent-blue: #667eea;
--accent-purple: #764ba2;
--accent-green: #4ade80;
--accent-yellow: #fbbf24;
--accent-red: #f87171;
--accent-orange: #fb923c;
--accent-cyan: #22d3ee;
--accent-pink: #f472b6;
--gradient-main: linear-gradient(135deg, #667eea, #764ba2);
--gradient-green: linear-gradient(135deg, #4ade80, #22d3ee);
--gradient-orange: linear-gradient(135deg, #fb923c, #f87171);
--gradient-pink: linear-gradient(135deg, #f472b6, #764ba2);
--shadow-card: 0 4px 24px rgba(0,0,0,0.3);
--radius: 16px;
--radius-sm: 10px;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', Roboto, sans-serif;
background: var(--bg-primary); color: var(--text-primary);
min-height: 100vh; line-height: 1.6;
}
.dashboard { max-width: 1440px; margin: 0 auto; padding: 24px; }
/* Header */
.header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 28px; flex-wrap: wrap; gap: 12px; }
.header-left { display: flex; align-items: center; gap: 16px; }
.header-logo { width: 52px; height: 52px; background: linear-gradient(135deg, #667eea, #f472b6); border-radius: 14px; display: flex; align-items: center; justify-content: center; font-size: 26px; box-shadow: 0 4px 16px rgba(102,126,234,0.3); }
.header h1 { font-size: 22px; font-weight: 700; }
.header .subtitle { color: var(--text-secondary); font-size: 13px; margin-top: 2px; }
.header-right { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.btn { display: inline-flex; align-items: center; gap: 6px; padding: 8px 16px; border-radius: 10px; border: 1px solid var(--border); background: var(--bg-card); color: var(--text-primary); font-size: 13px; cursor: pointer; transition: all 0.2s; text-decoration: none; }
.btn:hover { background: var(--bg-card-hover); border-color: var(--accent-blue); }
.btn-primary { background: var(--gradient-main); border: none; color: #fff; }
.mode-badge { display: inline-flex; align-items: center; gap: 6px; padding: 6px 14px; border-radius: 20px; font-size: 12px; font-weight: 600; }
.mode-mock { background: rgba(251,191,36,0.12); color: var(--accent-yellow); }
.mode-live { background: rgba(74,222,128,0.12); color: var(--accent-green); }
/* Grid */
.grid { display: grid; gap: 20px; }
.grid-6 { grid-template-columns: repeat(6, 1fr); }
.grid-4 { grid-template-columns: repeat(4, 1fr); }
.grid-3 { grid-template-columns: repeat(3, 1fr); }
.grid-2 { grid-template-columns: repeat(2, 1fr); }
.grid-2-1 { grid-template-columns: 2fr 1fr; }
.grid-1-2 { grid-template-columns: 1fr 2fr; }
@media (max-width: 1200px) { .grid-6 { grid-template-columns: repeat(3, 1fr); } .grid-4 { grid-template-columns: repeat(2, 1fr); } .grid-2-1, .grid-1-2 { grid-template-columns: 1fr; } }
@media (max-width: 768px) { .dashboard { padding: 16px; } .grid-6, .grid-4, .grid-3, .grid-2 { grid-template-columns: 1fr; } }
/* Card */
.card { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 24px; box-shadow: var(--shadow-card); transition: border-color 0.3s; }
.card:hover { border-color: rgba(102,126,234,0.3); }
.card-title { font-size: 14px; font-weight: 600; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 16px; display: flex; align-items: center; gap: 8px; }
.card-title .icon { width: 28px; height: 28px; border-radius: 8px; display: flex; align-items: center; justify-content: center; font-size: 14px; }
/* KPI */
.kpi-card { position: relative; overflow: hidden; }
.kpi-card::before { content: ''; position: absolute; top: 0; left: 0; right: 0; height: 3px; }
.kpi-card:nth-child(1)::before { background: var(--gradient-main); }
.kpi-card:nth-child(2)::before { background: var(--gradient-green); }
.kpi-card:nth-child(3)::before { background: linear-gradient(135deg, var(--accent-yellow), var(--accent-orange)); }
.kpi-card:nth-child(4)::before { background: linear-gradient(135deg, var(--accent-cyan), var(--accent-blue)); }
.kpi-value { font-size: 36px; font-weight: 800; line-height: 1.1; margin: 8px 0 4px; }
.kpi-value.g1 { background: var(--gradient-main); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; }
.kpi-value.g2 { background: var(--gradient-green); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; }
.kpi-value.g3 { background: linear-gradient(135deg, var(--accent-yellow), var(--accent-orange)); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; }
.kpi-value.g4 { background: linear-gradient(135deg, var(--accent-cyan), var(--accent-blue)); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; }
.kpi-label { font-size: 13px; color: var(--text-secondary); }
.kpi-sub { font-size: 12px; color: var(--text-muted); margin-top: 8px; }
/* Tab */
.tab-nav { display: flex; gap: 4px; margin-bottom: 16px; background: rgba(0,0,0,0.2); border-radius: 10px; padding: 4px; flex-wrap: wrap; }
.tab-btn { padding: 8px 16px; border: none; background: none; color: var(--text-muted); font-size: 13px; font-weight: 500; border-radius: 8px; cursor: pointer; transition: all 0.2s; }
.tab-btn.active { background: var(--bg-card-hover); color: var(--text-primary); }
.tab-btn:hover:not(.active) { color: var(--text-secondary); }
.tab-content { display: none; }
.tab-content.active { display: block; }
/* Medal Card */
.medal-card { position: relative; overflow: hidden; text-align: center; padding: 28px 16px; }
.medal-card .medal { font-size: 42px; margin-bottom: 8px; }
.medal-card .model-name { font-size: 18px; font-weight: 800; margin-bottom: 12px; }
.medal-card .model-score { font-size: 32px; font-weight: 800; }
.medal-card .model-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-top: 16px; padding-top: 16px; border-top: 1px solid rgba(42,46,66,0.5); }
.medal-card .stat-val { font-size: 14px; font-weight: 700; }
.medal-card .stat-label { font-size: 10px; color: var(--text-muted); text-transform: uppercase; }
.medal-gold { border-color: rgba(251,191,36,0.4); }
.medal-gold .model-score { color: var(--accent-yellow); }
.medal-silver { border-color: rgba(139,143,163,0.3); }
.medal-silver .model-score { color: var(--text-secondary); }
.medal-bronze { border-color: rgba(251,146,60,0.3); }
.medal-bronze .model-score { color: var(--accent-orange); }
/* Ranking Table */
.rank-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.rank-table th { text-align: left; padding: 12px 14px; color: var(--text-secondary); font-weight: 600; font-size: 11px; text-transform: uppercase; letter-spacing: 0.3px; border-bottom: 1px solid var(--border); white-space: nowrap; }
.rank-table td { padding: 12px 14px; border-bottom: 1px solid rgba(42,46,66,0.3); }
.rank-table tr:hover td { background: rgba(102,126,234,0.03); }
/* Quality Badge */
.q-badge { display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px; border-radius: 6px; font-size: 12px; font-weight: 700; }
.q-green { background: rgba(74,222,128,0.15); color: var(--accent-green); }
.q-blue { background: rgba(102,126,234,0.15); color: var(--accent-blue); }
.q-yellow { background: rgba(251,191,36,0.15); color: var(--accent-yellow); }
.q-red { background: rgba(248,113,113,0.15); color: var(--accent-red); }
/* Category leaders */
.cat-item { display: flex; align-items: center; justify-content: space-between; padding: 10px 12px; border-radius: 10px; background: rgba(255,255,255,0.02); margin-bottom: 6px; transition: background 0.2s; }
.cat-item:hover { background: rgba(102,126,234,0.05); }
.cat-name { font-weight: 600; font-size: 13px; }
.cat-model { font-size: 12px; color: var(--accent-cyan); font-weight: 600; }
.cat-count { font-size: 11px; color: var(--text-muted); }
/* Matrix */
.matrix-toolbar { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; }
.matrix-toolbar input, .matrix-toolbar select {
padding: 8px 12px; background: rgba(0,0,0,0.25); border: 1px solid var(--border);
border-radius: 8px; color: var(--text-primary); font-size: 13px; outline: none;
}
.matrix-toolbar input:focus, .matrix-toolbar select:focus { border-color: var(--accent-blue); }
.matrix-toolbar input { flex: 1; min-width: 200px; max-width: 320px; }
.matrix-table-wrap { overflow-x: auto; max-height: 600px; overflow-y: auto; }
.matrix-table { width: 100%; border-collapse: collapse; font-size: 12px; }
.matrix-table th { position: sticky; top: 0; z-index: 2; background: var(--bg-card); padding: 10px 8px; font-size: 11px; color: var(--text-secondary); font-weight: 600; text-transform: uppercase; letter-spacing: 0.3px; border-bottom: 1px solid var(--border); white-space: nowrap; cursor: pointer; user-select: none; }
.matrix-table th:hover { color: var(--text-primary); }
.matrix-table td { padding: 8px; border-bottom: 1px solid rgba(42,46,66,0.2); text-align: center; white-space: nowrap; }
.matrix-table td:first-child, .matrix-table td:nth-child(2), .matrix-table td:nth-child(3) { text-align: left; }
.matrix-table tr:hover td { background: rgba(102,126,234,0.03); }
.matrix-cell { display: inline-flex; align-items: center; gap: 3px; font-weight: 700; font-size: 13px; }
/* Chart */
.chart-container { position: relative; width: 100%; }
.chart-container canvas { max-height: 100%; }
/* Pagination */
.pagination { display: flex; align-items: center; justify-content: center; gap: 6px; margin-top: 16px; padding-top: 16px; border-top: 1px solid rgba(42,46,66,0.5); }
.page-btn { padding: 6px 12px; border: 1px solid var(--border); border-radius: 8px; background: transparent; color: var(--text-secondary); font-size: 12px; cursor: pointer; transition: all 0.2s; }
.page-btn:hover { border-color: var(--accent-blue); color: var(--text-primary); }
.page-btn.active { background: var(--accent-blue); border-color: var(--accent-blue); color: #fff; }
.page-btn:disabled { opacity: 0.3; cursor: not-allowed; }
.page-info { font-size: 12px; color: var(--text-muted); padding: 0 8px; }
/* Section */
.section { margin-bottom: 20px; }
.footer { text-align: center; padding: 32px 0 16px; color: var(--text-muted); font-size: 12px; }
/* Scrollbar */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #3a3e52; border-radius: 3px; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
.fade-in { animation: fadeIn 0.3s ease forwards; }
</style>
</head>
<body>
<div class="dashboard">
<!-- ════════ Header ════════ -->
<header class="header">
<div class="header-left">
<div class="header-logo">🧪</div>
<div>
<h1>TOP1000 基准评测</h1>
<p class="subtitle">{{ data.skills_count }} Skills × {{ data.models|length }} Models · {{ data.generated_at[:10] if data.generated_at != '未知' else '未知' }}</p>
</div>
</div>
<div class="header-right">
<span class="mode-badge {{ 'mode-mock' if data.mode == 'mock' else 'mode-live' }}">
{{ '🎭 Mock 模式' if data.mode == 'mock' else '🔴 Live 模式' }}
</span>
<a class="btn" href="/overview">🌐 全局总览</a>
<a class="btn btn-primary" href="/api/benchmark/summary" target="_blank">📥 API 数据</a>
</div>
</header>
{% if not data.model_ranking %}
<div class="card">
<div style="text-align:center;padding:48px;color:var(--text-muted)">
<div style="font-size:48px;margin-bottom:16px">🧪</div>
<h3 style="font-size:18px;color:var(--text-secondary);margin-bottom:8px">暂无评测数据</h3>
<p style="font-size:13px">请先运行 BatchBenchmark 生成评测矩阵。</p>
</div>
</div>
{% else %}
<!-- ════════ KPI 概览 ════════ -->
<div class="section grid grid-4">
<div class="card kpi-card">
<div class="card-title"><span class="icon" style="background:rgba(102,126,234,0.15)">📦</span> 评测规模</div>
<div class="kpi-value g1">{{ data.skills_count }}</div>
<div class="kpi-label">Skills × {{ data.models|length }} 模型</div>
<div class="kpi-sub">共 {{ data.skills_count * (data.models|length) }} 评测单元</div>
</div>
<div class="card kpi-card">
<div class="card-title"><span class="icon" style="background:rgba(74,222,128,0.15)">🏆</span> 最强模型</div>
<div class="kpi-value g2">{{ data.model_ranking[0].avg_quality_score }}</div>
<div class="kpi-label">{{ data.model_ranking[0].model_name }}</div>
<div class="kpi-sub">成功率 {{ data.model_ranking[0].avg_success_rate }}%</div>
</div>
<div class="card kpi-card">
<div class="card-title"><span class="icon" style="background:rgba(251,191,36,0.15)">⚡</span> 最快模型</div>
{% set fastest = data.model_ranking|sort(attribute='avg_latency_ms')|first %}
<div class="kpi-value g3">{{ fastest.avg_latency_ms|round(0)|int }}ms</div>
<div class="kpi-label">{{ fastest.model_name }}</div>
<div class="kpi-sub">质量 {{ fastest.avg_quality_score }}</div>
</div>
<div class="card kpi-card">
<div class="card-title"><span class="icon" style="background:rgba(34,211,238,0.15)">💰</span> 性价比之王</div>
{% set cheapest = data.model_ranking|sort(attribute='total_cost_usd')|first %}
<div class="kpi-value g4">{ cheapest.total_cost_usd}</div>
<div class="kpi-label">{{ cheapest.model_name }}</div>
<div class="kpi-sub">质量 {{ cheapest.avg_quality_score }}</div>
</div>
</div>
<!-- ════════ 模型排行 Top 3 ════════ -->
<div class="section grid grid-3">
{% for m in data.model_ranking[:3] %}
{% set medals = ['🥇', '🥈', '🥉'] %}
{% set medal_classes = ['medal-gold', 'medal-silver', 'medal-bronze'] %}
<div class="card medal-card {{ medal_classes[loop.index0] }}">
<div class="medal">{{ medals[loop.index0] }}</div>
<div class="model-name">{{ m.model_name }}</div>
<div class="model-score">{{ m.avg_quality_score }}</div>
<div style="font-size:11px;color:var(--text-muted)">综合质量评分</div>
<div class="model-stats">
<div><div class="stat-val">{{ m.avg_success_rate }}%</div><div class="stat-label">成功率</div></div>
<div><div class="stat-val">{{ m.avg_latency_ms|round(0)|int }}ms</div><div class="stat-label">延迟</div></div>
<div><div class="stat-val">{ m.total_cost_usd}</div><div class="stat-label">总费用</div></div>
</div>
</div>
{% endfor %}
</div>
<!-- ════════ Tabs: 详细排行 / 分类分析 / 雷达对比 ════════ -->
<div class="section">
<div class="card">
<div class="tab-nav">
<button class="tab-btn active" onclick="switchTab('ranking')">📊 模型详细排行</button>
<button class="tab-btn" onclick="switchTab('category')">📂 分类最佳模型</button>
<button class="tab-btn" onclick="switchTab('radar')">🕸️ 雷达对比</button>
<button class="tab-btn" onclick="switchTab('cost')">💰 成本分析</button>
</div>
<!-- Tab 1: 模型排行 -->
<div id="tab-ranking" class="tab-content active">
<table class="rank-table">
<thead>
<tr>
<th>排名</th><th>模型</th><th>综合质量</th><th>成功率</th><th>平均延迟</th><th>总费用</th><th>评测数</th><th>最强分类</th>
</tr>
</thead>
<tbody>
{% for m in data.model_ranking %}
<tr>
<td style="font-size:18px">{{ ['🥇','🥈','🥉','4️⃣','5️⃣','6️⃣'][loop.index0] if loop.index0 < 6 else loop.index }}</td>
<td style="font-weight:700">{{ m.model_name }}</td>
<td>
<span class="q-badge {{ 'q-green' if m.avg_quality_score >= 83 else 'q-blue' if m.avg_quality_score >= 80 else 'q-yellow' }}">
{{ m.avg_quality_score }}
</span>
</td>
<td>{{ m.avg_success_rate }}%</td>
<td>{{ m.avg_latency_ms|round(0)|int }}ms</td>
<td>{ m.total_cost_usd}</td>
<td>{{ m.skills_evaluated }}</td>
<td style="font-size:12px;color:var(--text-muted)">{{ m.best_categories[:2]|join(', ') }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Tab 2: 分类最佳模型 -->
<div id="tab-category" class="tab-content">
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:8px">
{% for cat, model in data.category_leaders.items() %}
<div class="cat-item">
<div>
<div class="cat-name">{{ cat }}</div>
<div class="cat-count">
{% if data.category_summaries and cat in data.category_summaries %}
{{ data.category_summaries[cat].get('total_skills', 0) }} Skills
{% endif %}
</div>
</div>
<div class="cat-model">🏆 {{ model }}</div>
</div>
{% endfor %}
</div>
</div>
<!-- Tab 3: 雷达对比 -->
<div id="tab-radar" class="tab-content">
<div class="chart-container" style="height:400px">
<canvas id="radarChart"></canvas>
</div>
</div>
<!-- Tab 4: 成本分析 -->
<div id="tab-cost" class="tab-content">
<div class="chart-container" style="height:350px">
<canvas id="costChart"></canvas>
</div>
</div>
</div>
</div>
<!-- ════════ 质量分布 + 延迟分布 ════════ -->
<div class="section grid grid-2">
<div class="card">
<div class="card-title"><span class="icon" style="background:rgba(74,222,128,0.15)">📊</span> 模型质量分布</div>
<div class="chart-container" style="height:280px">
<canvas id="qualityBarChart"></canvas>
</div>
</div>
<div class="card">
<div class="card-title"><span class="icon" style="background:rgba(34,211,238,0.15)">⏱️</span> 模型延迟分布</div>
<div class="chart-container" style="height:280px">
<canvas id="latencyBarChart"></canvas>
</div>
</div>
</div>
<!-- ════════ 完整矩阵 ════════ -->
<div class="section">
<div class="card">
<div class="card-title"><span class="icon" style="background:rgba(244,114,182,0.15)">🧬</span> 完整评测矩阵</div>
<div class="matrix-toolbar">
<input type="text" id="matrixSearch" placeholder="🔍 搜索 Skill 名称..." oninput="filterMatrix()">
<select id="matrixCatFilter" onchange="filterMatrix()">
<option value="">全部分类</option>
</select>
<select id="matrixSort" onchange="sortMatrix()">
<option value="rank">按排名</option>
<option value="name">按名称</option>
<option value="best_q">按最高质量 ↓</option>
</select>
<span id="matrixInfo" style="font-size:12px;color:var(--text-muted);margin-left:auto"></span>
</div>
<div class="matrix-table-wrap" id="matrixWrap">
<table class="matrix-table" id="matrixTable">
<thead id="matrixHead"></thead>
<tbody id="matrixBody"></tbody>
</table>
</div>
<div class="pagination" id="matrixPagination"></div>
</div>
</div>
{% endif %}
<footer class="footer">
<p>Skills Monitor Benchmark v0.6.1 · TOP1000 × 6 Models · Powered by CodeBuddy · {{ data.generated_at[:19] if data.generated_at != '未知' else '' }}</p>
<p style="margin-top:4px"><a href="/overview" style="color:var(--accent-blue);text-decoration:none">← 返回全局总览</a></p>
</footer>
</div>
<!-- ════════ JavaScript ════════ -->
<script>
const DATA = {{ data|tojson }};
const MODELS = DATA.models || [];
const RANKING = DATA.model_ranking || [];
const MATRIX = DATA.matrix || {}; // {slug: {model_key: {q,sr,ms,cost}}}
const CATEGORIES = DATA.category_leaders || {};
const MODEL_COLORS = {
'claude-opus-4.6': '#667eea',
'gpt-5.4': '#4ade80',
'deepseek-3.2': '#22d3ee',
'minimax-2.5': '#f472b6',
'glm-5': '#fbbf24',
'gemini-3.0-pro': '#fb923c',
};
Chart.defaults.color = '#5a5e72';
Chart.defaults.font.family = "-apple-system, BlinkMacSystemFont, 'SF Pro Display', sans-serif";
// ═══ Tab 切换 ═══
function switchTab(name) {
document.querySelectorAll('.tab-btn').forEach((b, i) => {
const tabs = ['ranking', 'category', 'radar', 'cost'];
b.classList.toggle('active', tabs[i] === name);
});
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
const el = document.getElementById('tab-' + name);
if (el) el.classList.add('active');
if (name === 'radar' && !window._radarInited) { initRadarChart(); window._radarInited = true; }
if (name === 'cost' && !window._costInited) { initCostChart(); window._costInited = true; }
}
// ═══ 雷达图 ═══
function initRadarChart() {
const canvas = document.getElementById('radarChart');
if (!canvas || RANKING.length === 0) return;
const labels = ['综合质量', '成功率', '速度(反)', '性价比(反)', '评测覆盖'];
const maxLatency = Math.max(...RANKING.map(m => m.avg_latency_ms)) * 1.1;
const maxCost = Math.max(...RANKING.map(m => m.total_cost_usd)) * 1.1;
const datasets = RANKING.map(m => ({
label: m.model_name,
data: [
m.avg_quality_score,
m.avg_success_rate,
Math.max(0, 100 - (m.avg_latency_ms / maxLatency * 100)),
Math.max(0, 100 - (m.total_cost_usd / maxCost * 100)),
(m.skills_evaluated / (DATA.skills_count || 1000)) * 100,
],
borderColor: MODEL_COLORS[m.model_key] || '#667eea',
backgroundColor: (MODEL_COLORS[m.model_key] || '#667eea') + '20',
borderWidth: 2, pointRadius: 3,
}));
new Chart(canvas.getContext('2d'), {
type: 'radar',
data: { labels, datasets },
options: {
responsive: true, maintainAspectRatio: false,
scales: { r: { min: 0, max: 100, ticks: { stepSize: 20, font: { size: 10 } }, grid: { color: 'rgba(255,255,255,0.06)' }, angleLines: { color: 'rgba(255,255,255,0.06)' }, pointLabels: { font: { size: 12 } } } },
plugins: { legend: { position: 'bottom', labels: { padding: 16, usePointStyle: true, font: { size: 11 } } } },
},
});
}
// ═══ 成本分析图 ═══
function initCostChart() {
const canvas = document.getElementById('costChart');
if (!canvas || RANKING.length === 0) return;
const labels = RANKING.map(m => m.model_name);
new Chart(canvas.getContext('2d'), {
type: 'bar',
data: {
labels,
datasets: [
{ label: '总费用 ($)', data: RANKING.map(m => m.total_cost_usd), backgroundColor: RANKING.map(m => (MODEL_COLORS[m.model_key] || '#667eea') + 'bb'), borderRadius: 6, barPercentage: 0.6 },
]
},
options: {
responsive: true, maintainAspectRatio: false, indexAxis: 'y',
plugins: {
legend: { display: false },
tooltip: { callbacks: { label: ctx => `$ctx.raw.toFixed(4)`, afterLabel: ctx => { const m = RANKING[ctx.dataIndex]; return `质量: m.avg_quality_score · 性价比: (m.avg_quality_score / Math.max(m.total_cost_usd, 0.001)).toFixed(0)`; } } },
},
scales: { x: { grid: { color: 'rgba(255,255,255,0.04)' } }, y: { grid: { display: false } } },
},
});
}
// ═══ 质量 & 延迟柱状图 ═══
function initBarCharts() {
const labels = RANKING.map(m => m.model_name);
const colors = RANKING.map(m => MODEL_COLORS[m.model_key] || '#667eea');
// 质量分布
const qCanvas = document.getElementById('qualityBarChart');
if (qCanvas) {
new Chart(qCanvas.getContext('2d'), {
type: 'bar',
data: {
labels,
datasets: [
{ label: '综合质量', data: RANKING.map(m => m.avg_quality_score), backgroundColor: colors.map(c => c + 'bb'), borderRadius: 8, barPercentage: 0.55 },
{ label: '成功率', data: RANKING.map(m => m.avg_success_rate), backgroundColor: colors.map(c => c + '44'), borderRadius: 8, barPercentage: 0.55 },
]
},
options: {
responsive: true, maintainAspectRatio: false,
plugins: { legend: { position: 'top', labels: { font: { size: 11 }, usePointStyle: true, padding: 16 } } },
scales: { y: { min: 70, max: 100, grid: { color: 'rgba(255,255,255,0.04)' } }, x: { grid: { display: false } } },
},
});
}
// 延迟分布
const lCanvas = document.getElementById('latencyBarChart');
if (lCanvas) {
new Chart(lCanvas.getContext('2d'), {
type: 'bar',
data: {
labels,
datasets: [{
label: '平均延迟 (ms)',
data: RANKING.map(m => m.avg_latency_ms),
backgroundColor: RANKING.map(m => {
if (m.avg_latency_ms < 2000) return 'rgba(74,222,128,0.7)';
if (m.avg_latency_ms < 3000) return 'rgba(102,126,234,0.7)';
return 'rgba(251,191,36,0.7)';
}),
borderRadius: 8, barPercentage: 0.55,
}]
},
options: {
responsive: true, maintainAspectRatio: false,
plugins: { legend: { display: false }, tooltip: { callbacks: { label: ctx => `ctx.raw.toFixed(0)ms` } } },
scales: { y: { grid: { color: 'rgba(255,255,255,0.04)' } }, x: { grid: { display: false } } },
},
});
}
}
// ═══ 评测矩阵 ═══
const MATRIX_PAGE_SIZE = 50;
let matrixPage = 1;
let matrixData = []; // [{rank, slug, name, category, models:{key:{q,sr,ms,cost}}}]
let filteredMatrix = [];
function initMatrixData() {
// 从 dataset_sample 和 matrix 合并
const dataset = DATA.dataset_sample || [];
const allSlugs = Object.keys(MATRIX);
const catSet = new Set();
// 用 matrix 数据构建完整列表
matrixData = allSlugs.map((slug, i) => {
const info = dataset.find(d => d.slug === slug) || {};
const cat = info.category || 'other';
catSet.add(cat);
const models = MATRIX[slug] || {};
const bestQ = Math.max(0, ...Object.values(models).map(m => m.q || 0));
return { rank: info.rank || (i + 1), slug, name: info.name || slug, category: cat, models, bestQ };
});
matrixData.sort((a, b) => a.rank - b.rank);
filteredMatrix = [...matrixData];
// 填充分类下拉
const select = document.getElementById('matrixCatFilter');
[...catSet].sort().forEach(cat => {
const opt = document.createElement('option');
opt.value = cat; opt.textContent = cat;
select.appendChild(opt);
});
renderMatrix();
}
function filterMatrix() {
const q = (document.getElementById('matrixSearch').value || '').toLowerCase();
const cat = document.getElementById('matrixCatFilter').value;
filteredMatrix = matrixData.filter(d => {
if (cat && d.category !== cat) return false;
if (q && !d.name.toLowerCase().includes(q) && !d.slug.toLowerCase().includes(q)) return false;
return true;
});
matrixPage = 1;
sortMatrix(false);
renderMatrix();
}
function sortMatrix(doRender = true) {
const sortKey = document.getElementById('matrixSort').value;
filteredMatrix.sort((a, b) => {
if (sortKey === 'name') return a.name.localeCompare(b.name);
if (sortKey === 'best_q') return b.bestQ - a.bestQ;
return a.rank - b.rank;
});
if (doRender) renderMatrix();
}
function renderMatrix() {
const totalPages = Math.ceil(filteredMatrix.length / MATRIX_PAGE_SIZE) || 1;
if (matrixPage > totalPages) matrixPage = totalPages;
const start = (matrixPage - 1) * MATRIX_PAGE_SIZE;
const pageData = filteredMatrix.slice(start, start + MATRIX_PAGE_SIZE);
// Info
document.getElementById('matrixInfo').textContent =
`filteredMatrix.length Skills` + (totalPages > 1 ? ` · 第 matrixPage/totalPages 页` : '');
// Header
const thead = document.getElementById('matrixHead');
thead.innerHTML = `<tr><th>#</th><th>Skill</th><th>分类</th>MODELS.map(m => `<th>${m.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase()).split(' ').slice(0, 2).join(' ')</th>`).join('')}</tr>`;
// Body
const tbody = document.getElementById('matrixBody');
tbody.innerHTML = pageData.map(d => {
let row = `<td>d.rank</td><td style="font-weight:600;max-width:200px;overflow:hidden;text-overflow:ellipsis">escHtml(d.name)</td><td style="font-size:11px;color:var(--text-muted)">d.category</td>`;
MODELS.forEach(mk => {
const cell = d.models[mk];
if (cell) {
const q = cell.q || 0;
let icon = '🔴', cls = 'q-red';
if (q >= 80) { icon = '🟢'; cls = 'q-green'; }
else if (q >= 60) { icon = '🟡'; cls = 'q-yellow'; }
else if (q >= 40) { icon = '🟠'; cls = 'q-yellow'; }
row += `<td><span class="q-badge cls" title="成功率:cell.sr% 延迟:cell.msms 费用:$cell.cost">icon q.toFixed(0)</span></td>`;
} else {
row += `<td style="color:var(--text-muted)">—</td>`;
}
});
return `<tr class="fade-in">row</tr>`;
}).join('');
// Pagination
renderMatrixPagination(totalPages);
}
function renderMatrixPagination(totalPages) {
const pag = document.getElementById('matrixPagination');
if (totalPages <= 1) { pag.innerHTML = ''; return; }
let html = `<button class="page-btn" onclick="matrixGoPage(matrixPage - 1)" ''>‹</button>`;
const pages = [];
if (totalPages <= 7) { for (let i = 1; i <= totalPages; i++) pages.push(i); }
else {
pages.push(1);
let s = Math.max(2, matrixPage - 2), e = Math.min(totalPages - 1, matrixPage + 2);
if (s > 2) pages.push(-1);
for (let i = s; i <= e; i++) pages.push(i);
if (e < totalPages - 1) pages.push(-1);
pages.push(totalPages);
}
pages.forEach(p => {
if (p === -1) html += '<span class="page-info">…</span>';
else html += `<button class="page-btn ''" onclick="matrixGoPage(p)">p</button>`;
});
html += `<button class="page-btn" onclick="matrixGoPage(matrixPage + 1)" ''>›</button>`;
pag.innerHTML = html;
}
function matrixGoPage(p) {
const totalPages = Math.ceil(filteredMatrix.length / MATRIX_PAGE_SIZE) || 1;
if (p < 1 || p > totalPages) return;
matrixPage = p;
renderMatrix();
document.getElementById('matrixWrap').scrollTop = 0;
}
function escHtml(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
// ═══ 初始化 ═══
document.addEventListener('DOMContentLoaded', () => {
if (RANKING.length > 0) {
initBarCharts();
initMatrixData();
}
});
</script>
</body>
</html>
FILE:server/templates/h5_error.html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Skills Monitor — 错误</title>
<link rel="stylesheet" href="/static/css/h5.css">
</head>
<body>
<div class="container">
<header class="header">
<div class="header-icon">⚠️</div>
<h1>出错了</h1>
</header>
<section class="card empty-state">
<div class="empty-icon">😕</div>
<h3>{{ message or '页面不存在' }}</h3>
<p>请返回重试或联系管理员</p>
</section>
<footer class="footer">
<p>Skills Monitor v0.4.0 · Powered by CodeBuddy</p>
</footer>
</div>
</body>
</html>
FILE:server/templates/h5_report.html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Skills Monitor — {% if agent %}{{ agent.name or 'Agent' }}{% endif %} 报告中心</title>
<link rel="stylesheet" href="/static/css/h5.css">
</head>
<body>
{% set display_report = report %}
{% set display_data = report_data or latest_daily_data or latest_diag_data %}
{% set recommendations = recommendations or [] %}
{% set installed_skills = installed_skills or [] %}
{% set diagnostic = diagnostic_payload or {} %}
{% set report_markdown = latest_diag_data.get('report_markdown') or display_data.get('report_markdown') or '' %}
<div class="container">
<header class="header">
<div class="header-icon">🛰️</div>
<h1>Skills Monitor</h1>
<p class="subtitle">
{% if display_report %}
{{ display_report.report_date.strftime('%Y-%m-%d') if display_report.report_date else '今日' }} · {{ display_report.report_type|upper }}
{% else %}
暂无数据
{% endif %}
</p>
</header>
{% if display_report %}
<section class="card summary-banner">
<div>
<div class="summary-title">网页端同步状态</div>
<div class="summary-desc">首次诊断、每日报告、推荐安装与评分已汇总到这里展示。</div>
</div>
<span class="status-pill status-{{ sync_status.status }}">{{ sync_status.status_label }}</span>
</section>
<section class="card health-card">
<div class="health-score-wrapper">
<div class="health-ring" data-score="{{ display_report.health_score or 0 }}">
<svg viewBox="0 0 120 120">
<circle cx="60" cy="60" r="52" class="ring-bg"/>
<circle cx="60" cy="60" r="52" class="ring-fill" style="stroke-dasharray: {{ (display_report.health_score or 0) * 3.267 }}, 326.7"/>
</svg>
<div class="score-text">
<span class="score-num">{{ '%.0f' % (display_report.health_score or 0) }}</span>
<span class="score-label">健康度</span>
</div>
</div>
</div>
<div class="metrics-row">
<div class="metric">
<div class="metric-value">{{ display_report.total_runs or display_data.get('overview', {}).get('total_runs', 0) }}</div>
<div class="metric-label">今日运行</div>
</div>
<div class="metric">
<div class="metric-value">{{ '%.1f' % (display_report.success_rate or display_data.get('overview', {}).get('success_rate', 0)) }}%</div>
<div class="metric-label">成功率</div>
</div>
<div class="metric">
<div class="metric-value">{{ display_report.active_skills or display_data.get('overview', {}).get('active_skills', 0) }}</div>
<div class="metric-label">活跃 Skills</div>
</div>
</div>
</section>
<section class="card">
<h2 class="card-title">🔄 报告同步保障</h2>
<div class="sync-grid">
<div class="sync-item">
<span class="sync-label">首次诊断</span>
<strong>{{ '已补齐' if sync_status.has_initial_diagnostic else '缺失' }}</strong>
<span class="sync-sub">{{ sync_status.first_diagnostic_date or '-' }}</span>
</div>
<div class="sync-item">
<span class="sync-label">近 7 日日报</span>
<strong>{{ sync_status.recent_daily_count }}</strong>
<span class="sync-sub">建议 ≥ 5 次</span>
</div>
<div class="sync-item">
<span class="sync-label">累计日报</span>
<strong>{{ sync_status.daily_count }}</strong>
<span class="sync-sub">{{ sync_status.latest_daily_date or '-' }}</span>
</div>
<div class="sync-item">
<span class="sync-label">累计诊断</span>
<strong>{{ sync_status.diagnostic_count }}</strong>
<span class="sync-sub">{{ sync_status.latest_diag_date or '-' }}</span>
</div>
</div>
</section>
{% if recommendations %}
<section class="card">
<h2 class="card-title">💡 推荐安装 Skills</h2>
<div class="logic-note">
{% if installed_skills|length == 0 %}
当前未检测到已安装 skills,已按 <strong>官方 TOP10</strong> 从热度、成功率和能力覆盖中精选 3 个兜底推荐。
{% else %}
已按当前安装结构、使用频率和满意度生成推荐,优先补足能力短板并替换低表现 skill。
{% endif %}
</div>
<div class="recommendation-list">
{% for rec in recommendations[:6] %}
<article class="recommendation-card">
<div class="recommend-head">
<div>
<div class="recommend-name">{{ rec.get('name') }}</div>
<div class="recommend-meta">{{ rec.get('category', '未分类') }} · {{ rec.get('slug') }}</div>
</div>
<div class="recommend-score">{{ '%.0f' % rec.get('recommendation_score', 0) }}</div>
</div>
<div class="pill-list">
<span class="pill">{{ rec.get('reason_label') or rec.get('reason_type') }}</span>
{% if rec.get('official_rank') %}<span class="pill">TOP{{ rec.get('official_rank') }}</span>{% endif %}
{% if rec.get('hub_installs') %}<span class="pill">{{ rec.get('hub_installs') }} installs</span>{% endif %}
</div>
<p class="recommend-desc">{{ rec.get('description') }}</p>
<p class="recommend-logic">{{ rec.get('reason_detail') }}</p>
{% if rec.get('selection_logic') %}
<div class="logic-chip">{{ rec.get('selection_logic') }}</div>
{% endif %}
</article>
{% endfor %}
</div>
</section>
{% endif %}
{% if trend %}
<section class="card">
<h2 class="card-title">📈 7 日趋势</h2>
<canvas id="trendChart" height="200"></canvas>
</section>
{% endif %}
{% if display_data.get('scores') %}
<section class="card">
<h2 class="card-title">🏆 Skill 评分排行</h2>
<div class="skill-list">
{% for s in display_data.get('scores', [])[:10] %}
{% set raw = s.get('raw_factors', {}) %}
<div class="skill-item skill-item-rich">
<div class="skill-rank {% if loop.index <= 3 %}top3{% endif %}">{{ loop.index }}</div>
<div class="skill-info skill-info-rich">
<div class="skill-name-row">
<div class="skill-name">{{ s.get('skill_id', 'unknown') }}</div>
<div class="skill-grade grade-{{ s.get('grade', 'C')|lower }}">{{ s.get('grade', '-') }}</div>
</div>
<div class="skill-meta-row">
<span>成功率 {{ '%.0f%%' % raw.get('success_rate', 0) if raw.get('success_rate') is not none else '-' }}</span>
<span>响应 {{ '%.0fms' % raw.get('response_time', 0) if raw.get('response_time') is not none else '-' }}</span>
<span>满意度 {{ '%.1f/5' % raw.get('satisfaction', 0) if raw.get('satisfaction') is not none else '-' }}</span>
</div>
</div>
<div class="skill-score">{{ '%.1f' % s.get('total_score', 0) }}</div>
<div class="skill-bar">
<div class="skill-bar-fill" style="width: {{ s.get('total_score', 0) }}%"></div>
</div>
</div>
{% endfor %}
</div>
</section>
{% endif %}
{% if installed_skills %}
<section class="card">
<h2 class="card-title">🧩 已安装 Skills</h2>
<div class="inventory-list">
{% for skill in installed_skills[:12] %}
<div class="inventory-item">
<div>
<div class="inventory-name">{{ skill.get('name') or skill.get('slug') }}</div>
<div class="inventory-meta">{{ skill.get('category', '未分类') }} · {{ '可运行' if skill.get('runnable') else '仅文档' }}</div>
</div>
<div class="inventory-side">
{% if skill.get('total_score') is not none %}
<div class="inventory-score">{{ '%.1f' % skill.get('total_score', 0) }}</div>
<div class="inventory-grade">{{ skill.get('grade', '-') }}</div>
{% else %}
<div class="inventory-grade muted">待评分</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
</section>
{% endif %}
{% if diagnostic %}
<section class="card">
<h2 class="card-title">🩺 诊断摘要</h2>
{% if diagnostic.get('issues') %}
<div class="diag-block">
<div class="diag-title">发现问题</div>
<ul class="info-list compact">
{% for item in diagnostic.get('issues', [])[:5] %}
<li>{{ item }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
{% if diagnostic.get('suggestions') %}
<div class="diag-block">
<div class="diag-title">行动建议</div>
<ul class="info-list compact">
{% for item in diagnostic.get('suggestions', [])[:5] %}
<li>{{ item }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
</section>
{% endif %}
{% if first_diag or latest_diag %}
<section class="card">
<h2 class="card-title">📄 首次诊断兜底</h2>
<div class="overview-grid">
<div class="ov-item">
<span class="ov-label">首次诊断日期</span>
<span class="ov-value">{{ sync_status.first_diagnostic_date or '-' }}</span>
</div>
<div class="ov-item">
<span class="ov-label">最新诊断日期</span>
<span class="ov-value">{{ sync_status.latest_diag_date or '-' }}</span>
</div>
<div class="ov-item">
<span class="ov-label">首次触发方式</span>
<span class="ov-value">{{ first_diag_data.get('trigger') or '-' }}</span>
</div>
<div class="ov-item">
<span class="ov-label">最新触发方式</span>
<span class="ov-value">{{ latest_diag_data.get('trigger') or '-' }}</span>
</div>
</div>
{% if report_markdown %}
<div class="markdown-preview">{{ report_markdown[:1800] }}</div>
{% endif %}
</section>
{% endif %}
<section class="card">
<h2 class="card-title">🤖 Agent 信息</h2>
<div class="agent-info">
<div class="ov-item">
<span class="ov-label">名称</span>
<span class="ov-value">{{ agent.name or 'Agent-' + agent.agent_id[:8] }}</span>
</div>
<div class="ov-item">
<span class="ov-label">最近上报</span>
<span class="ov-value">{{ agent.last_report_at.strftime('%m-%d %H:%M') if agent.last_report_at else '-' }}</span>
</div>
<div class="ov-item">
<span class="ov-label">系统</span>
<span class="ov-value">{{ agent.os_info or '-' }}</span>
</div>
<div class="ov-item">
<span class="ov-label">Monitor 版本</span>
<span class="ov-value">{{ agent.monitor_version or '-' }}</span>
</div>
</div>
</section>
{% else %}
<section class="card empty-state">
<div class="empty-icon">📭</div>
<h3>暂无报告数据</h3>
<p>Agent 尚未上报任何数据。请先运行自动上报或手动上传日报与诊断。</p>
<code>skills-monitor upload --type diagnostic</code>
</section>
{% endif %}
<footer class="footer">
<p>Skills Monitor v0.6.x · Powered by CodeBuddy</p>
</footer>
</div>
{% if trend %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
<script>
const trend = {{ trend|tojson }};
if (trend.length > 0) {
const ctx = document.getElementById('trendChart').getContext('2d');
new Chart(ctx, {
type: 'line',
data: {
labels: trend.map(t => t.date.slice(5)),
datasets: [{
label: '健康度',
data: trend.map(t => t.health_score),
borderColor: '#667eea',
backgroundColor: 'rgba(102,126,234,0.12)',
fill: true,
tension: 0.35,
borderWidth: 2,
pointRadius: 4,
pointBackgroundColor: '#667eea'
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
y: { min: 0, max: 100, grid: { color: 'rgba(255,255,255,0.05)' }, ticks: { color: '#999' } },
x: { grid: { display: false }, ticks: { color: '#999' } }
}
}
});
}
</script>
{% endif %}
</body>
</html>
FILE:server/templates/pwa.html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<meta name="theme-color" content="#0a0a1a">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="description" content="Skills Monitor — AI 智能体技能监控面板">
<title>Skills Monitor</title>
<link rel="manifest" href="/static/pwa/manifest.json">
<link rel="apple-touch-icon" href="/static/img/icon-192.png">
<link rel="stylesheet" href="/static/pwa/app.css?v=20260319f">
</head>
<body>
<div id="app">
<!-- ═══════ 登录页 ═══════ -->
<div id="login-page" class="page login-page active">
<div class="login-logo">
<div class="icon">🎯</div>
<h1>Skills Monitor</h1>
<p>AI 智能体技能监控面板</p>
</div>
<div class="card">
<div class="form-group">
<label>智能体 ID</label>
<input type="text" id="login-agent-id" placeholder="输入智能体 ID" autocomplete="off">
</div>
<div class="form-group">
<label>Key</label>
<input type="password" id="login-agent-token" placeholder="输入 Key" autocomplete="off">
</div>
<button id="login-btn" class="btn btn-primary">登 录</button>
</div>
<div class="login-help">
💡 在终端运行以下命令获取凭证:<br>
<code>skills-monitor identity --show-key</code>
</div>
</div>
<!-- ═══════ 仪表盘 ═══════ -->
<div id="dashboard-page" class="page">
<div class="page-header">
<div class="icon">📊</div>
<h1>仪表盘</h1>
<p class="subtitle">Skills Monitor 数据总览</p>
</div>
<div id="dashboard-content">
<div class="loading"><div class="spinner"></div>加载中...</div>
</div>
</div>
<!-- ═══════ 报告列表 ═══════ -->
<div id="reports-page" class="page">
<div class="page-header">
<div class="icon">📋</div>
<h1>报告</h1>
<p class="subtitle">历史报告列表</p>
</div>
<div id="reports-content">
<div class="loading"><div class="spinner"></div>加载中...</div>
</div>
</div>
<!-- ═══════ 报告详情 ═══════ -->
<div id="report-detail-page" class="page">
<div id="report-detail-content">
<div class="loading"><div class="spinner"></div>加载中...</div>
</div>
</div>
<!-- ═══════ Skill 详情 ═══════ -->
<div id="skill-detail-page" class="page">
<div id="skill-detail-content">
<div class="loading"><div class="spinner"></div>加载中...</div>
</div>
</div>
<!-- ═══════ 我的 ═══════ -->
<div id="mine-page" class="page">
<div class="page-header">
<div class="icon">👤</div>
<h1>我的</h1>
<p class="subtitle">账户与设置</p>
</div>
<div id="mine-content">
<div class="loading"><div class="spinner"></div>加载中...</div>
</div>
</div>
</div>
<!-- Tab Bar -->
<nav id="tab-bar" class="tab-bar hidden">
<div class="tab-item active" data-page="dashboard">
<span class="tab-icon">📊</span>
<span>仪表盘</span>
</div>
<div class="tab-item" data-page="reports">
<span class="tab-icon">📋</span>
<span>报告</span>
</div>
<div class="tab-item" data-page="mine">
<span class="tab-icon">👤</span>
<span>我的</span>
</div>
</nav>
<!-- Toast -->
<div id="toast" class="toast"></div>
<!-- Chart.js CDN -->
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
<!-- App JS -->
<script src="/static/pwa/app.js?v=20260319g"></script>
</body>
</html>
FILE:server/api/wechat_api.py
"""
微信公众号回调 API
==================
- GET /api/wechat/callback 接口验证
- POST /api/wechat/callback 消息/事件接收
"""
import xml.etree.ElementTree as ET
from datetime import datetime
from flask import Blueprint, request, make_response, current_app
from server.models.database import db, User
from server.services.wechat_service import wechat_service
from server.services.report_service import bind_user_agent
wechat_bp = Blueprint("wechat_api", __name__, url_prefix="/api/wechat")
def _xml_response(to_user: str, from_user: str, content: str) -> str:
"""构建文本消息 XML 响应"""
return f"""<xml>
<ToUserName><![CDATA[{to_user}]]></ToUserName>
<FromUserName><![CDATA[{from_user}]]></FromUserName>
<CreateTime>{int(datetime.now().timestamp())}</CreateTime>
<MsgType><![CDATA[text]]></MsgType>
<Content><![CDATA[{content}]]></Content>
</xml>"""
def _get_or_create_user(openid: str) -> User:
"""获取或创建微信用户"""
user = User.query.filter_by(openid=openid).first()
if not user:
user = User(openid=openid, subscribe=True, subscribe_time=datetime.utcnow())
db.session.add(user)
db.session.commit()
# 尝试获取用户信息
try:
info = wechat_service.get_user_info(openid)
if info.get("nickname"):
user.nickname = info["nickname"]
user.avatar_url = info.get("headimgurl", "")
user.union_id = info.get("unionid")
db.session.commit()
except Exception:
pass
return user
# ──────── 接口验证 ────────
@wechat_bp.route("/callback", methods=["GET"])
def verify():
"""微信接口验证(GET 请求)"""
signature = request.args.get("signature", "")
timestamp = request.args.get("timestamp", "")
nonce = request.args.get("nonce", "")
echostr = request.args.get("echostr", "")
if wechat_service.verify_signature(signature, timestamp, nonce):
return make_response(echostr)
return make_response("验证失败", 403)
# ──────── 消息/事件接收 ────────
@wechat_bp.route("/callback", methods=["POST"])
def receive():
"""接收微信消息和事件推送"""
try:
xml_data = request.data.decode("utf-8")
root = ET.fromstring(xml_data)
msg_type = root.findtext("MsgType", "")
from_user = root.findtext("FromUserName", "") # 用户 openid
to_user = root.findtext("ToUserName", "") # 公众号 ID
# 事件消息
if msg_type == "event":
event = root.findtext("Event", "").lower()
return _handle_event(event, root, from_user, to_user)
# 文本消息
if msg_type == "text":
content = root.findtext("Content", "").strip()
return _handle_text(content, from_user, to_user)
return make_response("success")
except Exception as e:
current_app.logger.error(f"微信回调处理异常: {e}")
return make_response("success")
def _handle_event(event: str, root, from_user: str, to_user: str):
"""处理事件消息"""
# 关注事件
if event == "subscribe":
user = _get_or_create_user(from_user)
user.subscribe = True
user.subscribe_time = datetime.utcnow()
db.session.commit()
# 带参二维码关注(扫码绑定 Agent)
event_key = root.findtext("EventKey", "")
if event_key.startswith("qrscene_bind:"):
return _handle_bind_scan(event_key.replace("qrscene_", ""), from_user, to_user)
reply = (
"🎉 欢迎关注 Skills Monitor!\n\n"
"我可以帮您:\n"
"📊 每天推送 Skills 健康报告\n"
"🔗 绑定您的 Agent 查看详情\n"
"📱 通过小程序随时查看\n\n"
"回复「绑定」或点击菜单开始使用"
)
resp = make_response(_xml_response(from_user, to_user, reply))
resp.content_type = "application/xml"
return resp
# 取消关注
if event == "unsubscribe":
user = User.query.filter_by(openid=from_user).first()
if user:
user.subscribe = False
db.session.commit()
return make_response("success")
# 扫码事件(已关注用户扫码)
if event == "scan":
event_key = root.findtext("EventKey", "")
if event_key.startswith("bind:"):
return _handle_bind_scan(event_key, from_user, to_user)
return make_response("success")
# 菜单点击事件
if event == "click":
event_key = root.findtext("EventKey", "")
if event_key == "BIND_AGENT":
reply = (
"🔗 绑定 Agent\n\n"
"请在本地终端运行:\n"
" skills-monitor bind --server\n\n"
"然后扫描终端显示的二维码即可完成绑定。"
)
resp = make_response(_xml_response(from_user, to_user, reply))
resp.content_type = "application/xml"
return resp
return make_response("success")
def _handle_bind_scan(event_key: str, from_user: str, to_user: str):
"""处理扫码绑定"""
# event_key: "bind:{agent_id}:{token_prefix}"
parts = event_key.split(":")
if len(parts) >= 3:
agent_id = parts[1]
token_prefix = parts[2]
user = _get_or_create_user(from_user)
ok, msg = bind_user_agent(user, agent_id, token_prefix)
if ok:
reply = f"✅ 绑定成功!\n\nAgent: {agent_id[:8]}...\n\n您将开始收到每日健康报告推送。"
else:
reply = f"❌ 绑定失败:{msg}\n\n请确认 Agent ID 和 Token 是否正确。"
else:
reply = "❌ 二维码格式无效,请重新生成。"
resp = make_response(_xml_response(from_user, to_user, reply))
resp.content_type = "application/xml"
return resp
def _handle_text(content: str, from_user: str, to_user: str):
"""处理文本消息"""
# 绑定指令
if content in ("绑定", "bind", "绑定Agent", "绑定agent"):
reply = (
"🔗 绑定 Agent 方法:\n\n"
"1️⃣ 在终端运行:\n"
" skills-monitor bind --server\n\n"
"2️⃣ 扫描终端显示的二维码\n\n"
"3️⃣ 绑定成功后即可接收每日推送"
)
# 报告指令
elif content in ("报告", "report", "今日报告"):
reply = (
"📊 查看报告:\n\n"
"• 点击菜单「查看报告」\n"
"• 或打开小程序查看详情"
)
# 帮助指令
elif content in ("帮助", "help", "?", "?"):
reply = (
"📖 Skills Monitor 帮助\n\n"
"回复以下关键词:\n"
" 「绑定」— 绑定 Agent\n"
" 「报告」— 查看今日报告\n"
" 「帮助」— 显示本帮助\n\n"
"或点击下方菜单操作 👇"
)
else:
reply = "回复「帮助」查看功能说明 📖"
resp = make_response(_xml_response(from_user, to_user, reply))
resp.content_type = "application/xml"
return resp
FILE:server/api/overview_api.py
"""
多 Agent 汇总总览 API — 全局分析仪表盘
========================================
- GET /overview 总览主页(所有 Agent 汇总)
- GET /api/overview/data 总览 JSON 数据
"""
import json
from datetime import date, datetime, timedelta
from collections import Counter, defaultdict
from flask import Blueprint, request, render_template, jsonify
from server.models.database import db, Agent, SkillReport
overview_bp = Blueprint("overview_api", __name__)
def _collect_overview_data(days: int = 30) -> dict:
"""
汇总所有 Agent 的全局分析数据
"""
end_date = date.today()
start_date = end_date - timedelta(days=days - 1)
# ── 获取所有 Agent ──
all_agents = Agent.query.order_by(Agent.last_report_at.desc().nullslast()).all()
total_agents = len(all_agents)
if total_agents == 0:
return {
"generated_at": datetime.now().isoformat(),
"period_days": days,
"total_agents": 0,
"agents": [],
"fleet_overview": {},
"trend": [],
"skill_global_rankings": [],
"score_distribution": {"excellent": 0, "good": 0, "average": 0, "poor": 0},
"agent_comparison": [],
"os_distribution": {},
"version_distribution": {},
"alert_summary": [],
}
# ── 1. 各 Agent 摘要信息 ──
agents_summary = []
active_agents = 0
total_health = 0
total_health_count = 0
total_skills_installed = 0
total_skills_runnable = 0
total_reports = 0
os_counter = Counter()
version_counter = Counter()
all_latest_scores = [] # 所有 Agent 的 skill 评分汇总
for agent in all_agents:
# 最新 daily 报告
latest = SkillReport.query.filter_by(
agent_db_id=agent.id, report_type="daily"
).order_by(SkillReport.report_date.desc()).first()
# 报告总数
report_count = SkillReport.query.filter_by(agent_db_id=agent.id).count()
total_reports += report_count
# 最近 7 天报告数
recent_reports = SkillReport.query.filter(
SkillReport.agent_db_id == agent.id,
SkillReport.report_type == "daily",
SkillReport.report_date >= end_date - timedelta(days=6),
).count()
# 判断是否活跃(7 天内有上报)
is_active = recent_reports > 0
if is_active:
active_agents += 1
# 健康度统计
health = agent.health_score or (latest.health_score if latest else None)
if health is not None:
total_health += health
total_health_count += 1
# Skill 数量
total_skills_installed += (agent.total_skills or 0)
total_skills_runnable += (agent.runnable_skills or 0)
# OS / 版本统计
if agent.os_info:
os_key = agent.os_info.split()[0] if agent.os_info else "Unknown"
os_counter[os_key] += 1
if agent.monitor_version:
version_counter[agent.monitor_version] += 1
# 最新评分数据
latest_data = latest.get_data() if latest else {}
scores = latest_data.get("scores", [])
for s in scores:
s["_agent_name"] = agent.name or f"Agent-{agent.agent_id[:8]}"
s["_agent_id"] = agent.agent_id
all_latest_scores.extend(scores)
# 最新运行数据
overview = latest_data.get("overview", {})
# 确定状态
if not latest:
status = "offline"
status_label = "未上报"
elif not is_active:
status = "idle"
status_label = "不活跃"
elif health is not None and health < 50:
status = "warning"
status_label = "需关注"
else:
status = "healthy"
status_label = "正常"
agents_summary.append({
"agent_id": agent.agent_id,
"name": agent.name or f"Agent-{agent.agent_id[:8]}",
"health_score": round(health, 1) if health else None,
"total_skills": agent.total_skills or 0,
"runnable_skills": agent.runnable_skills or 0,
"total_runs": overview.get("total_runs", latest.total_runs if latest else 0),
"success_rate": overview.get("success_rate", latest.success_rate if latest else 0),
"active_skills": overview.get("active_skills", latest.active_skills if latest else 0),
"avg_duration_ms": overview.get("avg_duration_ms", 0),
"os_info": agent.os_info or "未知",
"python_version": agent.python_version or "",
"monitor_version": agent.monitor_version or "",
"report_count": report_count,
"recent_reports_7d": recent_reports,
"last_report_at": agent.last_report_at.strftime("%Y-%m-%d %H:%M") if agent.last_report_at else "未上报",
"created_at": agent.created_at.strftime("%Y-%m-%d") if agent.created_at else "",
"status": status,
"status_label": status_label,
})
# ── 2. 舰队全局概览 ──
avg_health = round(total_health / total_health_count, 1) if total_health_count > 0 else 0
# 全局总运行次数、成功率
global_runs = sum(a["total_runs"] or 0 for a in agents_summary)
rates = [a["success_rate"] for a in agents_summary if a["success_rate"] and a["success_rate"] > 0]
global_avg_rate = round(sum(rates) / len(rates), 1) if rates else 0
global_active_skills = sum(a["active_skills"] or 0 for a in agents_summary)
fleet_overview = {
"total_agents": total_agents,
"active_agents": active_agents,
"inactive_agents": total_agents - active_agents,
"avg_health_score": avg_health,
"total_skills_installed": total_skills_installed,
"total_skills_runnable": total_skills_runnable,
"global_runnable_rate": round(total_skills_runnable / total_skills_installed * 100, 1) if total_skills_installed > 0 else 0,
"global_total_runs": global_runs,
"global_avg_success_rate": global_avg_rate,
"global_active_skills": global_active_skills,
"total_reports": total_reports,
}
# ── 3. 全局趋势(按天聚合所有 Agent) ──
range_reports = SkillReport.query.filter(
SkillReport.report_type == "daily",
SkillReport.report_date >= start_date,
SkillReport.report_date <= end_date,
).order_by(SkillReport.report_date.asc()).all()
# 按日期聚合
date_agg = defaultdict(lambda: {
"health_scores": [], "total_runs": 0, "success_rates": [],
"active_skills": 0, "agent_count": 0,
})
for r in range_reports:
d = r.report_date.isoformat()
date_agg[d]["health_scores"].append(r.health_score or 0)
date_agg[d]["total_runs"] += (r.total_runs or 0)
if r.success_rate and r.success_rate > 0:
date_agg[d]["success_rates"].append(r.success_rate)
date_agg[d]["active_skills"] += (r.active_skills or 0)
date_agg[d]["agent_count"] += 1
trend_data = []
for d_str in sorted(date_agg.keys()):
agg = date_agg[d_str]
avg_h = round(sum(agg["health_scores"]) / len(agg["health_scores"]), 1) if agg["health_scores"] else 0
avg_r = round(sum(agg["success_rates"]) / len(agg["success_rates"]), 1) if agg["success_rates"] else 0
trend_data.append({
"date": d_str,
"avg_health_score": avg_h,
"total_runs": agg["total_runs"],
"avg_success_rate": avg_r,
"total_active_skills": agg["active_skills"],
"reporting_agents": agg["agent_count"],
})
# ── 4. 全局 Skill 评分排行(跨 Agent 合并) ──
skill_agg = defaultdict(lambda: {"scores": [], "agents": set()})
for s in all_latest_scores:
sid = s.get("skill_id", "unknown")
skill_agg[sid]["scores"].append(s.get("total_score", 0))
skill_agg[sid]["agents"].add(s.get("_agent_name", ""))
skill_global_rankings = []
for sid, info in skill_agg.items():
avg_score = round(sum(info["scores"]) / len(info["scores"]), 1)
# 等级判定
if avg_score >= 90:
grade = "S(卓越)"
elif avg_score >= 80:
grade = "A(优秀)"
elif avg_score >= 70:
grade = "B(良好)"
elif avg_score >= 60:
grade = "C(一般)"
else:
grade = "D(需改善)"
skill_global_rankings.append({
"skill_id": sid,
"avg_score": avg_score,
"grade": grade,
"agent_count": len(info["agents"]),
"agents": sorted(info["agents"]),
"min_score": round(min(info["scores"]), 1),
"max_score": round(max(info["scores"]), 1),
})
skill_global_rankings.sort(key=lambda x: -x["avg_score"])
# ── 5. 全局评分分布 ──
score_distribution = {"excellent": 0, "good": 0, "average": 0, "poor": 0}
for s in all_latest_scores:
sc = s.get("total_score", 0)
if sc >= 90:
score_distribution["excellent"] += 1
elif sc >= 75:
score_distribution["good"] += 1
elif sc >= 60:
score_distribution["average"] += 1
else:
score_distribution["poor"] += 1
# ── 6. Agent 对比数据(雷达图用) ──
agent_comparison = []
for a in agents_summary:
agent_comparison.append({
"name": a["name"],
"agent_id": a["agent_id"],
"health": a["health_score"] or 0,
"runs": a["total_runs"] or 0,
"rate": a["success_rate"] or 0,
"active": a["active_skills"] or 0,
"skills": a["runnable_skills"] or 0,
})
# ── 7. 告警摘要 ──
alert_summary = []
for a in agents_summary:
if a["status"] == "offline":
alert_summary.append({
"level": "error",
"agent": a["name"],
"message": f"Agent 从未上报数据",
})
elif a["status"] == "idle":
alert_summary.append({
"level": "warning",
"agent": a["name"],
"message": f"已超过 7 天未上报(最后: {a['last_report_at']})",
})
elif a["health_score"] is not None and a["health_score"] < 50:
alert_summary.append({
"level": "warning",
"agent": a["name"],
"message": f"健康度仅 {a['health_score']},低于 50 分警戒线",
})
# 低评分 Skill 告警
for sk in skill_global_rankings:
if sk["avg_score"] < 60:
alert_summary.append({
"level": "info",
"agent": "全局",
"message": f"Skill [{sk['skill_id']}] 平均评分仅 {sk['avg_score']},需关注",
})
return {
"generated_at": datetime.now().isoformat(),
"period_days": days,
"total_agents": total_agents,
"fleet_overview": fleet_overview,
"agents": agents_summary,
"trend": trend_data,
"skill_global_rankings": skill_global_rankings[:50], # 支持更多 Skill
"score_distribution": score_distribution,
"agent_comparison": agent_comparison,
"os_distribution": dict(os_counter),
"version_distribution": dict(version_counter),
"alert_summary": alert_summary,
}
# ──────── 页面路由 ────────
@overview_bp.route("/overview")
def overview_page():
"""多 Agent 汇总总览页面"""
days = min(int(request.args.get("days", "30")), 90)
data = _collect_overview_data(days)
return render_template("overview.html", data=data)
# ──────── 数据 API ────────
@overview_bp.route("/api/overview/data")
def overview_data():
"""总览 JSON 数据接口"""
days = min(int(request.args.get("days", "30")), 90)
data = _collect_overview_data(days)
return jsonify({"ok": True, "data": data})
FILE:server/api/miniprogram_api.py
"""
微信小程序后端 API
==================
- POST /api/mp/login 小程序登录 (code2session)
- GET /api/mp/agents 获取绑定的 Agent 列表
- GET /api/mp/dashboard 仪表盘数据
- GET /api/mp/reports 报告列表
- GET /api/mp/report/<id> 报告详情
- GET /api/mp/skills/<agent> Skill 列表
- POST /api/mp/bind 绑定 Agent
- POST /api/mp/unbind 解绑 Agent
"""
import json
import hashlib
import secrets
from datetime import datetime, date, timedelta
from flask import Blueprint, request, jsonify, session
from server.models.database import db, User, Agent, UserAgent, SkillReport
from server.services.wechat_service import wechat_service
from server.services.report_service import bind_user_agent, get_user_agents
mp_bp = Blueprint("miniprogram_api", __name__, url_prefix="/api/mp")
def _get_current_user():
"""从请求中获取当前用户(通过 session + token 双重验证)"""
# 从 session 获取用户 ID
user_id = session.get("mp_user_id")
if not user_id:
return None
# 验证请求中携带的 token 与 session 中一致(防止 session 劫持)
req_token = request.headers.get("X-MP-Token", "")
session_token = session.get("mp_token", "")
if session_token and req_token != session_token:
return None
return User.query.get(user_id)
def _require_user(f):
"""装饰器:要求登录"""
from functools import wraps
@wraps(f)
def wrapper(*args, **kwargs):
user = _get_current_user()
if not user:
return jsonify({"error": "请先登录", "code": 401}), 401
return f(user, *args, **kwargs)
return wrapper
# ──────── 登录 ────────
@mp_bp.route("/login", methods=["POST"])
def mp_login():
"""
小程序登录
Body: {code: "wx_login_code"}
"""
data = request.get_json(force=True)
code = data.get("code", "")
if not code:
return jsonify({"error": "缺少 code"}), 400
# 调用微信 code2session
result = wechat_service.mp_code2session(code)
openid = result.get("openid")
session_key = result.get("session_key")
if not openid:
return jsonify({"error": "登录失败", "detail": result}), 400
# 查找或创建用户
user = User.query.filter_by(mp_openid=openid).first()
if not user:
# 检查是否有同 unionid 的公众号用户
union_id = result.get("unionid")
if union_id:
user = User.query.filter_by(union_id=union_id).first()
if user:
user.mp_openid = openid
if not user:
user = User(
openid=f"mp_{openid}", # 区分公众号 openid
mp_openid=openid,
union_id=result.get("unionid"),
subscribe=False,
)
db.session.add(user)
db.session.commit()
# 设置 session
session["mp_user_id"] = user.id
session["mp_session_key"] = session_key
# 生成自定义 token(用于后续 API 调用)
custom_token = secrets.token_hex(32)
session["mp_token"] = custom_token
return jsonify({
"ok": True,
"user": user.to_dict(),
"token": custom_token,
})
# ──────── Agent 列表 ────────
@mp_bp.route("/agents", methods=["GET"])
@_require_user
def mp_agents(user):
"""获取用户绑定的 Agent 列表"""
agents = get_user_agents(user)
return jsonify({"ok": True, "agents": agents})
# ──────── 仪表盘 ────────
@mp_bp.route("/dashboard", methods=["GET"])
@_require_user
def mp_dashboard(user):
"""仪表盘数据 — 所有 Agent 汇总"""
bindings = UserAgent.query.filter_by(user_id=user.id).all()
if not bindings:
return jsonify({
"ok": True,
"has_agents": False,
"message": "未绑定任何智能体",
})
agents_summary = []
for ua in bindings:
agent = ua.agent
if not agent:
continue
latest = SkillReport.query.filter_by(
agent_db_id=agent.id, report_type="daily"
).order_by(SkillReport.report_date.desc()).first()
agents_summary.append({
"agent_id": agent.agent_id,
"name": ua.alias or agent.name or f"Agent-{agent.agent_id[:8]}",
"is_primary": ua.is_primary,
"health_score": agent.health_score,
"total_skills": agent.total_skills,
"runnable_skills": agent.runnable_skills,
"last_report": latest.to_dict() if latest else None,
})
return jsonify({
"ok": True,
"has_agents": True,
"agents": agents_summary,
})
# ──────── 报告列表 ────────
@mp_bp.route("/reports", methods=["GET"])
@_require_user
def mp_reports(user):
"""获取报告列表"""
agent_id_str = request.args.get("agent_id", "")
limit = min(int(request.args.get("limit", "20")), 50)
# 获取用户有权查看的 Agent
bindings = UserAgent.query.filter_by(user_id=user.id).all()
allowed_agent_ids = {ua.agent.agent_id for ua in bindings if ua.agent}
if agent_id_str and agent_id_str not in allowed_agent_ids:
return jsonify({"error": "无权查看该 Agent"}), 403
query = SkillReport.query.filter(
SkillReport.agent_id_str.in_(allowed_agent_ids if not agent_id_str else [agent_id_str])
)
reports = query.order_by(
SkillReport.report_date.desc()
).limit(limit).all()
return jsonify({
"ok": True,
"reports": [r.to_dict() for r in reports],
})
# ──────── 报告详情 ────────
@mp_bp.route("/report/<int:report_id>", methods=["GET"])
@_require_user
def mp_report_detail(user, report_id):
"""报告详情"""
report = SkillReport.query.get(report_id)
if not report:
return jsonify({"error": "报告不存在"}), 404
# 验证权限
bindings = UserAgent.query.filter_by(user_id=user.id).all()
allowed_agent_ids = {ua.agent.agent_id for ua in bindings if ua.agent}
if report.agent_id_str not in allowed_agent_ids:
return jsonify({"error": "无权查看该报告"}), 403
return jsonify({
"ok": True,
"report": report.to_dict(include_data=True),
})
# ──────── Skill 列表 ────────
@mp_bp.route("/skills/<agent_id_str>", methods=["GET"])
@_require_user
def mp_skills(user, agent_id_str):
"""获取某 Agent 的 Skill 列表"""
# 验证权限
bindings = UserAgent.query.filter_by(user_id=user.id).all()
allowed_agent_ids = {ua.agent.agent_id for ua in bindings if ua.agent}
if agent_id_str not in allowed_agent_ids:
return jsonify({"error": "无权查看该 Agent"}), 403
# 从最新报告中提取 Skill 列表
agent = Agent.query.filter_by(agent_id=agent_id_str).first()
if not agent:
return jsonify({"error": "Agent 不存在"}), 404
latest = SkillReport.query.filter_by(
agent_db_id=agent.id, report_type="daily"
).order_by(SkillReport.report_date.desc()).first()
skills = []
if latest:
data = latest.get_data()
scores = data.get("scores", [])
skills = scores
return jsonify({
"ok": True,
"agent_id": agent_id_str,
"skills": skills,
})
# ──────── 绑定/解绑 ────────
@mp_bp.route("/bind", methods=["POST"])
@_require_user
def mp_bind(user):
"""
绑定 Agent
Body: {agent_id, token, alias?}
"""
data = request.get_json(force=True)
agent_id = data.get("agent_id", "")
token = data.get("token", "")
alias = data.get("alias", "")
if not agent_id or not token:
return jsonify({"error": "智能体 ID 和 Key 必填"}), 400
ok, msg = bind_user_agent(user, agent_id, token, alias)
if ok:
return jsonify({"ok": True, "message": msg})
return jsonify({"error": msg}), 400
@mp_bp.route("/unbind", methods=["POST"])
@_require_user
def mp_unbind(user):
"""
解绑 Agent
Body: {agent_id}
"""
data = request.get_json(force=True)
agent_id_str = data.get("agent_id", "")
agent = Agent.query.filter_by(agent_id=agent_id_str).first()
if not agent:
return jsonify({"error": "Agent 不存在"}), 404
binding = UserAgent.query.filter_by(user_id=user.id, agent_id=agent.id).first()
if not binding:
return jsonify({"error": "未绑定该 Agent"}), 400
db.session.delete(binding)
db.session.commit()
return jsonify({"ok": True, "message": "已解绑"})
# ──────── v0.5.0: 公众号关注状态 + 推送设置 + 操作追踪 ────────
@mp_bp.route("/oa-follow-status", methods=["GET"])
@_require_user
def mp_oa_follow_status(user):
"""
检查用户是否已关注公众号(通过 unionid 关联)
GET /api/mp/oa-follow-status
"""
has_followed = False
recent_articles = []
# 通过 unionid 检查是否有公众号关注记录
if user.union_id:
oa_user = User.query.filter(
User.union_id == user.union_id,
User.subscribe == True,
User.openid != user.openid, # 排除自己(小程序 openid)
).first()
has_followed = oa_user is not None
elif user.subscribe:
has_followed = True
return jsonify({
"ok": True,
"has_followed": has_followed,
"recent_articles": recent_articles,
})
@mp_bp.route("/push-settings", methods=["POST"])
@_require_user
def mp_push_settings(user):
"""
更新推送设置
Body: {push_enabled: bool, push_hour?: int}
"""
data = request.get_json(force=True)
if "push_enabled" in data:
user.push_enabled = bool(data["push_enabled"])
if "push_hour" in data:
hour = int(data["push_hour"])
if 0 <= hour <= 23:
user.push_hour = hour
db.session.commit()
return jsonify({
"ok": True,
"push_enabled": user.push_enabled,
"push_hour": user.push_hour,
})
@mp_bp.route("/track", methods=["POST"])
@_require_user
def mp_track_event(user):
"""
上报操作追踪事件(小程序端调用)
Body: {agent_id, event, track_id?, ...}
"""
data = request.get_json(force=True)
agent_id = data.get("agent_id", "")
event = data.get("event", "")
if not agent_id or not event:
return jsonify({"error": "agent_id 和 event 必填"}), 400
try:
from server.services.operation_tracker import OperationTracker, TrackEvent
# 校验事件类型
valid_events = {e.value for e in TrackEvent}
if event not in valid_events:
return jsonify({"error": f"无效事件类型: {event}"}), 400
OperationTracker.track(
user_id=user.id,
agent_id=agent_id,
event=TrackEvent(event),
data={k: v for k, v in data.items() if k not in ("agent_id", "event")},
)
return jsonify({"ok": True})
except Exception as e:
return jsonify({"error": str(e)}), 500
@mp_bp.route("/push-analytics", methods=["GET"])
@_require_user
def mp_push_analytics(user):
"""
获取推送效果分析
GET /api/mp/push-analytics?days=7
"""
days = min(int(request.args.get("days", "7")), 30)
try:
from server.services.operation_tracker import OperationTracker
analytics = OperationTracker.get_push_analytics(days=days)
return jsonify({"ok": True, **analytics})
except Exception as e:
return jsonify({"error": str(e)}), 500
FILE:server/api/h5_api.py
"""
H5 报告页 API — 供微信公众号 H5 页面使用
=========================================
- GET /h5/dashboard 仪表盘(重定向到主 Agent 报告)
- GET /h5/report/<agent_id> 某 Agent 最新报告页
- GET /h5/report/<agent_id>/<date> 指定日期报告
- GET /h5/agents Agent 列表页
- GET /h5/settings 推送设置页
- GET /api/h5/trend/<agent_id> 趋势数据 JSON
"""
from datetime import date, timedelta
from flask import Blueprint, request, render_template, jsonify
from server.models.database import db, User, Agent, UserAgent, SkillReport
h5_bp = Blueprint("h5_api", __name__)
def _get_user_by_openid(openid: str):
"""通过 openid 获取用户"""
if not openid:
return None
return User.query.filter_by(openid=openid).first()
def _build_h5_context(agent: Agent, report_date: str = None) -> dict:
daily_query = SkillReport.query.filter_by(agent_db_id=agent.id, report_type="daily")
if report_date:
try:
target_day = date.fromisoformat(report_date)
daily_query = daily_query.filter_by(report_date=target_day)
except ValueError:
pass
selected_daily = daily_query.order_by(SkillReport.report_date.desc(), SkillReport.created_at.desc()).first()
latest_daily = SkillReport.query.filter_by(
agent_db_id=agent.id, report_type="daily"
).order_by(SkillReport.report_date.desc(), SkillReport.created_at.desc()).first()
latest_diag = SkillReport.query.filter_by(
agent_db_id=agent.id, report_type="diagnostic"
).order_by(SkillReport.report_date.desc(), SkillReport.created_at.desc()).first()
first_diag = SkillReport.query.filter_by(
agent_db_id=agent.id, report_type="diagnostic"
).order_by(SkillReport.report_date.asc(), SkillReport.created_at.asc()).first()
report = selected_daily or latest_diag
report_data = report.get_data() if report else {}
latest_daily_data = latest_daily.get_data() if latest_daily else {}
latest_diag_data = latest_diag.get_data() if latest_diag else {}
first_diag_data = first_diag.get_data() if first_diag else {}
preferred_payload = report_data or latest_daily_data or latest_diag_data
recommendations = preferred_payload.get("recommendations") or latest_diag_data.get("recommendations", [])
installed_skills = preferred_payload.get("installed_skills") or latest_diag_data.get("installed_skills", [])
diagnostic_payload = report_data.get("diagnostics") or latest_diag_data.get("diagnostics") or {}
daily_reports = SkillReport.query.filter_by(agent_db_id=agent.id, report_type="daily").all()
diag_reports = SkillReport.query.filter_by(agent_db_id=agent.id, report_type="diagnostic").all()
end_date = date.today()
recent_daily_count = sum(1 for r in daily_reports if r.report_date and r.report_date >= end_date - timedelta(days=6))
sync_status = {
"daily_count": len(daily_reports),
"diagnostic_count": len(diag_reports),
"recent_daily_count": recent_daily_count,
"has_initial_diagnostic": bool(first_diag),
"latest_daily_date": latest_daily.report_date.isoformat() if latest_daily and latest_daily.report_date else "",
"latest_diag_date": latest_diag.report_date.isoformat() if latest_diag and latest_diag.report_date else "",
"first_diagnostic_date": first_diag.report_date.isoformat() if first_diag and first_diag.report_date else "",
"status": "healthy" if first_diag and recent_daily_count >= 5 else ("warning" if daily_reports or diag_reports else "empty"),
"status_label": "同步正常" if first_diag and recent_daily_count >= 5 else ("待补齐" if daily_reports or diag_reports else "未开始"),
}
return {
"report": report,
"report_data": report_data,
"latest_daily": latest_daily,
"latest_daily_data": latest_daily_data,
"latest_diag": latest_diag,
"latest_diag_data": latest_diag_data,
"first_diag": first_diag,
"first_diag_data": first_diag_data,
"recommendations": recommendations,
"installed_skills": installed_skills,
"diagnostic_payload": diagnostic_payload,
"sync_status": sync_status,
"trend": _get_trend_data(agent.id, 7),
}
@h5_bp.route("/h5/dashboard")
def h5_dashboard():
"""仪表盘入口 — 展示用户主 Agent 的最新报告"""
openid = request.args.get("openid", "")
user = _get_user_by_openid(openid)
if not user:
return render_template("h5_bind_guide.html")
primary = UserAgent.query.filter_by(user_id=user.id, is_primary=True).first()
if not primary:
primary = UserAgent.query.filter_by(user_id=user.id).first()
if not primary or not primary.agent:
return render_template("h5_bind_guide.html")
agent = primary.agent
ctx = _build_h5_context(agent)
return render_template("h5_report.html", agent=agent, user=user, **ctx)
@h5_bp.route("/h5/report/<agent_id_str>")
@h5_bp.route("/h5/report/<agent_id_str>/<report_date>")
def h5_report(agent_id_str, report_date=None):
"""指定 Agent / 日期的报告页(需要验证用户对该 Agent 的绑定关系)"""
openid = request.args.get("openid", "")
user = _get_user_by_openid(openid)
if not user:
return render_template("h5_error.html", message="请先登录"), 403
agent = Agent.query.filter_by(agent_id=agent_id_str).first()
if not agent:
return render_template("h5_error.html", message="智能体不存在"), 404
binding = UserAgent.query.filter_by(user_id=user.id, agent_id=agent.id).first()
if not binding:
return render_template("h5_error.html", message="您未绑定该智能体,无权查看"), 403
ctx = _build_h5_context(agent, report_date)
return render_template("h5_report.html", agent=agent, user=user, **ctx)
@h5_bp.route("/h5/agents")
def h5_agents():
"""Agent 列表页"""
openid = request.args.get("openid", "")
user = _get_user_by_openid(openid)
if not user:
return render_template("h5_bind_guide.html")
bindings = UserAgent.query.filter_by(user_id=user.id).all()
agents_data = []
for ua in bindings:
if ua.agent:
agents_data.append({
"agent": ua.agent,
"alias": ua.alias,
"is_primary": ua.is_primary,
})
return render_template("h5_agents.html", agents=agents_data, user=user)
@h5_bp.route("/h5/settings")
def h5_settings():
"""推送设置页"""
openid = request.args.get("openid", "")
user = _get_user_by_openid(openid)
if not user:
return render_template("h5_bind_guide.html")
return render_template("h5_settings.html", user=user)
@h5_bp.route("/api/h5/trend/<agent_id_str>")
def api_trend(agent_id_str):
"""获取 Agent 趋势数据(需验证用户权限)"""
openid = request.args.get("openid", "")
user = _get_user_by_openid(openid)
if not user:
return jsonify({"error": "请先登录"}), 403
agent = Agent.query.filter_by(agent_id=agent_id_str).first()
if not agent:
return jsonify({"error": "智能体不存在"}), 404
binding = UserAgent.query.filter_by(user_id=user.id, agent_id=agent.id).first()
if not binding:
return jsonify({"error": "无权查看该智能体数据"}), 403
days = min(int(request.args.get("days", "30")), 90)
trend = _get_trend_data(agent.id, days)
return jsonify({"ok": True, "trend": trend})
@h5_bp.route("/api/h5/settings", methods=["POST"])
def api_update_settings():
"""更新推送设置"""
data = request.get_json(force=True)
openid = data.get("openid", "")
user = _get_user_by_openid(openid)
if not user:
return jsonify({"error": "用户不存在"}), 404
if "push_enabled" in data:
user.push_enabled = bool(data["push_enabled"])
if "push_hour" in data:
user.push_hour = max(0, min(23, int(data["push_hour"])))
db.session.commit()
return jsonify({"ok": True, "user": user.to_dict()})
def _get_trend_data(agent_db_id: int, days: int = 7) -> list:
"""获取趋势数据"""
end_date = date.today()
start_date = end_date - timedelta(days=days - 1)
reports = SkillReport.query.filter(
SkillReport.agent_db_id == agent_db_id,
SkillReport.report_type == "daily",
SkillReport.report_date >= start_date,
SkillReport.report_date <= end_date,
).order_by(SkillReport.report_date.asc()).all()
return [
{
"date": r.report_date.isoformat(),
"health_score": r.health_score,
"total_runs": r.total_runs,
"success_rate": r.success_rate,
"active_skills": r.active_skills,
}
for r in reports
]
FILE:server/api/pwa_api.py
"""
PWA API — 供 Skills Monitor PWA 单页应用使用
=============================================
登录方式: Agent ID + API Key → 签发 Token
所有接口均通过 X-PWA-Token 认证
路由前缀: /api/pwa
"""
import hashlib
import secrets
from datetime import date, timedelta
from functools import wraps
from flask import Blueprint, request, jsonify, render_template
from server.models.database import db, User, Agent, UserAgent, SkillReport
from server.services.report_service import verify_agent_token
pwa_bp = Blueprint("pwa_api", __name__)
# ──────── Token 管理 ────────
# 简单的内存 token store(重启后失效,用户需重新登录)
_pwa_tokens: dict[str, dict] = {}
def _hash_token(raw: str) -> str:
return hashlib.sha256(raw.encode()).hexdigest()
def _require_pwa_auth(f):
"""PWA Token 认证装饰器"""
@wraps(f)
def decorated(*args, **kwargs):
token = request.headers.get("X-PWA-Token", "")
if not token or token not in _pwa_tokens:
return jsonify({"ok": False, "error": "未登录,请先登录"}), 401
request._pwa_user_id = _pwa_tokens[token]["user_id"]
return f(*args, **kwargs)
return decorated
def _get_pwa_user():
"""获取当前 PWA 登录用户"""
user_id = getattr(request, "_pwa_user_id", None)
if not user_id:
return None
return User.query.get(user_id)
def _get_user_agents(user):
"""获取用户绑定的所有 Agent"""
bindings = UserAgent.query.filter_by(user_id=user.id).all()
return [(ua, ua.agent) for ua in bindings if ua.agent]
# ──────── PWA 页面 ────────
@pwa_bp.route("/pwa/")
def pwa_index():
"""PWA 入口页面"""
return render_template("pwa.html")
# ──────── 登录 ────────
@pwa_bp.route("/api/pwa/login", methods=["POST"])
def pwa_login():
"""用 Agent ID + API Key 登录"""
data = request.get_json(force=True)
agent_id = data.get("agent_id", "").strip()
agent_token = data.get("agent_token", "").strip()
if not agent_id or not agent_token:
return jsonify({"ok": False, "error": "请输入智能体 ID 和 Key"})
agent = verify_agent_token(agent_id, agent_token)
if not agent:
# 区分是 agent 不存在还是 key 不对
check = Agent.query.filter_by(agent_id=agent_id).first()
if not check:
return jsonify({"ok": False, "error": "智能体不存在,请检查智能体 ID"})
return jsonify({"ok": False, "error": "Key 不正确"})
# 查找或创建 PWA 用户(使用 agent_id 作为 openid)
pwa_openid = f"pwa_{agent_id}"
user = User.query.filter_by(openid=pwa_openid).first()
if not user:
user = User(openid=pwa_openid, nickname="PWA 用户")
db.session.add(user)
db.session.flush()
# 确保绑定关系
binding = UserAgent.query.filter_by(user_id=user.id, agent_id=agent.id).first()
if not binding:
binding = UserAgent(user_id=user.id, agent_id=agent.id, is_primary=True)
db.session.add(binding)
db.session.commit()
# 签发 token
raw_token = secrets.token_hex(32)
_pwa_tokens[raw_token] = {"user_id": user.id, "agent_id": agent.id}
agent_count = UserAgent.query.filter_by(user_id=user.id).count()
return jsonify({
"ok": True,
"token": raw_token,
"user": {
"id": user.id,
"nickname": user.nickname or "PWA 用户",
"agent_count": agent_count,
}
})
# ──────── 用户信息 ────────
@pwa_bp.route("/api/pwa/user")
@_require_pwa_auth
def pwa_user():
user = _get_pwa_user()
if not user:
return jsonify({"ok": False, "error": "用户不存在"}), 404
agent_count = UserAgent.query.filter_by(user_id=user.id).count()
return jsonify({
"ok": True,
"user": {
"id": user.id,
"nickname": user.nickname or "PWA 用户",
"agent_count": agent_count,
}
})
# ──────── 仪表盘(增强版:含最新报告的推荐/诊断数据) ────────
@pwa_bp.route("/api/pwa/dashboard")
@_require_pwa_auth
def pwa_dashboard():
user = _get_pwa_user()
if not user:
return jsonify({"ok": False, "error": "用户不存在"}), 404
bindings = _get_user_agents(user)
if not bindings:
return jsonify({"ok": True, "dashboard": {
"total_agents": 0, "total_skills": 0, "total_runs": 0,
"avg_health": 0, "avg_success_rate": 0, "trend": [], "agents": [],
"latest_report": None,
}})
agents_data = []
total_health = 0
total_success = 0
total_runs = 0
total_skills = 0
for ua, agent in bindings:
latest = SkillReport.query.filter_by(agent_db_id=agent.id).order_by(
SkillReport.report_date.desc(), SkillReport.created_at.desc()
).first()
health = latest.health_score if latest else 0
total_health += health
total_success += (latest.success_rate if latest else 0)
total_runs += (latest.total_runs if latest else 0)
total_skills += (latest.active_skills if latest else 0)
# 统计该 Agent 的已安装技能总数(从最新报告中取)
skill_count = 0
if latest:
rd = latest.get_data()
ov = rd.get("overview", {})
skill_count = ov.get("total_installed", 0)
agents_data.append({
"agent_id": agent.agent_id,
"name": agent.name or f"Agent-{agent.agent_id[:8]}",
"health_score": health,
"is_primary": ua.is_primary,
"total_skills": skill_count,
})
n = len(bindings)
avg_health = total_health / n if n else 0
avg_success = total_success / n if n else 0
# 趋势数据(取主 Agent 的)
primary_agent = next((a for ua, a in bindings if ua.is_primary), bindings[0][1] if bindings else None)
trend = _get_trend(primary_agent.id, 7) if primary_agent else []
# ── 增强部分:获取最新报告的完整数据 ──
latest_report_summary = None
if primary_agent:
# 优先取最新诊断报告(内容最丰富),fallback 到最新日报
latest_diag = SkillReport.query.filter_by(
agent_db_id=primary_agent.id, report_type="diagnostic"
).order_by(SkillReport.report_date.desc(), SkillReport.created_at.desc()).first()
latest_daily = SkillReport.query.filter_by(
agent_db_id=primary_agent.id, report_type="daily"
).order_by(SkillReport.report_date.desc(), SkillReport.created_at.desc()).first()
best_report = latest_diag or latest_daily
if best_report:
rd = best_report.get_data()
# 推荐安装
recs = rd.get("recommendations", [])
# 诊断数据
diag = rd.get("diagnostics", {})
# 已安装技能
installed = rd.get("installed_skills", [])
# 概览
overview = rd.get("overview", {})
latest_report_summary = {
"id": best_report.id,
"report_type": best_report.report_type,
"report_date": best_report.report_date.isoformat() if best_report.report_date else "",
"health_score": best_report.health_score,
"overview": overview,
"recommendations": recs[:6],
"diagnostics": {
"issues": diag.get("issues", []),
"suggestions": diag.get("suggestions", []),
"coverage": diag.get("coverage", []),
"unused_runnable": diag.get("unused_runnable", []),
},
"installed_skills": installed[:15],
"installed_skills_total": len(installed),
}
return jsonify({
"ok": True,
"dashboard": {
"total_agents": n,
"total_skills": total_skills or (agents_data[0].get("total_skills", 0) if agents_data else 0),
"total_runs": total_runs,
"avg_health": round(avg_health, 1),
"avg_success_rate": round(avg_success, 1),
"trend": trend,
"agents": agents_data,
"latest_report": latest_report_summary,
}
})
# ──────── 报告列表 ────────
@pwa_bp.route("/api/pwa/reports")
@_require_pwa_auth
def pwa_reports():
user = _get_pwa_user()
if not user:
return jsonify({"ok": False, "error": "用户不存在"}), 404
limit = min(int(request.args.get("limit", "30")), 100)
report_type = request.args.get("type", "all")
bindings = _get_user_agents(user)
agent_ids = [a.id for _, a in bindings]
agent_map = {a.id: a for _, a in bindings}
query = SkillReport.query.filter(SkillReport.agent_db_id.in_(agent_ids))
if report_type != "all":
query = query.filter_by(report_type=report_type)
reports = query.order_by(
SkillReport.report_date.desc(), SkillReport.created_at.desc()
).limit(limit).all()
result = []
for r in reports:
agent = agent_map.get(r.agent_db_id)
result.append({
"id": r.id,
"report_type": r.report_type,
"report_date": r.report_date.isoformat() if r.report_date else "",
"health_score": r.health_score,
"total_runs": r.total_runs,
"success_rate": r.success_rate,
"active_skills": r.active_skills,
"agent_id": agent.agent_id if agent else "",
"agent_name": agent.name if agent else "",
})
return jsonify({"ok": True, "reports": result})
# ──────── 报告详情 ────────
@pwa_bp.route("/api/pwa/report/<int:report_id>")
@_require_pwa_auth
def pwa_report_detail(report_id):
user = _get_pwa_user()
if not user:
return jsonify({"ok": False, "error": "用户不存在"}), 404
report = SkillReport.query.get(report_id)
if not report:
return jsonify({"ok": False, "error": "报告不存在"}), 404
# 权限校验
bindings = _get_user_agents(user)
agent_ids = [a.id for _, a in bindings]
if report.agent_db_id not in agent_ids:
return jsonify({"ok": False, "error": "无权查看该报告"}), 403
agent = Agent.query.get(report.agent_db_id)
rd = report.get_data()
return jsonify({
"ok": True,
"report": {
"id": report.id,
"report_type": report.report_type,
"report_date": report.report_date.isoformat() if report.report_date else "",
"health_score": report.health_score,
"total_runs": report.total_runs,
"success_rate": report.success_rate,
"active_skills": report.active_skills,
"data_size_bytes": report.data_size_bytes,
"created_at": report.created_at.isoformat() if report.created_at else "",
"agent_id": agent.agent_id if agent else "",
"agent_name": agent.name if agent else "",
"trigger": rd.get("trigger", ""),
"overview": rd.get("overview", {}),
"scores": rd.get("scores", []),
"recommendations": rd.get("recommendations", []),
"diagnostics": rd.get("diagnostics", {}),
"report_markdown": rd.get("report_markdown", ""),
}
})
# ──────── Agent Skills ────────
@pwa_bp.route("/api/pwa/skills/<agent_id_str>")
@_require_pwa_auth
def pwa_agent_skills(agent_id_str):
user = _get_pwa_user()
if not user:
return jsonify({"ok": False, "error": "用户不存在"}), 404
agent = Agent.query.filter_by(agent_id=agent_id_str).first()
if not agent:
return jsonify({"ok": False, "error": "智能体不存在"}), 404
# 权限校验
binding = UserAgent.query.filter_by(user_id=user.id, agent_id=agent.id).first()
if not binding:
return jsonify({"ok": False, "error": "无权查看"}), 403
# 从最新报告中取 scores
latest = SkillReport.query.filter_by(agent_db_id=agent.id).order_by(
SkillReport.report_date.desc(), SkillReport.created_at.desc()
).first()
skills = []
if latest:
rd = latest.get_data()
skills = rd.get("scores", [])
return jsonify({
"ok": True,
"agent_name": agent.name or f"Agent-{agent.agent_id[:8]}",
"skills": skills,
})
# ──────── Agent 列表 ────────
@pwa_bp.route("/api/pwa/agents")
@_require_pwa_auth
def pwa_agents():
user = _get_pwa_user()
if not user:
return jsonify({"ok": False, "error": "用户不存在"}), 404
bindings = _get_user_agents(user)
agents = []
for ua, agent in bindings:
latest = SkillReport.query.filter_by(agent_db_id=agent.id).order_by(
SkillReport.report_date.desc(), SkillReport.created_at.desc()
).first()
skill_count = 0
if latest:
rd = latest.get_data()
skill_count = rd.get("overview", {}).get("total_installed", 0)
agents.append({
"agent_id": agent.agent_id,
"name": agent.name or f"Agent-{agent.agent_id[:8]}",
"health_score": latest.health_score if latest else 0,
"total_skills": skill_count,
"is_primary": ua.is_primary,
})
return jsonify({"ok": True, "agents": agents})
# ──────── 绑定新 Agent ────────
@pwa_bp.route("/api/pwa/bind", methods=["POST"])
@_require_pwa_auth
def pwa_bind():
user = _get_pwa_user()
if not user:
return jsonify({"ok": False, "error": "用户不存在"}), 404
data = request.get_json(force=True)
agent_id = data.get("agent_id", "").strip()
agent_token = data.get("agent_token", "").strip()
if not agent_id or not agent_token:
return jsonify({"ok": False, "error": "请输入智能体 ID 和 Key"})
agent = verify_agent_token(agent_id, agent_token)
if not agent:
check = Agent.query.filter_by(agent_id=agent_id).first()
if not check:
return jsonify({"ok": False, "error": "智能体不存在"})
return jsonify({"ok": False, "error": "Key 不正确"})
existing = UserAgent.query.filter_by(user_id=user.id, agent_id=agent.id).first()
if existing:
return jsonify({"ok": False, "error": "已经绑定过该智能体"})
binding = UserAgent(user_id=user.id, agent_id=agent.id, is_primary=False)
db.session.add(binding)
db.session.commit()
return jsonify({"ok": True})
# ──────── 工具函数 ────────
def _get_trend(agent_db_id: int, days: int = 7) -> list:
end_date = date.today()
start_date = end_date - timedelta(days=days - 1)
reports = SkillReport.query.filter(
SkillReport.agent_db_id == agent_db_id,
SkillReport.report_type == "daily",
SkillReport.report_date >= start_date,
SkillReport.report_date <= end_date,
).order_by(SkillReport.report_date.asc()).all()
return [
{
"date": r.report_date.isoformat(),
"health_score": r.health_score,
"total_runs": r.total_runs,
"success_rate": r.success_rate,
"active_skills": r.active_skills,
}
for r in reports
]
FILE:server/api/__init__.py
"""
API 蓝图注册
"""
from .agent_api import agent_bp
from .wechat_api import wechat_bp
from .h5_api import h5_bp
from .miniprogram_api import mp_bp
from .dashboard_api import dashboard_bp
from .overview_api import overview_bp
from .benchmark_api import benchmark_bp
from .pwa_api import pwa_bp
def register_blueprints(app):
"""注册所有 API 蓝图"""
app.register_blueprint(agent_bp)
app.register_blueprint(wechat_bp)
app.register_blueprint(h5_bp)
app.register_blueprint(mp_bp)
app.register_blueprint(dashboard_bp)
app.register_blueprint(overview_bp)
app.register_blueprint(benchmark_bp)
app.register_blueprint(pwa_bp)
FILE:server/api/agent_api.py
"""
Agent API v0.5.0 — 供本地 Agent 调用的接口
===========================================
- POST /api/agent/register 注册/更新 Agent
- POST /api/agent/heartbeat 心跳
- POST /api/agent/report 上报数据
- GET /api/agent/reports 查询历史报告
- GET /api/agent/report/<id> 报告详情
- POST /api/agent/rotate-key 轮换 API Key (v0.5.0)
- POST /api/agent/revoke-key 撤销 API Key (v0.5.0)
- GET /api/agent/export-data 导出全部数据 (v0.5.0 GDPR)
- POST /api/agent/delete-data 删除全部数据 (v0.5.0 GDPR)
"""
import hashlib
from datetime import datetime
from flask import Blueprint, request, jsonify
from server.models.database import db, Agent
from server.services.report_service import (
register_agent,
verify_agent_token,
process_report_upload,
get_agent_reports,
get_report_detail,
)
agent_bp = Blueprint("agent_api", __name__, url_prefix="/api/agent")
def _auth_agent():
"""从请求头中提取并验证 Agent 身份"""
agent_id = request.headers.get("X-Agent-ID", "")
token = request.headers.get("X-Agent-Token", "")
if not agent_id or not token:
return None, jsonify({"error": "缺少 X-Agent-ID 或 X-Agent-Token"}), 401
agent = verify_agent_token(agent_id, token)
if not agent:
return None, jsonify({"error": "Agent 认证失败"}), 401
return agent, None, None
# ──────── 注册 / 更新 ────────
@agent_bp.route("/register", methods=["POST"])
def api_register():
"""
POST /api/agent/register
Body: {agent_id, token, name?, os_info?, python_version?, monitor_version?, total_skills?, runnable_skills?}
"""
data = request.get_json(force=True)
agent_id = data.get("agent_id")
token = data.get("token")
if not agent_id or not token:
return jsonify({"error": "agent_id 和 token 必填"}), 400
agent = register_agent(
agent_id=agent_id,
token=token,
name=data.get("name"),
os_info=data.get("os_info"),
python_version=data.get("python_version"),
monitor_version=data.get("monitor_version"),
total_skills=data.get("total_skills"),
runnable_skills=data.get("runnable_skills"),
)
return jsonify({
"ok": True,
"agent": agent.to_dict(),
"message": "注册成功",
})
# ──────── 心跳 ────────
@agent_bp.route("/heartbeat", methods=["POST"])
def api_heartbeat():
"""
POST /api/agent/heartbeat
Headers: X-Agent-ID, X-Agent-Token
"""
agent, err, code = _auth_agent()
if err:
return err, code
agent.last_heartbeat_at = datetime.utcnow()
db.session.commit()
return jsonify({"ok": True, "message": "心跳已记录"})
# ──────── 数据上报 ────────
@agent_bp.route("/report", methods=["POST"])
def api_upload_report():
"""
POST /api/agent/report
Headers: X-Agent-ID, X-Agent-Token
Body: {report_type?, report_date?, data: {...}}
"""
agent, err, code = _auth_agent()
if err:
return err, code
body = request.get_json(force=True)
report_data = body.get("data", {})
report_type = body.get("report_type", "daily")
report_date_str = body.get("report_date")
report_date = None
if report_date_str:
try:
from datetime import date as date_cls
report_date = date_cls.fromisoformat(report_date_str)
except ValueError:
pass
report = process_report_upload(
agent=agent,
report_data=report_data,
report_type=report_type,
report_date=report_date,
)
return jsonify({
"ok": True,
"report": report.to_dict(include_data=False),
"message": "上报成功",
})
# ──────── 查询历史报告 ────────
@agent_bp.route("/reports", methods=["GET"])
def api_get_reports():
"""
GET /api/agent/reports?agent_id=xxx&type=daily&limit=30
Headers: X-Agent-ID, X-Agent-Token
"""
agent, err, code = _auth_agent()
if err:
return err, code
report_type = request.args.get("type")
limit = min(int(request.args.get("limit", "30")), 100)
reports = get_agent_reports(
agent_id=agent.agent_id,
report_type=report_type,
limit=limit,
)
return jsonify({"ok": True, "reports": reports, "count": len(reports)})
# ──────── 报告详情 ────────
@agent_bp.route("/report/<int:report_id>", methods=["GET"])
def api_get_report_detail(report_id):
"""
GET /api/agent/report/<id>
Headers: X-Agent-ID, X-Agent-Token
"""
agent, err, code = _auth_agent()
if err:
return err, code
detail = get_report_detail(report_id)
if not detail:
return jsonify({"error": "报告不存在"}), 404
# 验证报告归属
if detail.get("agent_id") != agent.agent_id:
return jsonify({"error": "无权访问该报告"}), 403
return jsonify({"ok": True, "report": detail})
# ──────── v0.5.0: Key 轮换 ────────
@agent_bp.route("/rotate-key", methods=["POST"])
def api_rotate_key():
"""
POST /api/agent/rotate-key
Headers: X-Agent-ID, X-Agent-Token
Body: {new_token_hash: "sha256_of_new_key"}
"""
agent, err, code = _auth_agent()
if err:
return err, code
body = request.get_json(force=True)
new_hash = body.get("new_token_hash")
if not new_hash:
return jsonify({"error": "需要 new_token_hash 参数"}), 400
# 更新 token_hash
old_hash = agent.token_hash
agent.token_hash = new_hash
agent.updated_at = datetime.utcnow()
db.session.commit()
return jsonify({
"ok": True,
"message": "Key 已轮换",
"old_hash_prefix": old_hash[:8] + "...",
"new_hash_prefix": new_hash[:8] + "...",
})
# ──────── v0.5.0: Key 撤销 ────────
@agent_bp.route("/revoke-key", methods=["POST"])
def api_revoke_key():
"""
POST /api/agent/revoke-key
Headers: X-Agent-ID, X-Agent-Token
"""
agent, err, code = _auth_agent()
if err:
return err, code
# 使 token_hash 失效(设为空 hash)
agent.token_hash = hashlib.sha256(b"__revoked__").hexdigest()
agent.updated_at = datetime.utcnow()
db.session.commit()
return jsonify({"ok": True, "message": "Key 已撤销,Agent 需要重新注册"})
# ──────── v0.5.0: GDPR 数据导出 ────────
@agent_bp.route("/export-data", methods=["GET"])
def api_export_data():
"""
GET /api/agent/export-data
Headers: X-Agent-ID, X-Agent-Token
导出该 Agent 在服务端的全部数据
"""
agent, err, code = _auth_agent()
if err:
return err, code
from server.models.database import SkillReport, DailyDigest
# 收集所有报告
reports = SkillReport.query.filter_by(agent_id_str=agent.agent_id).all()
reports_data = [r.to_dict(include_data=True) for r in reports]
# 收集推送记录
digests = DailyDigest.query.filter_by(agent_db_id=agent.id).all()
digests_data = []
for d in digests:
digests_data.append({
"digest_date": d.digest_date.isoformat() if d.digest_date else None,
"push_type": d.push_type,
"push_status": d.push_status,
"created_at": d.created_at.isoformat() if d.created_at else None,
})
export = {
"export_version": "1.0",
"exported_at": datetime.utcnow().isoformat(),
"agent": agent.to_dict(),
"reports": reports_data,
"digests": digests_data,
"total_reports": len(reports_data),
"total_digests": len(digests_data),
}
return jsonify({"ok": True, "data": export})
# ──────── v0.5.0: GDPR 数据删除 ────────
@agent_bp.route("/delete-data", methods=["POST"])
def api_delete_data():
"""
POST /api/agent/delete-data
Headers: X-Agent-ID, X-Agent-Token
删除该 Agent 在服务端的全部数据(被遗忘权)
"""
agent, err, code = _auth_agent()
if err:
return err, code
from server.models.database import SkillReport, DailyDigest, UserAgent
deleted = {}
# 删除报告
count = SkillReport.query.filter_by(agent_id_str=agent.agent_id).delete()
deleted["reports"] = count
# 删除推送记录
count = DailyDigest.query.filter_by(agent_db_id=agent.id).delete()
deleted["digests"] = count
# 解除用户绑定
count = UserAgent.query.filter_by(agent_id=agent.id).delete()
deleted["bindings"] = count
# 删除 Agent 记录
db.session.delete(agent)
db.session.commit()
deleted["agent"] = 1
return jsonify({
"ok": True,
"message": "全部数据已删除",
"deleted": deleted,
})
FILE:server/api/dashboard_api.py
"""
后台分析仪表盘 API — 提供汇总分析数据
======================================
- GET /dashboard/<agent_id> 仪表盘主页
- GET /api/dashboard/<agent_id>/data 仪表盘 JSON 数据
"""
from datetime import date, datetime, timedelta
from collections import Counter
from flask import Blueprint, request, render_template, jsonify
from server.models.database import Agent, SkillReport
from server.config import DASHBOARD_TOKEN
dashboard_bp = Blueprint("dashboard_api", __name__)
def _require_dashboard_token() -> bool:
"""
若配置了 SM_DASHBOARD_TOKEN,则要求请求携带 token:
- Header: X-Dashboard-Token
- Query: ?token=...
"""
if not DASHBOARD_TOKEN:
return True
token = request.headers.get("X-Dashboard-Token", "") or request.args.get("token", "")
return bool(token) and token == DASHBOARD_TOKEN
def _extract_raw_factors(score_payload: dict) -> dict:
raw = score_payload.get("raw_factors")
if isinstance(raw, dict) and raw:
return raw
factors = score_payload.get("factors", {})
if isinstance(factors, dict) and factors:
if all(not isinstance(v, dict) for v in factors.values()):
return factors
return {}
def _collect_dashboard_data(agent: Agent, days: int = 30) -> dict:
"""汇总 Agent 的所有分析数据,供仪表盘使用"""
end_date = date.today()
start_date = end_date - timedelta(days=days - 1)
all_reports = SkillReport.query.filter(
SkillReport.agent_db_id == agent.id,
).order_by(SkillReport.report_date.desc(), SkillReport.created_at.desc()).all()
daily_reports = [r for r in all_reports if r.report_type == "daily"]
diag_reports = [r for r in all_reports if r.report_type == "diagnostic"]
latest_daily = daily_reports[0] if daily_reports else None
latest_diag = diag_reports[0] if diag_reports else None
first_diag = diag_reports[-1] if diag_reports else None
latest_data = latest_daily.get_data() if latest_daily else {}
latest_diag_data = latest_diag.get_data() if latest_diag else {}
first_diag_data = first_diag.get_data() if first_diag else {}
preferred_payload = latest_data or latest_diag_data
overview = preferred_payload.get("overview", {})
recommendations = preferred_payload.get("recommendations") or latest_diag_data.get("recommendations", [])
installed_skills = preferred_payload.get("installed_skills") or latest_diag_data.get("installed_skills", [])
installed_map = {
item.get("slug"): item for item in installed_skills
if isinstance(item, dict) and item.get("slug")
}
overview_metrics = {
"health_score": latest_daily.health_score if latest_daily else (latest_diag.health_score if latest_diag else (agent.health_score or 0)),
"total_installed": overview.get("total_installed", agent.total_skills or 0),
"total_runnable": overview.get("total_runnable", agent.runnable_skills or 0),
"total_runs": overview.get("total_runs", latest_daily.total_runs if latest_daily else 0),
"success_rate": overview.get("success_rate", latest_daily.success_rate if latest_daily else 0),
"active_skills": overview.get("active_skills", latest_daily.active_skills if latest_daily else 0),
"avg_duration_ms": overview.get("avg_duration_ms", 0),
"total_reports": len(all_reports),
"last_report_at": agent.last_report_at.strftime("%Y-%m-%d %H:%M") if agent.last_report_at else "未上报",
}
total = overview_metrics["total_installed"]
runnable = overview_metrics["total_runnable"]
overview_metrics["runnable_rate"] = round(runnable / total * 100, 1) if total > 0 else 0
range_reports = SkillReport.query.filter(
SkillReport.agent_db_id == agent.id,
SkillReport.report_type == "daily",
SkillReport.report_date >= start_date,
SkillReport.report_date <= end_date,
).order_by(SkillReport.report_date.asc()).all()
trend_data = []
for report in range_reports:
trend_data.append({
"date": report.report_date.isoformat(),
"health_score": report.health_score or 0,
"total_runs": report.total_runs or 0,
"success_rate": report.success_rate or 0,
"active_skills": report.active_skills or 0,
"avg_duration_ms": report.avg_duration_ms or 0,
})
scores = preferred_payload.get("scores", [])
skill_rankings = []
grade_distribution = Counter()
score_distribution = {"excellent": 0, "good": 0, "average": 0, "poor": 0}
for score in scores:
grade = score.get("grade", "C")
grade_distribution[grade] += 1
raw_factors = _extract_raw_factors(score)
skill_id = score.get("skill_id", "unknown")
inventory = installed_map.get(skill_id, {})
skill_rankings.append({
"skill_id": skill_id,
"skill_name": inventory.get("name", skill_id),
"category": inventory.get("category", "未分类"),
"description": inventory.get("description", ""),
"total_score": score.get("total_score", 0),
"grade": grade,
"grade_label": score.get("grade_label", grade),
"success_rate": raw_factors.get("success_rate"),
"response_time": raw_factors.get("response_time"),
"satisfaction": raw_factors.get("satisfaction"),
"stability": raw_factors.get("stability"),
"raw_factors": raw_factors,
"factor_cards": score.get("factors", {}),
"details": score.get("details", {}),
})
total_score = score.get("total_score", 0)
if total_score >= 90:
score_distribution["excellent"] += 1
elif total_score >= 75:
score_distribution["good"] += 1
elif total_score >= 60:
score_distribution["average"] += 1
else:
score_distribution["poor"] += 1
report_history = []
for report in all_reports[:20]:
report_history.append({
"id": report.id,
"date": report.report_date.isoformat() if report.report_date else "",
"type": report.report_type,
"health_score": report.health_score or 0,
"total_runs": report.total_runs or 0,
"success_rate": report.success_rate or 0,
"active_skills": report.active_skills or 0,
"data_size": report.data_size_bytes or 0,
"compressed": report.is_compressed,
"created_at": report.created_at.strftime("%m-%d %H:%M") if report.created_at else "",
})
health_changes = []
if len(trend_data) >= 2:
for i in range(1, len(trend_data)):
prev = trend_data[i - 1]["health_score"]
curr = trend_data[i]["health_score"]
change = curr - prev
health_changes.append({
"date": trend_data[i]["date"],
"score": curr,
"change": round(change, 1),
"direction": "up" if change > 0 else ("down" if change < 0 else "flat"),
})
agent_info = {
"agent_id": agent.agent_id,
"name": agent.name or f"Agent-{agent.agent_id[:8]}",
"os_info": agent.os_info or "未知",
"python_version": agent.python_version or "未知",
"monitor_version": agent.monitor_version or "未知",
"created_at": agent.created_at.strftime("%Y-%m-%d %H:%M") if agent.created_at else "",
"last_heartbeat": agent.last_heartbeat_at.strftime("%Y-%m-%d %H:%M") if agent.last_heartbeat_at else "无",
}
diagnostic_summary = None
if latest_diag_data:
diag_payload = latest_diag_data.get("diagnostics", {})
diagnostic_summary = {
"date": latest_diag.report_date.isoformat() if latest_diag else "",
"markdown": latest_diag_data.get("report_markdown", ""),
"health_score": latest_diag.health_score if latest_diag else 0,
"trigger": latest_diag_data.get("trigger"),
"issues": diag_payload.get("issues", []),
"suggestions": diag_payload.get("suggestions", []),
"recommendations": latest_diag_data.get("recommendations", []),
}
first_diagnostic = None
if first_diag:
first_diagnostic = {
"date": first_diag.report_date.isoformat() if first_diag.report_date else "",
"created_at": first_diag.created_at.strftime("%Y-%m-%d %H:%M") if first_diag.created_at else "",
"health_score": first_diag.health_score or 0,
"trigger": first_diag_data.get("trigger"),
"markdown": first_diag_data.get("report_markdown", ""),
}
recent_daily_count = sum(1 for r in daily_reports if r.report_date and r.report_date >= end_date - timedelta(days=6))
report_sync = {
"daily_count": len(daily_reports),
"diagnostic_count": len(diag_reports),
"recent_daily_count": recent_daily_count,
"has_initial_diagnostic": bool(first_diag),
"first_report_date": all_reports[-1].report_date.isoformat() if all_reports and all_reports[-1].report_date else "",
"latest_daily_date": latest_daily.report_date.isoformat() if latest_daily and latest_daily.report_date else "",
"latest_diag_date": latest_diag.report_date.isoformat() if latest_diag and latest_diag.report_date else "",
"first_diagnostic_date": first_diag.report_date.isoformat() if first_diag and first_diag.report_date else "",
"status": "healthy" if first_diag and recent_daily_count >= 5 else ("warning" if daily_reports or diag_reports else "empty"),
"status_label": "同步正常" if first_diag and recent_daily_count >= 5 else ("待补齐" if daily_reports or diag_reports else "未开始"),
}
return {
"generated_at": datetime.now().isoformat(),
"period_days": days,
"agent": agent_info,
"overview": overview_metrics,
"trend": trend_data,
"skill_rankings": skill_rankings,
"score_distribution": score_distribution,
"grade_distribution": dict(grade_distribution),
"report_history": report_history,
"health_changes": health_changes,
"diagnostic_summary": diagnostic_summary,
"first_diagnostic": first_diagnostic,
"report_sync": report_sync,
"recommendations": recommendations[:6],
"installed_skills": installed_skills[:50],
}
@dashboard_bp.route("/dashboard/<agent_id_str>")
def dashboard_page(agent_id_str):
"""后台分析仪表盘页面"""
if not _require_dashboard_token():
return render_template("h5_error.html", message="未授权访问"), 401
agent = Agent.query.filter_by(agent_id=agent_id_str).first()
if not agent:
return render_template("h5_error.html", message="Agent 不存在"), 404
days = min(int(request.args.get("days", "30")), 90)
data = _collect_dashboard_data(agent, days)
return render_template("dashboard.html", agent=agent, data=data)
@dashboard_bp.route("/api/dashboard/<agent_id_str>/data")
def dashboard_data(agent_id_str):
"""仪表盘 JSON 数据接口"""
if not _require_dashboard_token():
return jsonify({"error": "未授权访问"}), 401
agent = Agent.query.filter_by(agent_id=agent_id_str).first()
if not agent:
return jsonify({"error": "Agent 不存在"}), 404
days = min(int(request.args.get("days", "30")), 90)
data = _collect_dashboard_data(agent, days)
return jsonify({"ok": True, "data": data})
FILE:server/api/benchmark_api.py
"""
TOP1000 基准评测 API — LLM 跨模型评测数据服务
=============================================
- GET /benchmark 基准评测 Dashboard 页面
- GET /api/benchmark/summary 评测概览摘要
- GET /api/benchmark/models 模型排行榜
- GET /api/benchmark/categories 分类最佳模型
- GET /api/benchmark/matrix 完整评测矩阵 (支持分页/筛选)
- GET /api/benchmark/skill/<slug> 单个 Skill 跨模型评测
- GET /api/benchmark/model/<key> 单个模型全 Skills 评测
- GET /api/benchmark/compare 模型间对比
"""
import json
import os
import logging
from pathlib import Path
from flask import Blueprint, request, render_template, jsonify
logger = logging.getLogger(__name__)
benchmark_bp = Blueprint("benchmark_api", __name__)
# ── 数据缓存 ──
_benchmark_cache = {"data": None, "lite": None}
def _get_project_root() -> Path:
"""获取项目根目录"""
return Path(__file__).resolve().parent.parent.parent
def _load_benchmark_data() -> dict:
"""加载完整的 benchmark 矩阵数据"""
if _benchmark_cache["data"]:
return _benchmark_cache["data"]
# 按时间倒序找到最新的 benchmark 文件
report_dir = _get_project_root() / "reports" / "benchmark"
if not report_dir.exists():
return {}
# 查找 benchmark_matrix_*.json(完整数据)
matrix_files = sorted(report_dir.glob("benchmark_matrix_*.json"), reverse=True)
if not matrix_files:
return {}
try:
with open(matrix_files[0], "r", encoding="utf-8") as f:
data = json.load(f)
_benchmark_cache["data"] = data
logger.info(f"加载评测矩阵: {matrix_files[0].name} ({len(data.get('cells', []))} cells)")
return data
except Exception as e:
logger.error(f"加载评测矩阵失败: {e}")
return {}
def _load_benchmark_lite() -> dict:
"""加载精简版 benchmark 数据"""
if _benchmark_cache["lite"]:
return _benchmark_cache["lite"]
report_dir = _get_project_root() / "reports" / "benchmark"
if not report_dir.exists():
return {}
lite_files = sorted(report_dir.glob("benchmark_lite_*.json"), reverse=True)
if not lite_files:
return {}
try:
with open(lite_files[0], "r", encoding="utf-8") as f:
data = json.load(f)
_benchmark_cache["lite"] = data
logger.info(f"加载精简评测: {lite_files[0].name}")
return data
except Exception as e:
logger.error(f"加载精简评测失败: {e}")
return {}
def _load_dataset() -> list:
"""加载 TOP1000 Skills 数据集"""
ds_path = _get_project_root() / "skills_monitor" / "data" / "top1000_skills_dataset.json"
if not ds_path.exists():
return []
try:
with open(ds_path, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
return []
# ──────── 页面路由 ────────
@benchmark_bp.route("/benchmark")
def benchmark_page():
"""基准评测 Dashboard 页面"""
lite = _load_benchmark_lite()
data = _load_benchmark_data()
dataset = _load_dataset()
# 构建页面数据
page_data = {
"version": lite.get("version", "0.6.0"),
"mode": lite.get("mode", "mock"),
"generated_at": lite.get("generated_at", "未知"),
"skills_count": lite.get("skills_count", 0),
"models": lite.get("models", []),
"model_ranking": lite.get("model_ranking", []),
"category_leaders": lite.get("category_leaders", {}),
"matrix": lite.get("matrix", {}),
"model_summaries": data.get("model_summaries", {}),
"category_summaries": data.get("category_summaries", {}),
"dataset_sample": dataset[:20], # 页面只需部分数据
}
return render_template("benchmark.html", data=page_data)
# ──────── 数据 API ────────
@benchmark_bp.route("/api/benchmark/summary")
def benchmark_summary():
"""评测概览摘要"""
lite = _load_benchmark_lite()
data = _load_benchmark_data()
if not lite:
return jsonify({"ok": False, "error": "暂无评测数据"}), 404
# 计算统计
cells = data.get("cells", [])
total_cells = len(cells)
avg_quality = sum(c.get("quality_score", 0) for c in cells) / max(total_cells, 1)
avg_success = sum(c.get("success_rate", 0) for c in cells) / max(total_cells, 1)
total_cost = sum(c.get("total_cost_usd", 0) for c in cells)
return jsonify({
"ok": True,
"data": {
"version": lite.get("version"),
"mode": lite.get("mode"),
"generated_at": lite.get("generated_at"),
"skills_count": lite.get("skills_count", 0),
"models_count": len(lite.get("models", [])),
"total_cells": total_cells,
"avg_quality_score": round(avg_quality, 1),
"avg_success_rate": round(avg_success, 1),
"total_cost_usd": round(total_cost, 4),
"model_ranking": lite.get("model_ranking", []),
}
})
@benchmark_bp.route("/api/benchmark/models")
def benchmark_models():
"""模型排行榜"""
lite = _load_benchmark_lite()
if not lite:
return jsonify({"ok": False, "error": "暂无评测数据"}), 404
return jsonify({
"ok": True,
"data": {
"models": lite.get("models", []),
"ranking": lite.get("model_ranking", []),
}
})
@benchmark_bp.route("/api/benchmark/categories")
def benchmark_categories():
"""分类最佳模型"""
lite = _load_benchmark_lite()
data = _load_benchmark_data()
if not lite:
return jsonify({"ok": False, "error": "暂无评测数据"}), 404
return jsonify({
"ok": True,
"data": {
"category_leaders": lite.get("category_leaders", {}),
"category_summaries": data.get("category_summaries", {}),
}
})
@benchmark_bp.route("/api/benchmark/matrix")
def benchmark_matrix():
"""完整评测矩阵 (支持分页/筛选)"""
data = _load_benchmark_data()
if not data:
return jsonify({"ok": False, "error": "暂无评测数据"}), 404
# 参数
page = max(1, int(request.args.get("page", 1)))
per_page = min(100, max(10, int(request.args.get("per_page", 50))))
category = request.args.get("category", "")
model = request.args.get("model", "")
search = request.args.get("q", "").lower()
sort_by = request.args.get("sort", "quality_score")
order = request.args.get("order", "desc")
cells = data.get("cells", [])
# 筛选
if category:
cells = [c for c in cells if c.get("category") == category]
if model:
cells = [c for c in cells if c.get("model_key") == model]
if search:
cells = [c for c in cells if search in c.get("skill_slug", "").lower()
or search in c.get("skill_name", "").lower()
or search in c.get("category", "").lower()]
# 排序
reverse = order == "desc"
if sort_by in ("quality_score", "success_rate", "avg_latency_ms", "avg_cost_usd"):
cells = sorted(cells, key=lambda c: c.get(sort_by, 0), reverse=reverse)
elif sort_by == "skill_name":
cells = sorted(cells, key=lambda c: c.get("skill_name", ""), reverse=reverse)
# 分页
total = len(cells)
total_pages = max(1, (total + per_page - 1) // per_page)
start = (page - 1) * per_page
page_cells = cells[start:start + per_page]
return jsonify({
"ok": True,
"data": {
"cells": page_cells,
"pagination": {
"page": page,
"per_page": per_page,
"total": total,
"total_pages": total_pages,
},
"filters": {
"category": category,
"model": model,
"search": search,
}
}
})
@benchmark_bp.route("/api/benchmark/skill/<slug>")
def benchmark_skill(slug):
"""单个 Skill 跨模型评测"""
data = _load_benchmark_data()
if not data:
return jsonify({"ok": False, "error": "暂无评测数据"}), 404
cells = [c for c in data.get("cells", []) if c.get("skill_slug") == slug]
if not cells:
return jsonify({"ok": False, "error": f"未找到 Skill: {slug}"}), 404
# 找到最佳模型
best = max(cells, key=lambda c: c.get("quality_score", 0))
# 从数据集获取 Skill 详情
dataset = _load_dataset()
skill_info = next((s for s in dataset if s["slug"] == slug), {})
return jsonify({
"ok": True,
"data": {
"skill_slug": slug,
"skill_name": cells[0].get("skill_name", slug),
"skill_info": skill_info,
"best_model": best.get("model_name"),
"best_quality": best.get("quality_score"),
"models": cells,
}
})
@benchmark_bp.route("/api/benchmark/model/<key>")
def benchmark_model(key):
"""单个模型全 Skills 评测"""
data = _load_benchmark_data()
if not data:
return jsonify({"ok": False, "error": "暂无评测数据"}), 404
cells = [c for c in data.get("cells", []) if c.get("model_key") == key]
if not cells:
return jsonify({"ok": False, "error": f"未找到模型: {key}"}), 404
summary = data.get("model_summaries", {}).get(key, {})
# 按分类分组
from collections import defaultdict
cat_groups = defaultdict(list)
for c in cells:
cat_groups[c.get("category", "other")].append(c)
return jsonify({
"ok": True,
"data": {
"model_key": key,
"model_name": cells[0].get("model_name", key),
"summary": summary,
"total_skills": len(cells),
"categories": {
cat: {
"count": len(skills),
"avg_quality": round(sum(s.get("quality_score", 0) for s in skills) / len(skills), 1),
"avg_success_rate": round(sum(s.get("success_rate", 0) for s in skills) / len(skills), 1),
}
for cat, skills in cat_groups.items()
},
"skills": sorted(cells, key=lambda c: c.get("quality_score", 0), reverse=True),
}
})
@benchmark_bp.route("/api/benchmark/compare")
def benchmark_compare():
"""模型间对比"""
data = _load_benchmark_data()
if not data:
return jsonify({"ok": False, "error": "暂无评测数据"}), 404
models = request.args.get("models", "").split(",")
models = [m.strip() for m in models if m.strip()]
if len(models) < 2:
return jsonify({"ok": False, "error": "请指定至少 2 个模型"}), 400
summaries = data.get("model_summaries", {})
result = {}
for mk in models:
if mk in summaries:
result[mk] = summaries[mk]
return jsonify({
"ok": True,
"data": {
"models": result,
"categories": data.get("category_summaries", {}),
}
})
# ──────── 基线数据服务(供 Agent 对比使用) ────────
@benchmark_bp.route("/api/benchmark/baseline/<slug>")
def get_baseline(slug):
"""获取指定 Skill 的基准线数据(供 Agent Skill 评估使用)"""
lite = _load_benchmark_lite()
if not lite:
return jsonify({"ok": False, "error": "暂无基线数据"}), 404
matrix = lite.get("matrix", {})
skill_data = matrix.get(slug)
if skill_data:
# 精确匹配
avg_quality = sum(m["q"] for m in skill_data.values()) / len(skill_data)
avg_success = sum(m["sr"] for m in skill_data.values()) / len(skill_data)
avg_latency = sum(m["ms"] for m in skill_data.values()) / len(skill_data)
best_model = max(skill_data.items(), key=lambda x: x[1]["q"])
return jsonify({
"ok": True,
"data": {
"slug": slug,
"source": "exact",
"avg_quality": round(avg_quality, 1),
"avg_success_rate": round(avg_success, 1),
"avg_latency_ms": round(avg_latency, 0),
"best_model": best_model[0],
"best_quality": best_model[1]["q"],
"models": skill_data,
}
})
# 全局平均作为回退
ranking = lite.get("model_ranking", [])
if ranking:
avg_q = sum(m.get("avg_quality_score", 0) for m in ranking) / len(ranking)
avg_s = sum(m.get("avg_success_rate", 0) for m in ranking) / len(ranking)
avg_l = sum(m.get("avg_latency_ms", 0) for m in ranking) / len(ranking)
return jsonify({
"ok": True,
"data": {
"slug": slug,
"source": "global_avg",
"avg_quality": round(avg_q, 1),
"avg_success_rate": round(avg_s, 1),
"avg_latency_ms": round(avg_l, 0),
"n_models": len(ranking),
}
})
return jsonify({"ok": False, "error": "暂无基线数据"}), 404
FILE:server/static/pwa/app.css
/* ====================================
Skills Monitor PWA — 暗色主题
移动端优先 + 桌面端自适应
==================================== */
:root {
--bg-primary: #0a0a1a;
--bg-secondary: #0e0e22;
--bg-card: #141428;
--bg-card-hover: #1a1a36;
--bg-input: #1a1a30;
--text-primary: #e8e8f0;
--text-secondary: #999aaa;
--text-muted: #666678;
--accent: #667eea;
--accent-hover: #5a6fd6;
--accent-light: rgba(102, 126, 234, 0.15);
--success: #27ae60;
--success-light: rgba(39, 174, 96, 0.15);
--warning: #f39c12;
--warning-light: rgba(243, 156, 18, 0.15);
--danger: #e74c3c;
--danger-light: rgba(231, 76, 60, 0.15);
--border: rgba(255, 255, 255, 0.06);
--radius: 16px;
--radius-sm: 10px;
--shadow: 0 4px 24px rgba(0, 0, 0, 0.3);
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Helvetica Neue", sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
line-height: 1.6;
-webkit-font-smoothing: antialiased;
min-height: 100vh;
min-height: 100dvh;
}
/* ──── Layout ──── */
#app {
max-width: 480px;
margin: 0 auto;
padding: 0 16px 80px;
min-height: 100vh;
min-height: 100dvh;
}
.page { display: none; }
.page.active { display: block; }
/* ──── Header ──── */
.page-header {
text-align: center;
padding: 32px 0 20px;
}
.page-header .icon { font-size: 48px; margin-bottom: 8px; }
.page-header h1 { font-size: 22px; font-weight: 700; }
.page-header .subtitle { font-size: 14px; color: var(--text-secondary); margin-top: 4px; }
/* ──── Card ──── */
.card {
background: var(--bg-card);
border-radius: var(--radius);
padding: 20px;
margin-bottom: 16px;
border: 1px solid var(--border);
}
.card-title {
font-size: 16px;
font-weight: 600;
margin-bottom: 16px;
display: flex;
align-items: center;
gap: 8px;
}
/* ──── Login Page ──── */
.login-page.active {
display: flex;
flex-direction: column;
justify-content: center;
min-height: 100vh;
min-height: 100dvh;
padding: 40px 0;
}
.login-logo {
text-align: center;
margin-bottom: 40px;
}
.login-logo .icon { font-size: 64px; }
.login-logo h1 { font-size: 28px; font-weight: 800; margin-top: 12px; }
.login-logo p { color: var(--text-secondary); margin-top: 8px; font-size: 15px; }
.form-group {
margin-bottom: 16px;
}
.form-group label {
display: block;
font-size: 13px;
color: var(--text-secondary);
margin-bottom: 6px;
font-weight: 500;
}
.form-group input {
width: 100%;
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 12px 16px;
color: var(--text-primary);
font-size: 15px;
outline: none;
transition: border-color 0.2s;
}
.form-group input:focus {
border-color: var(--accent);
}
.form-group input::placeholder {
color: var(--text-muted);
}
.btn {
width: 100%;
padding: 14px;
border: none;
border-radius: var(--radius-sm);
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.btn-primary {
background: var(--accent);
color: white;
}
.btn-primary:hover { background: var(--accent-hover); }
.btn-primary:active { transform: scale(0.98); }
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-danger {
background: var(--danger-light);
color: var(--danger);
}
.btn-outline {
background: transparent;
border: 1px solid var(--border);
color: var(--text-secondary);
}
.login-help {
text-align: center;
margin-top: 24px;
font-size: 13px;
color: var(--text-muted);
line-height: 1.8;
}
.login-help code {
background: rgba(102, 126, 234, 0.1);
color: var(--accent);
padding: 2px 8px;
border-radius: 4px;
font-family: "SF Mono", "Fira Code", monospace;
font-size: 12px;
}
/* ──── Health Score Ring ──── */
.health-card { text-align: center; }
.health-score-wrapper {
display: flex;
justify-content: center;
margin-bottom: 20px;
}
.health-ring {
position: relative;
width: 140px;
height: 140px;
}
.health-ring svg {
transform: rotate(-90deg);
width: 100%;
height: 100%;
}
.ring-bg { fill: none; stroke: rgba(255, 255, 255, 0.06); stroke-width: 8; }
.ring-fill {
fill: none;
stroke: var(--accent);
stroke-width: 8;
stroke-linecap: round;
transition: stroke-dasharray 1s ease;
}
.score-text {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
}
.score-num { display: block; font-size: 36px; font-weight: 800; color: var(--accent); line-height: 1; }
.score-label { display: block; font-size: 12px; color: var(--text-muted); margin-top: 4px; }
/* ──── Compact Hero (替代大仪表盘) ──── */
.compact-hero {
display: flex;
justify-content: space-between;
align-items: center;
padding: 18px 20px !important;
position: relative;
overflow: hidden;
}
.compact-hero::before {
content: '';
position: absolute;
inset: 0;
background: radial-gradient(circle at top right, rgba(102,126,234,0.12), transparent 60%);
pointer-events: none;
}
.compact-hero-left { position: relative; z-index: 1; }
.compact-hero-right { position: relative; z-index: 1; text-align: center; }
.compact-title { font-size: 22px; font-weight: 800; letter-spacing: -0.02em; margin: 4px 0 0; }
.compact-agent-name { font-size: 18px; font-weight: 700; }
.compact-subtitle { font-size: 13px; color: var(--text-secondary); margin-top: 4px; }
.compact-id { opacity: 0.5; margin-left: 6px; }
.compact-score { font-size: 42px; font-weight: 900; line-height: 1; }
.compact-score-label { font-size: 11px; color: var(--text-muted); margin-top: 2px; }
/* ──── Compact Metrics (紧凑四格统计) ──── */
.compact-metrics {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1px;
background: var(--border);
border-radius: var(--radius-sm);
overflow: hidden;
margin-bottom: 16px;
}
.cm-item {
display: flex;
flex-direction: column;
align-items: center;
padding: 12px 4px;
background: var(--bg-card);
}
.cm-item.clickable { cursor: pointer; }
.cm-item.clickable:active { background: var(--bg-card-hover); }
.cm-val { font-size: 18px; font-weight: 700; color: var(--text-primary); }
.cm-lbl { font-size: 10px; color: var(--text-muted); margin-top: 2px; }
/* ──── Highlight Cards (诊断 & 推荐突出) ──── */
.highlight-card {
position: relative;
overflow: hidden;
}
.highlight-card::before {
content: '';
position: absolute;
top: 0; left: 0; right: 0;
height: 3px;
border-radius: 3px 3px 0 0;
}
.highlight-diag { border-color: rgba(231, 76, 60, 0.25); }
.highlight-diag::before { background: linear-gradient(90deg, #e74c3c, #f39c12); }
.highlight-rec { border-color: rgba(118, 75, 162, 0.25); }
.highlight-rec::before { background: linear-gradient(90deg, #764ba2, #667eea); }
.highlight-issue-item {
border-left: 3px solid rgba(231,76,60,0.5);
padding-left: 12px !important;
}
.highlight-suggest-item {
border-left: 3px solid rgba(241,196,15,0.5);
padding-left: 12px !important;
}
/* ── 诊断卡片内展开原文行 ── */
.diag-expand-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 16px;
padding: 10px 14px;
background: rgba(102,126,234,0.08);
border-radius: 10px;
cursor: pointer;
font-size: 13px;
color: #8da2f0;
font-weight: 500;
transition: background 0.2s;
}
.diag-expand-row:hover { background: rgba(102,126,234,0.15); }
/* ── 诊断原文展开区 ── */
.diagnostic-report-body-wrap {
animation: fadeSlideIn 0.3s ease;
}
/* ──── Collapsible Panels (折叠面板通用) ──── */
.collapsible-header {
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
padding: 4px 0;
user-select: none;
}
.collapsible-header:hover .collapsible-icon { color: var(--accent); }
.collapsible-icon {
font-size: 14px;
color: var(--text-secondary);
transition: color 0.2s, transform 0.2s;
}
.collapsible-body {
animation: fadeSlideIn 0.3s ease;
}
/* ──── Metrics Grid ──── */
.metrics-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
margin-bottom: 16px;
}
.metric-card {
background: var(--bg-card);
border-radius: var(--radius-sm);
padding: 16px;
border: 1px solid var(--border);
text-align: center;
}
.metric-card .value {
font-size: 24px;
font-weight: 700;
color: var(--text-primary);
}
.metric-card .label {
font-size: 12px;
color: var(--text-muted);
margin-top: 4px;
}
/* ──── Agent List ──── */
.agent-card {
cursor: pointer;
transition: background 0.2s;
}
.agent-card:active { background: var(--bg-card-hover); }
.agent-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.agent-name { font-size: 16px; font-weight: 600; }
.badge {
font-size: 10px;
padding: 2px 8px;
border-radius: 4px;
margin-left: 6px;
font-weight: 600;
}
.badge-primary { background: var(--accent-light); color: var(--accent); }
.badge-online { background: var(--success-light); color: var(--success); }
.agent-health {
font-size: 18px;
font-weight: 700;
}
.health-good { color: var(--success); }
.health-warn { color: var(--warning); }
.health-bad { color: var(--danger); }
.agent-meta {
display: flex;
justify-content: space-between;
font-size: 12px;
color: var(--text-muted);
}
/* ──── Report List ──── */
.report-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 14px 0;
border-bottom: 1px solid var(--border);
cursor: pointer;
}
.report-item:last-child { border-bottom: none; }
.report-item:active { opacity: 0.7; }
.report-info .report-date { font-size: 15px; font-weight: 500; }
.report-info .report-agent { font-size: 12px; color: var(--text-muted); margin-top: 2px; }
.report-score {
font-size: 20px;
font-weight: 700;
}
/* ──── Skill List ──── */
.skill-list { display: flex; flex-direction: column; gap: 8px; }
.skill-item {
display: grid;
grid-template-columns: 28px 1fr 50px;
grid-template-rows: auto auto;
gap: 0 10px;
align-items: center;
padding: 10px 0;
border-bottom: 1px solid var(--border);
}
.skill-item:last-child { border-bottom: none; }
.skill-rank {
grid-row: 1 / 3;
width: 28px; height: 28px; border-radius: 50%;
background: rgba(255, 255, 255, 0.06);
display: flex; align-items: center; justify-content: center;
font-size: 12px; font-weight: 700; color: var(--text-secondary);
}
.skill-rank.top3 { background: var(--accent-light); color: var(--accent); }
.skill-info { display: flex; align-items: center; gap: 8px; }
.skill-name {
font-size: 14px; font-weight: 500;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.skill-grade {
font-size: 11px; font-weight: 700;
padding: 1px 6px; border-radius: 4px; flex-shrink: 0;
}
.grade-a\+, .grade-a { background: rgba(39, 174, 96, 0.2); color: #27ae60; }
.grade-b { background: rgba(102, 126, 234, 0.2); color: #667eea; }
.grade-c { background: rgba(243, 156, 18, 0.2); color: #f39c12; }
.grade-d, .grade-f { background: rgba(231, 76, 60, 0.2); color: #e74c3c; }
.skill-score { font-size: 16px; font-weight: 700; text-align: right; color: var(--accent); }
.skill-bar {
grid-column: 2 / 4;
height: 4px; background: rgba(255, 255, 255, 0.04);
border-radius: 2px; overflow: hidden; margin-top: 4px;
}
.skill-bar-fill {
height: 100%;
background: linear-gradient(90deg, var(--accent), #764ba2);
border-radius: 2px; transition: width 0.8s ease;
}
/* ──── Tab Bar ──── */
.tab-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: var(--bg-secondary);
border-top: 1px solid var(--border);
display: flex;
justify-content: space-around;
padding: 8px 0;
padding-bottom: max(8px, env(safe-area-inset-bottom));
z-index: 100;
}
.tab-bar.hidden { display: none; }
.tab-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
cursor: pointer;
padding: 4px 16px;
color: var(--text-muted);
font-size: 10px;
transition: color 0.2s;
-webkit-tap-highlight-color: transparent;
}
.tab-item .tab-icon { font-size: 22px; }
.tab-item.active { color: var(--accent); }
/* ──── Overview Grid ──── */
.overview-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.ov-item { display: flex; flex-direction: column; gap: 2px; }
.ov-label { font-size: 11px; color: var(--text-muted); }
.ov-value { font-size: 15px; font-weight: 600; }
/* ──── Trend Chart ──── */
.chart-container {
position: relative;
height: 200px;
}
/* ──── Loading & Empty ──── */
.loading {
text-align: center;
padding: 60px 20px;
color: var(--text-muted);
}
.loading .spinner {
width: 40px; height: 40px;
border: 3px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin: 0 auto 16px;
}
@keyframes spin { to { transform: rotate(360deg); } }
.empty-state {
text-align: center;
padding: 40px 20px;
}
.empty-state .icon { font-size: 48px; margin-bottom: 12px; }
.empty-state h3 { font-size: 18px; margin-bottom: 8px; }
.empty-state p { color: var(--text-muted); font-size: 14px; }
/* ──── Toast ──── */
.toast {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: rgba(0, 0, 0, 0.8);
color: white;
padding: 12px 24px;
border-radius: 8px;
font-size: 14px;
z-index: 999;
opacity: 0;
transition: opacity 0.3s;
pointer-events: none;
}
.toast.show { opacity: 1; }
/* ──── Pull to refresh indicator ──── */
.pull-indicator {
text-align: center;
padding: 12px;
color: var(--text-muted);
font-size: 13px;
display: none;
}
/* ──── Back button ──── */
.back-btn {
display: inline-flex;
align-items: center;
gap: 6px;
color: #dbe1ff;
font-size: 13px;
cursor: pointer;
padding: 10px 14px;
margin-bottom: 12px;
border-radius: 999px;
background: rgba(102, 126, 234, 0.12);
border: 1px solid rgba(102, 126, 234, 0.22);
}
/* ──── Settings ──── */
.setting-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 0;
border-bottom: 1px solid var(--border);
}
.setting-item:last-child { border-bottom: none; }
.setting-label { font-size: 15px; font-weight: 500; }
.setting-desc { font-size: 12px; color: var(--text-muted); margin-top: 2px; }
.user-avatar {
width: 56px; height: 56px;
border-radius: 50%;
background: var(--accent-light);
display: flex; align-items: center; justify-content: center;
font-size: 28px; margin: 0 auto 12px;
}
/* ──── Footer ──── */
.footer {
text-align: center;
padding: 24px 0;
color: var(--text-muted);
font-size: 12px;
}
/* ──── Detail / Rich Report UI ──── */
.detail-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: 999px;
background: rgba(102,126,234,0.14);
border: 1px solid rgba(102,126,234,0.26);
color: #dfe4ff;
font-size: 12px;
margin-bottom: 4px;
}
.report-metrics-grid { margin-top: 4px; }
.glass-card { background: rgba(255,255,255,0.03); }
.report-section-card { overflow: hidden; }
.empty-inline { color: var(--text-muted); font-size: 13px; }
.recommend-list,
.score-detail-list,
.coverage-list,
.usage-list,
.bullet-card-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.recommend-card,
.score-detail-item,
.coverage-item,
.usage-item,
.bullet-card-item {
background: rgba(255,255,255,0.03);
border: 1px solid rgba(255,255,255,0.06);
border-radius: 16px;
padding: 14px;
}
.recommend-top,
.score-detail-top,
.coverage-item,
.usage-item {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 10px;
}
.recommend-rank {
font-size: 11px;
color: var(--text-muted);
margin-bottom: 4px;
}
.recommend-name,
.score-skill-name,
.coverage-name,
.usage-name {
font-size: 15px;
font-weight: 700;
}
.recommend-score,
.score-big {
font-size: 28px;
font-weight: 800;
color: #c7d2ff;
}
.recommend-meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 12px 0 10px;
}
.pill {
display: inline-flex;
align-items: center;
padding: 4px 10px;
border-radius: 999px;
font-size: 11px;
color: var(--text-secondary);
background: rgba(255,255,255,0.05);
border: 1px solid rgba(255,255,255,0.06);
}
.pill.accent {
color: #dfe4ff;
background: rgba(102,126,234,0.12);
border-color: rgba(102,126,234,0.22);
}
.recommend-desc,
.recommend-reason,
.score-skill-meta,
.coverage-meta,
.usage-meta {
font-size: 13px;
color: var(--text-secondary);
}
.recommend-reason { margin-top: 8px; color: var(--text-primary); }
/* 推荐卡片 — 安装操作按钮 */
.recommend-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 14px;
padding-top: 14px;
border-top: 1px solid rgba(102, 126, 234, 0.1);
}
.rec-action-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 16px;
border: none;
border-radius: 20px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.25s ease;
text-decoration: none;
white-space: nowrap;
letter-spacing: 0.3px;
}
.rec-action-btn:active {
transform: scale(0.95);
}
/* 复制安装命令 — 主色调 */
.rec-copy-btn {
background: rgba(102, 126, 234, 0.15);
color: #8da2f0;
}
.rec-copy-btn:hover {
background: rgba(102, 126, 234, 0.28);
color: #a8bcff;
box-shadow: 0 0 12px rgba(102, 126, 234, 0.2);
}
/* 复制下载链接 — 青色调 */
.rec-url-btn {
background: rgba(56, 178, 172, 0.12);
color: #5ec4bf;
}
.rec-url-btn:hover {
background: rgba(56, 178, 172, 0.25);
color: #7eddd8;
box-shadow: 0 0 12px rgba(56, 178, 172, 0.2);
}
/* 查看详情 — 柔和紫调 */
.rec-detail-btn {
background: rgba(118, 75, 162, 0.12);
color: #b08cd4;
}
.rec-detail-btn:hover {
background: rgba(118, 75, 162, 0.25);
color: #caa8e8;
box-shadow: 0 0 12px rgba(118, 75, 162, 0.2);
}
/* 复制成功反馈 */
.rec-action-btn.copied {
background: rgba(46, 204, 113, 0.18) !important;
color: #5be6a0 !important;
box-shadow: 0 0 10px rgba(46, 204, 113, 0.15);
}
.rec-btn-icon { font-size: 12px; line-height: 1; }
/* ── (旧快捷导航和诊断折叠面板已移除,使用新的 compact/highlight/collapsible 系统) ── */
@keyframes fadeSlideIn {
from { opacity: 0; transform: translateY(-8px); }
to { opacity: 1; transform: translateY(0); }
}
.score-left { display: flex; gap: 12px; align-items: center; }
.factor-chip-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 14px;
}
.factor-chip {
min-width: 120px;
flex: 1 1 140px;
background: rgba(255,255,255,0.04);
border: 1px solid rgba(255,255,255,0.05);
border-radius: 14px;
padding: 10px 12px;
}
.factor-name { display: block; font-size: 12px; color: var(--text-secondary); }
.factor-score { display: block; font-size: 17px; font-weight: 700; margin-top: 4px; }
.factor-desc { display: block; font-size: 11px; color: var(--text-muted); margin-top: 4px; }
.coverage-right,
.usage-rank {
font-size: 18px;
font-weight: 800;
}
.usage-rank {
width: 28px;
height: 28px;
border-radius: 50%;
background: rgba(102,126,234,0.14);
color: #dfe4ff;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.markdown-card { padding-bottom: 8px; }
.markdown-body { font-size: 14px; color: var(--text-primary); }
.markdown-body h2,
.markdown-body h3,
.markdown-body h4 {
margin: 18px 0 10px;
font-weight: 800;
}
.markdown-body h2 { font-size: 20px; }
.markdown-body h3 { font-size: 17px; }
.markdown-body h4 { font-size: 15px; }
.markdown-body p,
.markdown-body blockquote,
.markdown-body li { margin-bottom: 10px; }
.markdown-body code {
background: rgba(102,126,234,0.12);
color: #dfe4ff;
border-radius: 8px;
padding: 2px 6px;
font-size: 12px;
}
.markdown-body blockquote {
border-left: 3px solid rgba(102,126,234,0.5);
padding: 8px 12px;
background: rgba(255,255,255,0.03);
border-radius: 0 12px 12px 0;
color: var(--text-secondary);
}
.markdown-divider {
border: none;
border-top: 1px solid rgba(255,255,255,0.07);
margin: 18px 0;
}
.markdown-list { padding-left: 18px; }
.markdown-table-wrap { overflow-x: auto; margin: 12px 0; }
.markdown-table {
width: 100%;
border-collapse: collapse;
min-width: 520px;
font-size: 13px;
}
.markdown-table th,
.markdown-table td {
padding: 10px 12px;
border-bottom: 1px solid rgba(255,255,255,0.06);
text-align: left;
}
.markdown-table th {
color: #dfe4ff;
font-weight: 700;
background: rgba(102,126,234,0.08);
}
/* ──── Report Source Card ──── */
.report-source-card {
padding: 12px 16px !important;
background: rgba(102,126,234,0.08);
border: 1px solid rgba(102,126,234,0.2);
}
.report-source-inner {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 13px;
color: var(--text-secondary);
}
.report-source-link {
color: var(--accent);
font-weight: 600;
white-space: nowrap;
}
/* ──── Diagnostic Summary in Dashboard ──── */
.diag-section { margin-bottom: 4px; }
.diag-subtitle {
font-size: 14px;
font-weight: 600;
margin-bottom: 8px;
color: var(--text-primary);
}
/* ──── Installed Skills List ──── */
.installed-skill-list { display: flex; flex-direction: column; gap: 0; }
.installed-skill-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 0;
border-bottom: 1px solid rgba(255,255,255,0.04);
}
.installed-skill-item:last-child { border-bottom: none; }
.installed-name {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
}
.installed-meta {
font-size: 12px;
color: var(--text-muted);
margin-top: 2px;
}
.installed-right { text-align: right; }
.installed-score {
font-size: 16px;
font-weight: 700;
color: var(--accent);
}
.installed-score.muted {
color: var(--text-muted);
font-size: 13px;
font-weight: 400;
}
/* ──── Desktop Adaptation ──── */
@media (min-width: 600px) {
#app { max-width: 560px; padding: 0 24px 80px; }
.page-header { padding: 48px 0 28px; }
.page-header h1 { font-size: 26px; }
.card { padding: 24px; }
.metrics-grid { grid-template-columns: repeat(4, 1fr); }
}
FILE:server/static/pwa/manifest.json
{
"name": "Skills Monitor",
"short_name": "SkillsMon",
"description": "AI Agent 技能监控面板",
"start_url": "/pwa/",
"display": "standalone",
"background_color": "#0a0a1a",
"theme_color": "#667eea",
"orientation": "portrait-primary",
"icons": [
{
"src": "/static/img/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/static/img/icon-512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}
FILE:server/static/pwa/app.js
/**
* Skills Monitor PWA — 前端应用
* 单页应用,暗色主题,通过 Agent ID + Token 登录
*/
const API_BASE = '/api/pwa';
let currentUser = null;
let currentPage = 'login';
let trendChart = null;
// ──────── 工具函数 ────────
function getToken() {
return localStorage.getItem('sm_pwa_token') || '';
}
function setToken(token) {
localStorage.setItem('sm_pwa_token', token);
}
function clearToken() {
localStorage.removeItem('sm_pwa_token');
}
async function api(path, options = {}) {
const token = getToken();
const headers = { 'Content-Type': 'application/json' };
if (token) headers['X-PWA-Token'] = token;
const res = await fetch(API_BASE + path, { ...options, headers });
const data = await res.json();
if (res.status === 401) {
clearToken();
showPage('login');
return null;
}
return data;
}
function showToast(msg, duration = 2000) {
const el = document.getElementById('toast');
el.textContent = msg;
el.classList.add('show');
setTimeout(() => el.classList.remove('show'), duration);
}
function copyToClipboard(text, btnEl) {
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(() => {
showToast('✅ 已复制到剪贴板');
if (btnEl) {
const orig = btnEl.innerHTML;
btnEl.innerHTML = '<span class="rec-btn-icon">✅</span> 已复制';
btnEl.classList.add('copied');
setTimeout(() => { btnEl.innerHTML = orig; btnEl.classList.remove('copied'); }, 1500);
}
}).catch(() => fallbackCopy(text, btnEl));
} else {
fallbackCopy(text, btnEl);
}
}
function fallbackCopy(text, btnEl) {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.cssText = 'position:fixed;left:-9999px;top:-9999px';
document.body.appendChild(ta);
ta.select();
try {
document.execCommand('copy');
showToast('✅ 已复制到剪贴板');
if (btnEl) {
const orig = btnEl.innerHTML;
btnEl.innerHTML = '<span class="rec-btn-icon">✅</span> 已复制';
btnEl.classList.add('copied');
setTimeout(() => { btnEl.innerHTML = orig; btnEl.classList.remove('copied'); }, 1500);
}
} catch (e) {
showToast('❌ 复制失败,请手动复制');
}
document.body.removeChild(ta);
}
function $(id) { return document.getElementById(id); }
function scrollToSection(id) {
const el = document.getElementById(id);
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
function toggleDiagnosticReport() {
const wrap = document.getElementById('sec-diagnostic-report');
const icon = document.getElementById('diag-toggle-icon');
if (!wrap) return;
const isHidden = wrap.style.display === 'none';
wrap.style.display = isHidden ? 'block' : 'none';
if (icon) icon.textContent = isHidden ? '▼' : '▶';
if (isHidden) {
setTimeout(() => {
wrap.scrollIntoView({ behavior: 'smooth', block: 'start' });
}, 50);
}
}
function toggleOverviewPanel() {
const body = document.getElementById('ov-panel-body');
const icon = document.getElementById('ov-toggle-icon');
if (!body) return;
const isHidden = body.style.display === 'none';
body.style.display = isHidden ? 'block' : 'none';
if (icon) icon.textContent = isHidden ? '▼' : '▶';
}
function toggleDetailDataPanel() {
const body = document.getElementById('detail-data-body');
const icon = document.getElementById('detail-data-toggle-icon');
if (!body) return;
const isHidden = body.style.display === 'none';
body.style.display = isHidden ? 'block' : 'none';
if (icon) icon.textContent = isHidden ? '▼' : '▶';
}
function getHealthClass(score) {
if (score >= 80) return 'health-good';
if (score >= 60) return 'health-warn';
return 'health-bad';
}
function getHealthColor(score) {
if (score >= 80) return '#27ae60';
if (score >= 60) return '#f39c12';
return '#e74c3c';
}
function escapeHtml(str = '') {
return String(str)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
function renderInlineMarkdown(text = '') {
return escapeHtml(text)
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/`([^`]+)`/g, '<code>$1</code>');
}
function renderMarkdownTable(lines) {
if (!lines.length) return '';
const rows = lines.map(line => line.split('|').slice(1, -1).map(cell => renderInlineMarkdown(cell.trim())));
const header = rows[0] || [];
const body = rows.slice(2);
return `
<div class="markdown-table-wrap">
<table class="markdown-table">
<thead><tr>header.map(cell => `<th>${cell</th>`).join('')}</tr></thead>
<tbody>
body.map(row => `<tr>${row.map(cell => `<td>${cell</td>`).join('')}</tr>`).join('')}
</tbody>
</table>
</div>`;
}
function renderMarkdown(markdown = '') {
if (!markdown) return '';
const lines = markdown.split('\n');
let html = '';
let i = 0;
let inList = false;
const closeList = () => {
if (inList) {
html += '</ul>';
inList = false;
}
};
while (i < lines.length) {
const raw = lines[i] || '';
const line = raw.trim();
if (!line) {
closeList();
i += 1;
continue;
}
if (/^\|/.test(line)) {
closeList();
const tableLines = [];
while (i < lines.length && /^\|/.test((lines[i] || '').trim())) {
tableLines.push((lines[i] || '').trim());
i += 1;
}
html += renderMarkdownTable(tableLines);
continue;
}
if (/^---+$/.test(line)) {
closeList();
html += '<hr class="markdown-divider">';
i += 1;
continue;
}
if (/^###\s+/.test(line)) {
closeList();
html += `<h4>renderInlineMarkdown(line.replace(/^###\s+/, ''))</h4>`;
i += 1;
continue;
}
if (/^##\s+/.test(line)) {
closeList();
html += `<h3>renderInlineMarkdown(line.replace(/^##\s+/, ''))</h3>`;
i += 1;
continue;
}
if (/^#\s+/.test(line)) {
closeList();
html += `<h2>renderInlineMarkdown(line.replace(/^#\s+/, ''))</h2>`;
i += 1;
continue;
}
if (/^>\s+/.test(line)) {
closeList();
const quoteLines = [];
while (i < lines.length && /^>\s+/.test((lines[i] || '').trim())) {
quoteLines.push(renderInlineMarkdown((lines[i] || '').trim().replace(/^>\s+/, '')));
i += 1;
}
html += `<blockquote>quoteLines.join('<br>')</blockquote>`;
continue;
}
if (/^-\s+/.test(line)) {
if (!inList) {
html += '<ul class="markdown-list">';
inList = true;
}
html += `<li>renderInlineMarkdown(line.replace(/^-\s+/, ''))</li>`;
i += 1;
continue;
}
closeList();
html += `<p>renderInlineMarkdown(line)</p>`;
i += 1;
}
closeList();
return `<div class="markdown-body">html</div>`;
}
function getReportTypeLabel(type) {
if (type === 'diagnostic') return '🏥 诊断报告';
if (type === 'daily') return '📊 日报';
return type || '报告';
}
function renderFactorChips(factors = {}) {
const entries = Object.entries(factors || {});
if (!entries.length) return '';
return `<div class="factor-chip-list">entries.map(([label, info]) => `
<div class="factor-chip">
<span class="factor-name">${escapeHtml(label)</span>
<span class="factor-score">info?.score ?? '-'分</span>
<span class="factor-desc">escapeHtml(info?.desc || '')</span>
</div>`).join('')}</div>`;
}
function renderRecommendations(recommendations = []) {
if (!recommendations.length) return '';
return `
<div class="card report-section-card">
<h2 class="card-title">✨ 推荐安装与替换建议</h2>
<div class="recommend-list">
//clawhub.ai/skills/${slug` : '');
const installCmd = rec.install_command || (slug ? `python install_skills.py slug` : '');
const installUrl = rec.install_url || (slug ? `https://clawhub.ai/api/v1/download?slug=slug` : '');
return `
<div class="recommend-card">
<div class="recommend-top">
<div>
<div class="recommend-rank">TOP index + 1</div>
<div class="recommend-name">escapeHtml(rec.name || slug || '未知 Skill')</div>
</div>
<div class="recommend-score">Math.round(rec.recommendation_score || 0)</div>
</div>
<div class="recommend-meta">
<span class="pill">escapeHtml(rec.category || '未分类')</span>
<span class="pill accent">escapeHtml(rec.reason_label || rec.reason_type || '推荐')</span>
rec.hub_rating ? `<span class="pill">⭐ ${rec.hub_rating</span>` : ''}
rec.hub_installs ? `<span class="pill">⬇ ${rec.hub_installs</span>` : ''}
</div>
<div class="recommend-desc">escapeHtml(rec.description || '')</div>
<div class="recommend-reason">escapeHtml(rec.reason_detail || '')</div>
<div class="recommend-actions">
installCmd ? `<button class="rec-action-btn rec-copy-btn" onclick="copyToClipboard('${escapeHtml(installCmd)', this)" title="复制安装命令">
<span class="rec-btn-icon">📋</span> 复制安装命令
</button>` : ''}
installUrl ? `<button class="rec-action-btn rec-url-btn" onclick="copyToClipboard('${escapeHtml(installUrl)', this)" title="复制下载链接">
<span class="rec-btn-icon">🔗</span> 复制下载链接
</button>` : ''}
detailUrl ? `<a class="rec-action-btn rec-detail-btn" href="${escapeHtml(detailUrl)" target="_blank" rel="noopener noreferrer">
<span class="rec-btn-icon">🌐</span> 查看 ClawHub 详情
</a>` : ''}
</div>
</div>`;
}).join('')}
</div>
</div>`;
}
function renderStringListCard(title, items = [], emptyText = '') {
if (!items.length) return emptyText ? `<div class="card report-section-card"><h2 class="card-title">title</h2><div class="empty-inline">emptyText</div></div>` : '';
return `
<div class="card report-section-card">
<h2 class="card-title">title</h2>
<div class="bullet-card-list">
items.map(item => `<div class="bullet-card-item">${escapeHtml(item)</div>`).join('')}
</div>
</div>`;
}
function renderCoverage(coverage = []) {
if (!coverage.length) return '';
return `
<div class="card report-section-card">
<h2 class="card-title">🧩 能力覆盖</h2>
<div class="coverage-list">
coverage.map(item => `
<div class="coverage-item">
<div>
<div class="coverage-name">${escapeHtml(item.category)</div>
<div class="coverage-meta">已安装 item.installed · 可运行 item.runnable</div>
</div>
<div class="coverage-right 'health-warn'">Math.round(item.coverage_ratio || 0)%</div>
</div>`).join('')}
</div>
</div>`;
}
function renderUsageTop(usageTop = []) {
if (!usageTop.length) return '';
return `
<div class="card report-section-card">
<h2 class="card-title">📈 最近 7 天使用情况</h2>
<div class="usage-list">
usageTop.map((item, index) => `
<div class="usage-item">
<div class="usage-rank">${index + 1</div>
<div class="usage-main">
<div class="usage-name">escapeHtml(item.skill_id)</div>
<div class="usage-meta">item.count 次 · 成功 item.success_count 次 · 成功率 item.success_rate%</div>
</div>
</div>`).join('')}
</div>
</div>`;
}
function renderScoreList(scores = []) {
if (!scores.length) {
return `<div class="card report-section-card"><h2 class="card-title">🏆 技能评分</h2><div class="empty-inline">当前没有可展示的运行评分,通常是最近还没有产生有效运行记录。</div></div>`;
}
return `
<div class="card report-section-card">
<h2 class="card-title">🏆 技能评分与因子拆解</h2>
<div class="score-detail-list">
''">i + 1</div>
<div>
<div class="score-skill-name">escapeHtml(s.skill_id || 'unknown')</div>
<div class="score-skill-meta">escapeHtml(s.grade_label || s.grade || '-') · 运行 s.details?.total_runs || 0 次</div>
</div>
</div>
<div class="score-big">Number(s.total_score || 0).toFixed(1)</div>
</div>
renderFactorChips(s.factors || {)}
</div>`).join('')}
</div>
</div>`;
}
// ──────── 页面切换 ────────
function showPage(page) {
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
const el = $(page + '-page');
if (el) el.classList.add('active');
// Tab bar
const tabBar = $('tab-bar');
tabBar.classList.toggle('hidden', page === 'login' || page === 'report-detail' || page === 'skill-detail');
// Tab active state
document.querySelectorAll('.tab-item').forEach(t => {
t.classList.toggle('active', t.dataset.page === page);
});
currentPage = page;
// Load page data
if (page === 'dashboard') loadDashboard();
else if (page === 'reports') loadReports();
else if (page === 'mine') loadMine();
}
// ──────── 登录 ────────
async function doLogin() {
const agentId = $('login-agent-id').value.trim();
const agentToken = $('login-agent-token').value.trim();
if (!agentId || !agentToken) {
showToast('请输入智能体 ID 和 Key');
return;
}
const btn = $('login-btn');
btn.disabled = true;
btn.textContent = '登录中...';
try {
const data = await api('/login', {
method: 'POST',
body: JSON.stringify({ agent_id: agentId, agent_token: agentToken }),
});
if (data && data.ok) {
setToken(data.token);
currentUser = data.user;
// 登录成功后清空输入框
$('login-agent-id').value = '';
$('login-agent-token').value = '';
showToast('登录成功');
showPage('dashboard');
} else {
showToast(data?.error || '登录失败');
}
} catch (e) {
showToast('网络错误,请重试');
}
btn.disabled = false;
btn.textContent = '登 录';
}
// ──────── 仪表盘 ────────
async function loadDashboard() {
const container = $('dashboard-content');
container.innerHTML = '<div class="loading"><div class="spinner"></div>加载中...</div>';
const data = await api('/dashboard');
if (!data || !data.ok) {
container.innerHTML = '<div class="empty-state"><div class="icon">📭</div><h3>暂无数据</h3><p>Agent 尚未上报任何数据</p></div>';
return;
}
const d = data.dashboard;
const ringColor = getHealthColor(d.avg_health);
const firstAgentId = d.agents && d.agents.length > 0 ? d.agents[0].agent_id : '';
let agentsHtml = '';
if (d.agents && d.agents.length > 0) {
agentsHtml = d.agents.map(a => `
<div class="card agent-card" onclick="loadAgentSkills('a.agent_id')">
<div class="agent-header">
<div class="agent-name">a.name''</div>
<div class="agent-health getHealthClass(a.health_score)">Math.round(a.health_score || 0)分</div>
</div>
</div>
`).join('');
}
// ── 从最新报告数据中提取丰富内容 ──
const lr = d.latest_report || {};
const lrOv = lr.overview || {};
const lrRecs = lr.recommendations || [];
const lrDiag = lr.diagnostics || {};
const lrInstalled = lr.installed_skills || [];
// 用最新报告中的 overview 数据覆盖仪表盘汇总(更准确)
const displayTotalSkills = lrOv.total_installed || d.total_skills || 0;
const displayRunnableSkills = lrOv.total_runnable || 0;
// 诊断问题 & 建议 HTML
let diagHtml = '';
const issues = lrDiag.issues || [];
const suggestions = lrDiag.suggestions || [];
if (issues.length > 0 || suggestions.length > 0) {
diagHtml += '<div class="card report-section-card">';
diagHtml += '<h2 class="card-title">🩺 诊断摘要</h2>';
if (issues.length > 0) {
diagHtml += '<div class="diag-section"><div class="diag-subtitle">🚨 发现问题</div>';
diagHtml += '<div class="bullet-card-list">';
issues.forEach(item => { diagHtml += `<div class="bullet-card-item">escapeHtml(item)</div>`; });
diagHtml += '</div></div>';
}
if (suggestions.length > 0) {
diagHtml += '<div class="diag-section" style="margin-top:12px"><div class="diag-subtitle">🛠 优化建议</div>';
diagHtml += '<div class="bullet-card-list">';
suggestions.forEach(item => { diagHtml += `<div class="bullet-card-item">escapeHtml(item)</div>`; });
diagHtml += '</div></div>';
}
diagHtml += '</div>';
}
// 推荐安装 HTML
let recsHtml = '';
if (lrRecs.length > 0) {
recsHtml = renderRecommendations(lrRecs);
}
// 已安装技能 HTML
let installedHtml = '';
if (lrInstalled.length > 0) {
installedHtml = `
<div class="card report-section-card">
<h2 class="card-title">🧩 已安装 Skills <span style="font-size:13px; color:var(--text-muted); font-weight:400;">lr.installed_skills_total || lrInstalled.length 个</span></h2>
<div class="installed-skill-list">
lrInstalled.map(s => `
<div class="installed-skill-item">
<div>
<div class="installed-name">${escapeHtml(s.name || s.slug || 'unknown')</div>
<div class="installed-meta">escapeHtml(s.category || '未分类') · '仅文档'</div>
</div>
<div class="installed-right">
s.total_score != null ? `<span class="installed-score">${Number(s.total_score).toFixed(1)</span>` : '<span class="installed-score muted">待评分</span>'}
</div>
</div>`).join('')}
</div>
</div>`;
}
// 能力覆盖 HTML
let coverageHtml = renderCoverage(lrDiag.coverage || []);
// 未使用技能 HTML
let unusedHtml = '';
const unused = lrDiag.unused_runnable || [];
if (unused.length > 0) {
unusedHtml = renderStringListCard('💤 未使用的可运行 Skills', unused);
}
// 报告来源标注
let reportBadge = '';
if (lr.id) {
reportBadge = `
<div class="card report-source-card" onclick="loadReportDetail(lr.id)" style="cursor:pointer">
<div class="report-source-inner">
<span>📄 数据来源:getReportTypeLabel(lr.report_type) · lr.report_date || ''</span>
<span class="report-source-link">查看完整报告 →</span>
</div>
</div>`;
}
container.innerHTML = `
<div class="compact-hero card">
<div class="compact-hero-left">
<div class="compact-agent-name">'智能体')</div>
<p class="compact-subtitle">数据总览</p>
</div>
<div class="compact-hero-right">
<div class="compact-score" style="color: ringColor">Math.round(d.avg_health || 0)</div>
<div class="compact-score-label">健康度</div>
</div>
</div>
<div class="compact-metrics">
<div class="cm-item"><span class="cm-val">displayTotalSkills</span><span class="cm-lbl">已安装</span></div>
<div class="cm-item clickable" onclick="firstAgentId ? `loadAgentSkills('${firstAgentId')` : ''}"><span class="cm-val">displayRunnableSkills</span><span class="cm-lbl">可运行 →</span></div>
<div class="cm-item"><span class="cm-val">d.total_runs</span><span class="cm-lbl">运行</span></div>
<div class="cm-item"><span class="cm-val">d.avg_success_rate%</span><span class="cm-lbl">成功率</span></div>
</div>
reportBadge
diagHtml
recsHtml
coverageHtml
installedHtml
unusedHtml
''
16px 0 8px;">🤖 我的智能体</h2>${agentsHtml` : ''}
<footer class="footer">Skills Monitor PWA v1.0 · Powered by CodeBuddy</footer>
`;
// 画趋势图
if (d.trend && d.trend.length > 1) {
renderTrendChart(d.trend);
}
}
function renderTrendChart(trend) {
const ctx = document.getElementById('trendCanvas');
if (!ctx) return;
if (trendChart) trendChart.destroy();
trendChart = new Chart(ctx, {
type: 'line',
data: {
labels: trend.map(t => t.date.slice(5)),
datasets: [{
label: '健康度',
data: trend.map(t => t.health_score),
borderColor: '#667eea',
backgroundColor: 'rgba(102,126,234,0.1)',
fill: true,
tension: 0.4,
borderWidth: 2,
pointRadius: 4,
pointBackgroundColor: '#667eea',
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
y: { min: 0, max: 100, grid: { color: 'rgba(255,255,255,0.05)' }, ticks: { color: '#999' } },
x: { grid: { display: false }, ticks: { color: '#999' } }
}
}
});
}
// ──────── Agent Skills ────────
async function loadAgentSkills(agentId) {
showPage('skill-detail');
const container = $('skill-detail-content');
container.innerHTML = '<div class="loading"><div class="spinner"></div>加载中...</div>';
const data = await api('/skills/' + agentId);
if (!data || !data.ok) {
container.innerHTML = '<div class="empty-state"><div class="icon">📭</div><h3>暂无数据</h3></div>';
return;
}
const skills = data.skills || [];
const sorted = skills.sort((a, b) => (b.total_score || 0) - (a.total_score || 0));
let html = `
<div class="back-btn" onclick="showPage('dashboard')">← 返回仪表盘</div>
<div class="page-header" style="padding-top: 0">
<div class="icon">🧠</div>
<h1>escapeHtml(data.agent_name || '智能体')</h1>
<p class="subtitle">skills.length 个有评分的技能</p>
</div>
`;
if (sorted.length > 0) {
html += renderScoreList(sorted);
} else {
html += '<div class="empty-state"><div class="icon">📭</div><h3>暂无技能数据</h3><p>这通常表示最近还没有有效运行记录,或报告尚未重新上报。</p></div>';
}
container.innerHTML = html;
}
// ──────── 报告列表 ────────
async function loadReports() {
const container = $('reports-content');
container.innerHTML = '<div class="loading"><div class="spinner"></div>加载中...</div>';
const data = await api('/reports?limit=30&type=all');
if (!data || !data.ok || !data.reports.length) {
container.innerHTML = '<div class="empty-state"><div class="icon">📋</div><h3>暂无报告</h3><p>智能体尚未上报任何数据</p></div>';
return;
}
let html = '<div class="card">';
data.reports.forEach(r => {
const scoreColor = getHealthColor(r.health_score);
const typeLabel = r.report_type === 'diagnostic' ? '🏥 诊断' : r.report_type === 'daily' ? '📊 日报' : r.report_type;
html += `
<div class="report-item" onclick="loadReportDetail(r.id)">
<div class="report-info">
<div class="report-date">r.report_date <span style="font-size:11px;color:var(--text-muted)">typeLabel</span></div>
<div class="report-agent">r.agent_name || r.agent_id.slice(0, 8)</div>
</div>
<div class="report-score" style="color: scoreColor">Math.round(r.health_score || 0)</div>
</div>`;
});
html += '</div>';
container.innerHTML = html;
}
// ──────── 报告详情 ────────
async function loadReportDetail(reportId) {
showPage('report-detail');
const container = $('report-detail-content');
container.innerHTML = '<div class="loading"><div class="spinner"></div>加载中...</div>';
const data = await api('/report/' + reportId);
if (!data || !data.ok) {
container.innerHTML = '<div class="empty-state"><div class="icon">❌</div><h3>加载失败</h3></div>';
return;
}
const r = data.report;
const ringColor = getHealthColor(r.health_score);
const scores = r.scores || [];
const sorted = scores.sort((a, b) => (b.total_score || 0) - (a.total_score || 0));
const ov = r.overview || {};
const dg = r.diagnostics || {};
const recommendations = r.recommendations || [];
const reportTypeLabel = getReportTypeLabel(r.report_type);
const healthScore = Math.round(r.health_score || 0);
// ── 紧凑顶栏:返回 + 报告基本信息 + 健康分数 ──
let html = `
<div class="back-btn" onclick="showPage('reports')">← 返回报告列表</div>
<div class="compact-hero card">
<div class="compact-hero-left">
<div class="detail-badge">reportTypeLabel <span class="compact-id">#r.id</span></div>
<h1 class="compact-title">escapeHtml(r.report_date || '')</h1>
<p class="compact-subtitle">escapeHtml(r.agent_name || (r.agent_id || '').slice(0, 8)) · escapeHtml(r.trigger || '已上报')</p>
</div>
<div class="compact-hero-right">
<div class="compact-score" style="color: ringColor">healthScore</div>
<div class="compact-score-label">健康度</div>
</div>
</div>
<div class="compact-metrics">
<div class="cm-item"><span class="cm-val">r.total_runs || 0</span><span class="cm-lbl">7天运行</span></div>
<div class="cm-item"><span class="cm-val">Number(r.success_rate || 0).toFixed(1)%</span><span class="cm-lbl">成功率</span></div>
<div class="cm-item"><span class="cm-val">r.active_skills || 0</span><span class="cm-lbl">活跃</span></div>
<div class="cm-item"><span class="cm-val">Math.round(ov.avg_duration_ms || 0)ms</span><span class="cm-lbl">响应</span></div>
</div>
`;
// ── 诊断摘要 — 直接突出展示 ──
const issues = dg.issues || [];
const suggestions = dg.suggestions || [];
const hasMarkdown = !!r.report_markdown;
if (issues.length > 0 || suggestions.length > 0) {
html += `
<div class="card highlight-card highlight-diag">
<h2 class="card-title">🩺 诊断摘要</h2>`;
if (issues.length > 0) {
html += `<div class="diag-section"><div class="diag-subtitle">🚨 发现 issues.length 个问题</div>`;
html += '<div class="bullet-card-list">';
issues.forEach(item => { html += `<div class="bullet-card-item highlight-issue-item">escapeHtml(item)</div>`; });
html += '</div></div>';
}
if (suggestions.length > 0) {
html += `<div class="diag-section" style="margin-top:12px"><div class="diag-subtitle">🛠 suggestions.length 条优化建议</div>`;
html += '<div class="bullet-card-list">';
suggestions.forEach(item => { html += `<div class="bullet-card-item highlight-suggest-item">escapeHtml(item)</div>`; });
html += '</div></div>';
}
if (hasMarkdown) {
html += `<div class="diag-expand-row" onclick="toggleDiagnosticReport()"><span>📝 查看完整诊断报告原文</span><span id="diag-toggle-icon">▶</span></div>`;
}
html += `</div>`;
}
// ── 诊断原文(折叠,不占空间) ──
if (hasMarkdown) {
html += `
<div id="sec-diagnostic-report" class="diagnostic-report-body-wrap" style="display:none;" id="diag-report-body">
<div class="card report-section-card">
<h2 class="card-title" style="margin-bottom:12px">📝 完整诊断报告</h2>
renderMarkdown(r.report_markdown)
</div>
</div>`;
}
// ── 推荐安装 — 紧随诊断,突出展示 ──
if (recommendations.length > 0) {
html += `<div id="sec-recommend"></div>`;
html += `
<div class="card highlight-card highlight-rec">
<h2 class="card-title">✨ 推荐安装(recommendations.length 条)</h2>
<div class="recommend-list">
//clawhub.ai/skills/${slug` : '');
const installCmd = rec.install_command || (slug ? `python install_skills.py slug` : '');
const installUrl = rec.install_url || (slug ? `https://clawhub.ai/api/v1/download?slug=slug` : '');
return `
<div class="recommend-card">
<div class="recommend-top">
<div>
<div class="recommend-rank">TOP index + 1</div>
<div class="recommend-name">escapeHtml(rec.name || slug || '未知 Skill')</div>
</div>
<div class="recommend-score">Math.round(rec.recommendation_score || 0)</div>
</div>
<div class="recommend-meta">
<span class="pill">escapeHtml(rec.category || '未分类')</span>
<span class="pill accent">escapeHtml(rec.reason_label || rec.reason_type || '推荐')</span>
rec.hub_rating ? `<span class="pill">⭐ ${rec.hub_rating</span>` : ''}
rec.hub_installs ? `<span class="pill">⬇ ${rec.hub_installs</span>` : ''}
</div>
<div class="recommend-desc">escapeHtml(rec.description || '')</div>
<div class="recommend-reason">escapeHtml(rec.reason_detail || '')</div>
<div class="recommend-actions">
installCmd ? `<button class="rec-action-btn rec-copy-btn" onclick="copyToClipboard('${escapeHtml(installCmd)', this)" title="复制安装命令">
<span class="rec-btn-icon">📋</span> 复制安装命令
</button>` : ''}
installUrl ? `<button class="rec-action-btn rec-url-btn" onclick="copyToClipboard('${escapeHtml(installUrl)', this)" title="复制下载链接">
<span class="rec-btn-icon">🔗</span> 复制下载链接
</button>` : ''}
detailUrl ? `<a class="rec-action-btn rec-detail-btn" href="${escapeHtml(detailUrl)" target="_blank" rel="noopener noreferrer">
<span class="rec-btn-icon">🌐</span> 查看 ClawHub 详情
</a>` : ''}
</div>
</div>`;
}).join('')}
</div>
</div>`;
}
// ── 报告概览(折叠面板,默认隐藏) ──
if (Object.keys(ov).length > 0) {
html += `
<div class="card report-section-card">
<div class="collapsible-header" onclick="toggleOverviewPanel()">
<h2 class="card-title" style="margin:0">📋 报告概览</h2>
<span class="collapsible-icon" id="ov-toggle-icon">▶</span>
</div>
<div class="collapsible-body" id="ov-panel-body" style="display:none;">
<div class="overview-grid" style="margin-top:14px;">
<div class="ov-item"><span class="ov-label">已安装技能</span><span class="ov-value">ov.total_installed || '-'</span></div>
<div class="ov-item"><span class="ov-label">可运行技能</span><span class="ov-value">ov.total_runnable || '-'</span></div>
<div class="ov-item"><span class="ov-label">平均得分</span><span class="ov-value">'-'</span></div>
<div class="ov-item"><span class="ov-label">最佳 Skill</span><span class="ov-value">escapeHtml(ov.top_skill || '-')</span></div>
<div class="ov-item"><span class="ov-label">关注 Skill</span><span class="ov-value">escapeHtml(ov.worst_skill || '-')</span></div>
<div class="ov-item"><span class="ov-label">数据体积</span><span class="ov-value">r.data_size_bytes || 0B</span></div>
</div>
</div>
</div>`;
}
// ── 详细数据(折叠面板,默认隐藏) ──
const hasCoverage = (dg.coverage || []).length > 0;
const hasUsageTop = (dg.usage_top || []).length > 0;
const hasUnused = (dg.unused_runnable || []).length > 0;
const hasScores = sorted.length > 0;
if (hasCoverage || hasUsageTop || hasUnused || hasScores) {
html += `
<div class="card report-section-card">
<div class="collapsible-header" onclick="toggleDetailDataPanel()">
<h2 class="card-title" style="margin:0">📊 详细数据</h2>
<span class="collapsible-icon" id="detail-data-toggle-icon">▶</span>
</div>
<div class="collapsible-body" id="detail-data-body" style="display:none;">
<div style="margin-top:14px;">
renderCoverage(dg.coverage || [])
renderUsageTop(dg.usage_top || [])
renderStringListCard('💤 未使用的可运行 Skills', dg.unused_runnable || [], '最近 7 天没有未使用的可运行技能')
renderScoreList(sorted.slice(0, 15))
</div>
</div>
</div>`;
}
container.innerHTML = html;
}
// ──────── 我的 ────────
async function loadMine() {
const container = $('mine-content');
container.innerHTML = '<div class="loading"><div class="spinner"></div>加载中...</div>';
const data = await api('/user');
if (!data || !data.ok) {
container.innerHTML = '<div class="empty-state"><div class="icon">❌</div><h3>加载失败</h3></div>';
return;
}
const u = data.user;
const agentsData = await api('/agents');
const agents = agentsData?.agents || [];
let agentsHtml = agents.map(a => `
<div class="setting-item">
<div>
<div class="setting-label">a.name''</div>
<div class="setting-desc">技能: a.total_skills || 0 · 健康度: Math.round(a.health_score || 0)</div>
</div>
<div class="agent-health getHealthClass(a.health_score)">Math.round(a.health_score || 0)</div>
</div>
`).join('');
container.innerHTML = `
<div class="card" style="text-align: center;">
<div class="user-avatar">👤</div>
<div style="font-size: 18px; font-weight: 600;">u.nickname || 'PWA 用户'</div>
<div style="font-size: 13px; color: var(--text-muted); margin-top: 4px;">
已绑定 u.agent_count 个智能体
</div>
</div>
<div class="card">
<h2 class="card-title">🤖 我的智能体</h2>
agentsHtml || '<div class="empty-state"><p>暂无绑定的智能体</p></div>'
</div>
<div class="card">
<h2 class="card-title" style="cursor: pointer;" onclick="toggleBindForm()">➕ 绑定新智能体 <span id="bind-toggle-arrow" style="float:right; font-size:14px; transition: transform .2s;">▶</span></h2>
<div id="bind-form" style="display: none;">
<div class="form-group">
<label>智能体 ID</label>
<input type="text" id="bind-agent-id" placeholder="输入智能体 ID">
</div>
<div class="form-group">
<label>Key</label>
<input type="password" id="bind-agent-token" placeholder="输入 Key">
</div>
<button class="btn btn-primary" onclick="doBind()" style="margin-top: 8px;">绑定</button>
</div>
</div>
<div class="card">
<h2 class="card-title">⚙️ 设置</h2>
<div class="setting-item">
<div>
<div class="setting-label">关于</div>
<div class="setting-desc">Skills Monitor PWA v1.0</div>
</div>
</div>
<div class="setting-item">
<div>
<div class="setting-label" style="color: var(--danger); cursor: pointer;" onclick="doLogout()">退出登录</div>
</div>
</div>
</div>
<footer class="footer">Skills Monitor PWA v1.0 · Powered by CodeBuddy</footer>
`;
}
function toggleBindForm() {
const form = $('bind-form');
const arrow = $('bind-toggle-arrow');
if (form.style.display === 'none') {
form.style.display = 'block';
arrow.textContent = '▼';
} else {
form.style.display = 'none';
arrow.textContent = '▶';
}
}
async function doBind() {
const agentId = $('bind-agent-id').value.trim();
const agentToken = $('bind-agent-token').value.trim();
if (!agentId || !agentToken) {
showToast('请输入智能体 ID 和 Key');
return;
}
const data = await api('/bind', {
method: 'POST',
body: JSON.stringify({ agent_id: agentId, agent_token: agentToken }),
});
if (data && data.ok) {
showToast('绑定成功');
loadMine();
} else {
showToast(data?.error || '绑定失败');
}
}
function doLogout() {
clearToken();
currentUser = null;
showPage('login');
showToast('已退出');
}
// ──────── 初始化 ────────
async function init() {
// 清理旧缓存 & 注册 Service Worker
if ('serviceWorker' in navigator) {
// 先 unregister 所有旧的 SW,确保缓存被清理
const regs = await navigator.serviceWorker.getRegistrations();
for (const reg of regs) {
await reg.unregister();
}
// 清理所有 caches
if (window.caches) {
const keys = await caches.keys();
await Promise.all(keys.map(k => caches.delete(k)));
}
// 注册新的 SW
navigator.serviceWorker.register('/static/pwa/sw.js?v=20260319a').catch(() => {});
}
// Tab bar 事件
document.querySelectorAll('.tab-item').forEach(tab => {
tab.addEventListener('click', () => showPage(tab.dataset.page));
});
// 登录按钮
$('login-btn').addEventListener('click', doLogin);
// 回车登录
$('login-agent-token').addEventListener('keydown', e => {
if (e.key === 'Enter') doLogin();
});
// 检查登录状态
const token = getToken();
if (token) {
const data = await api('/user');
if (data && data.ok) {
currentUser = data.user;
showPage('dashboard');
return;
}
}
showPage('login');
}
document.addEventListener('DOMContentLoaded', init);
FILE:server/static/css/h5.css
/* ====================================
Skills Monitor H5 — 暗色主题样式
移动端优先 + 桌面端自适应
==================================== */
:root {
--bg-primary: #0a0a1a;
--bg-card: #141428;
--bg-card-hover: #1a1a36;
--text-primary: #e8e8f0;
--text-secondary: #999aaa;
--text-muted: #666678;
--accent: #667eea;
--accent-light: rgba(102, 126, 234, 0.15);
--success: #27ae60;
--warning: #f39c12;
--danger: #e74c3c;
--border: rgba(255, 255, 255, 0.06);
--radius: 16px;
--radius-sm: 10px;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Helvetica Neue", sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
line-height: 1.6;
-webkit-font-smoothing: antialiased;
}
.container {
max-width: 480px;
margin: 0 auto;
padding: 16px;
min-height: 100vh;
}
/* ──── Header ──── */
.header {
text-align: center;
padding: 32px 0 20px;
}
.header-icon {
font-size: 48px;
margin-bottom: 8px;
}
.header h1 {
font-size: 22px;
font-weight: 700;
color: var(--text-primary);
}
.subtitle {
font-size: 14px;
color: var(--text-secondary);
margin-top: 4px;
}
/* ──── Card ──── */
.card {
background: var(--bg-card);
border-radius: var(--radius);
padding: 20px;
margin-bottom: 16px;
border: 1px solid var(--border);
}
.card-title {
font-size: 16px;
font-weight: 600;
margin-bottom: 16px;
color: var(--text-primary);
}
/* ──── Health Score Ring ──── */
.health-card {
text-align: center;
}
.health-score-wrapper {
display: flex;
justify-content: center;
margin-bottom: 20px;
}
.health-ring {
position: relative;
width: 120px;
height: 120px;
}
.health-ring svg {
transform: rotate(-90deg);
width: 100%;
height: 100%;
}
.ring-bg {
fill: none;
stroke: rgba(255, 255, 255, 0.06);
stroke-width: 8;
}
.ring-fill {
fill: none;
stroke: var(--accent);
stroke-width: 8;
stroke-linecap: round;
transition: stroke-dasharray 1s ease;
}
.score-text {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
}
.score-num {
display: block;
font-size: 32px;
font-weight: 800;
color: var(--accent);
line-height: 1;
}
.score-label {
display: block;
font-size: 11px;
color: var(--text-muted);
margin-top: 4px;
}
/* ──── Metrics Row ──── */
.metrics-row {
display: flex;
justify-content: space-around;
padding-top: 16px;
border-top: 1px solid var(--border);
}
.metric {
text-align: center;
}
.metric-value {
font-size: 20px;
font-weight: 700;
color: var(--text-primary);
}
.metric-label {
font-size: 11px;
color: var(--text-muted);
margin-top: 2px;
}
/* ──── Skill List ──── */
.skill-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.skill-item {
display: grid;
grid-template-columns: 28px 1fr 50px;
grid-template-rows: auto auto;
gap: 0 10px;
align-items: center;
padding: 10px 0;
border-bottom: 1px solid var(--border);
}
.skill-item:last-child { border-bottom: none; }
.skill-rank {
grid-row: 1 / 3;
width: 28px;
height: 28px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.06);
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 700;
color: var(--text-secondary);
}
.skill-rank.top3 {
background: var(--accent-light);
color: var(--accent);
}
.skill-info {
display: flex;
align-items: center;
gap: 8px;
}
.skill-name {
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.skill-grade {
font-size: 11px;
font-weight: 700;
padding: 1px 6px;
border-radius: 4px;
flex-shrink: 0;
}
.grade-a\+, .grade-a { background: rgba(39, 174, 96, 0.2); color: #27ae60; }
.grade-b { background: rgba(102, 126, 234, 0.2); color: #667eea; }
.grade-c { background: rgba(243, 156, 18, 0.2); color: #f39c12; }
.grade-d, .grade-f { background: rgba(231, 76, 60, 0.2); color: #e74c3c; }
.skill-score {
font-size: 16px;
font-weight: 700;
text-align: right;
color: var(--accent);
}
.skill-bar {
grid-column: 2 / 4;
height: 4px;
background: rgba(255, 255, 255, 0.04);
border-radius: 2px;
overflow: hidden;
margin-top: 4px;
}
.skill-bar-fill {
height: 100%;
background: linear-gradient(90deg, var(--accent), #764ba2);
border-radius: 2px;
transition: width 0.8s ease;
}
/* ──── Overview Grid ──── */
.overview-grid, .agent-info {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.ov-item {
display: flex;
flex-direction: column;
gap: 2px;
}
.ov-label {
font-size: 11px;
color: var(--text-muted);
}
.ov-value {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
}
/* ──── Agent Card ──── */
.agent-card {
cursor: pointer;
transition: background 0.2s;
}
.agent-card:active {
background: var(--bg-card-hover);
}
.agent-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.agent-name {
font-size: 16px;
font-weight: 600;
}
.badge {
font-size: 10px;
padding: 1px 6px;
border-radius: 4px;
margin-left: 6px;
}
.badge.primary {
background: var(--accent-light);
color: var(--accent);
}
.agent-health {
font-size: 15px;
font-weight: 700;
}
.agent-health.health-good { color: var(--success); }
.agent-health.health-warn { color: var(--warning); }
.agent-health.health-bad { color: var(--danger); }
.agent-meta {
display: flex;
justify-content: space-between;
font-size: 12px;
color: var(--text-muted);
}
/* ──── Settings ──── */
.setting-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 0;
border-bottom: 1px solid var(--border);
}
.setting-item:last-child { border-bottom: none; }
.setting-label {
font-size: 15px;
font-weight: 500;
}
.setting-desc {
font-size: 12px;
color: var(--text-muted);
margin-top: 2px;
}
/* Switch Toggle */
.switch {
position: relative;
display: inline-block;
width: 48px;
height: 28px;
}
.switch input { opacity: 0; width: 0; height: 0; }
.slider {
position: absolute;
cursor: pointer;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(255, 255, 255, 0.1);
transition: 0.3s;
border-radius: 28px;
}
.slider::before {
content: "";
position: absolute;
height: 22px;
width: 22px;
left: 3px;
bottom: 3px;
background: white;
transition: 0.3s;
border-radius: 50%;
}
input:checked + .slider {
background: var(--accent);
}
input:checked + .slider::before {
transform: translateX(20px);
}
.select-time {
background: rgba(255, 255, 255, 0.06);
border: 1px solid var(--border);
color: var(--text-primary);
padding: 6px 12px;
border-radius: var(--radius-sm);
font-size: 14px;
}
.info-list {
list-style: none;
padding: 0;
}
.info-list li {
padding: 6px 0;
font-size: 13px;
color: var(--text-secondary);
border-bottom: 1px solid var(--border);
}
.info-list li::before {
content: "•";
color: var(--accent);
margin-right: 8px;
}
.info-list li:last-child { border-bottom: none; }
/* ──── Steps (Bind Guide) ──── */
.steps {
display: flex;
flex-direction: column;
gap: 16px;
}
.step {
display: flex;
gap: 14px;
align-items: flex-start;
}
.step-num {
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--accent-light);
color: var(--accent);
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
flex-shrink: 0;
}
.step-content h3 {
font-size: 15px;
font-weight: 600;
margin-bottom: 4px;
}
.step-content p, .step-content code {
font-size: 13px;
color: var(--text-secondary);
}
code {
background: rgba(102, 126, 234, 0.1);
color: var(--accent);
padding: 2px 8px;
border-radius: 4px;
font-family: "SF Mono", "Fira Code", monospace;
font-size: 13px;
}
.summary-banner {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
background: linear-gradient(135deg, rgba(102,126,234,0.18), rgba(118,75,162,0.12));
}
.summary-title {
font-size: 15px;
font-weight: 700;
}
.summary-desc {
font-size: 12px;
color: var(--text-secondary);
margin-top: 4px;
}
.status-pill {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 72px;
padding: 8px 12px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
flex-shrink: 0;
}
.status-healthy { background: rgba(39,174,96,0.18); color: var(--success); }
.status-warning { background: rgba(243,156,18,0.16); color: var(--warning); }
.status-empty { background: rgba(231,76,60,0.16); color: var(--danger); }
.sync-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.sync-item {
padding: 14px;
border-radius: 12px;
background: rgba(255,255,255,0.03);
border: 1px solid var(--border);
display: flex;
flex-direction: column;
gap: 4px;
}
.sync-label {
font-size: 11px;
color: var(--text-muted);
}
.sync-item strong {
font-size: 18px;
}
.sync-sub {
font-size: 11px;
color: var(--text-secondary);
}
.logic-note {
font-size: 12px;
color: var(--text-secondary);
margin-bottom: 14px;
line-height: 1.7;
}
.recommendation-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.recommendation-card {
padding: 14px;
border-radius: 14px;
border: 1px solid var(--border);
background: rgba(255,255,255,0.03);
}
.recommend-head {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: flex-start;
}
.recommend-name {
font-size: 15px;
font-weight: 700;
}
.recommend-meta,
.recommend-desc,
.recommend-logic {
font-size: 12px;
color: var(--text-secondary);
}
.recommend-meta { margin-top: 2px; }
.recommend-desc { margin-top: 10px; }
.recommend-logic { margin-top: 8px; line-height: 1.7; }
.recommend-score {
font-size: 22px;
font-weight: 800;
color: var(--accent);
line-height: 1;
}
.pill-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
}
.pill,
.logic-chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 5px 10px;
border-radius: 999px;
background: rgba(102,126,234,0.12);
color: var(--accent);
font-size: 11px;
font-weight: 600;
}
.logic-chip {
margin-top: 10px;
white-space: normal;
line-height: 1.5;
}
.skill-item-rich {
grid-template-columns: 30px 1fr 56px;
}
.skill-info-rich {
display: flex;
flex-direction: column;
gap: 6px;
align-items: stretch;
}
.skill-name-row {
display: flex;
align-items: center;
gap: 8px;
}
.skill-meta-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
font-size: 11px;
color: var(--text-muted);
}
.inventory-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.inventory-item {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid var(--border);
}
.inventory-item:last-child { border-bottom: none; }
.inventory-name {
font-size: 14px;
font-weight: 600;
}
.inventory-meta {
font-size: 12px;
color: var(--text-secondary);
margin-top: 3px;
}
.inventory-side {
text-align: right;
}
.inventory-score {
font-size: 18px;
font-weight: 800;
color: var(--accent);
}
.inventory-grade {
font-size: 12px;
color: var(--text-secondary);
}
.muted { color: var(--text-muted); }
.diag-block + .diag-block {
margin-top: 16px;
}
.diag-title {
font-size: 13px;
font-weight: 700;
margin-bottom: 8px;
}
.info-list.compact li {
padding: 8px 0;
line-height: 1.6;
}
.markdown-preview {
margin-top: 16px;
max-height: 320px;
overflow-y: auto;
padding: 14px;
border-radius: 12px;
background: rgba(0,0,0,0.22);
font-size: 12px;
line-height: 1.8;
color: var(--text-secondary);
white-space: pre-wrap;
word-break: break-word;
}
/* ──── Empty State ──── */
.empty-state {
text-align: center;
padding: 40px 20px;
}
.empty-icon {
font-size: 48px;
margin-bottom: 12px;
}
.empty-state h3 {
font-size: 18px;
margin-bottom: 8px;
}
.empty-state p {
color: var(--text-muted);
font-size: 14px;
}
/* ──── Footer ──── */
.footer {
text-align: center;
padding: 24px 0;
color: var(--text-muted);
font-size: 12px;
}
/* ──── Desktop Adaptation ──── */
@media (min-width: 600px) {
.container {
max-width: 560px;
padding: 24px;
}
.header { padding: 48px 0 28px; }
.header h1 { font-size: 26px; }
.card { padding: 24px; }
}
FILE:server/models/database.py
"""
中心化服务器 — 数据库模型 (SQLAlchemy)
=====================================
支持 SQLite(轻量部署)和 PostgreSQL(生产环境)。
核心表:
- users 微信用户(openid 绑定)
- agents Agent 实例
- user_agents 用户-Agent 绑定关系(多对多)
- skill_reports Agent 上报的数据(每日汇总)
- daily_digests 每日推送摘要
"""
import os
import gzip
import json
from datetime import datetime
from pathlib import Path
from flask_sqlalchemy import SQLAlchemy
db = SQLAlchemy()
# ──────── 用户表(微信用户) ────────
class User(db.Model):
"""微信用户 — 通过扫码关注公众号获取 openid"""
__tablename__ = "users"
id = db.Column(db.Integer, primary_key=True)
openid = db.Column(db.String(64), unique=True, nullable=False, index=True)
union_id = db.Column(db.String(64), index=True) # 微信开放平台 unionid
nickname = db.Column(db.String(128))
avatar_url = db.Column(db.String(512))
subscribe = db.Column(db.Boolean, default=True) # 是否关注公众号
subscribe_time = db.Column(db.DateTime)
mp_openid = db.Column(db.String(64), index=True) # 小程序 openid(可选)
# 推送设置
push_enabled = db.Column(db.Boolean, default=True)
push_hour = db.Column(db.Integer, default=21) # 每日推送时间(小时)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# 关系
agents = db.relationship("UserAgent", back_populates="user", lazy="dynamic")
def to_dict(self):
return {
"id": self.id,
"openid": self.openid[:8] + "***",
"nickname": self.nickname,
"subscribe": self.subscribe,
"push_enabled": self.push_enabled,
"agent_count": self.agents.count(),
"created_at": self.created_at.isoformat() if self.created_at else None,
}
# ──────── Agent 表 ────────
class Agent(db.Model):
"""Agent 实例 — 每个本地安装生成一个 agent_id + token"""
__tablename__ = "agents"
id = db.Column(db.Integer, primary_key=True)
agent_id = db.Column(db.String(64), unique=True, nullable=False, index=True)
token_hash = db.Column(db.String(128), nullable=False) # SHA256(token)
name = db.Column(db.String(128)) # 用户可设置名称
description = db.Column(db.String(512))
# 状态
last_report_at = db.Column(db.DateTime)
last_heartbeat_at = db.Column(db.DateTime)
total_skills = db.Column(db.Integer, default=0)
runnable_skills = db.Column(db.Integer, default=0)
health_score = db.Column(db.Float)
# 系统信息
os_info = db.Column(db.String(128))
python_version = db.Column(db.String(32))
monitor_version = db.Column(db.String(16))
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# 关系
users = db.relationship("UserAgent", back_populates="agent", lazy="dynamic")
reports = db.relationship("SkillReport", back_populates="agent", lazy="dynamic",
order_by="SkillReport.report_date.desc()")
def to_dict(self, include_token=False):
d = {
"id": self.id,
"agent_id": self.agent_id,
"name": self.name or f"Agent-{self.agent_id[:8]}",
"last_report_at": self.last_report_at.isoformat() if self.last_report_at else None,
"total_skills": self.total_skills,
"runnable_skills": self.runnable_skills,
"health_score": self.health_score,
"monitor_version": self.monitor_version,
"created_at": self.created_at.isoformat() if self.created_at else None,
}
return d
# ──────── 用户-Agent 绑定(多对多) ────────
class UserAgent(db.Model):
"""
一个微信用户可以绑定多个 Agent(多个设备/环境)
绑定时需要 agent_id + token 验证
"""
__tablename__ = "user_agents"
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)
agent_id = db.Column(db.Integer, db.ForeignKey("agents.id"), nullable=False)
bind_token = db.Column(db.String(128)) # 绑定时使用的 token(加密)
alias = db.Column(db.String(64)) # 用户给 Agent 设的别名
is_primary = db.Column(db.Boolean, default=False) # 是否为主 Agent
created_at = db.Column(db.DateTime, default=datetime.utcnow)
# 唯一约束:同一用户同一 Agent 只能绑定一次
__table_args__ = (
db.UniqueConstraint("user_id", "agent_id", name="uq_user_agent"),
)
user = db.relationship("User", back_populates="agents")
agent = db.relationship("Agent", back_populates="users")
def to_dict(self):
return {
"id": self.id,
"agent_id": self.agent.agent_id if self.agent else None,
"agent_name": self.alias or (self.agent.name if self.agent else ""),
"is_primary": self.is_primary,
"created_at": self.created_at.isoformat() if self.created_at else None,
}
# ──────── Skill 报告数据(Agent 上报) ────────
class SkillReport(db.Model):
"""
Agent 每日上报的数据快照
数据可能是压缩的(gzip),根据大小自动决定
"""
__tablename__ = "skill_reports"
id = db.Column(db.Integer, primary_key=True)
agent_db_id = db.Column(db.Integer, db.ForeignKey("agents.id"), nullable=False)
agent_id_str = db.Column(db.String(64), nullable=False, index=True) # 冗余,方便查询
report_date = db.Column(db.Date, nullable=False, index=True)
report_type = db.Column(db.String(32), default="daily") # daily / diagnostic / install
# 上报数据(JSON,可能压缩)
data_raw = db.Column(db.LargeBinary) # 压缩后的二进制数据
data_json = db.Column(db.Text) # 未压缩的 JSON 文本
is_compressed = db.Column(db.Boolean, default=False)
data_size_bytes = db.Column(db.Integer) # 原始大小
# 解析后的关键指标(方便查询,不用每次解压)
health_score = db.Column(db.Float)
total_runs = db.Column(db.Integer, default=0)
success_rate = db.Column(db.Float)
active_skills = db.Column(db.Integer, default=0)
avg_duration_ms = db.Column(db.Float)
# 评分 Top3
top_skills_json = db.Column(db.Text) # JSON: [{skill_id, score, grade}]
created_at = db.Column(db.DateTime, default=datetime.utcnow)
# 唯一约束
__table_args__ = (
db.UniqueConstraint("agent_db_id", "report_date", "report_type",
name="uq_agent_report_date_type"),
)
agent = db.relationship("Agent", back_populates="reports")
def set_data(self, data: dict, compress_threshold: int = 10240):
"""
设置报告数据,超过阈值自动 gzip 压缩
"""
json_str = json.dumps(data, ensure_ascii=False, default=str)
raw_bytes = json_str.encode("utf-8")
self.data_size_bytes = len(raw_bytes)
if len(raw_bytes) > compress_threshold:
self.data_raw = gzip.compress(raw_bytes)
self.data_json = None
self.is_compressed = True
else:
self.data_json = json_str
self.data_raw = None
self.is_compressed = False
def get_data(self) -> dict:
"""获取报告数据(自动解压)"""
if self.is_compressed and self.data_raw:
raw = gzip.decompress(self.data_raw)
return json.loads(raw.decode("utf-8"))
elif self.data_json:
return json.loads(self.data_json)
return {}
def to_dict(self, include_data=False):
d = {
"id": self.id,
"agent_id": self.agent_id_str,
"report_date": self.report_date.isoformat() if self.report_date else None,
"report_type": self.report_type,
"health_score": self.health_score,
"total_runs": self.total_runs,
"success_rate": self.success_rate,
"active_skills": self.active_skills,
"is_compressed": self.is_compressed,
"data_size_bytes": self.data_size_bytes,
"created_at": self.created_at.isoformat() if self.created_at else None,
}
if include_data:
d["data"] = self.get_data()
return d
# ──────── 每日推送摘要 ────────
class DailyDigest(db.Model):
"""记录每日推送的结果"""
__tablename__ = "daily_digests"
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)
agent_db_id = db.Column(db.Integer, db.ForeignKey("agents.id"), nullable=False)
digest_date = db.Column(db.Date, nullable=False)
push_type = db.Column(db.String(32), default="template_msg") # template_msg / custom_msg
push_status = db.Column(db.String(16), default="pending") # pending / sent / failed
push_result = db.Column(db.Text) # JSON: 推送结果
h5_url = db.Column(db.String(512)) # H5 报告链接
created_at = db.Column(db.DateTime, default=datetime.utcnow)
__table_args__ = (
db.UniqueConstraint("user_id", "agent_db_id", "digest_date",
name="uq_digest_user_agent_date"),
)
# ──────── 初始化辅助函数 ────────
def init_db(app):
"""初始化数据库(在 Flask app 上下文中调用)"""
db.init_app(app)
with app.app_context():
db.create_all()
return db
FILE:server/models/__init__.py
from .database import db, User, Agent, UserAgent, SkillReport, DailyDigest, init_db
FILE:skills_monitor/__init__.py
"""
Skills Monitor v0.6.0 — 本地 Skills 监控评估系统
核心模块:
- identity: 身份管理 (agent_id + API Key + Keychain + 生命周期)
- secure_store: OS Keychain 安全凭证存储
- interceptor: SDK 拦截器 (@skill_monitor 装饰器 + 实时上报)
- implicit_feedback: 隐性对话语义评分引擎
- feedback: 轻量情感分析工具
- benchmark: 基准运行器
- comparator: 对比分析器
- evaluator: 7因子综合评估引擎(+社区热度/评分)
- recommender: Skill 推荐引擎
- reporter: 报告生成器
- diagnostic: 诊断报告生成器
- sanitizer: 敏感信息自动脱敏引擎
- auto_reporter: 后台自动上报(定时诊断 + 增量同步)
- realtime_reporter: 实时调用反馈上报(异步队列 + 批量)
- scheduler: 定时任务管理 + 首次启动交互式确认
- llm_baseline: 大模型基准线测试 + TOP50×6模型批量评测 🆕
- store: SQLite 数据存储
- gdpr_manager: GDPR 合规管理
- category_mapping: 官方标签分类映射
- benchmark_prompts: 分类基准评测 Prompt 模板 🆕
- clawhub_client: ClawHub 社区数据采集器
- skill_registry: Skill 注册与发现
- runners: Skill 运行适配器
"""
__version__ = "0.6.1"
# 便捷导入
from skills_monitor.core.identity import IdentityManager
from skills_monitor.core.secure_store import SecureStore
from skills_monitor.core.interceptor import configure, run_skill_function
from skills_monitor.core.implicit_feedback import (
ImplicitFeedbackEngine,
ConversationSignal,
)
from skills_monitor.core.feedback import analyze_sentiment_simple
from skills_monitor.core.benchmark import BenchmarkRunner
from skills_monitor.core.comparator import SkillComparator
from skills_monitor.core.evaluator import SkillEvaluator
from skills_monitor.core.recommender import SkillRecommender
from skills_monitor.core.reporter import ReportGenerator
from skills_monitor.core.diagnostic import DiagnosticReporter
from skills_monitor.core.sanitizer import DataSanitizer
from skills_monitor.core.uploader import DataUploader
from skills_monitor.core.auto_reporter import AutoReporter
from skills_monitor.core.realtime_reporter import RealtimeReporter
from skills_monitor.core.scheduler import ScheduleManager
from skills_monitor.core.llm_baseline import LLMBaselineTester, BatchBenchmark
from skills_monitor.data.store import DataStore
from skills_monitor.data.gdpr_manager import GDPRManager
from skills_monitor.data.category_mapping import get_category, match_tags
from skills_monitor.data.benchmark_prompts import get_benchmark_prompt
from skills_monitor.adapters.skill_registry import SkillRegistry
from skills_monitor.adapters.runners import SkillRunner, get_adapter
from skills_monitor.adapters.clawhub_client import ClawHubClient
__all__ = [
"IdentityManager",
"SecureStore",
"configure",
"run_skill_function",
"ImplicitFeedbackEngine",
"ConversationSignal",
"analyze_sentiment_simple",
"BenchmarkRunner",
"SkillComparator",
"SkillEvaluator",
"SkillRecommender",
"ReportGenerator",
"DiagnosticReporter",
"DataSanitizer",
"DataStore",
"SkillRegistry",
"DataUploader",
"AutoReporter",
"RealtimeReporter",
"ScheduleManager",
"LLMBaselineTester",
"BatchBenchmark",
"GDPRManager",
"ClawHubClient",
"SkillRunner",
"get_adapter",
"get_category",
"match_tags",
"get_benchmark_prompt",
]
FILE:skills_monitor/data/store.py
"""
数据存储层 — SQLite 本地数据库
存储 skill 运行记录、指标、隐性对话反馈等
"""
import json
import os
import sqlite3
import threading
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional
DEFAULT_DB_DIR = os.path.expanduser(os.environ.get("SKILLS_MONITOR_HOME", "~/.skills_monitor"))
DB_NAME = "skills_monitor.db"
class DataStore:
"""SQLite 数据存储"""
def __init__(self, db_dir: str = DEFAULT_DB_DIR):
Path(db_dir).mkdir(parents=True, exist_ok=True)
self.db_path = os.path.join(db_dir, DB_NAME)
self._local = threading.local()
self._init_tables()
@property
def _conn(self) -> sqlite3.Connection:
"""每个线程一个连接"""
if not hasattr(self._local, "conn") or self._local.conn is None:
self._local.conn = sqlite3.connect(self.db_path)
self._local.conn.row_factory = sqlite3.Row
self._local.conn.execute("PRAGMA journal_mode=WAL")
return self._local.conn
def _init_tables(self):
"""初始化表结构"""
conn = self._conn
conn.executescript("""
-- skill 运行记录
CREATE TABLE IF NOT EXISTS skill_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
run_id TEXT NOT NULL UNIQUE,
agent_id TEXT NOT NULL,
skill_id TEXT NOT NULL,
task_name TEXT,
status TEXT NOT NULL DEFAULT 'running', -- running / success / error
start_time TEXT NOT NULL,
end_time TEXT,
duration_ms REAL,
input_data TEXT, -- JSON
output_data TEXT, -- JSON (truncated)
error_msg TEXT,
metadata TEXT, -- JSON
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- 聚合指标(按 skill_id 汇总)
CREATE TABLE IF NOT EXISTS skill_metrics (
id INTEGER PRIMARY KEY AUTOINCREMENT,
agent_id TEXT NOT NULL,
skill_id TEXT NOT NULL,
date TEXT NOT NULL, -- YYYY-MM-DD
total_runs INTEGER NOT NULL DEFAULT 0,
success_count INTEGER NOT NULL DEFAULT 0,
error_count INTEGER NOT NULL DEFAULT 0,
avg_duration_ms REAL,
p95_duration_ms REAL,
min_duration_ms REAL,
max_duration_ms REAL,
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(agent_id, skill_id, date)
);
-- 旧版用户反馈表(已废弃,保留兼容)
CREATE TABLE IF NOT EXISTS user_feedback (
id INTEGER PRIMARY KEY AUTOINCREMENT,
agent_id TEXT NOT NULL,
skill_id TEXT NOT NULL,
rating INTEGER NOT NULL CHECK(rating BETWEEN 1 AND 5),
comment TEXT,
sentiment TEXT,
run_id TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- 隐性对话反馈(取代人工评分)
CREATE TABLE IF NOT EXISTS implicit_feedback (
id INTEGER PRIMARY KEY AUTOINCREMENT,
agent_id TEXT NOT NULL,
skill_id TEXT NOT NULL,
run_id TEXT,
implicit_rating REAL NOT NULL, -- 1.0-5.0 推断评分
confidence REAL NOT NULL, -- 0.0-1.0 置信度
sentiment_label TEXT, -- positive/neutral/negative
dimensions TEXT, -- JSON: 5维度详细分数
evidence TEXT, -- JSON: 支撑证据列表
source TEXT DEFAULT 'conversation', -- 来源标识
user_messages_count INTEGER DEFAULT 0,
run_status TEXT, -- success/error
retry_count INTEGER DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- 索引
CREATE INDEX IF NOT EXISTS idx_runs_skill ON skill_runs(skill_id, start_time);
CREATE INDEX IF NOT EXISTS idx_runs_agent ON skill_runs(agent_id, start_time);
CREATE INDEX IF NOT EXISTS idx_metrics_skill ON skill_metrics(skill_id, date);
CREATE INDEX IF NOT EXISTS idx_feedback_skill ON user_feedback(skill_id, created_at);
CREATE INDEX IF NOT EXISTS idx_implicit_fb_skill ON implicit_feedback(skill_id, created_at);
CREATE INDEX IF NOT EXISTS idx_implicit_fb_agent ON implicit_feedback(agent_id, skill_id);
""")
conn.commit()
# ──────── 写入 ────────
def insert_run(self, run: Dict[str, Any]) -> None:
"""插入一条运行记录"""
self._conn.execute(
"""INSERT INTO skill_runs
(run_id, agent_id, skill_id, task_name, status,
start_time, end_time, duration_ms,
input_data, output_data, error_msg, metadata)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
run["run_id"],
run["agent_id"],
run["skill_id"],
run.get("task_name", ""),
run["status"],
run["start_time"],
run.get("end_time"),
run.get("duration_ms"),
json.dumps(run.get("input_data"), ensure_ascii=False) if run.get("input_data") else None,
_truncate_json(run.get("output_data")),
run.get("error_msg"),
json.dumps(run.get("metadata"), ensure_ascii=False) if run.get("metadata") else None,
),
)
self._conn.commit()
def update_run(self, run_id: str, **kwargs) -> None:
"""更新运行记录"""
allowed = {"status", "end_time", "duration_ms", "output_data", "error_msg"}
updates = {k: v for k, v in kwargs.items() if k in allowed}
if not updates:
return
if "output_data" in updates:
updates["output_data"] = _truncate_json(updates["output_data"])
set_clause = ", ".join(f"{k} = ?" for k in updates)
values = list(updates.values()) + [run_id]
self._conn.execute(
f"UPDATE skill_runs SET {set_clause} WHERE run_id = ?",
values,
)
self._conn.commit()
def insert_feedback(self, feedback: Dict[str, Any]) -> None:
"""[已废弃] 插入旧版用户反馈 — 保留向后兼容"""
self._conn.execute(
"""INSERT INTO user_feedback
(agent_id, skill_id, rating, comment, sentiment, run_id)
VALUES (?, ?, ?, ?, ?, ?)""",
(
feedback["agent_id"],
feedback["skill_id"],
feedback["rating"],
feedback.get("comment", ""),
feedback.get("sentiment", "neutral"),
feedback.get("run_id"),
),
)
self._conn.commit()
def insert_implicit_feedback(self, feedback: Dict[str, Any]) -> None:
"""插入隐性对话反馈"""
self._conn.execute(
"""INSERT INTO implicit_feedback
(agent_id, skill_id, run_id, implicit_rating, confidence,
sentiment_label, dimensions, evidence, source,
user_messages_count, run_status, retry_count)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
feedback["agent_id"],
feedback["skill_id"],
feedback.get("run_id"),
feedback["implicit_rating"],
feedback["confidence"],
feedback.get("sentiment_label", "neutral"),
json.dumps(feedback.get("dimensions", {}), ensure_ascii=False),
json.dumps(feedback.get("evidence", []), ensure_ascii=False),
feedback.get("source", "conversation"),
feedback.get("user_messages_count", 0),
feedback.get("run_status"),
feedback.get("retry_count", 0),
),
)
self._conn.commit()
def upsert_daily_metrics(self, agent_id: str, skill_id: str, date: str) -> None:
"""重新计算并更新某天的聚合指标"""
rows = self._conn.execute(
"""SELECT duration_ms, status FROM skill_runs
WHERE agent_id = ? AND skill_id = ?
AND date(start_time) = ?""",
(agent_id, skill_id, date),
).fetchall()
if not rows:
return
durations = [r["duration_ms"] for r in rows if r["duration_ms"] is not None]
total = len(rows)
success = sum(1 for r in rows if r["status"] == "success")
errors = sum(1 for r in rows if r["status"] == "error")
avg_d = sum(durations) / len(durations) if durations else None
sorted_d = sorted(durations) if durations else []
p95_d = sorted_d[int(len(sorted_d) * 0.95)] if sorted_d else None
min_d = min(sorted_d) if sorted_d else None
max_d = max(sorted_d) if sorted_d else None
self._conn.execute(
"""INSERT INTO skill_metrics
(agent_id, skill_id, date, total_runs, success_count, error_count,
avg_duration_ms, p95_duration_ms, min_duration_ms, max_duration_ms, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
ON CONFLICT(agent_id, skill_id, date) DO UPDATE SET
total_runs = excluded.total_runs,
success_count = excluded.success_count,
error_count = excluded.error_count,
avg_duration_ms = excluded.avg_duration_ms,
p95_duration_ms = excluded.p95_duration_ms,
min_duration_ms = excluded.min_duration_ms,
max_duration_ms = excluded.max_duration_ms,
updated_at = excluded.updated_at""",
(agent_id, skill_id, date, total, success, errors, avg_d, p95_d, min_d, max_d),
)
self._conn.commit()
# ──────── 查询 ────────
def get_runs(
self,
skill_id: Optional[str] = None,
agent_id: Optional[str] = None,
limit: int = 50,
status: Optional[str] = None,
) -> List[Dict[str, Any]]:
"""查询运行记录"""
conditions = []
params: list = []
if skill_id:
conditions.append("skill_id = ?")
params.append(skill_id)
if agent_id:
conditions.append("agent_id = ?")
params.append(agent_id)
if status:
conditions.append("status = ?")
params.append(status)
where = "WHERE " + " AND ".join(conditions) if conditions else ""
rows = self._conn.execute(
f"SELECT * FROM skill_runs {where} ORDER BY start_time DESC LIMIT ?",
params + [limit],
).fetchall()
return [dict(r) for r in rows]
def get_metrics(
self,
skill_id: Optional[str] = None,
agent_id: Optional[str] = None,
days: int = 7,
) -> List[Dict[str, Any]]:
"""查询聚合指标"""
conditions = ["date >= date('now', ?)"]
params: list = [f"-{days} days"]
if skill_id:
conditions.append("skill_id = ?")
params.append(skill_id)
if agent_id:
conditions.append("agent_id = ?")
params.append(agent_id)
where = "WHERE " + " AND ".join(conditions)
rows = self._conn.execute(
f"SELECT * FROM skill_metrics {where} ORDER BY date DESC",
params,
).fetchall()
return [dict(r) for r in rows]
def get_feedback(
self,
skill_id: Optional[str] = None,
limit: int = 20,
) -> List[Dict[str, Any]]:
"""[已废弃] 查询旧版用户反馈 — 保留向后兼容"""
if skill_id:
rows = self._conn.execute(
"SELECT * FROM user_feedback WHERE skill_id = ? ORDER BY created_at DESC LIMIT ?",
(skill_id, limit),
).fetchall()
else:
rows = self._conn.execute(
"SELECT * FROM user_feedback ORDER BY created_at DESC LIMIT ?",
(limit,),
).fetchall()
return [dict(r) for r in rows]
def get_implicit_feedback(
self,
skill_id: Optional[str] = None,
agent_id: Optional[str] = None,
limit: int = 50,
) -> List[Dict[str, Any]]:
"""查询隐性对话反馈"""
conditions = []
params: list = []
if skill_id:
conditions.append("skill_id = ?")
params.append(skill_id)
if agent_id:
conditions.append("agent_id = ?")
params.append(agent_id)
where = "WHERE " + " AND ".join(conditions) if conditions else ""
rows = self._conn.execute(
f"SELECT * FROM implicit_feedback {where} ORDER BY created_at DESC LIMIT ?",
params + [limit],
).fetchall()
results = []
for r in rows:
d = dict(r)
# 反序列化 JSON 字段
if d.get("dimensions"):
try:
d["dimensions"] = json.loads(d["dimensions"])
except (json.JSONDecodeError, TypeError):
pass
if d.get("evidence"):
try:
d["evidence"] = json.loads(d["evidence"])
except (json.JSONDecodeError, TypeError):
pass
results.append(d)
return results
def get_skill_summary(self, skill_id: str, agent_id: str) -> Dict[str, Any]:
"""获取某个 skill 的汇总信息(使用隐性评分替代人工评分)"""
total = self._conn.execute(
"SELECT COUNT(*) as cnt FROM skill_runs WHERE skill_id=? AND agent_id=?",
(skill_id, agent_id),
).fetchone()["cnt"]
success = self._conn.execute(
"SELECT COUNT(*) as cnt FROM skill_runs WHERE skill_id=? AND agent_id=? AND status='success'",
(skill_id, agent_id),
).fetchone()["cnt"]
avg_dur = self._conn.execute(
"SELECT AVG(duration_ms) as avg_d FROM skill_runs WHERE skill_id=? AND agent_id=? AND status='success'",
(skill_id, agent_id),
).fetchone()["avg_d"]
# 隐性评分:置信度加权平均
implicit_row = self._conn.execute(
"""SELECT
SUM(implicit_rating * confidence) as weighted_sum,
SUM(confidence) as weight_total,
COUNT(*) as fb_count,
AVG(confidence) as avg_conf
FROM implicit_feedback
WHERE skill_id=? AND agent_id=?""",
(skill_id, agent_id),
).fetchone()
avg_implicit_rating = None
implicit_feedback_count = 0
avg_confidence = None
if implicit_row and implicit_row["weight_total"] and implicit_row["weight_total"] > 0:
avg_implicit_rating = round(
implicit_row["weighted_sum"] / implicit_row["weight_total"], 2
)
implicit_feedback_count = implicit_row["fb_count"]
avg_confidence = round(implicit_row["avg_conf"], 3) if implicit_row["avg_conf"] else None
return {
"skill_id": skill_id,
"total_runs": total,
"success_count": success,
"success_rate": round(success / total * 100, 1) if total > 0 else 0,
"avg_duration_ms": round(avg_dur, 1) if avg_dur else None,
"avg_rating": avg_implicit_rating, # 现在来自隐性评分
"implicit_feedback_count": implicit_feedback_count,
"avg_confidence": avg_confidence,
}
def close(self):
if hasattr(self._local, "conn") and self._local.conn:
self._local.conn.close()
self._local.conn = None
def _truncate_json(data: Any, max_len: int = 2000) -> Optional[str]:
"""将数据转 JSON 并截断"""
if data is None:
return None
text = json.dumps(data, ensure_ascii=False, default=str)
if len(text) > max_len:
return text[:max_len] + "...(truncated)"
return text
FILE:skills_monitor/data/__init__.py
# data subpackage
FILE:skills_monitor/data/gdpr_manager.py
"""
GDPR 合规管理器 v0.5.0
========================
提供数据最小化、用户同意、数据导出/删除、保留期限、审计日志等能力。
核心功能:
- 数据导出:export_all_data() → 导出全部个人数据(JSON)
- 数据删除:purge_all_data() → 删除本地+请求服务端删除
- 保留期限:clean_expired_data() → 自动清理过期数据
- 审计日志:log_operation() → 记录数据操作轨迹
Usage:
gdpr = GDPRManager(store, identity_mgr)
gdpr.export_all_data("export.json")
gdpr.purge_all_data(notify_server=True)
"""
import json
import logging
import os
import sqlite3
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
DEFAULT_RETENTION_DAYS = 180 # 服务端数据默认保留 180 天
AUDIT_LOG_TABLE = "audit_log"
class GDPRManager:
"""GDPR 合规管理器"""
def __init__(self, store=None, identity_mgr=None):
"""
Args:
store: DataStore 实例(本地 SQLite)
identity_mgr: IdentityManager 实例
"""
self.store = store
self.identity = identity_mgr
self._ensure_audit_table()
def _ensure_audit_table(self):
"""确保审计日志表存在"""
if not self.store:
return
try:
conn = self.store._conn
conn.execute(f"""
CREATE TABLE IF NOT EXISTS {AUDIT_LOG_TABLE} (
id INTEGER PRIMARY KEY AUTOINCREMENT,
agent_id TEXT,
operation TEXT NOT NULL,
target TEXT,
details TEXT,
operator TEXT DEFAULT 'user',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)
""")
conn.execute(f"""
CREATE INDEX IF NOT EXISTS idx_audit_time
ON {AUDIT_LOG_TABLE}(created_at)
""")
conn.commit()
except Exception as e:
logger.warning(f"审计表创建失败: {e}")
# ──────── 审计日志 ────────
def log_operation(self, operation: str, target: str = "", details: str = "", operator: str = "user"):
"""记录数据操作到审计日志"""
if not self.store:
return
try:
agent_id = self.identity.agent_id if self.identity else ""
self.store._conn.execute(
f"INSERT INTO {AUDIT_LOG_TABLE} (agent_id, operation, target, details, operator) VALUES (?,?,?,?,?)",
(agent_id, operation, target, details, operator),
)
self.store._conn.commit()
except Exception as e:
logger.warning(f"审计日志写入失败: {e}")
def get_audit_log(self, limit: int = 100) -> List[Dict]:
"""查询审计日志"""
if not self.store:
return []
rows = self.store._conn.execute(
f"SELECT * FROM {AUDIT_LOG_TABLE} ORDER BY created_at DESC LIMIT ?",
(limit,),
).fetchall()
return [dict(r) for r in rows]
# ──────── 数据导出 ────────
def export_all_data(self, output_path: str = None) -> Dict[str, Any]:
"""
导出该 Agent 的全部个人数据(GDPR 数据可携权)
Returns:
包含所有个人数据的字典
"""
self.log_operation("data_export", "all", "用户请求导出全部数据")
agent_id = self.identity.agent_id if self.identity else ""
export_data = {
"export_version": "1.0",
"exported_at": datetime.now().isoformat(),
"agent_id": agent_id,
"sections": {},
}
if self.store:
conn = self.store._conn
# 运行记录
runs = conn.execute(
"SELECT * FROM skill_runs WHERE agent_id = ? ORDER BY start_time DESC",
(agent_id,),
).fetchall()
export_data["sections"]["skill_runs"] = [dict(r) for r in runs]
# 聚合指标
metrics = conn.execute(
"SELECT * FROM skill_metrics WHERE agent_id = ? ORDER BY date DESC",
(agent_id,),
).fetchall()
export_data["sections"]["skill_metrics"] = [dict(r) for r in metrics]
# 隐性反馈
feedback = conn.execute(
"SELECT * FROM implicit_feedback WHERE agent_id = ? ORDER BY created_at DESC",
(agent_id,),
).fetchall()
export_data["sections"]["implicit_feedback"] = [dict(r) for r in feedback]
# 旧版反馈
old_feedback = conn.execute(
"SELECT * FROM user_feedback WHERE agent_id = ? ORDER BY created_at DESC",
(agent_id,),
).fetchall()
export_data["sections"]["user_feedback"] = [dict(r) for r in old_feedback]
# 审计日志
audit = conn.execute(
f"SELECT * FROM {AUDIT_LOG_TABLE} WHERE agent_id = ? ORDER BY created_at DESC",
(agent_id,),
).fetchall()
export_data["sections"]["audit_log"] = [dict(r) for r in audit]
# 身份配置(脱敏)
if self.identity:
export_data["sections"]["config"] = self.identity.get_config()
# 统计
export_data["summary"] = {
table: len(records)
for table, records in export_data["sections"].items()
}
if output_path:
os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True)
with open(output_path, "w", encoding="utf-8") as f:
json.dump(export_data, f, ensure_ascii=False, indent=2, default=str)
logger.info(f"数据已导出到: {output_path}")
return export_data
# ──────── 数据删除 ────────
def purge_all_data(self, notify_server: bool = True, server_url: str = None) -> Dict[str, Any]:
"""
删除该 Agent 的全部本地数据(GDPR 被遗忘权)
Args:
notify_server: 是否请求服务端也删除数据
server_url: 服务端 URL
"""
self.log_operation("data_purge", "all", "用户请求删除全部数据")
result = {"local": {}, "server": None}
agent_id = self.identity.agent_id if self.identity else ""
if self.store:
conn = self.store._conn
tables_to_clean = ["skill_runs", "skill_metrics", "implicit_feedback", "user_feedback"]
for table in tables_to_clean:
try:
cursor = conn.execute(
f"DELETE FROM {table} WHERE agent_id = ?", (agent_id,)
)
result["local"][table] = cursor.rowcount
except Exception as e:
result["local"][table] = f"error: {e}"
conn.commit()
logger.info(f"本地数据已清除: {result['local']}")
# 请求服务端删除
if notify_server and server_url:
result["server"] = self._request_server_delete(server_url, agent_id)
# 清除安全存储
if self.identity and self.identity._secure_store:
try:
self.identity._secure_store.delete_credential("api_key")
self.identity._secure_store.delete_credential("agent_id")
result["local"]["secure_store"] = "cleared"
except Exception:
pass
return result
def _request_server_delete(self, server_url: str, agent_id: str) -> Dict:
"""请求服务端删除该 Agent 的全部数据"""
try:
import requests
api_key = self.identity.api_key if self.identity else ""
resp = requests.post(
f"{server_url}/api/agent/delete-data",
headers={
"X-Agent-ID": agent_id,
"X-Agent-Token": api_key or "",
},
json={"agent_id": agent_id, "reason": "gdpr_purge"},
timeout=15,
)
return resp.json()
except Exception as e:
return {"error": str(e)}
# ──────── 数据保留期限 ────────
def clean_expired_data(self, retention_days: int = DEFAULT_RETENTION_DAYS) -> Dict[str, int]:
"""清理超过保留期限的数据"""
if not self.store:
return {}
cutoff = (datetime.now() - timedelta(days=retention_days)).isoformat()
self.log_operation("data_cleanup", "expired", f"清理 {retention_days} 天前的数据")
conn = self.store._conn
result = {}
for table, time_col in [
("skill_runs", "start_time"),
("skill_metrics", "date"),
("implicit_feedback", "created_at"),
("user_feedback", "created_at"),
]:
try:
cursor = conn.execute(f"DELETE FROM {table} WHERE {time_col} < ?", (cutoff,))
result[table] = cursor.rowcount
except Exception as e:
result[table] = 0
logger.warning(f"清理 {table} 失败: {e}")
conn.commit()
return result
# ──────── 用户同意管理 ────────
def show_consent_prompt(self) -> str:
"""生成数据收集声明文本"""
return """
╔══════════════════════════════════════════════════════╗
║ Skills Monitor 数据收集声明 ║
╠══════════════════════════════════════════════════════╣
║ ║
║ 为提供 Skills 诊断和评估服务,我们会收集以下数据: ║
║ ║
║ 📊 运行数据:Skill 调用次数、成功率、响应时间 ║
║ 📈 评分数据:5 因子评分指标、趋势分析 ║
║ 🔒 健康度:诊断报告的聚合健康度评分 ║
║ ║
║ 我们 不会 收集: ║
║ ❌ Skill 的输入/输出内容 ║
║ ❌ 您的文件路径、个人信息 ║
║ ❌ 大模型的对话内容 ║
║ ║
║ 您的权利: ║
║ 📤 随时导出全部数据:skills-monitor export ║
║ 🗑️ 随时删除全部数据:skills-monitor purge ║
║ ⏸️ 随时停止数据收集:skills-monitor config --no-collect║
║ ║
╚══════════════════════════════════════════════════════╝
"""
FILE:skills_monitor/data/benchmark_prompts.py
"""
分类基准评测 Prompt 模板 v0.5.0
================================
为 ClawHub 15 个分类定义标准化评测 Prompt。
每个 Prompt 模板接受 {task} 占位符,由具体 Skill 的 benchmark_task 填入。
使用:
from skills_monitor.data.benchmark_prompts import get_benchmark_prompt
prompt = get_benchmark_prompt("data_processing", "分析销售数据...")
"""
from typing import Dict, Optional
# ──────── task_type → Prompt 模板 ────────
BENCHMARK_PROMPT_TEMPLATES: Dict[str, str] = {
"search_and_summarize": (
"你是一个专业的信息检索与摘要助手。\n\n"
"## 任务\n{task}\n\n"
"## 要求\n"
"1. 返回结构化 JSON 结果\n"
"2. 每条结果包含: title, url, summary (50 字以内), relevance_score (0-1)\n"
"3. 按相关度降序排列\n"
"4. 附带一段 100 字以内的综合摘要\n\n"
"## 输出格式\n"
"```json\n"
'{{ "results": [...], "summary": "..." }}\n'
"```"
),
"translation": (
"你是一个专业的多语言翻译助手,精通中英日韩等主流语言。\n\n"
"## 任务\n{task}\n\n"
"## 要求\n"
"1. 翻译准确、自然,符合目标语言表达习惯\n"
"2. 保留专业术语,必要时附注原文\n"
"3. 返回结构化 JSON\n\n"
"## 输出格式\n"
"```json\n"
'{{ "original": "...", "translations": {{ "en": "...", "ja": "...", "ko": "..." }} }}\n'
"```"
),
"file_operation": (
"你是一个文件系统操作专家。\n\n"
"## 任务\n{task}\n\n"
"## 要求\n"
"1. 给出具体的命令或脚本\n"
"2. 处理异常情况(权限不足、文件不存在等)\n"
"3. 返回结构化 JSON 结果\n\n"
"## 输出格式\n"
"```json\n"
'{{ "commands": [...], "expected_output": {{ ... }}, "error_handling": "..." }}\n'
"```"
),
"code_analysis": (
"你是一个资深软件工程师,擅长代码审查和性能分析。\n\n"
"## 任务\n{task}\n\n"
"## 要求\n"
"1. 系统性分析,覆盖正确性、性能、安全性、可维护性\n"
"2. 每个问题给出严重程度(critical/warning/info)\n"
"3. 提供具体的修复代码\n"
"4. 返回结构化 JSON\n\n"
"## 输出格式\n"
"```json\n"
'{{ "issues": [{{ "severity": "...", "description": "...", "fix": "..." }}], '
'"overall_score": 0-100, "summary": "..." }}\n'
"```"
),
"api_query": (
"你是一个 API 数据查询专家。\n\n"
"## 任务\n{task}\n\n"
"## 要求\n"
"1. 返回结构化数据\n"
"2. 数据字段完整,格式统一\n"
"3. 标注数据来源和时间\n\n"
"## 输出格式\n"
"```json\n"
'{{ "data": [...], "source": "...", "queried_at": "..." }}\n'
"```"
),
"data_processing": (
"你是一个数据分析专家,精通统计分析和数据可视化。\n\n"
"## 任务\n{task}\n\n"
"## 要求\n"
"1. 给出完整的分析步骤\n"
"2. 计算结果精确到小数点后 2 位\n"
"3. 提供数据解读和建议\n"
"4. 返回结构化 JSON\n\n"
"## 输出格式\n"
"```json\n"
'{{ "analysis": {{ ... }}, "metrics": {{ ... }}, "insights": [...], "recommendations": [...] }}\n'
"```"
),
"document_processing": (
"你是一个文档处理专家。\n\n"
"## 任务\n{task}\n\n"
"## 要求\n"
"1. 保持文档结构的完整性\n"
"2. 正确处理各种格式元素\n"
"3. 返回结构化 JSON\n\n"
"## 输出格式\n"
"```json\n"
'{{ "content": {{ ... }}, "metadata": {{ ... }}, "summary": "..." }}\n'
"```"
),
"text_formatting": (
"你是一个 Markdown 排版专家。\n\n"
"## 任务\n{task}\n\n"
"## 要求\n"
"1. 符合 CommonMark 规范\n"
"2. 结构清晰,格式正确\n"
"3. 返回纯 Markdown 文本\n\n"
"## 输出\n直接返回格式化后的 Markdown 文本。"
),
"text_generation": (
"你是一个专业的文案写作助手。\n\n"
"## 任务\n{task}\n\n"
"## 要求\n"
"1. 内容准确、逻辑清晰\n"
"2. 语言得体,符合场景要求\n"
"3. 返回结构化 JSON\n\n"
"## 输出格式\n"
"```json\n"
'{{ "content": "...", "word_count": 0, "key_points": [...] }}\n'
"```"
),
"code_generation": (
"你是一个全栈开发工程师。\n\n"
"## 任务\n{task}\n\n"
"## 要求\n"
"1. 代码可直接运行\n"
"2. 包含必要注释\n"
"3. 覆盖边界情况\n"
"4. 返回结构化 JSON\n\n"
"## 输出格式\n"
"```json\n"
'{{ "code": "...", "language": "...", "test_cases": [...], "explanation": "..." }}\n'
"```"
),
"config_generation": (
"你是一个 DevOps / 基础设施配置专家。\n\n"
"## 任务\n{task}\n\n"
"## 要求\n"
"1. 配置可直接使用\n"
"2. 遵循最佳实践\n"
"3. 包含注释说明\n"
"4. 返回结构化 JSON\n\n"
"## 输出格式\n"
"```json\n"
'{{ "config": "...", "format": "...", "notes": [...] }}\n'
"```"
),
"sql_generation": (
"你是一个数据库专家,精通 SQL 和 NoSQL 查询优化。\n\n"
"## 任务\n{task}\n\n"
"## 要求\n"
"1. SQL 语法正确,兼容主流数据库\n"
"2. 考虑性能(索引、执行计划)\n"
"3. 附带解释说明\n"
"4. 返回结构化 JSON\n\n"
"## 输出格式\n"
"```json\n"
'{{ "query": "...", "explanation": "...", "optimization_notes": [...] }}\n'
"```"
),
"financial_analysis": (
"你是一个专业的金融量化分析师。\n\n"
"## 任务\n{task}\n\n"
"## 要求\n"
"1. 分析逻辑严谨,数据来源可追溯\n"
"2. 风险提示明确\n"
"3. 返回结构化 JSON\n\n"
"## 输出格式\n"
"```json\n"
'{{ "analysis": {{ ... }}, "recommendations": [...], "risk_warning": "..." }}\n'
"```"
),
"workflow_design": (
"你是一个系统架构师,擅长工作流设计和自动化方案。\n\n"
"## 任务\n{task}\n\n"
"## 要求\n"
"1. 流程步骤清晰,有明确的输入输出\n"
"2. 考虑异常处理和重试机制\n"
"3. 返回结构化 JSON\n\n"
"## 输出格式\n"
"```json\n"
'{{ "steps": [{{ "name": "...", "input": "...", "output": "...", "error_handling": "..." }}], '
'"estimated_time": "...", "dependencies": [...] }}\n'
"```"
),
"media_processing": (
"你是一个多媒体处理专家。\n\n"
"## 任务\n{task}\n\n"
"## 要求\n"
"1. 给出完整的处理流程\n"
"2. 推荐最优的工具/库\n"
"3. 考虑性能和质量平衡\n"
"4. 返回结构化 JSON\n\n"
"## 输出格式\n"
"```json\n"
'{{ "pipeline": [...], "tools": [...], "quality_settings": {{ ... }} }}\n'
"```"
),
"nlp_analysis": (
"你是一个 NLP / AI 工程师。\n\n"
"## 任务\n{task}\n\n"
"## 要求\n"
"1. 分析方法论清晰\n"
"2. 结果可量化\n"
"3. 返回结构化 JSON\n\n"
"## 输出格式\n"
"```json\n"
'{{ "results": [...], "method": "...", "confidence": 0.0 }}\n'
"```"
),
"security_task": (
"你是一个网络安全专家。\n\n"
"## 任务\n{task}\n\n"
"## 要求\n"
"1. 分析全面,不遗漏关键风险点\n"
"2. 严重程度分级(critical/high/medium/low)\n"
"3. 给出具体修复方案\n"
"4. 返回结构化 JSON\n\n"
"## 输出格式\n"
"```json\n"
'{{ "findings": [{{ "severity": "...", "description": "...", "fix": "..." }}], '
'"overall_risk": "...", "score": 0-100 }}\n'
"```"
),
"api_testing": (
"你是一个 API 测试工程师。\n\n"
"## 任务\n{task}\n\n"
"## 要求\n"
"1. 测试用例完整,覆盖正常和异常场景\n"
"2. 请求格式规范(含 headers、body、params)\n"
"3. 预期响应明确\n"
"4. 返回结构化 JSON\n\n"
"## 输出格式\n"
"```json\n"
'{{ "test_cases": [{{ "method": "...", "url": "...", "headers": {{ }}, '
'"body": {{ }}, "expected_status": 200, "expected_response": {{ }} }}] }}\n'
"```"
),
"calculation": (
"你是一个数学求解专家。\n\n"
"## 任务\n{task}\n\n"
"## 要求\n"
"1. 给出详细的解题步骤\n"
"2. 最终答案明确\n"
"3. 附带验证过程\n"
"4. 返回结构化 JSON\n\n"
"## 输出格式\n"
"```json\n"
'{{ "solution": {{ "steps": [...], "answer": "...", "verification": "..." }} }}\n'
"```"
),
"troubleshooting": (
"你是一个高级运维工程师。\n\n"
"## 任务\n{task}\n\n"
"## 要求\n"
"1. 排查步骤系统化\n"
"2. 覆盖常见原因\n"
"3. 给出具体解决命令/操作\n"
"4. 返回结构化 JSON\n\n"
"## 输出格式\n"
"```json\n"
'{{ "diagnosis": {{ "symptoms": [...], "possible_causes": [...], '
'"troubleshooting_steps": [...], "solution": "..." }} }}\n'
"```"
),
}
def get_benchmark_prompt(task_type: str, task: str) -> str:
"""
获取评测 Prompt
Args:
task_type: 任务类型 (对应 top50 dataset 中的 task_type)
task: 具体任务描述 (对应 benchmark_task)
Returns:
完整的评测 Prompt 文本
"""
template = BENCHMARK_PROMPT_TEMPLATES.get(
task_type,
BENCHMARK_PROMPT_TEMPLATES.get("text_generation") # 默认降级
)
return template.format(task=task)
def get_available_task_types() -> list:
"""获取所有可用的任务类型"""
return sorted(BENCHMARK_PROMPT_TEMPLATES.keys())
FILE:skills_monitor/data/category_mapping.py
"""
官方标签分类映射 v0.5.0
========================
将 ClawHub 官方分类标签对齐到本地分类体系。
使用方式:
from skills_monitor.data.category_mapping import get_category, match_tags
cat = get_category("finance") # → {"name": "金融投资", "icon": "📈", ...}
tags = match_tags(["stock", "trading"]) # → ["finance"]
"""
from typing import Dict, List, Optional, Set
# ──────── ClawHub 官方分类 → 本地标签映射 ────────
CLAWHUB_CATEGORIES: Dict[str, Dict] = {
"finance": {
"name": "金融投资",
"name_en": "Finance & Investment",
"icon": "📈",
"keywords": {"stock", "trading", "finance", "invest", "market", "fund", "crypto", "forex"},
"description": "股票、基金、加密货币、外汇等金融交易工具",
},
"data-analysis": {
"name": "数据分析",
"name_en": "Data Analysis",
"icon": "📊",
"keywords": {"data", "analysis", "analytics", "statistics", "visualization", "chart", "report"},
"description": "数据处理、统计分析、数据可视化工具",
},
"web-scraping": {
"name": "数据采集",
"name_en": "Web Scraping",
"icon": "🕸️",
"keywords": {"scrape", "scraping", "crawl", "spider", "extract", "fetch"},
"description": "网页抓取、数据提取、爬虫工具",
},
"automation": {
"name": "自动化",
"name_en": "Automation",
"icon": "🤖",
"keywords": {"automate", "automation", "workflow", "pipeline", "schedule", "cron", "task"},
"description": "流程自动化、任务调度、工作流工具",
},
"communication": {
"name": "通讯协作",
"name_en": "Communication",
"icon": "💬",
"keywords": {"chat", "message", "email", "slack", "discord", "telegram", "wechat", "notification"},
"description": "即时通讯、邮件、通知、协作工具",
},
"development": {
"name": "开发工具",
"name_en": "Development",
"icon": "🛠️",
"keywords": {"dev", "code", "coding", "git", "github", "debug", "test", "deploy", "ci", "cd"},
"description": "代码开发、测试、部署、DevOps 工具",
},
"search": {
"name": "搜索引擎",
"name_en": "Search",
"icon": "🔍",
"keywords": {"search", "query", "find", "lookup", "google", "bing", "duckduckgo"},
"description": "网络搜索、信息检索工具",
},
"media": {
"name": "多媒体",
"name_en": "Media",
"icon": "🎨",
"keywords": {"image", "video", "audio", "media", "photo", "music", "art", "design", "draw"},
"description": "图片、视频、音频处理与生成工具",
},
"security": {
"name": "安全审计",
"name_en": "Security",
"icon": "🔐",
"keywords": {"security", "audit", "scan", "vulnerability", "password", "encrypt", "ssl"},
"description": "安全扫描、漏洞检测、加密工具",
},
"education": {
"name": "学习教育",
"name_en": "Education",
"icon": "📚",
"keywords": {"learn", "education", "study", "course", "tutorial", "knowledge", "quiz"},
"description": "在线学习、知识管理、教程工具",
},
"productivity": {
"name": "效率工具",
"name_en": "Productivity",
"icon": "⚡",
"keywords": {"productivity", "todo", "note", "calendar", "time", "organize", "manage"},
"description": "任务管理、笔记、日历、时间管理工具",
},
"ai-model": {
"name": "AI 模型",
"name_en": "AI Model",
"icon": "🧠",
"keywords": {"ai", "ml", "model", "llm", "gpt", "claude", "deepseek", "gemini", "openai"},
"description": "AI 模型调用、提示工程、模型管理工具",
},
"database": {
"name": "数据库",
"name_en": "Database",
"icon": "🗄️",
"keywords": {"database", "sql", "mysql", "postgres", "mongodb", "redis", "supabase", "firebase"},
"description": "数据库操作、查询、管理工具",
},
"cloud": {
"name": "云服务",
"name_en": "Cloud",
"icon": "☁️",
"keywords": {"cloud", "aws", "azure", "gcp", "tencent", "aliyun", "docker", "kubernetes"},
"description": "云平台操作、容器管理、服务部署工具",
},
"document": {
"name": "文档处理",
"name_en": "Document",
"icon": "📄",
"keywords": {"document", "pdf", "word", "excel", "markdown", "csv", "file", "convert"},
"description": "文档读写、格式转换、文件处理工具",
},
"other": {
"name": "其他",
"name_en": "Other",
"icon": "📦",
"keywords": set(),
"description": "未分类工具",
},
}
# ──────── 公共 API ────────
def get_category(category_id: str) -> Optional[Dict]:
"""获取分类详情"""
return CLAWHUB_CATEGORIES.get(category_id)
def get_all_categories() -> Dict[str, Dict]:
"""获取所有分类"""
return dict(CLAWHUB_CATEGORIES)
def match_tags(tags: List[str]) -> List[str]:
"""
根据标签列表匹配分类 ID
Args:
tags: ["stock", "trading", "analysis"]
Returns:
["finance", "data-analysis"]
"""
if not tags:
return ["other"]
tag_set = {t.lower().strip() for t in tags}
matched = []
for cat_id, cat_info in CLAWHUB_CATEGORIES.items():
if cat_id == "other":
continue
keywords = cat_info.get("keywords", set())
if tag_set & keywords: # 交集非空
matched.append(cat_id)
return matched if matched else ["other"]
def infer_category_from_name(skill_name: str) -> str:
"""从 skill 名称推断分类"""
name_lower = skill_name.lower().replace("-", " ").replace("_", " ")
best_match = "other"
best_score = 0
for cat_id, cat_info in CLAWHUB_CATEGORIES.items():
if cat_id == "other":
continue
score = sum(1 for kw in cat_info.get("keywords", set()) if kw in name_lower)
if score > best_score:
best_score = score
best_match = cat_id
return best_match
def get_category_display(category_id: str) -> str:
"""获取分类的显示文本(含 icon)"""
cat = CLAWHUB_CATEGORIES.get(category_id, CLAWHUB_CATEGORIES["other"])
return f"{cat['icon']} {cat['name']}"
FILE:skills_monitor/data/clawhub_popular_fallback.json
[
{
"slug": "a-share-short-decision",
"name": "A股短线决策",
"installs": 8520,
"stars": 342,
"star_density": 0.0401,
"tags": ["finance", "stock", "trading"],
"security_score": "A",
"last_updated": "2026-03-01",
"description": "A股短线决策辅助工具,提供市场情绪分析和交易信号"
},
{
"slug": "web-search",
"name": "Web Search",
"installs": 125000,
"stars": 6800,
"star_density": 0.0544,
"tags": ["search", "web", "productivity"],
"security_score": "A",
"last_updated": "2026-03-10",
"description": "Universal web search skill for AI agents"
},
{
"slug": "code-review",
"name": "Code Review Assistant",
"installs": 45000,
"stars": 3200,
"star_density": 0.0711,
"tags": ["development", "code", "review"],
"security_score": "A",
"last_updated": "2026-03-08",
"description": "Automated code review with best practices analysis"
},
{
"slug": "file-manager",
"name": "File Manager",
"installs": 89000,
"stars": 4100,
"star_density": 0.0461,
"tags": ["filesystem", "productivity", "utility"],
"security_score": "A",
"last_updated": "2026-03-05",
"description": "Advanced file operations and management"
},
{
"slug": "data-analysis",
"name": "Data Analysis",
"installs": 67000,
"stars": 3800,
"star_density": 0.0567,
"tags": ["data", "analytics", "visualization"],
"security_score": "A",
"last_updated": "2026-03-07",
"description": "Data analysis and visualization with pandas and matplotlib"
},
{
"slug": "markdown-editor",
"name": "Markdown Editor",
"installs": 52000,
"stars": 2600,
"star_density": 0.05,
"tags": ["writing", "markdown", "editor"],
"security_score": "A",
"last_updated": "2026-03-06",
"description": "Rich markdown editing and preview"
},
{
"slug": "git-assistant",
"name": "Git Assistant",
"installs": 78000,
"stars": 5100,
"star_density": 0.0654,
"tags": ["development", "git", "version-control"],
"security_score": "A",
"last_updated": "2026-03-09",
"description": "Git operations assistant for branch management and commit analysis"
},
{
"slug": "api-tester",
"name": "API Tester",
"installs": 34000,
"stars": 1800,
"star_density": 0.0529,
"tags": ["development", "api", "testing"],
"security_score": "A",
"last_updated": "2026-03-04",
"description": "HTTP API testing and documentation tool"
},
{
"slug": "image-processor",
"name": "Image Processor",
"installs": 41000,
"stars": 2200,
"star_density": 0.0537,
"tags": ["media", "image", "processing"],
"security_score": "A",
"last_updated": "2026-03-03",
"description": "Image manipulation, conversion and optimization"
},
{
"slug": "text-translator",
"name": "Text Translator",
"installs": 95000,
"stars": 5600,
"star_density": 0.0589,
"tags": ["language", "translation", "productivity"],
"security_score": "A",
"last_updated": "2026-03-10",
"description": "Multi-language text translation with context awareness"
},
{
"slug": "database-query",
"name": "Database Query",
"installs": 28000,
"stars": 1500,
"star_density": 0.0536,
"tags": ["database", "sql", "data"],
"security_score": "B+",
"last_updated": "2026-03-02",
"description": "SQL query builder and database management"
},
{
"slug": "task-scheduler",
"name": "Task Scheduler",
"installs": 19000,
"stars": 980,
"star_density": 0.0516,
"tags": ["automation", "scheduling", "productivity"],
"security_score": "A",
"last_updated": "2026-02-28",
"description": "Automated task scheduling and workflow management"
},
{
"slug": "pdf-processor",
"name": "PDF Processor",
"installs": 56000,
"stars": 2900,
"star_density": 0.0518,
"tags": ["document", "pdf", "processing"],
"security_score": "A",
"last_updated": "2026-03-08",
"description": "PDF reading, creation, and manipulation"
},
{
"slug": "email-assistant",
"name": "Email Assistant",
"installs": 31000,
"stars": 1400,
"star_density": 0.0452,
"tags": ["communication", "email", "productivity"],
"security_score": "A",
"last_updated": "2026-03-01",
"description": "Email drafting, scheduling and management"
},
{
"slug": "weather-query",
"name": "Weather Query",
"installs": 72000,
"stars": 3500,
"star_density": 0.0486,
"tags": ["utility", "weather", "api"],
"security_score": "A",
"last_updated": "2026-03-05",
"description": "Global weather forecast and historical data"
}
]
FILE:skills_monitor/adapters/clawhub_client.py
"""
ClawHub 社区数据采集器 v0.5.0 — 公共 Skill 注册库数据获取
========================================================
从 ClawHub (clawhub.ai) 获取 Skill 的社区元数据:
- 下载量 (installs)
- 星标数 (stars)
- 安全扫描结果
- 标签/分类
- 更新时间
由于 ClawHub 无官方 REST API,本模块使用:
- 优先: 尝试内部 API 端点 (JSON 响应)
- 降级: 本地维护的热门 Skills 缓存文件
使用方式:
from skills_monitor.adapters.clawhub_client import ClawHubClient
client = ClawHubClient()
meta = client.get_skill_metadata("a-share-short-decision")
popular = client.get_popular_skills(category="finance", limit=20)
"""
import json
import logging
import os
import time
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
# ClawHub 配置
CLAWHUB_BASE_URL = "https://clawhub.ai"
CLAWHUB_API_BASE = f"{CLAWHUB_BASE_URL}/api" # 猜测的 API 路径
CLAWHUB_REGISTRY_URL = f"{CLAWHUB_BASE_URL}/registry"
# 缓存配置
CACHE_DIR = os.path.expanduser("~/.skills_monitor/clawhub_cache")
CACHE_TTL_HOURS = 24 # 缓存有效期 24 小时
POPULAR_CACHE_FILE = "popular_skills.json"
METADATA_CACHE_FILE = "skill_metadata.json"
# 本地降级数据
LOCAL_FALLBACK_FILE = os.path.join(
os.path.dirname(os.path.dirname(__file__)),
"data", "clawhub_popular_fallback.json",
)
# 请求配置
REQUEST_TIMEOUT = 15
MAX_RETRIES = 2
RATE_LIMIT_DELAY = 0.5 # 请求间隔(秒)
class ClawHubClient:
"""
ClawHub 社区数据采集器
数据获取优先级:
1. 本地缓存 (< 24h)
2. ClawHub API / 网页请求
3. 本地降级 JSON 文件
"""
def __init__(
self,
base_url: str = CLAWHUB_BASE_URL,
cache_ttl_hours: int = CACHE_TTL_HOURS,
):
self.base_url = base_url.rstrip("/")
self.api_base = f"{self.base_url}/api"
self.cache_ttl = timedelta(hours=cache_ttl_hours)
# 确保缓存目录
Path(CACHE_DIR).mkdir(parents=True, exist_ok=True)
# 加载缓存
self._metadata_cache: Dict[str, Dict] = self._load_cache(METADATA_CACHE_FILE)
self._popular_cache: Dict[str, Any] = self._load_cache(POPULAR_CACHE_FILE)
# 请求会话
self._session = None
self._last_request_time = 0.0
# ──────── 公开 API ────────
def get_skill_metadata(self, slug: str) -> Dict[str, Any]:
"""
获取单个 Skill 的社区元数据
Args:
slug: Skill 的 slug (e.g. "a-share-short-decision")
Returns:
{
"slug": "...",
"installs": 1234,
"stars": 56,
"star_density": 0.045, # stars / installs
"tags": ["finance", "stock"],
"security_score": "A",
"last_updated": "2026-03-10",
"source": "api" | "cache" | "fallback",
}
"""
# 1. 检查缓存
cached = self._get_cached_metadata(slug)
if cached:
cached["source"] = "cache"
return cached
# 2. 尝试在线获取
online = self._fetch_skill_metadata_online(slug)
if online:
online["source"] = "api"
self._cache_metadata(slug, online)
return online
# 3. 降级到本地文件
fallback = self._get_fallback_metadata(slug)
if fallback:
fallback["source"] = "fallback"
return fallback
# 4. 无数据
return {
"slug": slug,
"installs": None,
"stars": None,
"star_density": None,
"tags": [],
"security_score": None,
"last_updated": None,
"source": "none",
}
def get_popular_skills(
self,
category: str = None,
limit: int = 50,
) -> List[Dict[str, Any]]:
"""
获取热门 Skills 排行
Args:
category: 按分类过滤 (可选)
limit: 返回数量上限
Returns:
[{"slug": ..., "installs": ..., "stars": ..., ...}, ...]
"""
# 1. 检查缓存
cache_key = f"popular_{category or 'all'}"
if cache_key in self._popular_cache:
cached = self._popular_cache[cache_key]
if self._is_cache_valid(cached.get("cached_at")):
skills = cached.get("skills", [])
return skills[:limit]
# 2. 尝试在线获取
skills = self._fetch_popular_online(category)
if skills:
self._popular_cache[cache_key] = {
"skills": skills,
"cached_at": datetime.now().isoformat(),
}
self._save_cache(POPULAR_CACHE_FILE, self._popular_cache)
return skills[:limit]
# 3. 降级到本地
fallback = self._load_fallback()
if fallback:
skills = fallback
if category:
skills = [s for s in skills if category in s.get("tags", [])]
return skills[:limit]
return []
def batch_fetch(self, slugs: List[str]) -> Dict[str, Dict[str, Any]]:
"""
批量获取多个 Skill 的元数据
Args:
slugs: Skill slug 列表
Returns:
{slug: metadata_dict, ...}
"""
results = {}
for slug in slugs:
results[slug] = self.get_skill_metadata(slug)
# 限流
time.sleep(RATE_LIMIT_DELAY)
return results
def get_community_scores(self, slug: str) -> Dict[str, Optional[float]]:
"""
获取 Skill 的社区评分指标(用于评分引擎)
Returns:
{
"community_popularity": 0.0-1.0 (对数归一化下载量),
"community_rating": 0.0-1.0 (star density 归一化),
}
"""
meta = self.get_skill_metadata(slug)
popularity = None
rating = None
installs = meta.get("installs")
stars = meta.get("stars")
if installs is not None and installs > 0:
import math
# 对数归一化: log10(installs) / log10(max_expected_installs)
# 假设最大下载量 100 万
popularity = min(1.0, math.log10(max(installs, 1)) / 6.0)
if stars is not None and installs and installs > 0:
# star density 归一化: star_density / max_expected_density
# 假设最高 density 0.2 (20% 用户给了 star)
density = stars / installs
rating = min(1.0, density / 0.2)
return {
"community_popularity": round(popularity, 4) if popularity is not None else None,
"community_rating": round(rating, 4) if rating is not None else None,
"source": meta.get("source", "none"),
}
# ──────── 在线获取 ────────
def _fetch_skill_metadata_online(self, slug: str) -> Optional[Dict[str, Any]]:
"""尝试从 ClawHub 在线获取 Skill 元数据"""
session = self._get_session()
# 尝试多个可能的 API 端点
endpoints = [
f"{self.api_base}/v1/skills/{slug}",
f"{self.api_base}/skills/{slug}",
f"{self.api_base}/v1/registry/{slug}",
]
for url in endpoints:
try:
self._rate_limit()
resp = session.get(url, timeout=REQUEST_TIMEOUT)
if resp.status_code == 200:
data = resp.json()
return self._normalize_metadata(slug, data)
except Exception as e:
logger.debug(f"ClawHub API 请求失败 [{url}]: {e}")
continue
# 尝试从公开页面解析
return self._scrape_skill_page(slug)
def _scrape_skill_page(self, slug: str) -> Optional[Dict[str, Any]]:
"""从 ClawHub 公开页面提取数据(降级方案)"""
session = self._get_session()
url = f"{self.base_url}/skills/{slug}"
try:
self._rate_limit()
resp = session.get(url, timeout=REQUEST_TIMEOUT)
if resp.status_code != 200:
return None
# 简单的 JSON-LD / meta 标签解析
# ClawHub 页面可能在 script 标签中包含 JSON 数据
text = resp.text
return self._parse_page_data(slug, text)
except Exception as e:
logger.debug(f"ClawHub 页面抓取失败 [{slug}]: {e}")
return None
def _parse_page_data(self, slug: str, html: str) -> Optional[Dict[str, Any]]:
"""从 HTML 页面中提取结构化数据"""
import re
metadata = {
"slug": slug,
"installs": None,
"stars": None,
"tags": [],
"security_score": None,
"last_updated": None,
}
# 尝试从 script[type="application/json"] 或 __NEXT_DATA__ 中提取
patterns = [
r'<script\s+id="__NEXT_DATA__"[^>]*>(.*?)</script>',
r'"installs?[Cc]ount?":\s*(\d+)',
r'"stars?":\s*(\d+)',
r'"downloads?":\s*(\d+)',
]
# 提取 installs
match = re.search(r'"(?:installs?|downloads?)[Cc]?ount?":\s*(\d+)', html)
if match:
metadata["installs"] = int(match.group(1))
# 提取 stars
match = re.search(r'"stars?":\s*(\d+)', html)
if match:
metadata["stars"] = int(match.group(1))
# 提取 tags
tag_matches = re.findall(r'"tags?":\s*\[(.*?)\]', html)
if tag_matches:
try:
tags_str = tag_matches[0]
metadata["tags"] = [t.strip().strip('"\'') for t in tags_str.split(",") if t.strip()]
except Exception:
pass
# 如果至少获取到一个有效字段
if metadata["installs"] is not None or metadata["stars"] is not None:
if metadata["installs"] and metadata["stars"]:
metadata["star_density"] = round(metadata["stars"] / metadata["installs"], 4)
return metadata
return None
def _fetch_popular_online(self, category: str = None) -> Optional[List[Dict[str, Any]]]:
"""在线获取热门 Skills 列表"""
session = self._get_session()
params = {"sort": "popular", "limit": 100}
if category:
params["category"] = category
endpoints = [
f"{self.api_base}/v1/skills",
f"{self.api_base}/skills",
f"{self.api_base}/v1/registry",
]
for url in endpoints:
try:
self._rate_limit()
resp = session.get(url, params=params, timeout=REQUEST_TIMEOUT)
if resp.status_code == 200:
data = resp.json()
items = data if isinstance(data, list) else data.get("items", data.get("skills", []))
return [self._normalize_metadata(s.get("slug", s.get("id", "")), s) for s in items]
except Exception as e:
logger.debug(f"ClawHub 热门列表请求失败 [{url}]: {e}")
continue
return None
# ──────── 数据标准化 ────────
def _normalize_metadata(self, slug: str, raw: Dict) -> Dict[str, Any]:
"""将不同来源的数据标准化为统一格式"""
installs = (
raw.get("installs") or raw.get("installCount") or
raw.get("downloads") or raw.get("downloadCount") or 0
)
stars = raw.get("stars") or raw.get("starCount") or raw.get("favorites") or 0
tags = raw.get("tags") or raw.get("categories") or raw.get("labels") or []
if isinstance(tags, str):
tags = [t.strip() for t in tags.split(",")]
return {
"slug": slug,
"name": raw.get("name") or raw.get("title") or slug,
"installs": int(installs) if installs else 0,
"stars": int(stars) if stars else 0,
"star_density": round(int(stars) / max(int(installs), 1), 4) if installs else 0.0,
"tags": tags,
"security_score": raw.get("securityScore") or raw.get("security_grade"),
"last_updated": raw.get("updatedAt") or raw.get("updated_at") or raw.get("lastPublished"),
"description": (raw.get("description") or "")[:200],
"author": raw.get("author") or raw.get("publisher") or raw.get("owner"),
}
# ──────── 缓存管理 ────────
def _load_cache(self, filename: str) -> Dict:
"""加载缓存文件"""
filepath = os.path.join(CACHE_DIR, filename)
try:
if os.path.exists(filepath):
with open(filepath, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
pass
return {}
def _save_cache(self, filename: str, data: Dict):
"""保存缓存文件"""
filepath = os.path.join(CACHE_DIR, filename)
try:
with open(filepath, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
except Exception as e:
logger.warning(f"保存缓存失败: {e}")
def _get_cached_metadata(self, slug: str) -> Optional[Dict]:
"""获取缓存的元数据"""
entry = self._metadata_cache.get(slug)
if entry and self._is_cache_valid(entry.get("cached_at")):
return entry.get("data")
return None
def _cache_metadata(self, slug: str, data: Dict):
"""缓存元数据"""
self._metadata_cache[slug] = {
"data": data,
"cached_at": datetime.now().isoformat(),
}
self._save_cache(METADATA_CACHE_FILE, self._metadata_cache)
def _is_cache_valid(self, cached_at: Optional[str]) -> bool:
"""检查缓存是否有效"""
if not cached_at:
return False
try:
ct = datetime.fromisoformat(cached_at)
return datetime.now() - ct < self.cache_ttl
except (ValueError, TypeError):
return False
# ──────── 降级数据 ────────
def _load_fallback(self) -> Optional[List[Dict]]:
"""加载本地降级数据"""
try:
if os.path.exists(LOCAL_FALLBACK_FILE):
with open(LOCAL_FALLBACK_FILE, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
pass
return None
def _get_fallback_metadata(self, slug: str) -> Optional[Dict]:
"""从降级数据中查找特定 Skill"""
fallback = self._load_fallback()
if fallback:
for skill in fallback:
if skill.get("slug") == slug:
return skill
return None
# ──────── HTTP 会话 ────────
def _get_session(self):
"""获取 requests 会话(懒加载)"""
if self._session is None:
import requests
self._session = requests.Session()
self._session.headers.update({
"User-Agent": "SkillsMonitor/0.5.0",
"Accept": "application/json, text/html",
})
return self._session
def _rate_limit(self):
"""简单限流"""
now = time.time()
elapsed = now - self._last_request_time
if elapsed < RATE_LIMIT_DELAY:
time.sleep(RATE_LIMIT_DELAY - elapsed)
self._last_request_time = time.time()
# ──────── 清理 ────────
def clear_cache(self):
"""清空所有缓存"""
self._metadata_cache = {}
self._popular_cache = {}
for f in (METADATA_CACHE_FILE, POPULAR_CACHE_FILE):
try:
os.remove(os.path.join(CACHE_DIR, f))
except FileNotFoundError:
pass
def close(self):
"""关闭 HTTP 会话"""
if self._session:
self._session.close()
self._session = None
FILE:skills_monitor/adapters/__init__.py
# adapters subpackage
FILE:skills_monitor/adapters/runners.py
"""
Skill 运行适配器
针对不同 skill 的入口方式,提供统一的运行接口
"""
import importlib
import json
import os
import subprocess
import sys
import io
from contextlib import redirect_stdout, redirect_stderr
from typing import Any, Callable, Dict, List, Optional
from skills_monitor.adapters.skill_registry import SkillInfo
class SkillRunner:
"""统一的 Skill 运行器"""
def __init__(self, skill_info: SkillInfo):
self.info = skill_info
def run(self, task_name: str = "", params: Optional[Dict] = None) -> Dict[str, Any]:
"""
运行 skill
返回 {"success": bool, "output": ..., "error": ...}
"""
if params is None:
params = {}
if self.info.entry_type == "none":
return {
"success": False,
"output": None,
"error": f"Skill [{self.info.slug}] 没有可执行入口",
}
if self.info.entry_type == "cli":
return self._run_cli(task_name, params)
elif self.info.entry_type == "function":
return self._run_function(task_name, params)
else:
return {
"success": False,
"output": None,
"error": f"未知的入口类型: {self.info.entry_type}",
}
def _run_cli(self, task_name: str, params: Dict) -> Dict[str, Any]:
"""通过 CLI (subprocess) 运行 skill"""
entry_path = os.path.join(self.info.dir_path, self.info.entry_file)
if not os.path.isfile(entry_path):
return {"success": False, "output": None, "error": f"入口文件不存在: {entry_path}"}
# 构建命令行参数
cmd = [sys.executable, entry_path]
if task_name:
cmd.append(task_name)
for k, v in params.items():
if v is True:
cmd.append(f"--{k}")
elif v is not None and v is not False:
cmd.append(f"--{k}")
cmd.append(str(v))
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=120,
cwd=self.info.dir_path,
env={**os.environ, "PYTHONPATH": self.info.dir_path},
)
output = result.stdout.strip()
stderr = result.stderr.strip()
# 尝试解析 JSON 输出
parsed_output = output
try:
parsed_output = json.loads(output)
except (json.JSONDecodeError, ValueError):
pass
if result.returncode == 0:
return {"success": True, "output": parsed_output, "error": None}
else:
return {
"success": False,
"output": parsed_output,
"error": stderr or f"exit code: {result.returncode}",
}
except subprocess.TimeoutExpired:
return {"success": False, "output": None, "error": "执行超时 (120s)"}
except Exception as e:
return {"success": False, "output": None, "error": str(e)}
def _run_function(self, task_name: str, params: Dict) -> Dict[str, Any]:
"""通过直接导入函数运行 skill"""
entry_path = os.path.join(self.info.dir_path, self.info.entry_file)
if not os.path.isfile(entry_path):
return {"success": False, "output": None, "error": f"入口文件不存在: {entry_path}"}
try:
# 动态导入
spec = importlib.util.spec_from_file_location(
f"skill_{self.info.slug}", entry_path
)
module = importlib.util.module_from_spec(spec)
# 添加 skill 目录到 path
if self.info.dir_path not in sys.path:
sys.path.insert(0, self.info.dir_path)
spec.loader.exec_module(module)
# 查找目标函数
func = None
if task_name and hasattr(module, task_name):
func = getattr(module, task_name)
elif hasattr(module, "main"):
func = module.main
elif hasattr(module, "run"):
func = module.run
else:
return {
"success": False,
"output": None,
"error": f"找不到函数: {task_name or 'main/run'}",
}
# 捕获 stdout
stdout_capture = io.StringIO()
with redirect_stdout(stdout_capture):
result = func(**params) if params else func()
stdout_text = stdout_capture.getvalue()
output = result if result is not None else stdout_text
return {"success": True, "output": output, "error": None}
except Exception as e:
return {"success": False, "output": None, "error": str(e)}
# ──────── 预定义的快捷适配器 ────────
class AShareShortDecisionAdapter:
"""a-share-short-decision 的专用适配器"""
TASKS = [
"get_market_sentiment",
"get_sector_rotation",
"scan_strong_stocks",
"analyze_capital_flow",
"short_term_signal_engine",
"short_term_risk_control",
"generate_daily_report",
]
def __init__(self, skill_info: SkillInfo):
self.runner = SkillRunner(skill_info)
self.info = skill_info
def list_tasks(self) -> List[str]:
return self.TASKS
def run_task(self, task: str, **kwargs) -> Dict[str, Any]:
return self.runner.run(task_name=task, params=kwargs)
class TradingSignalsAdapter:
"""trading-signals 的专用适配器"""
DEFAULT_SYMBOLS = ["AAPL", "BTC-USD", "0700.HK"]
def __init__(self, skill_info: SkillInfo):
self.runner = SkillRunner(skill_info)
self.info = skill_info
def list_tasks(self) -> List[str]:
return ["analyze_signals"]
def run_task(self, symbol: str = "AAPL") -> Dict[str, Any]:
return self.runner.run(task_name="", params={}) # 通过 sys.argv 传参
def run_with_symbol(self, symbol: str) -> Dict[str, Any]:
"""直接运行并传入 symbol"""
entry_path = os.path.join(self.info.dir_path, self.info.entry_file)
try:
result = subprocess.run(
[sys.executable, entry_path, symbol],
capture_output=True,
text=True,
timeout=60,
cwd=self.info.dir_path,
)
return {
"success": result.returncode == 0,
"output": result.stdout.strip(),
"error": result.stderr.strip() if result.returncode != 0 else None,
}
except Exception as e:
return {"success": False, "output": None, "error": str(e)}
class StockScreenerAdapter:
"""stock-screener-cn 的专用适配器"""
STRATEGIES = [
"均线多头排列", "均线向上", "缩量回踩",
"放量突破", "金叉", "大帅逼策略", "龙回头",
]
def __init__(self, skill_info: SkillInfo):
self.runner = SkillRunner(skill_info)
self.info = skill_info
def list_tasks(self) -> List[str]:
return self.STRATEGIES
def run_task(self, strategy: str = "金叉", limit: int = 10) -> Dict[str, Any]:
return self.runner.run(params={
"strategy": strategy,
"limit": limit,
"min-price": 5.0,
"max-price": 100.0,
})
def get_adapter(skill_info: SkillInfo):
"""工厂函数:为指定 skill 返回专用适配器"""
adapters = {
"a-share-short-decision": AShareShortDecisionAdapter,
"trading-signals": TradingSignalsAdapter,
"stock-screener-cn": StockScreenerAdapter,
}
adapter_class = adapters.get(skill_info.slug)
if adapter_class:
return adapter_class(skill_info)
# 通用 runner
return SkillRunner(skill_info)
FILE:skills_monitor/adapters/skill_registry.py
"""
Skill 注册与发现 — 读取已安装 skills 的元数据
构建统一的 skill 注册表
"""
import json
import os
import re
from pathlib import Path
from typing import Any, Dict, List, Optional
class SkillInfo:
"""单个 skill 的元信息"""
def __init__(
self,
slug: str,
name: str = "",
version: str = "",
description: str = "",
category: str = "未分类",
entry_file: str = "",
entry_type: str = "unknown", # cli / function / none
dir_path: str = "",
meta: Optional[Dict] = None,
):
self.slug = slug
self.name = name or slug
self.version = version
self.description = description
self.category = category
self.entry_file = entry_file
self.entry_type = entry_type
self.dir_path = dir_path
self.meta = meta or {}
def to_dict(self) -> Dict[str, Any]:
return {
"slug": self.slug,
"name": self.name,
"version": self.version,
"description": self.description,
"category": self.category,
"entry_file": self.entry_file,
"entry_type": self.entry_type,
"dir_path": self.dir_path,
}
# 分类映射(基于 slug 关键词)
CATEGORY_RULES = [
(r"(data|real-time|clouddream)", "数据采集"),
(r"(macro|gdp|cpi)", "宏观分析"),
(r"(news|finance-news)", "新闻情报"),
(r"(screen|analysis|research|stock-analysis)", "技术筛选"),
(r"(signal|trading|decision)", "交易信号"),
(r"(monitor)", "量化监控"),
(r"(backtest|strategy)", "策略回测"),
(r"(chart|image|render)", "可视化"),
(r"(celebrity|hot-money|money-flow)", "资金追踪"),
(r"(doc|webhook|search|robot)", "工具/通知"),
]
def _classify_skill(slug: str, description: str = "") -> str:
"""根据 slug 和描述推断分类"""
text = f"{slug} {description}".lower()
for pattern, category in CATEGORY_RULES:
if re.search(pattern, text):
return category
return "未分类"
def _parse_skill_md(skill_md_path: str) -> Dict[str, str]:
"""从 SKILL.md 的 frontmatter 中提取 name 和 description"""
result = {"name": "", "description": ""}
try:
with open(skill_md_path, "r", encoding="utf-8") as f:
content = f.read()
# 解析 YAML frontmatter
if content.startswith("---"):
parts = content.split("---", 2)
if len(parts) >= 3:
frontmatter = parts[1]
for line in frontmatter.split("\n"):
line = line.strip()
if line.startswith("name:"):
result["name"] = line[5:].strip().strip('"').strip("'")
elif line.startswith("description:"):
result["description"] = line[12:].strip().strip('"').strip("'")
except Exception:
pass
return result
def _find_entry_file(skill_dir: str) -> tuple:
"""查找 skill 的入口文件,返回 (entry_file, entry_type)"""
# 优先级:main.py > scripts/main.py > scripts/*.py > index.py
candidates = [
("main.py", "cli"),
("scripts/main.py", "cli"),
("index.py", "function"),
]
for rel_path, entry_type in candidates:
full_path = os.path.join(skill_dir, rel_path)
if os.path.isfile(full_path):
return rel_path, entry_type
# 扫描 scripts/ 下的 .py 文件
scripts_dir = os.path.join(skill_dir, "scripts")
if os.path.isdir(scripts_dir):
py_files = [f for f in os.listdir(scripts_dir) if f.endswith(".py")]
if py_files:
return f"scripts/{py_files[0]}", "cli"
return "", "none"
class SkillRegistry:
"""Skill 注册表"""
def __init__(self, skills_dir: str):
self.skills_dir = skills_dir
self._skills: Dict[str, SkillInfo] = {}
self._scan()
def _scan(self):
"""扫描 skills 目录,构建注册表"""
if not os.path.isdir(self.skills_dir):
return
for entry in os.listdir(self.skills_dir):
subdir = os.path.join(self.skills_dir, entry)
if not os.path.isdir(subdir) or entry.startswith((".","_","__")):
continue
meta_path = os.path.join(subdir, "_meta.json")
skill_md_path = os.path.join(subdir, "SKILL.md")
# 读取 _meta.json
meta = {}
slug = entry
version = ""
if os.path.isfile(meta_path):
try:
with open(meta_path, "r", encoding="utf-8") as f:
meta = json.load(f)
slug = meta.get("slug", entry)
version = meta.get("version", "")
except Exception:
pass
# 读取 SKILL.md
md_info = _parse_skill_md(skill_md_path) if os.path.isfile(skill_md_path) else {}
# 查找入口
entry_file, entry_type = _find_entry_file(subdir)
# 分类
category = _classify_skill(slug, md_info.get("description", ""))
info = SkillInfo(
slug=slug,
name=md_info.get("name", "") or slug,
version=version,
description=md_info.get("description", ""),
category=category,
entry_file=entry_file,
entry_type=entry_type,
dir_path=subdir,
meta=meta,
)
self._skills[slug] = info
def list_skills(self) -> List[SkillInfo]:
"""列出所有已注册的 skills"""
return list(self._skills.values())
def get_skill(self, slug: str) -> Optional[SkillInfo]:
"""获取指定 skill"""
return self._skills.get(slug)
def get_runnable_skills(self) -> List[SkillInfo]:
"""获取有可执行入口的 skills"""
return [s for s in self._skills.values() if s.entry_type != "none"]
def get_skills_by_category(self) -> Dict[str, List[SkillInfo]]:
"""按分类获取 skills"""
result: Dict[str, List[SkillInfo]] = {}
for s in self._skills.values():
result.setdefault(s.category, []).append(s)
return result
def summary(self) -> str:
"""返回汇总信息"""
total = len(self._skills)
runnable = len(self.get_runnable_skills())
categories = self.get_skills_by_category()
lines = [
f"📦 已注册 Skills: {total} 个 (可执行: {runnable} 个)",
"",
]
for cat, skills in sorted(categories.items()):
slugs = ", ".join(s.slug for s in skills)
lines.append(f" [{cat}] {slugs}")
return "\n".join(lines)
FILE:skills_monitor/core/realtime_reporter.py
"""
实时调用反馈上报器 v0.5.0 — 异步队列 + 批量上报
================================================
在 Skill 调用完成后,将运行指标实时上报到中心服务器。
特性:
- 异步队列:不阻塞 Skill 执行
- 批量上报:累积 N 条或 T 秒先到者触发
- 容错:上报失败静默忽略,不影响用户体验
- 自动脱敏:上报数据经过 sanitizer 处理
- 优雅关闭:进程退出时刷新剩余队列
使用方式:
from skills_monitor.core.realtime_reporter import RealtimeReporter
rt = RealtimeReporter.get_instance()
rt.enqueue({"skill_id": "...", "duration_ms": 120, ...})
"""
import atexit
import json
import logging
import queue
import threading
import time
from datetime import datetime
from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
# 批量上报配置
DEFAULT_BATCH_SIZE = 10 # 累积 10 条触发上报
DEFAULT_FLUSH_INTERVAL = 60.0 # 或 60 秒触发上报
DEFAULT_MAX_QUEUE_SIZE = 500 # 队列上限(防止内存泄漏)
DEFAULT_MAX_RETRIES = 2 # 上报失败最多重试 2 次
DEFAULT_SERVER_URL = "http://localhost:5100"
class RealtimeReporter:
"""
实时调用反馈上报器(单例模式)
架构:
enqueue() → [Queue] → [后台线程] → batch_upload() → 服务器
后台线程策略:
- 每 flush_interval 秒检查一次队列
- 队列中累积 >= batch_size 条时立即上报
- 进程退出时(atexit)刷新剩余数据
"""
_instance: Optional["RealtimeReporter"] = None
_lock = threading.Lock()
@classmethod
def get_instance(
cls,
server_url: str = DEFAULT_SERVER_URL,
batch_size: int = DEFAULT_BATCH_SIZE,
flush_interval: float = DEFAULT_FLUSH_INTERVAL,
) -> "RealtimeReporter":
"""获取单例实例"""
if cls._instance is None:
with cls._lock:
if cls._instance is None:
cls._instance = cls(server_url, batch_size, flush_interval)
return cls._instance
@classmethod
def reset_instance(cls):
"""重置单例(用于测试)"""
with cls._lock:
if cls._instance is not None:
cls._instance.shutdown()
cls._instance = None
def __init__(
self,
server_url: str = DEFAULT_SERVER_URL,
batch_size: int = DEFAULT_BATCH_SIZE,
flush_interval: float = DEFAULT_FLUSH_INTERVAL,
max_queue_size: int = DEFAULT_MAX_QUEUE_SIZE,
):
self.server_url = server_url.rstrip("/")
self.batch_size = batch_size
self.flush_interval = flush_interval
self._queue: queue.Queue = queue.Queue(maxsize=max_queue_size)
self._shutdown_event = threading.Event()
self._stats = {
"enqueued": 0,
"uploaded": 0,
"failed": 0,
"dropped": 0,
"batches_sent": 0,
}
# 身份信息(延迟加载)
self._agent_id: Optional[str] = None
self._token: Optional[str] = None
# 启动后台线程
self._thread = threading.Thread(
target=self._worker,
name="realtime-reporter",
daemon=True,
)
self._thread.start()
# 进程退出时刷新
atexit.register(self.shutdown)
# ──────── 入队 ────────
def enqueue(self, event: Dict[str, Any]) -> bool:
"""
将一个运行事件加入上报队列
Args:
event: 运行指标字典,至少包含:
- skill_id: str
- status: "success" | "error"
- duration_ms: float
- 可选: run_id, task_name, error_msg, ...
Returns:
True 成功入队, False 队列已满(被丢弃)
"""
if self._shutdown_event.is_set():
return False
# 附加时间戳
event.setdefault("reported_at", datetime.now().isoformat())
try:
self._queue.put_nowait(event)
self._stats["enqueued"] += 1
return True
except queue.Full:
self._stats["dropped"] += 1
logger.warning("实时上报队列已满,丢弃事件")
return False
# ──────── 后台工作线程 ────────
def _worker(self):
"""后台线程:定时检查队列并批量上报"""
logger.debug("RealtimeReporter 后台线程已启动")
while not self._shutdown_event.is_set():
# 等待 flush_interval 或被 shutdown 唤醒
self._shutdown_event.wait(timeout=self.flush_interval)
# 收集队列中的事件
batch = self._drain_queue(max_items=self.batch_size * 2)
if batch:
self._send_batch(batch)
# shutdown 后最终刷新
final_batch = self._drain_queue(max_items=self._queue.qsize() + 10)
if final_batch:
self._send_batch(final_batch)
logger.debug("RealtimeReporter 后台线程已退出")
def _drain_queue(self, max_items: int = 100) -> List[Dict[str, Any]]:
"""从队列中取出所有可用事件"""
items = []
while len(items) < max_items:
try:
item = self._queue.get_nowait()
items.append(item)
except queue.Empty:
break
return items
# ──────── 批量上报 ────────
def _send_batch(self, batch: List[Dict[str, Any]]):
"""将一批事件上报到中心服务器"""
if not batch:
return
# 自动脱敏
try:
from skills_monitor.core.sanitizer import DataSanitizer
sanitizer = DataSanitizer()
batch = [sanitizer.sanitize(event) for event in batch]
except ImportError:
pass
# 延迟加载身份
self._ensure_identity()
headers = {
"X-Agent-ID": self._agent_id or "",
"X-Agent-Token": self._token or "",
"Content-Type": "application/json",
}
payload = {
"data": {
"events": batch,
"batch_size": len(batch),
"reported_at": datetime.now().isoformat(),
},
"report_type": "realtime_feedback",
}
for attempt in range(DEFAULT_MAX_RETRIES + 1):
try:
import requests
resp = requests.post(
f"{self.server_url}/api/agent/report",
headers=headers,
json=payload,
timeout=10,
)
if resp.status_code == 200:
self._stats["uploaded"] += len(batch)
self._stats["batches_sent"] += 1
logger.debug(f"实时上报成功: {len(batch)} 条事件")
return
else:
logger.warning(f"实时上报响应异常: {resp.status_code}")
except Exception as e:
if attempt < DEFAULT_MAX_RETRIES:
time.sleep(1 * (attempt + 1)) # 指数退避
else:
self._stats["failed"] += len(batch)
logger.warning(f"实时上报失败 (重试{attempt}次): {e}")
def _ensure_identity(self):
"""延迟加载身份信息"""
if self._agent_id:
return
try:
from skills_monitor.core.identity import IdentityManager
mgr = IdentityManager()
self._agent_id = mgr.agent_id
self._token = mgr.api_key
except Exception as e:
logger.warning(f"加载身份失败: {e}")
# ──────── 生命周期管理 ────────
def shutdown(self):
"""优雅关闭:刷新剩余队列"""
if self._shutdown_event.is_set():
return
self._shutdown_event.set()
# 等待后台线程结束(最多 5 秒)
if self._thread.is_alive():
self._thread.join(timeout=5)
def flush(self):
"""手动触发刷新队列(不关闭线程)"""
batch = self._drain_queue(max_items=self._queue.qsize() + 10)
if batch:
self._send_batch(batch)
# ──────── 状态查询 ────────
@property
def stats(self) -> Dict[str, Any]:
"""获取统计信息"""
return {
**self._stats,
"queue_size": self._queue.qsize(),
"is_running": not self._shutdown_event.is_set(),
"thread_alive": self._thread.is_alive(),
}
@property
def pending_count(self) -> int:
"""队列中待上报的事件数"""
return self._queue.qsize()
FILE:skills_monitor/core/secure_store.py
"""
安全凭证存储 — OS Keychain 集成
===============================
优先使用操作系统原生密钥管理:
macOS → Keychain
Linux → Secret Service / KWallet
Windows → Windows Credential Locker
降级方案:若 keyring 不可用(无头服务器 / CI),使用 Fernet 对称加密文件。
Usage:
store = SecureStore()
store.store_credential("api_key", "sk-abc123...")
key = store.get_credential("api_key")
store.delete_credential("api_key")
"""
import hashlib
import json
import logging
import os
import platform
import secrets
import stat
from base64 import urlsafe_b64encode, urlsafe_b64decode
from pathlib import Path
from typing import Optional
logger = logging.getLogger(__name__)
SERVICE_NAME = "skills-monitor"
FALLBACK_DIR = os.path.expanduser(
os.path.join(os.environ.get("SKILLS_MONITOR_HOME", "~/.skills_monitor"), ".secrets")
)
FALLBACK_KEY_FILE = ".enc_key"
class SecureStore:
"""
安全凭证存储 — 优先 OS Keychain,降级到加密文件
特性:
- 透明降级:keyring 不可用时自动切换 Fernet 加密存储
- 线程安全:单次读写操作原子化
- 文件权限:降级文件自动设置 600 (仅 owner 可读写)
"""
def __init__(self, service_name: str = SERVICE_NAME):
self.service_name = service_name
self._backend = self._detect_backend()
logger.info(f"SecureStore 初始化完成,后端: {self._backend}")
# ──────── 公共 API ────────
def store_credential(self, key: str, value: str) -> bool:
"""
存储敏感信息
Args:
key: 凭证标识(如 "api_key", "server_token")
value: 凭证值(明文)
Returns:
True 存储成功
"""
if self._backend == "keyring":
return self._keyring_set(key, value)
else:
return self._file_set(key, value)
def get_credential(self, key: str) -> Optional[str]:
"""
读取敏感信息
Args:
key: 凭证标识
Returns:
凭证值(明文)或 None
"""
if self._backend == "keyring":
return self._keyring_get(key)
else:
return self._file_get(key)
def delete_credential(self, key: str) -> bool:
"""
删除凭证
Args:
key: 凭证标识
Returns:
True 删除成功
"""
if self._backend == "keyring":
return self._keyring_delete(key)
else:
return self._file_delete(key)
def has_credential(self, key: str) -> bool:
"""检查凭证是否存在"""
return self.get_credential(key) is not None
def list_credentials(self) -> list:
"""列出所有已存储的凭证 key(不含值)"""
if self._backend == "file":
return self._file_list_keys()
# keyring 不支持列出所有 key,返回已知的常用 key
known_keys = ["api_key", "server_token", "agent_secret"]
return [k for k in known_keys if self.has_credential(k)]
@property
def backend_name(self) -> str:
"""当前使用的存储后端名称"""
return self._backend
# ──────── 后端检测 ────────
def _detect_backend(self) -> str:
"""检测可用的存储后端"""
try:
import keyring
from keyring.errors import NoKeyringError, KeyringLocked
# 测试 keyring 是否真正可用
test_key = f"_test_{secrets.token_hex(4)}"
keyring.set_password(self.service_name, test_key, "test")
result = keyring.get_password(self.service_name, test_key)
keyring.delete_password(self.service_name, test_key)
if result == "test":
return "keyring"
else:
logger.warning("keyring 读写测试不一致,降级到文件存储")
return "file"
except ImportError:
logger.info("keyring 库未安装,使用加密文件存储")
return "file"
except Exception as e:
logger.warning(f"keyring 不可用 ({e}),降级到加密文件存储")
return "file"
# ──────── Keyring 后端 ────────
def _keyring_set(self, key: str, value: str) -> bool:
try:
import keyring
keyring.set_password(self.service_name, key, value)
return True
except Exception as e:
logger.error(f"Keychain 写入失败: {e}")
# 降级到文件
return self._file_set(key, value)
def _keyring_get(self, key: str) -> Optional[str]:
try:
import keyring
value = keyring.get_password(self.service_name, key)
if value is not None:
return value
# Keychain 中无此 key,尝试从文件降级存储读取
return self._file_get(key)
except Exception as e:
logger.error(f"Keychain 读取失败: {e}")
return self._file_get(key)
def _keyring_delete(self, key: str) -> bool:
try:
import keyring
keyring.delete_password(self.service_name, key)
return True
except Exception as e:
logger.warning(f"Keychain 删除失败: {e}")
return False
# ──────── 加密文件后端(降级方案)────────
def _ensure_fallback_dir(self) -> Path:
"""确保降级目录存在且权限安全"""
path = Path(FALLBACK_DIR)
path.mkdir(parents=True, exist_ok=True)
# 设置目录权限为 700
try:
os.chmod(str(path), stat.S_IRWXU)
except OSError:
pass
return path
def _get_encryption_key(self) -> bytes:
"""获取或生成对称加密密钥"""
key_path = self._ensure_fallback_dir() / FALLBACK_KEY_FILE
if key_path.exists():
return key_path.read_bytes()
# 生成 32 字节的加密密钥
key = urlsafe_b64encode(secrets.token_bytes(32))
key_path.write_bytes(key)
# 设置文件权限 600
try:
os.chmod(str(key_path), stat.S_IRUSR | stat.S_IWUSR)
except OSError:
pass
return key
def _encrypt(self, plaintext: str) -> str:
"""简单的 XOR + base64 加密(轻量级,适合本地存储)"""
key = self._get_encryption_key()
data = plaintext.encode("utf-8")
# 使用 key 的 SHA256 作为 XOR 密钥
key_hash = hashlib.sha256(key).digest()
encrypted = bytes(b ^ key_hash[i % len(key_hash)] for i, b in enumerate(data))
return urlsafe_b64encode(encrypted).decode("ascii")
def _decrypt(self, ciphertext: str) -> str:
"""解密"""
key = self._get_encryption_key()
data = urlsafe_b64decode(ciphertext)
key_hash = hashlib.sha256(key).digest()
decrypted = bytes(b ^ key_hash[i % len(key_hash)] for i, b in enumerate(data))
return decrypted.decode("utf-8")
def _get_vault_path(self) -> Path:
"""获取凭证保险库文件路径"""
return self._ensure_fallback_dir() / "vault.json"
def _load_vault(self) -> dict:
"""加载凭证保险库"""
vault_path = self._get_vault_path()
if not vault_path.exists():
return {}
try:
with open(vault_path, "r", encoding="utf-8") as f:
return json.load(f)
except (json.JSONDecodeError, OSError):
return {}
def _save_vault(self, vault: dict):
"""保存凭证保险库"""
vault_path = self._get_vault_path()
with open(vault_path, "w", encoding="utf-8") as f:
json.dump(vault, f, ensure_ascii=False, indent=2)
# 设置文件权限 600
try:
os.chmod(str(vault_path), stat.S_IRUSR | stat.S_IWUSR)
except OSError:
pass
def _file_set(self, key: str, value: str) -> bool:
try:
vault = self._load_vault()
vault[key] = self._encrypt(value)
self._save_vault(vault)
return True
except Exception as e:
logger.error(f"文件存储写入失败: {e}")
return False
def _file_get(self, key: str) -> Optional[str]:
try:
vault = self._load_vault()
encrypted = vault.get(key)
if encrypted is None:
return None
return self._decrypt(encrypted)
except Exception as e:
logger.error(f"文件存储读取失败: {e}")
return None
def _file_delete(self, key: str) -> bool:
try:
vault = self._load_vault()
if key in vault:
del vault[key]
self._save_vault(vault)
return True
except Exception as e:
logger.error(f"文件存储删除失败: {e}")
return False
def _file_list_keys(self) -> list:
vault = self._load_vault()
return list(vault.keys())
def migrate_to_secure_store(config_dir: str = None) -> bool:
"""
迁移工具:将旧版 config.json 中的凭证迁移到 SecureStore
旧版格式:config.json 含 api_key_hash,api_key 已不可逆
迁移策略:只能迁移 agent_id 等可读信息,api_key 需要重新生成
Returns:
True 迁移成功或无需迁移
"""
from skills_monitor.core.identity import DEFAULT_CONFIG_DIR
if config_dir is None:
config_dir = DEFAULT_CONFIG_DIR
config_path = Path(config_dir) / "config.json"
if not config_path.exists():
return True # 无需迁移
try:
with open(config_path, "r", encoding="utf-8") as f:
config = json.load(f)
except (json.JSONDecodeError, OSError):
return False
store = SecureStore()
# 迁移 agent_id
if config.get("agent_id") and not store.has_credential("agent_id"):
store.store_credential("agent_id", config["agent_id"])
logger.info("已迁移 agent_id 到安全存储")
# 设置配置文件权限为 600(无论是否迁移)
try:
os.chmod(str(config_path), stat.S_IRUSR | stat.S_IWUSR)
except OSError:
pass
# 在 config 中标记已迁移
if not config.get("_migrated_to_secure_store"):
config["_migrated_to_secure_store"] = True
config["_migrated_at"] = __import__("datetime").datetime.now().isoformat()
with open(config_path, "w", encoding="utf-8") as f:
json.dump(config, f, ensure_ascii=False, indent=2)
return True
FILE:skills_monitor/core/sanitizer.py
"""
敏感信息自动脱敏引擎 v0.5.0
============================
在数据上报到中心服务器之前,自动检测并脱敏以下敏感信息:
- 用户文件路径 (/Users/xxx, /home/xxx, C:\\Users\\xxx)
- IP 地址
- Email 地址
- 手机号码(中国大陆)
- API Key / Bearer Token
- 环境变量中的密钥
- 自定义正则(可配置白名单)
Usage:
sanitizer = DataSanitizer()
clean_data = sanitizer.sanitize(raw_data)
"""
import json
import logging
import os
import re
from copy import deepcopy
from pathlib import Path
from typing import Any, Dict, List, Optional, Set
logger = logging.getLogger(__name__)
# 默认脱敏规则
DEFAULT_PATTERNS = {
"user_path_unix": {
"pattern": r"/Users/[^/\s]+",
"replacement": "~",
"description": "macOS 用户路径",
},
"user_path_linux": {
"pattern": r"/home/[^/\s]+",
"replacement": "~",
"description": "Linux 用户路径",
},
"user_path_windows": {
"pattern": r"C:\\Users\\[^\\\s]+",
"replacement": "~",
"description": "Windows 用户路径",
},
"ip_address": {
"pattern": r"\b(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\b",
"replacement": "[IP_REDACTED]",
"description": "IPv4 地址",
},
"email": {
"pattern": r"\b[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}\b",
"replacement": "[EMAIL_REDACTED]",
"description": "Email 地址",
},
"phone_cn": {
"pattern": r"\b1[3-9]\d{9}\b",
"replacement": "[PHONE_REDACTED]",
"description": "中国大陆手机号",
},
"api_key_sk": {
"pattern": r"sk-[a-zA-Z0-9]{16,}",
"replacement": "sk-[REDACTED]",
"description": "API Key (sk-前缀)",
},
"bearer_token": {
"pattern": r"Bearer\s+[a-zA-Z0-9._\-]{20,}",
"replacement": "Bearer [REDACTED]",
"description": "Bearer Token",
},
"env_secrets": {
"pattern": r"(?:OPENAI_API_KEY|ANTHROPIC_API_KEY|SECRET_KEY|API_SECRET|ACCESS_TOKEN|PRIVATE_KEY)\s*=\s*[^\s]+",
"replacement": "[ENV_SECRET_REDACTED]",
"description": "环境变量密钥",
},
"jwt_token": {
"pattern": r"eyJ[a-zA-Z0-9_-]{10,}\.eyJ[a-zA-Z0-9_-]{10,}\.[a-zA-Z0-9_-]{10,}",
"replacement": "[JWT_REDACTED]",
"description": "JWT Token",
},
}
# 白名单路径(不脱敏)
DEFAULT_WHITELIST = {
"/usr/local",
"/usr/bin",
"/tmp",
"/var",
"/etc",
"/opt",
}
CONFIG_FILE = os.path.expanduser("~/.skills_monitor/sanitizer_config.json")
class DataSanitizer:
"""敏感信息自动脱敏引擎"""
def __init__(self, config_path: str = CONFIG_FILE):
self._patterns: Dict[str, dict] = dict(DEFAULT_PATTERNS)
self._whitelist: Set[str] = set(DEFAULT_WHITELIST)
self._disabled_rules: Set[str] = set()
self._compiled: Dict[str, re.Pattern] = {}
self._stats: Dict[str, int] = {} # 脱敏统计
# 加载用户自定义配置
self._load_config(config_path)
self._compile_patterns()
def _load_config(self, config_path: str):
"""加载用户自定义脱敏配置"""
if not os.path.exists(config_path):
return
try:
with open(config_path, "r", encoding="utf-8") as f:
config = json.load(f)
# 合并自定义规则
for name, rule in config.get("custom_patterns", {}).items():
self._patterns[name] = rule
# 合并白名单
self._whitelist.update(config.get("whitelist", []))
# 禁用的规则
self._disabled_rules.update(config.get("disabled_rules", []))
except Exception as e:
logger.warning(f"加载脱敏配置失败: {e}")
def _compile_patterns(self):
"""预编译正则表达式"""
for name, rule in self._patterns.items():
if name not in self._disabled_rules:
try:
self._compiled[name] = re.compile(rule["pattern"])
self._stats[name] = 0
except re.error as e:
logger.error(f"正则编译失败 [{name}]: {e}")
# ──────── 核心脱敏 ────────
def sanitize(self, data: Any) -> Any:
"""
递归脱敏任意数据结构
支持:dict / list / str / 嵌套结构
"""
self._stats = {k: 0 for k in self._stats} # 重置统计
return self._sanitize_recursive(data)
def _sanitize_recursive(self, obj: Any) -> Any:
if isinstance(obj, dict):
return {k: self._sanitize_recursive(v) for k, v in obj.items()}
elif isinstance(obj, list):
return [self._sanitize_recursive(item) for item in obj]
elif isinstance(obj, str):
return self._sanitize_string(obj)
else:
return obj
def _sanitize_string(self, text: str) -> str:
"""对单个字符串应用所有脱敏规则"""
result = text
for name, pattern in self._compiled.items():
replacement = self._patterns[name].get("replacement", "[REDACTED]")
def _replace(m, repl=replacement, rule_name=name):
matched = m.group(0)
# 白名单检查
if any(matched.startswith(w) for w in self._whitelist):
return matched
self._stats[rule_name] = self._stats.get(rule_name, 0) + 1
return repl
result = pattern.sub(_replace, result)
return result
def sanitize_path(self, path: str) -> str:
"""路径专用脱敏:/Users/lynn/project → ~/project"""
if not path:
return path
home = os.path.expanduser("~")
if path.startswith(home):
return "~" + path[len(home):]
# 其他用户路径
result = re.sub(r"/Users/[^/]+", "~", path)
result = re.sub(r"/home/[^/]+", "~", result)
result = re.sub(r"C:\\Users\\[^\\]+", "~", result)
return result
# ──────── 统计 & 配置 ────────
def get_stats(self) -> Dict[str, int]:
"""获取最近一次脱敏的统计"""
return dict(self._stats)
def get_total_redacted(self) -> int:
"""获取总脱敏次数"""
return sum(self._stats.values())
def save_default_config(self, path: str = CONFIG_FILE):
"""生成默认配置文件(供用户自定义)"""
config = {
"custom_patterns": {},
"whitelist": list(self._whitelist),
"disabled_rules": [],
"_comment": "自定义脱敏配置。custom_patterns 中的规则会与默认规则合并。",
}
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
json.dump(config, f, ensure_ascii=False, indent=2)
FILE:skills_monitor/core/benchmark.py
"""
基准运行器 — SkillHub 模拟基准
对选定 skill 运行 N 次,统计成功率和耗时,生成基准数据
"""
import json
import os
import time
import uuid
import statistics
from datetime import datetime
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Tuple
from skills_monitor.data.store import DataStore
from skills_monitor.adapters.skill_registry import SkillInfo, SkillRegistry
from skills_monitor.adapters.runners import SkillRunner, get_adapter
# ──────── 标准化测试任务 ────────
STANDARD_TEST_CASES: Dict[str, List[Dict[str, Any]]] = {
"a-share-short-decision": [
{"task": "get_market_sentiment", "params": {}, "description": "获取市场情绪"},
{"task": "get_sector_rotation", "params": {}, "description": "板块轮动分析"},
{"task": "scan_strong_stocks", "params": {}, "description": "强势股扫描"},
{"task": "analyze_capital_flow", "params": {}, "description": "资金流向分析"},
],
"stock-screener-cn": [
{"task": "screen_stocks", "params": {"strategy": "金叉", "limit": 10}, "description": "金叉策略筛选"},
{"task": "screen_stocks", "params": {"strategy": "均线多头排列", "limit": 10}, "description": "均线多头策略"},
{"task": "screen_stocks", "params": {"strategy": "放量突破", "limit": 10}, "description": "放量突破策略"},
],
"trading-signals": [
{"task": "analyze_signals", "params": {}, "description": "综合交易信号分析"},
],
"finance-news-analyzer": [
{"task": "", "params": {}, "description": "每日新闻摘要"},
],
"a-stock-monitor": [
{"task": "", "params": {}, "description": "A股监控"},
],
"macro-analyst": [
{"task": "", "params": {}, "description": "宏观经济分析"},
],
}
class BenchmarkResult:
"""单次基准运行结果"""
def __init__(
self,
skill_id: str,
task_name: str,
run_index: int,
success: bool,
duration_ms: float,
output: Any = None,
error: Optional[str] = None,
timestamp: Optional[str] = None,
):
self.skill_id = skill_id
self.task_name = task_name
self.run_index = run_index
self.success = success
self.duration_ms = duration_ms
self.output = output
self.error = error
self.timestamp = timestamp or datetime.now().isoformat()
def to_dict(self) -> Dict[str, Any]:
return {
"skill_id": self.skill_id,
"task_name": self.task_name,
"run_index": self.run_index,
"success": self.success,
"duration_ms": round(self.duration_ms, 2),
"error": self.error,
"timestamp": self.timestamp,
}
class BenchmarkStats:
"""基准运行统计"""
def __init__(self, skill_id: str, task_name: str, results: List[BenchmarkResult]):
self.skill_id = skill_id
self.task_name = task_name
self.total_runs = len(results)
self.success_count = sum(1 for r in results if r.success)
self.error_count = self.total_runs - self.success_count
self.success_rate = (
round(self.success_count / self.total_runs * 100, 1)
if self.total_runs > 0
else 0
)
durations = [r.duration_ms for r in results if r.success]
if durations:
self.avg_duration_ms = round(statistics.mean(durations), 2)
self.median_duration_ms = round(statistics.median(durations), 2)
self.min_duration_ms = round(min(durations), 2)
self.max_duration_ms = round(max(durations), 2)
self.p95_duration_ms = round(
sorted(durations)[int(len(durations) * 0.95)], 2
)
self.stddev_duration_ms = (
round(statistics.stdev(durations), 2) if len(durations) > 1 else 0
)
else:
self.avg_duration_ms = None
self.median_duration_ms = None
self.min_duration_ms = None
self.max_duration_ms = None
self.p95_duration_ms = None
self.stddev_duration_ms = None
self.results = results
self.timestamp = datetime.now().isoformat()
def to_dict(self) -> Dict[str, Any]:
return {
"skill_id": self.skill_id,
"task_name": self.task_name,
"total_runs": self.total_runs,
"success_count": self.success_count,
"error_count": self.error_count,
"success_rate": self.success_rate,
"avg_duration_ms": self.avg_duration_ms,
"median_duration_ms": self.median_duration_ms,
"min_duration_ms": self.min_duration_ms,
"max_duration_ms": self.max_duration_ms,
"p95_duration_ms": self.p95_duration_ms,
"stddev_duration_ms": self.stddev_duration_ms,
"timestamp": self.timestamp,
}
def summary_line(self) -> str:
"""一行式汇总"""
dur = f"{self.avg_duration_ms:.0f}ms" if self.avg_duration_ms else "N/A"
p95 = f"{self.p95_duration_ms:.0f}ms" if self.p95_duration_ms else "N/A"
return (
f"成功率: {self.success_rate}% ({self.success_count}/{self.total_runs}) "
f"平均: {dur} P95: {p95}"
)
class BenchmarkRunner:
"""基准运行器"""
def __init__(
self,
registry: SkillRegistry,
store: DataStore,
agent_id: str,
cache_dir: Optional[str] = None,
):
self.registry = registry
self.store = store
self.agent_id = agent_id
self.cache_dir = cache_dir
if cache_dir:
Path(cache_dir).mkdir(parents=True, exist_ok=True)
def get_test_cases(self, skill_id: str) -> List[Dict[str, Any]]:
"""获取指定 skill 的标准化测试任务"""
return STANDARD_TEST_CASES.get(skill_id, [])
def run_benchmark(
self,
skill_id: str,
task_name: str = "",
params: Optional[Dict] = None,
n_runs: int = 10,
delay_between: float = 0.5,
progress_callback: Optional[Callable[[int, int, BenchmarkResult], None]] = None,
) -> BenchmarkStats:
"""
对指定 skill+task 运行 N 次,返回统计结果
Args:
skill_id: skill 标识
task_name: 任务名称
params: 任务参数
n_runs: 运行次数
delay_between: 每次运行间隔(秒)
progress_callback: 进度回调 (current, total, result)
"""
if params is None:
params = {}
skill_info = self.registry.get_skill(skill_id)
if not skill_info:
raise ValueError(f"未找到 skill: {skill_id}")
adapter = get_adapter(skill_info)
results: List[BenchmarkResult] = []
for i in range(n_runs):
start_time = time.time()
success = False
output = None
error = None
try:
if isinstance(adapter, SkillRunner):
run_result = adapter.run(task_name=task_name, params=params)
elif hasattr(adapter, "run_task"):
run_result = adapter.run_task(task_name, **params)
else:
run_result = {"success": False, "error": "无适配器"}
success = run_result.get("success", False)
output = run_result.get("output")
error = run_result.get("error")
except Exception as e:
success = False
error = f"{type(e).__name__}: {e}"
duration_ms = (time.time() - start_time) * 1000
result = BenchmarkResult(
skill_id=skill_id,
task_name=task_name or "default",
run_index=i + 1,
success=success,
duration_ms=duration_ms,
output=output,
error=error,
)
results.append(result)
# 同时记录到数据库(作为基准运行记录)
run_id = f"bench-{str(uuid.uuid4())[:8]}"
self.store.insert_run({
"run_id": run_id,
"agent_id": self.agent_id,
"skill_id": skill_id,
"task_name": f"[benchmark] {task_name or 'default'}",
"status": "success" if success else "error",
"start_time": result.timestamp,
"end_time": datetime.now().isoformat(),
"duration_ms": round(duration_ms, 2),
"input_data": {"benchmark": True, "run_index": i + 1, "params": params},
"output_data": None, # 不存基准输出
"error_msg": error,
"metadata": {"benchmark_run": True, "n_runs": n_runs},
})
if progress_callback:
progress_callback(i + 1, n_runs, result)
# 运行间隔
if i < n_runs - 1 and delay_between > 0:
time.sleep(delay_between)
stats = BenchmarkStats(skill_id, task_name or "default", results)
# 缓存结果
if self.cache_dir:
self._cache_stats(stats)
return stats
def run_full_benchmark(
self,
skill_id: str,
n_runs: int = 5,
delay_between: float = 0.5,
progress_callback: Optional[Callable] = None,
) -> Dict[str, BenchmarkStats]:
"""
对指定 skill 的所有标准测试任务运行基准
Returns:
{task_name: BenchmarkStats}
"""
test_cases = self.get_test_cases(skill_id)
if not test_cases:
# 没有预定义的测试任务,运行默认
stats = self.run_benchmark(
skill_id, n_runs=n_runs,
delay_between=delay_between,
progress_callback=progress_callback,
)
return {"default": stats}
results = {}
for tc in test_cases:
task = tc["task"]
params = tc.get("params", {})
desc = tc.get("description", task)
if progress_callback:
progress_callback(-1, -1, None) # signal: new task starting
stats = self.run_benchmark(
skill_id,
task_name=task,
params=params,
n_runs=n_runs,
delay_between=delay_between,
progress_callback=progress_callback,
)
results[desc] = stats
return results
def run_simulated_benchmark(
self,
skill_id: str,
n_runs: int = 10,
base_duration_ms: float = 2000,
success_rate: float = 0.85,
duration_variance: float = 0.3,
) -> BenchmarkStats:
"""
模拟基准运行(不实际执行 skill,用于非交易时段或快速 Demo)
Args:
base_duration_ms: 基线耗时
success_rate: 模拟成功率
duration_variance: 耗时波动系数 (0-1)
"""
import random
results: List[BenchmarkResult] = []
for i in range(n_runs):
success = random.random() < success_rate
# 生成有波动的耗时
factor = 1 + random.uniform(-duration_variance, duration_variance)
duration = base_duration_ms * factor
if not success:
# 失败通常更快(超时除外)
duration = base_duration_ms * random.choice([0.1, 0.3, 2.5])
result = BenchmarkResult(
skill_id=skill_id,
task_name="simulated",
run_index=i + 1,
success=success,
duration_ms=duration,
error="模拟失败:超时" if not success else None,
)
results.append(result)
return BenchmarkStats(skill_id, "simulated", results)
# ──────── 缓存 ────────
def _cache_stats(self, stats: BenchmarkStats):
"""将基准结果缓存到本地 JSON"""
if not self.cache_dir:
return
cache_file = os.path.join(
self.cache_dir,
f"bench_{stats.skill_id}_{stats.task_name}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json",
)
with open(cache_file, "w", encoding="utf-8") as f:
data = stats.to_dict()
data["results"] = [r.to_dict() for r in stats.results]
json.dump(data, f, ensure_ascii=False, indent=2)
def load_cached_stats(self, skill_id: str, task_name: str = "") -> Optional[BenchmarkStats]:
"""加载最近的缓存基准数据"""
if not self.cache_dir:
return None
prefix = f"bench_{skill_id}_{task_name or ''}"
cache_files = sorted(
[
f
for f in os.listdir(self.cache_dir)
if f.startswith(prefix) and f.endswith(".json")
],
reverse=True,
)
if not cache_files:
return None
try:
with open(os.path.join(self.cache_dir, cache_files[0]), "r", encoding="utf-8") as f:
data = json.load(f)
results = [
BenchmarkResult(
skill_id=r["skill_id"],
task_name=r["task_name"],
run_index=r["run_index"],
success=r["success"],
duration_ms=r["duration_ms"],
error=r.get("error"),
timestamp=r.get("timestamp"),
)
for r in data.get("results", [])
]
return BenchmarkStats(skill_id, task_name, results)
except Exception:
return None
FILE:skills_monitor/core/comparator.py
"""
对比分析器 — 用户实际数据 vs 基准数据
生成对比报告,计算排名百分位
"""
from datetime import datetime
from typing import Any, Dict, List, Optional, Tuple
from skills_monitor.core.benchmark import BenchmarkStats
from skills_monitor.data.store import DataStore
class ComparisonResult:
"""单个 skill 的对比结果"""
def __init__(
self,
skill_id: str,
user_data: Dict[str, Any],
benchmark_data: Dict[str, Any],
):
self.skill_id = skill_id
self.user_data = user_data
self.benchmark_data = benchmark_data
# 计算对比指标
self.success_rate_diff = self._calc_diff("success_rate")
self.avg_duration_diff = self._calc_diff("avg_duration_ms", lower_is_better=True)
self.percentile = self._calc_percentile()
self.verdict = self._compute_verdict()
def _calc_diff(self, key: str, lower_is_better: bool = False) -> Dict[str, Any]:
"""计算差值和方向"""
user_val = self.user_data.get(key)
bench_val = self.benchmark_data.get(key)
if user_val is None or bench_val is None:
return {"user": user_val, "benchmark": bench_val, "diff": None, "better": None}
diff = user_val - bench_val
if lower_is_better:
better = diff < 0
else:
better = diff > 0
return {
"user": round(user_val, 2),
"benchmark": round(bench_val, 2),
"diff": round(diff, 2),
"diff_pct": round(diff / bench_val * 100, 1) if bench_val != 0 else 0,
"better": better,
}
def _calc_percentile(self) -> Optional[int]:
"""根据成功率估算百分位排名"""
user_rate = self.user_data.get("success_rate", 0)
bench_rate = self.benchmark_data.get("success_rate", 0)
if bench_rate == 0:
return None
# 简化估算:假设正态分布
ratio = user_rate / bench_rate if bench_rate > 0 else 1
if ratio >= 1.2:
return min(95, int(50 + ratio * 30))
elif ratio >= 1.0:
return int(50 + (ratio - 1) * 200)
elif ratio >= 0.8:
return int(50 - (1 - ratio) * 200)
else:
return max(5, int(50 - (1 - ratio) * 150))
def _compute_verdict(self) -> str:
"""综合评价"""
sr = self.success_rate_diff
dr = self.avg_duration_diff
sr_better = sr.get("better") if sr.get("better") is not None else False
dr_better = dr.get("better") if dr.get("better") is not None else False
if sr_better and dr_better:
return "🏆 优秀 — 成功率和响应速度均超越基准"
elif sr_better:
return "✅ 良好 — 成功率超越基准,响应速度可优化"
elif dr_better:
return "⚡ 合格 — 响应速度优秀,成功率有提升空间"
else:
return "⚠️ 待改进 — 成功率和响应速度均低于基准"
def to_dict(self) -> Dict[str, Any]:
return {
"skill_id": self.skill_id,
"user_data": self.user_data,
"benchmark_data": self.benchmark_data,
"success_rate_diff": self.success_rate_diff,
"avg_duration_diff": self.avg_duration_diff,
"percentile": self.percentile,
"verdict": self.verdict,
}
def format_report(self) -> str:
"""格式化对比报告"""
lines = [
f"📊 [{self.skill_id}] 基准对比报告",
f"{'─' * 50}",
]
# 成功率对比
sr = self.success_rate_diff
if sr.get("diff") is not None:
icon = "🟢" if sr["better"] else "🔴"
sign = "+" if sr["diff"] > 0 else ""
lines.append(
f" 成功率: 你 {sr['user']}% vs 基准 {sr['benchmark']}% "
f"{icon} ({sign}{sr['diff']}%)"
)
else:
lines.append(" 成功率: 数据不足")
# 响应时间对比
dr = self.avg_duration_diff
if dr.get("diff") is not None:
icon = "🟢" if dr["better"] else "🔴"
sign = "+" if dr["diff"] > 0 else ""
lines.append(
f" 响应时间: 你 {dr['user']:.0f}ms vs 基准 {dr['benchmark']:.0f}ms "
f"{icon} ({sign}{dr['diff']:.0f}ms)"
)
else:
lines.append(" 响应时间: 数据不足")
# 排名
if self.percentile is not None:
lines.append(f" 排名: P{self.percentile} (超过 {self.percentile}% 的用户)")
# 结论
lines.append(f"\n {self.verdict}")
return "\n".join(lines)
class SkillComparator:
"""Skill 对比分析器"""
def __init__(self, store: DataStore, agent_id: str):
self.store = store
self.agent_id = agent_id
def compare_with_benchmark(
self,
skill_id: str,
benchmark_stats: BenchmarkStats,
) -> ComparisonResult:
"""将用户的实际数据与基准数据对比"""
# 获取用户数据
user_summary = self.store.get_skill_summary(skill_id, self.agent_id)
# 排除基准运行记录(task_name 以 [benchmark] 开头的)
all_runs = self.store.get_runs(skill_id=skill_id, agent_id=self.agent_id, limit=1000)
user_runs = [r for r in all_runs if not (r.get("task_name", "").startswith("[benchmark]"))]
if user_runs:
success_runs = [r for r in user_runs if r["status"] == "success"]
durations = [r["duration_ms"] for r in success_runs if r.get("duration_ms")]
user_data = {
"total_runs": len(user_runs),
"success_count": len(success_runs),
"success_rate": round(len(success_runs) / len(user_runs) * 100, 1) if user_runs else 0,
"avg_duration_ms": sum(durations) / len(durations) if durations else None,
"avg_satisfaction": user_summary.get("avg_rating"),
}
else:
user_data = {
"total_runs": user_summary["total_runs"],
"success_count": user_summary["success_count"],
"success_rate": user_summary["success_rate"],
"avg_duration_ms": user_summary["avg_duration_ms"],
"avg_satisfaction": user_summary.get("avg_rating"),
}
# 基准数据
benchmark_data = {
"total_runs": benchmark_stats.total_runs,
"success_count": benchmark_stats.success_count,
"success_rate": benchmark_stats.success_rate,
"avg_duration_ms": benchmark_stats.avg_duration_ms,
"p95_duration_ms": benchmark_stats.p95_duration_ms,
}
return ComparisonResult(skill_id, user_data, benchmark_data)
def compare_multiple(
self,
benchmarks: Dict[str, BenchmarkStats],
) -> List[ComparisonResult]:
"""批量对比多个 skill"""
results = []
for skill_id, bench_stats in benchmarks.items():
try:
result = self.compare_with_benchmark(skill_id, bench_stats)
results.append(result)
except Exception:
pass
return results
def generate_comparison_report(
self,
comparisons: List[ComparisonResult],
) -> str:
"""生成完整的对比报告(Markdown 格式)"""
now = datetime.now()
lines = [
f"# 📊 Skills 基准对比报告",
f"",
f"> **生成时间**: {now.strftime('%Y-%m-%d %H:%M:%S')} ",
f"> **Agent ID**: {self.agent_id[:12]}... ",
f"> **对比 Skills 数**: {len(comparisons)}",
f"",
f"---",
f"",
f"## 总览",
f"",
f"| Skill | 你的成功率 | 基准成功率 | 差值 | 你的响应 | 基准响应 | 排名 | 评价 |",
f"|-------|-----------|-----------|------|---------|---------|------|------|",
]
for comp in comparisons:
sr = comp.success_rate_diff
dr = comp.avg_duration_diff
user_sr = f"{sr['user']}%" if sr.get("user") is not None else "N/A"
bench_sr = f"{sr['benchmark']}%" if sr.get("benchmark") is not None else "N/A"
sr_diff = f"{'+' if sr.get('diff', 0) > 0 else ''}{sr['diff']}%" if sr.get("diff") is not None else "-"
user_dr = f"{dr['user']:.0f}ms" if dr.get("user") is not None else "N/A"
bench_dr = f"{dr['benchmark']:.0f}ms" if dr.get("benchmark") is not None else "N/A"
pct = f"P{comp.percentile}" if comp.percentile is not None else "-"
# 简短评价
if comp.verdict.startswith("🏆"):
verdict_short = "🏆 优秀"
elif comp.verdict.startswith("✅"):
verdict_short = "✅ 良好"
elif comp.verdict.startswith("⚡"):
verdict_short = "⚡ 合格"
else:
verdict_short = "⚠️ 待改进"
lines.append(
f"| {comp.skill_id} | {user_sr} | {bench_sr} | {sr_diff} | "
f"{user_dr} | {bench_dr} | {pct} | {verdict_short} |"
)
lines.extend([
f"",
f"---",
f"",
f"## 详细分析",
f"",
])
for comp in comparisons:
lines.append(f"### {comp.skill_id}")
lines.append(f"")
lines.append(f"```")
lines.append(comp.format_report())
lines.append(f"```")
lines.append(f"")
# 总体建议
excellent = [c for c in comparisons if c.verdict.startswith("🏆")]
good = [c for c in comparisons if c.verdict.startswith("✅")]
needs_work = [c for c in comparisons if c.verdict.startswith("⚠️")]
lines.extend([
f"---",
f"",
f"## 💡 建议",
f"",
])
if excellent:
lines.append(f"- 🏆 **表现优秀**: {', '.join(c.skill_id for c in excellent)} — 继续保持")
if good:
lines.append(f"- ✅ **表现良好**: {', '.join(c.skill_id for c in good)} — 关注响应时间优化")
if needs_work:
lines.append(
f"- ⚠️ **需要关注**: {', '.join(c.skill_id for c in needs_work)} "
f"— 建议检查网络状况或考虑替换为同类更稳定的 skill"
)
return "\n".join(lines)
FILE:skills_monitor/core/feedback.py
"""
对话反馈采集模块(重构版)
============================
不再包含人工评分机制。
此模块仅保留轻量级的情感分析工具函数,
供 ImplicitFeedbackEngine 内部使用。
旧版的 collect_feedback_interactive / submit_feedback_programmatic
已被移除,评分完全来自隐性对话语义分析。
"""
import re
from typing import List
# ──────── 轻量情感分析(内部工具) ────────
def analyze_sentiment_simple(text: str) -> str:
"""
基于关键词的轻量情感分析(内部辅助函数)
返回: "positive" / "negative" / "neutral"
注意:这是一个简化版本,完整的多维度分析请使用
ImplicitFeedbackEngine.analyze_signal()
"""
if not text:
return "neutral"
text_lower = text.lower()
positive_kw = {
"好", "棒", "准确", "快", "优秀", "稳定", "不错", "可靠", "满意",
"给力", "精确", "强", "nice", "good", "great", "excellent", "fast",
"accurate", "stable", "reliable", "helpful", "awesome",
}
negative_kw = {
"差", "慢", "错误", "不准", "崩溃", "失败", "垃圾", "不行", "卡",
"超时", "不稳定", "不好", "不可靠", "不满意",
"bug", "bad", "slow", "wrong", "crash", "fail", "error",
"broken", "useless", "terrible", "poor", "unstable",
}
pos_count = sum(1 for kw in positive_kw if kw in text_lower)
neg_count = sum(1 for kw in negative_kw if kw in text_lower)
if pos_count > neg_count:
return "positive"
elif neg_count > pos_count:
return "negative"
return "neutral"
# ──────── 向后兼容别名(旧测试/旧代码引用) ────────
def analyze_sentiment(text: str) -> str:
return analyze_sentiment_simple(text)
FILE:skills_monitor/core/reporter.py
"""
报告生成器 — 综合日报 + 各类报告模板
支持一键生成包含所有维度的完整日报(Markdown 格式)
"""
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any, Dict, List, Optional
from skills_monitor.data.store import DataStore
from skills_monitor.adapters.skill_registry import SkillRegistry
from skills_monitor.core.benchmark import BenchmarkRunner
from skills_monitor.core.comparator import SkillComparator, ComparisonResult
from skills_monitor.core.evaluator import SkillEvaluator, SkillScore
from skills_monitor.core.recommender import SkillRecommender, Recommendation
class ReportGenerator:
"""综合报告生成器"""
def __init__(
self,
store: DataStore,
registry: SkillRegistry,
agent_id: str,
reports_dir: str = "reports/monitor",
):
self.store = store
self.registry = registry
self.agent_id = agent_id
self.reports_dir = reports_dir
Path(reports_dir).mkdir(parents=True, exist_ok=True)
def generate_daily_report(
self,
scores: Optional[List[SkillScore]] = None,
comparisons: Optional[List[ComparisonResult]] = None,
recommendations: Optional[List[Recommendation]] = None,
date: Optional[str] = None,
) -> str:
"""
生成完整日报(Markdown 格式)
整合:今日概况 + 评分排行 + 基准对比 + 趋势分析 + 推荐 + 建议
"""
now = datetime.now()
report_date = date or now.strftime("%Y-%m-%d")
lines = [
f"# 📊 Skills Monitor — 每日监控报告",
f"",
f"> **日期**: {report_date} ",
f"> **生成时间**: {now.strftime('%Y-%m-%d %H:%M:%S')} ",
f"> **Agent ID**: {self.agent_id[:12]}... ",
f"> **已安装 Skills**: {len(self.registry.list_skills())} 个 ",
f"> **可运行 Skills**: {len(self.registry.get_runnable_skills())} 个",
f"",
f"---",
f"",
]
# ── 今日概况 ──
lines.extend(self._section_overview(report_date))
# ── 评分排行 ──
if scores:
lines.extend(self._section_scores(scores))
# ── 基准对比 ──
if comparisons:
lines.extend(self._section_comparisons(comparisons))
# ── 趋势分析 ──
if scores:
lines.extend(self._section_trends(scores))
# ── 推荐 ──
if recommendations:
lines.extend(self._section_recommendations(recommendations))
# ── 操作建议 ──
lines.extend(self._section_suggestions(scores, comparisons))
# ── 页脚 ──
lines.extend([
f"",
f"---",
f"",
f"*本报告由 Skills Monitor v0.1.0 自动生成* ",
f"*查看详细数据: `python skills_monitor_cli.py summary`* ",
f"*Web 面板: `python skills_monitor_web.py` → http://localhost:5050*",
])
return "\n".join(lines)
def _section_overview(self, report_date: str) -> List[str]:
"""今日概况区块"""
lines = [
f"## 📈 今日概况",
f"",
]
# 获取今日运行数据
all_runs = self.store.get_runs(agent_id=self.agent_id, limit=2000)
today_runs = [r for r in all_runs if r["start_time"].startswith(report_date)]
# 排除基准运行
user_runs = [r for r in today_runs if not (r.get("task_name", "").startswith("[benchmark]"))]
success_runs = [r for r in user_runs if r["status"] == "success"]
error_runs = [r for r in user_runs if r["status"] == "error"]
# 活跃 skills
active_skills = set(r["skill_id"] for r in user_runs)
# 响应时间
durations = [r["duration_ms"] for r in success_runs if r.get("duration_ms")]
avg_duration = sum(durations) / len(durations) if durations else 0
# 成功率
success_rate = round(len(success_runs) / len(user_runs) * 100, 1) if user_runs else 0
lines.extend([
f"| 指标 | 数值 |",
f"|------|------|",
f"| 📊 任务执行 | **{len(user_runs)}** 次 (成功 {len(success_runs)} / 失败 {len(error_runs)}) |",
f"| ✅ 成功率 | **{success_rate}%** |",
f"| ⚡ 活跃 Skills | **{len(active_skills)}** 个 |",
f"| ⏱ 平均响应 | **{avg_duration:.0f}ms** |",
f"",
])
# 各 skill 今日运行明细
if active_skills:
lines.extend([
f"### 各 Skill 今日运行",
f"",
f"| Skill | 运行次数 | 成功率 | 平均响应 |",
f"|-------|---------|--------|---------|",
])
for sid in sorted(active_skills):
s_runs = [r for r in user_runs if r["skill_id"] == sid]
s_success = [r for r in s_runs if r["status"] == "success"]
s_durs = [r["duration_ms"] for r in s_success if r.get("duration_ms")]
s_rate = round(len(s_success) / len(s_runs) * 100, 1) if s_runs else 0
s_avg = f"{sum(s_durs) / len(s_durs):.0f}ms" if s_durs else "N/A"
lines.append(f"| {sid} | {len(s_runs)} | {s_rate}% | {s_avg} |")
lines.append(f"")
lines.extend([f"---", f""])
return lines
def _section_scores(self, scores: List[SkillScore]) -> List[str]:
"""评分排行区块"""
lines = [
f"## 🏆 综合评分排行",
f"",
f"| 排名 | Skill | 总分 | 等级 | 成功率 | 响应 | 满意度 | 复用 | 稳定性 |",
f"|------|-------|------|------|--------|------|--------|------|--------|",
]
for i, score in enumerate(scores, 1):
sr = f"{score.factors['success_rate']:.0f}%" if score.factors['success_rate'] is not None else "-"
rt = f"{score.factors['response_time']:.0f}ms" if score.factors['response_time'] is not None else "-"
ur = f"{score.factors['satisfaction']:.1f}" if score.factors['satisfaction'] is not None else "-"
rr = str(score.factors['reuse_rate']) if score.factors['reuse_rate'] is not None else "-"
st = f"{score.factors['stability']:.2f}" if score.factors['stability'] is not None else "-"
grade_short = score.grade.split("(")[0].strip()
# 排名 emoji
rank_emoji = {1: "🥇", 2: "🥈", 3: "🥉"}.get(i, f"{i}")
lines.append(
f"| {rank_emoji} | {score.skill_id} | **{score.total_score:.1f}** | {grade_short} | "
f"{sr} | {rt} | {ur} | {rr} | {st} |"
)
lines.extend([f"", f"---", f""])
return lines
def _section_comparisons(self, comparisons: List[ComparisonResult]) -> List[str]:
"""基准对比区块"""
lines = [
f"## 📊 基准对比",
f"",
f"| Skill | 你的成功率 | 基准成功率 | 差值 | 你的响应 | 基准响应 | 排名 | 评价 |",
f"|-------|-----------|-----------|------|---------|---------|------|------|",
]
for comp in comparisons:
sr = comp.success_rate_diff
dr = comp.avg_duration_diff
user_sr = f"{sr['user']}%" if sr.get("user") is not None else "N/A"
bench_sr = f"{sr['benchmark']}%" if sr.get("benchmark") is not None else "N/A"
sr_diff = f"{'+' if sr.get('diff', 0) > 0 else ''}{sr['diff']}%" if sr.get("diff") is not None else "-"
user_dr = f"{dr['user']:.0f}ms" if dr.get("user") is not None else "N/A"
bench_dr = f"{dr['benchmark']:.0f}ms" if dr.get("benchmark") is not None else "N/A"
pct = f"P{comp.percentile}" if comp.percentile is not None else "-"
if comp.verdict.startswith("🏆"):
verdict_short = "🏆 优秀"
elif comp.verdict.startswith("✅"):
verdict_short = "✅ 良好"
elif comp.verdict.startswith("⚡"):
verdict_short = "⚡ 合格"
else:
verdict_short = "⚠️ 待改进"
lines.append(
f"| {comp.skill_id} | {user_sr} | {bench_sr} | {sr_diff} | "
f"{user_dr} | {bench_dr} | {pct} | {verdict_short} |"
)
lines.extend([f"", f"---", f""])
return lines
def _section_trends(self, scores: List[SkillScore]) -> List[str]:
"""趋势分析区块"""
evaluator = SkillEvaluator(self.store, self.agent_id)
lines = [
f"## 📈 7 天趋势",
f"",
]
has_trend = False
for score in scores:
trend = evaluator.trend_analysis(score.skill_id, days=7)
if trend.get("success_rate_trend") == "no_data":
continue
has_trend = True
trend_icon = {
"improving": "📈 上升",
"declining": "📉 下降",
"stable": "➡️ 平稳",
"insufficient": "❓ 数据不足",
}.get(trend["success_rate_trend"], "❓")
first = trend.get("first_success_rate", "?")
latest = trend.get("latest_success_rate", "?")
points = trend.get("data_points", 0)
lines.append(f"- **{score.skill_id}**: {trend_icon} ({first}% → {latest}%), {points} 个数据点")
if not has_trend:
lines.append("暂无足够的历史数据生成趋势分析。")
lines.extend([f"", f"---", f""])
return lines
def _section_recommendations(self, recommendations: List[Recommendation]) -> List[str]:
"""推荐区块"""
lines = [
f"## 💡 Skill 推荐",
f"",
f"| 排名 | 名称 | 分类 | 推荐分 | 类型 | 理由 |",
f"|------|------|------|--------|------|------|",
]
for i, rec in enumerate(recommendations[:5], 1):
reason_short = {
"complement": "💡 互补",
"upgrade": "⬆️ 升级",
"collaborative": "🤝 协同",
"popular": "🔥 热门",
}.get(rec.reason_type, rec.reason_type)
detail = rec.reason_detail[:50]
lines.append(
f"| {i} | **{rec.skill_info['name']}** | {rec.skill_info['category']} | "
f"{rec.score:.0f} | {reason_short} | {detail} |"
)
lines.extend([f"", f"---", f""])
return lines
def _section_suggestions(
self,
scores: Optional[List[SkillScore]],
comparisons: Optional[List[ComparisonResult]],
) -> List[str]:
"""操作建议区块"""
lines = [
f"## 🔧 操作建议",
f"",
]
suggestions = []
if scores:
best = scores[0]
worst = scores[-1] if len(scores) > 1 else None
suggestions.append(
f"1. 🏆 **最佳 Skill**: `{best.skill_id}` ({best.total_score:.1f}分) — 表现稳定,继续使用"
)
if worst and worst.total_score < 60:
suggestions.append(
f"2. ⚠️ **关注**: `{worst.skill_id}` ({worst.total_score:.1f}分) — "
f"建议排查稳定性问题或考虑替代方案"
)
# 满意度数据不足的 skill
needs_data = [s for s in scores if s.factors.get("satisfaction") is None]
if needs_data:
names = ", ".join(f"`{s.skill_id}`" for s in needs_data[:3])
suggestions.append(
f"3. 📊 **数据不足**: {names} — 多使用以积累对话满意度数据"
)
if comparisons:
needs_work = [c for c in comparisons if c.verdict.startswith("⚠️")]
if needs_work:
names = ", ".join(f"`{c.skill_id}`" for c in needs_work)
suggestions.append(
f"4. 🔍 **低于基准**: {names} — 建议检查网络环境或考虑替换"
)
if not suggestions:
suggestions.append("✅ 当前系统运行状况良好,暂无需要关注的问题。")
lines.extend(suggestions)
lines.extend([f""])
return lines
def save_report(self, content: str, name: str = "daily") -> str:
"""保存报告到文件"""
now = datetime.now()
filename = f"{name}_{now.strftime('%Y%m%d_%H%M%S')}.md"
filepath = str(Path(self.reports_dir) / filename)
with open(filepath, "w", encoding="utf-8") as f:
f.write(content)
return filepath
def generate_and_save_daily(
self,
scores: Optional[List[SkillScore]] = None,
comparisons: Optional[List[ComparisonResult]] = None,
recommendations: Optional[List[Recommendation]] = None,
) -> str:
"""生成并保存日报,返回文件路径"""
content = self.generate_daily_report(scores, comparisons, recommendations)
return self.save_report(content, "daily")
# ── 辅助方法 ──
def get_dashboard_data(self) -> Dict[str, Any]:
"""
获取仪表盘所需的所有数据(供 Web 面板使用)
返回结构化数据而非 Markdown
"""
now = datetime.now()
today = now.strftime("%Y-%m-%d")
# 今日运行数据
all_runs = self.store.get_runs(agent_id=self.agent_id, limit=2000)
today_runs = [r for r in all_runs if r["start_time"].startswith(today)]
user_runs = [r for r in today_runs if not (r.get("task_name", "").startswith("[benchmark]"))]
success_runs = [r for r in user_runs if r["status"] == "success"]
durations = [r["duration_ms"] for r in success_runs if r.get("duration_ms")]
# 活跃 skills
active_skills = sorted(set(r["skill_id"] for r in user_runs))
# 评分
evaluator = SkillEvaluator(self.store, self.agent_id)
runnable = self.registry.get_runnable_skills()
skill_ids = [s.slug for s in runnable]
scores = evaluator.evaluate_all(skill_ids)
# 7 天趋势数据
trends = {}
for score in scores:
trend = evaluator.trend_analysis(score.skill_id, days=7)
trends[score.skill_id] = trend
# 最近隐性反馈
implicit_feedbacks = self.store.get_implicit_feedback(limit=10)
# 7 天运行次数趋势(按天汇总)
daily_runs = {}
for i in range(7):
day = (now - timedelta(days=i)).strftime("%Y-%m-%d")
day_runs = [r for r in all_runs if r["start_time"].startswith(day)]
day_user_runs = [r for r in day_runs if not (r.get("task_name", "").startswith("[benchmark]"))]
day_success = [r for r in day_user_runs if r["status"] == "success"]
daily_runs[day] = {
"total": len(day_user_runs),
"success": len(day_success),
"error": len(day_user_runs) - len(day_success),
}
return {
"date": today,
"generated_at": now.isoformat(),
"overview": {
"total_runs": len(user_runs),
"success_runs": len(success_runs),
"error_runs": len(user_runs) - len(success_runs),
"success_rate": round(len(success_runs) / len(user_runs) * 100, 1) if user_runs else 0,
"active_skills": len(active_skills),
"avg_duration_ms": round(sum(durations) / len(durations), 1) if durations else 0,
"total_installed": len(self.registry.list_skills()),
"total_runnable": len(runnable),
},
"scores": [s.to_dict() for s in scores],
"trends": trends,
"daily_runs": daily_runs,
"active_skills": active_skills,
"recent_feedbacks": implicit_feedbacks,
}
FILE:skills_monitor/core/uploader.py
"""
本地 Agent 数据上报模块 — 增量同步到中心化服务器
===============================================
与 skills_monitor 核心包对接,读取本地 SQLite 数据并上报。
使用方式:
from skills_monitor.core.uploader import DataUploader
uploader = DataUploader(server_url="https://your-server.com")
uploader.register() # 注册 Agent
uploader.upload_daily() # 上报每日数据
"""
import platform
from datetime import datetime, date, timedelta
from typing import Optional, Dict, Any, List, Tuple
import requests
class DataUploader:
"""
本地 Agent 数据上报器
- 自动发现身份 (agent_id + token)
- 读取本地 SQLite 数据
- 增量上报到中心化服务器
"""
def __init__(self, server_url: str = "http://localhost:5100", skills_dir: str = "skills"):
self.server_url = server_url.rstrip("/")
self.skills_dir = skills_dir
self._agent_id = None
self._token = None
self._identity = None
# ──────── 初始化 ────────
def init(self, agent_id: str = None, token: str = None) -> "DataUploader":
"""初始化身份(从参数或本地配置读取)"""
if agent_id and token:
self._agent_id = agent_id
self._token = token
else:
self._load_identity()
return self
def _load_identity(self):
"""从本地 skills_monitor 配置加载身份"""
try:
from skills_monitor.core.identity import IdentityManager
mgr = IdentityManager()
self._agent_id = mgr.agent_id
self._token = mgr.api_key
except ImportError:
raise RuntimeError("未安装 skills_monitor 包,请先运行 pip install -e .")
@property
def _headers(self) -> Dict[str, str]:
return {
"X-Agent-ID": self._agent_id or "",
"X-Agent-Token": self._token or "",
"Content-Type": "application/json",
}
# ──────── 注册 ────────
def register(self, name: str = None) -> Tuple[bool, Dict]:
"""向中心服务器注册/更新 Agent"""
import skills_monitor
payload = {
"agent_id": self._agent_id,
"token": self._token,
"name": name or f"Agent-{platform.node()}",
"os_info": f"{platform.system()} {platform.release()}",
"python_version": platform.python_version(),
"monitor_version": getattr(skills_monitor, "__version__", "0.4.0"),
}
try:
from skills_monitor.adapters.skill_registry import SkillRegistry
registry = SkillRegistry(self.skills_dir)
skills = registry.list_skills()
payload["total_skills"] = len(skills)
payload["runnable_skills"] = len(registry.get_runnable_skills())
except Exception:
pass
try:
resp = requests.post(
f"{self.server_url}/api/agent/register",
json=payload,
timeout=15,
)
data = resp.json()
return data.get("ok", False), data
except requests.RequestException as e:
return False, {"error": str(e)}
# ──────── 心跳 ────────
def heartbeat(self) -> Tuple[bool, Dict]:
"""发送心跳"""
try:
resp = requests.post(
f"{self.server_url}/api/agent/heartbeat",
headers=self._headers,
timeout=10,
)
data = resp.json()
return data.get("ok", False), data
except requests.RequestException as e:
return False, {"error": str(e)}
# ──────── 上报 ────────
def upload_daily(self, report_date: date = None, trigger: str = "manual") -> Tuple[bool, Dict]:
"""采集并上报每日数据"""
self.ensure_initial_diagnostic_uploaded()
report_day = report_date or date.today()
report_data = self._collect_daily_data(report_day, trigger=trigger)
return self._upload(report_data, "daily", report_day)
def upload_diagnostic(self, diagnostic_data: dict = None, trigger: str = "manual") -> Tuple[bool, Dict]:
"""上报诊断数据,外部传入 markdown 时自动补齐结构化字段"""
if diagnostic_data:
structured = self._collect_diagnostic_data(
trigger=diagnostic_data.get("trigger", trigger),
extra_context=diagnostic_data.get("extra_context"),
)
merged = dict(structured)
merged.update({k: v for k, v in diagnostic_data.items() if v is not None})
diagnostic_data = merged
else:
diagnostic_data = self._collect_diagnostic_data(trigger=trigger)
report_day = self._extract_report_date(diagnostic_data)
return self._upload(diagnostic_data, "diagnostic", report_day)
def upload_custom(self, data: dict, report_type: str = "custom") -> Tuple[bool, Dict]:
"""上报自定义数据"""
return self._upload(data, report_type)
def ensure_initial_diagnostic_uploaded(self) -> Tuple[bool, Dict]:
"""若服务端还没有诊断报告,则自动补传一次首次诊断报告"""
ok, reports = self.get_reports(report_type="diagnostic", limit=1)
if ok and reports:
return True, {"ok": True, "skipped": True, "reason": "diagnostic_exists"}
fallback_data = self._collect_diagnostic_data(
trigger="first_sync_fallback",
extra_context="首次运行诊断报告缺失,自动兜底补传",
)
report_day = self._extract_report_date(fallback_data) or date.today()
return self._upload(fallback_data, "diagnostic", report_day)
def _upload(self, data: dict, report_type: str, report_date: date = None) -> Tuple[bool, Dict]:
"""通用上传(发送前自动脱敏)"""
try:
from skills_monitor.core.sanitizer import DataSanitizer
sanitizer = DataSanitizer()
data = sanitizer.sanitize(data)
except ImportError:
pass
payload = {
"data": data,
"report_type": report_type,
}
if report_date:
payload["report_date"] = report_date.isoformat()
try:
resp = requests.post(
f"{self.server_url}/api/agent/report",
headers=self._headers,
json=payload,
timeout=30,
)
result = resp.json()
return result.get("ok", False), result
except requests.RequestException as e:
return False, {"error": str(e)}
# ──────── 数据采集 ────────
def _collect_daily_data(self, report_day: date, trigger: str = "manual") -> dict:
"""从本地 SQLite 采集每日数据"""
from skills_monitor.data.store import DataStore
from skills_monitor.adapters.skill_registry import SkillRegistry
from skills_monitor.core.recommender import SkillRecommender
from skills_monitor.core.reporter import ReportGenerator
from skills_monitor.core.diagnostic import DiagnosticReporter
store = DataStore()
registry = SkillRegistry(skills_dir=self.skills_dir)
all_skills = registry.list_skills()
runnable = registry.get_runnable_skills()
skill_ids = [s.slug for s in runnable]
evaluator = self._build_evaluator(store, skill_ids)
scores = evaluator.evaluate_all(skill_ids)
recommender = SkillRecommender(registry, store, self._agent_id)
recommendations = recommender.get_all_recommendations(max_per_type=3)
all_runs = self._get_all_runs(store)
today_runs = self._runs_for_day(all_runs, report_day)
week_runs = self._runs_since(all_runs, days=7)
user_runs = self._exclude_benchmark_runs(today_runs)
user_week_runs = self._exclude_benchmark_runs(week_runs)
diag = DiagnosticReporter(store, registry, self._agent_id, reports_dir="reports/diagnostic")
health_score = diag._calculate_health_score(
all_skills,
runnable,
scores,
user_runs,
user_week_runs,
recommendations,
)
overview = self._build_overview(
all_skills=all_skills,
runnable=runnable,
runs=user_runs,
week_runs=user_week_runs,
health_score=health_score,
)
installed_skills = self._build_installed_skills(store, all_skills, scores)
report_markdown = ReportGenerator(
store=store,
registry=registry,
agent_id=self._agent_id,
).generate_daily_report(
scores=scores,
recommendations=recommendations,
date=report_day.isoformat(),
)
return {
"collected_at": datetime.now().isoformat(),
"report_date": report_day.isoformat(),
"trigger": trigger,
"agent_id": self._agent_id,
"health_score": health_score,
"overview": overview,
"scores": [s.to_dict() for s in scores],
"recommendations": [r.to_dict() for r in recommendations],
"installed_skills": installed_skills,
"skill_details": installed_skills,
"report_markdown": report_markdown,
}
def _collect_diagnostic_data(self, trigger: str = "manual", extra_context: str = None) -> dict:
"""采集诊断数据并补齐结构化字段"""
from skills_monitor.data.store import DataStore
from skills_monitor.adapters.skill_registry import SkillRegistry
from skills_monitor.core.recommender import SkillRecommender
from skills_monitor.core.diagnostic import DiagnosticReporter
store = DataStore()
registry = SkillRegistry(skills_dir=self.skills_dir)
all_skills = registry.list_skills()
runnable = registry.get_runnable_skills()
skill_ids = [s.slug for s in runnable]
evaluator = self._build_evaluator(store, skill_ids)
scores = evaluator.evaluate_all(skill_ids)
recommender = SkillRecommender(registry, store, self._agent_id)
recommendations = recommender.get_all_recommendations(max_per_type=3)
all_runs = self._get_all_runs(store)
today_runs = self._runs_for_day(all_runs, date.today())
week_runs = self._runs_since(all_runs, days=7)
user_runs = self._exclude_benchmark_runs(today_runs)
user_week_runs = self._exclude_benchmark_runs(week_runs)
diag = DiagnosticReporter(store, registry, self._agent_id, reports_dir="reports/diagnostic")
report_markdown = diag.generate_diagnostic_report(trigger=trigger, extra_context=extra_context)
health_score = diag._calculate_health_score(
all_skills,
runnable,
scores,
user_runs,
user_week_runs,
recommendations,
)
return {
"collected_at": datetime.now().isoformat(),
"report_date": date.today().isoformat(),
"trigger": trigger,
"agent_id": self._agent_id,
"health_score": health_score,
"overview": self._build_overview(
all_skills=all_skills,
runnable=runnable,
runs=user_runs,
week_runs=user_week_runs,
health_score=health_score,
),
"scores": [s.to_dict() for s in scores],
"recommendations": [r.to_dict() for r in recommendations],
"installed_skills": self._build_installed_skills(store, all_skills, scores),
"diagnostics": self._build_diagnostics_payload(
registry=registry,
scores=scores,
all_skills=all_skills,
runnable=runnable,
recommendations=recommendations,
week_runs=user_week_runs,
),
"report_markdown": report_markdown,
}
def _build_evaluator(self, store, skill_ids: List[str]):
from skills_monitor.core.evaluator import SkillEvaluator
from skills_monitor.adapters.clawhub_client import ClawHubClient
community_data = {}
if skill_ids:
try:
client = ClawHubClient()
metadata = client.batch_fetch(skill_ids)
for slug, meta in metadata.items():
community_data[slug] = {
"downloads": meta.get("installs") or 0,
"stars": meta.get("stars") or 0,
"current_installs": meta.get("installs") or 0,
}
except Exception:
community_data = {}
return SkillEvaluator(store, self._agent_id, community_data=community_data)
def _get_all_runs(self, store) -> List[dict]:
try:
return store.get_runs(agent_id=self._agent_id, limit=5000)
except Exception:
try:
return store.get_all_runs(days=30)
except Exception:
return []
def _runs_for_day(self, runs: List[dict], day: date) -> List[dict]:
prefix = day.isoformat()
return [r for r in runs if str(r.get("start_time", "")).startswith(prefix)]
def _runs_since(self, runs: List[dict], days: int = 7) -> List[dict]:
cutoff = datetime.now() - timedelta(days=days)
result = []
for run in runs:
start_time = str(run.get("start_time", ""))
if not start_time:
continue
try:
run_time = datetime.fromisoformat(start_time.replace("Z", ""))
except ValueError:
continue
if run_time >= cutoff:
result.append(run)
return result
def _exclude_benchmark_runs(self, runs: List[dict]) -> List[dict]:
return [r for r in runs if not str(r.get("task_name", "")).startswith("[benchmark]")]
def _build_overview(
self,
all_skills: List[Any],
runnable: List[Any],
runs: List[dict],
week_runs: List[dict],
health_score: float,
) -> Dict[str, Any]:
total_runs = len(runs)
success_runs = sum(1 for r in runs if self._is_success_run(r))
success_rate = round(success_runs / total_runs * 100, 1) if total_runs else 0.0
durations = [r.get("duration_ms") for r in runs if r.get("duration_ms")]
avg_duration = round(sum(durations) / len(durations), 1) if durations else 0.0
active_today = len({r.get("skill_id") for r in runs if r.get("skill_id")})
active_week = len({r.get("skill_id") for r in week_runs if r.get("skill_id")})
return {
"health_score": round(health_score, 1),
"total_installed": len(all_skills),
"total_runnable": len(runnable),
"total_runs": total_runs,
"success_rate": success_rate,
"avg_duration_ms": avg_duration,
"active_skills": active_today,
"active_skills_7d": active_week,
}
def _build_installed_skills(self, store, all_skills: List[Any], scores: List[Any]) -> List[Dict[str, Any]]:
score_map = {score.skill_id: score for score in scores}
items: List[Dict[str, Any]] = []
for skill in all_skills:
summary = store.get_skill_summary(skill.slug, self._agent_id)
score = score_map.get(skill.slug)
score_payload = score.to_dict() if score else None
item = {
"slug": skill.slug,
"name": skill.name,
"category": skill.category,
"description": skill.description,
"version": skill.version,
"entry_type": skill.entry_type,
"runnable": skill.entry_type != "none",
"total_runs": summary.get("total_runs", 0),
"success_rate": summary.get("success_rate"),
"avg_rating": summary.get("avg_rating"),
"score": score_payload,
}
if score_payload:
item["total_score"] = score_payload.get("total_score")
item["grade"] = score_payload.get("grade")
item["raw_factors"] = score_payload.get("raw_factors", {})
item["factors"] = score_payload.get("factors", {})
items.append(item)
items.sort(
key=lambda x: (
-(x.get("total_score") or -1),
-(x.get("total_runs") or 0),
x.get("slug", ""),
)
)
return items
def _build_diagnostics_payload(
self,
registry,
scores: List[Any],
all_skills: List[Any],
runnable: List[Any],
recommendations: List[Any],
week_runs: List[dict],
) -> Dict[str, Any]:
issues = self._collect_issue_items(scores, all_skills, runnable, recommendations)
suggestions = self._collect_suggestion_items(scores, runnable, recommendations, week_runs)
coverage = []
for category, skills in sorted(registry.get_skills_by_category().items()):
total = len(skills)
runnable_count = len([s for s in skills if s.entry_type != "none"])
coverage.append({
"category": category,
"total": total,
"runnable": runnable_count,
"coverage_rate": round(runnable_count / max(total, 1) * 100, 1),
})
usage_top = []
counter: Dict[str, Dict[str, Any]] = {}
for run in week_runs:
skill_id = run.get("skill_id")
if not skill_id:
continue
counter.setdefault(skill_id, {"skill_id": skill_id, "runs": 0, "success": 0})
counter[skill_id]["runs"] += 1
if self._is_success_run(run):
counter[skill_id]["success"] += 1
for item in sorted(counter.values(), key=lambda x: x["runs"], reverse=True)[:5]:
item["success_rate"] = round(item["success"] / max(item["runs"], 1) * 100, 1)
usage_top.append(item)
runnable_slugs = {s.slug for s in runnable}
used_slugs = set(counter.keys())
unused_runnable = sorted(runnable_slugs - used_slugs)
return {
"issues": issues,
"suggestions": suggestions,
"coverage": coverage,
"usage_top": usage_top,
"unused_runnable": unused_runnable,
}
def _collect_issue_items(
self,
scores: List[Any],
all_skills: List[Any],
runnable: List[Any],
recommendations: List[Any],
) -> List[str]:
issues: List[str] = []
low_scores = [s for s in scores if s.total_score < 60]
for score in low_scores:
issues.append(
f"{score.skill_id} 综合评分仅 {score.total_score:.1f} 分,等级 {score.grade.split('(')[0].strip()}"
)
for score in scores:
raw_success = score.factors.get("success_rate", 100)
if raw_success is not None and raw_success < 80 and score not in low_scores:
issues.append(f"{score.skill_id} 成功率仅 {raw_success:.0f}%,建议排查")
non_runnable = len(all_skills) - len(runnable)
if non_runnable > len(runnable):
issues.append(f"不可运行 skills 达到 {non_runnable} 个,可运行率偏低")
complement_recs = [r for r in recommendations if r.reason_type == "complement"]
if complement_recs:
categories = sorted({r.skill_info.get("category", "未分类") for r in complement_recs})
issues.append(f"能力覆盖存在缺口,建议补齐 {', '.join(categories)}")
upgrade_recs = [r for r in recommendations if r.reason_type == "upgrade"]
for rec in upgrade_recs:
issues.append(f"{rec.related_installed} 建议升级为 {rec.skill_info['name']}")
return issues
def _collect_suggestion_items(
self,
scores: List[Any],
runnable: List[Any],
recommendations: List[Any],
week_runs: List[dict],
) -> List[str]:
suggestions: List[str] = []
priority = 1
low_scores = [s for s in scores if s.total_score < 60]
if low_scores:
names = ", ".join(f"`{s.skill_id}`" for s in low_scores[:3])
suggestions.append(
f"P{priority} 关注低评分 skills:{names},优先排查成功率与稳定性"
)
priority += 1
needs_data = [s for s in scores if s.factors.get("satisfaction") is None]
if needs_data:
names = ", ".join(f"`{s.skill_id}`" for s in needs_data[:3])
suggestions.append(
f"P{priority} 以下 skills 还缺少满意度数据:{names},建议增加真实使用样本"
)
priority += 1
used_week = {r.get("skill_id") for r in week_runs if r.get("skill_id")}
runnable_slugs = {s.slug for s in runnable}
unused = runnable_slugs - used_week
if len(unused) > 3:
suggestions.append(
f"P{priority} 过去 7 天有 {len(unused)} 个可运行 skills 未被调用,建议评估保留价值"
)
priority += 1
if recommendations:
top_names = ", ".join(rec.skill_info["name"] for rec in recommendations[:3])
suggestions.append(
f"P{priority} 优先关注推荐安装:{top_names}"
)
return suggestions
def _is_success_run(self, run: dict) -> bool:
return bool(run.get("success") or run.get("status") == "success")
def _extract_report_date(self, data: dict) -> Optional[date]:
report_date = data.get("report_date")
if isinstance(report_date, date):
return report_date
if isinstance(report_date, str):
try:
return date.fromisoformat(report_date)
except ValueError:
return None
return None
# ──────── 生成绑定二维码 ────────
def get_bind_qrcode(self) -> Tuple[bool, Dict]:
"""请求服务器生成带参二维码(用于微信扫码绑定)"""
try:
resp = requests.post(
f"{self.server_url}/api/agent/bind-qrcode",
headers=self._headers,
json={"agent_id": self._agent_id, "token": self._token},
timeout=10,
)
data = resp.json()
return data.get("ok", False), data
except requests.RequestException as e:
return False, {"error": str(e)}
# ──────── 查询历史 ────────
def get_reports(self, report_type: str = None, limit: int = 30) -> Tuple[bool, list]:
"""查询服务器上的历史报告"""
params = {"limit": limit}
if report_type:
params["type"] = report_type
try:
resp = requests.get(
f"{self.server_url}/api/agent/reports",
headers=self._headers,
params=params,
timeout=10,
)
data = resp.json()
return data.get("ok", False), data.get("reports", [])
except requests.RequestException:
return False, []
FILE:skills_monitor/core/__init__.py
# core subpackage
FILE:skills_monitor/core/diagnostic.py
"""
诊断报告生成器 — Skills Monitor 系统健康诊断 + 优化建议
=========================================================
整合评估引擎、推荐引擎、基准对比,生成完善的诊断报告:
- 系统健康度评分
- Skills 使用情况诊断
- 性能瓶颈识别
- 覆盖度分析
- 可操作的优化建议
- 企微推送支持
"""
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from skills_monitor.data.store import DataStore
from skills_monitor.adapters.skill_registry import SkillRegistry
from skills_monitor.adapters.clawhub_client import ClawHubClient
from skills_monitor.core.evaluator import SkillEvaluator, SkillScore
from skills_monitor.core.recommender import SkillRecommender, Recommendation
from skills_monitor.core.benchmark import BenchmarkRunner
# ──────── 健康度评级 ────────
def _health_grade(score: float) -> Tuple[str, str]:
"""综合健康度 → (等级, emoji)"""
if score >= 90:
return "优秀", "🟢"
elif score >= 75:
return "良好", "🟡"
elif score >= 60:
return "一般", "🟠"
else:
return "需关注", "🔴"
class DiagnosticReporter:
"""系统诊断报告生成器"""
def __init__(
self,
store: DataStore,
registry: SkillRegistry,
agent_id: str,
reports_dir: str = "reports/diagnostic",
):
self.store = store
self.registry = registry
self.agent_id = agent_id
self.reports_dir = reports_dir
Path(reports_dir).mkdir(parents=True, exist_ok=True)
def _load_community_data(self, skill_ids: List[str]) -> Dict[str, Dict[str, Any]]:
"""加载社区热度与评分数据,供 7 因子评分使用"""
if not skill_ids:
return {}
try:
client = ClawHubClient()
metadata = client.batch_fetch(skill_ids)
community_data = {}
for slug, meta in metadata.items():
community_data[slug] = {
"downloads": meta.get("installs") or 0,
"stars": meta.get("stars") or 0,
"current_installs": meta.get("installs") or 0,
}
return community_data
except Exception:
return {}
def generate_diagnostic_report(
self,
trigger: str = "scheduled",
extra_context: Optional[str] = None,
) -> str:
"""
生成完整诊断报告(Markdown 格式)
Args:
trigger: 触发方式 — "scheduled"(定时) / "post_install"(安装后) / "manual"(手动)
extra_context: 额外上下文(如刚安装的 skills 列表)
"""
now = datetime.now()
today = now.strftime("%Y-%m-%d")
# 收集所有分析数据
runnable = self.registry.get_runnable_skills()
all_skills = self.registry.list_skills()
skill_ids = [s.slug for s in runnable]
evaluator = SkillEvaluator(
self.store,
self.agent_id,
community_data=self._load_community_data(skill_ids),
)
scores = evaluator.evaluate_all(skill_ids)
recommender = SkillRecommender(self.registry, self.store, self.agent_id)
recommendations = recommender.get_all_recommendations(max_per_type=3)
# 运行历史
all_runs = self.store.get_runs(agent_id=self.agent_id, limit=5000)
today_runs = [r for r in all_runs if r["start_time"].startswith(today)]
user_runs = [r for r in today_runs if not (r.get("task_name", "").startswith("[benchmark]"))]
# 7 天运行数据
week_runs = []
for r in all_runs:
try:
run_date = datetime.fromisoformat(r["start_time"].replace("Z", ""))
if run_date >= now - timedelta(days=7):
week_runs.append(r)
except (ValueError, TypeError):
pass
# 计算总体健康度
health_score = self._calculate_health_score(
all_skills, runnable, scores, user_runs, week_runs, recommendations
)
health_label, health_icon = _health_grade(health_score)
# 触发方式标签
trigger_labels = {
"scheduled": "⏰ 每日定时诊断",
"launchagent": "⏰ LaunchAgent 定时诊断",
"crontab": "⏰ Crontab 定时诊断",
"retry": "🔁 失败后重试诊断",
"post_install": "📦 安装后自动诊断",
"manual": "🔍 手动诊断",
"first_sync_fallback": "🛟 首次同步兜底诊断",
}
trigger_label = trigger_labels.get(trigger, trigger)
# ── 构建报告 ──
lines = [
f"# 🏥 Skills Monitor 诊断报告",
f"",
f"> **触发方式**: {trigger_label} ",
f"> **日期**: {today} ",
f"> **生成时间**: {now.strftime('%H:%M:%S')} ",
f"> **Agent ID**: `{self.agent_id[:12]}...`",
f"",
]
# 额外上下文(安装后触发时)
if extra_context:
lines.extend([
f"> **触发原因**: {extra_context}",
f"",
])
lines.extend([
f"---",
f"",
])
# ── 1. 系统健康度总览 ──
lines.extend(self._section_health_overview(
health_score, health_label, health_icon,
all_skills, runnable, scores, user_runs, week_runs,
))
# ── 2. Skills 覆盖度分析 ──
lines.extend(self._section_coverage(all_skills, runnable))
# ── 3. 性能诊断 ──
lines.extend(self._section_performance(scores))
# ── 4. 使用情况诊断 ──
lines.extend(self._section_usage(scores, user_runs, week_runs))
# ── 5. 问题发现 ──
lines.extend(self._section_issues(scores, all_skills, runnable, recommendations))
# ── 6. 优化建议 ──
lines.extend(self._section_recommendations(
scores, recommendations, all_skills, runnable, week_runs,
))
# ── 7. 推荐安装 ──
if recommendations:
lines.extend(self._section_install_recommendations(recommendations))
# ── 页脚 ──
lines.extend([
f"",
f"---",
f"",
f"*🏥 诊断报告由 Skills Monitor v0.3.0 自动生成(隐性评分引擎)* ",
f"*详细数据: `skills-monitor evaluate -v` | Web: `skills-monitor web`*",
])
return "\n".join(lines)
def _calculate_health_score(
self,
all_skills, runnable, scores, today_runs, week_runs, recommendations,
) -> float:
"""计算系统综合健康度(0-100)"""
score = 0.0
total_weight = 0.0
# 1. 可运行率(20%)
if all_skills:
runnable_ratio = len(runnable) / len(all_skills)
score += runnable_ratio * 100 * 0.20
total_weight += 0.20
# 2. 平均评分(30%)
if scores:
avg_total = sum(s.total_score for s in scores) / len(scores)
score += avg_total * 0.30
total_weight += 0.30
# 3. 活跃度 — 7 天有运行记录(20%)
if runnable:
active_skills = set(r["skill_id"] for r in week_runs)
active_ratio = len(active_skills) / len(runnable) if runnable else 0
score += min(active_ratio * 1.5, 1.0) * 100 * 0.20 # 50%活跃就满分
total_weight += 0.20
# 4. 成功率(20%)
if week_runs:
success = sum(1 for r in week_runs if r["status"] == "success")
success_rate = success / len(week_runs) * 100
score += success_rate * 0.20
total_weight += 0.20
# 5. 缺失覆盖度惩罚(10%)
complement_recs = [r for r in recommendations if r.reason_type == "complement"]
if complement_recs:
penalty = min(len(complement_recs) * 10, 30)
score += max(0, 100 - penalty) * 0.10
else:
score += 100 * 0.10
total_weight += 0.10
return round(score / total_weight, 1) if total_weight > 0 else 50.0
def _section_health_overview(
self, health_score, health_label, health_icon,
all_skills, runnable, scores, today_runs, week_runs,
) -> List[str]:
"""系统健康度总览"""
success_today = [r for r in today_runs if r["status"] == "success"]
success_week = [r for r in week_runs if r["status"] == "success"]
durations = [r["duration_ms"] for r in success_week if r.get("duration_ms")]
avg_dur = sum(durations) / len(durations) if durations else 0
active_week = set(r["skill_id"] for r in week_runs)
avg_score = sum(s.total_score for s in scores) / len(scores) if scores else 0
top_skill = scores[0] if scores else None
worst_skill = scores[-1] if len(scores) > 1 else None
lines = [
f"## {health_icon} 系统健康度: {health_score:.0f}/100 — {health_label}",
f"",
f"| 维度 | 数值 | 状态 |",
f"|------|------|------|",
f"| 📦 已安装 Skills | {len(all_skills)} 个 | — |",
f"| ⚡ 可运行 Skills | {len(runnable)} 个 ({len(runnable)}/{len(all_skills)}) | {'✅' if len(runnable)/max(len(all_skills),1) >= 0.5 else '⚠️'} |",
f"| 📈 今日运行 | {len(today_runs)} 次 (成功 {len(success_today)}) | {'✅' if len(today_runs) > 0 else '💤'} |",
f"| 📊 7 天运行 | {len(week_runs)} 次 (成功 {len(success_week)}) | — |",
f"| 🎯 7 天活跃 Skills | {len(active_week)} 个 | {'✅' if len(active_week) >= 3 else '⚠️'} |",
f"| ⏱ 7 天平均响应 | {avg_dur:.0f}ms | {'✅' if avg_dur < 5000 else '⚠️'} |",
f"| 🏆 平均综合评分 | {avg_score:.1f}/100 | {'✅' if avg_score >= 70 else '⚠️'} |",
]
if top_skill:
lines.append(
f"| 👑 最佳 Skill | {top_skill.skill_id} ({top_skill.total_score:.1f}分) | 🏆 |"
)
if worst_skill and worst_skill.total_score < 60:
lines.append(
f"| ⚠️ 需关注 Skill | {worst_skill.skill_id} ({worst_skill.total_score:.1f}分) | 🔧 |"
)
lines.extend([f"", f"---", f""])
return lines
def _section_coverage(self, all_skills, runnable) -> List[str]:
"""Skills 覆盖度分析"""
categories = self.registry.get_skills_by_category()
lines = [
f"## 📋 Skills 覆盖度分析",
f"",
f"| 分类 | 已安装 | 可运行 | 覆盖度 |",
f"|------|--------|--------|--------|",
]
for cat, skills in sorted(categories.items()):
total = len(skills)
run = len([s for s in skills if s.entry_type != "none"])
ratio = f"{run/total*100:.0f}%" if total > 0 else "0%"
status = "✅" if run / max(total, 1) >= 0.5 else "⚠️"
lines.append(f"| {cat} | {total} | {run} | {ratio} {status} |")
lines.extend([f"", f"---", f""])
return lines
def _section_performance(self, scores: List[SkillScore]) -> List[str]:
"""性能诊断"""
if not scores:
return [f"## ⚡ 性能诊断", f"", f"暂无评估数据。", f"", f"---", f""]
lines = [
f"## ⚡ 性能诊断",
f"",
f"| Skill | 综合评分 | 等级 | 成功率 | 平均响应 | 满意度(隐性) | 稳定性 |",
f"|-------|---------|------|--------|---------|-------------|--------|",
]
for score in scores:
sr = f"{score.factors['success_rate']:.0f}%" if score.factors['success_rate'] is not None else "-"
rt = f"{score.factors['response_time']:.0f}ms" if score.factors['response_time'] is not None else "-"
ur = f"{score.factors['satisfaction']:.1f}/5" if score.factors.get('satisfaction') else "-"
cv = score.factors.get('stability')
st = f"{cv:.2f}" if cv is not None else "-"
grade_short = score.grade.split("(")[0].strip()
lines.append(
f"| {score.skill_id} | **{score.total_score:.1f}** | {grade_short} | {sr} | {rt} | {ur} | {st} |"
)
lines.extend([f"", f"---", f""])
return lines
def _section_usage(self, scores, today_runs, week_runs) -> List[str]:
"""使用情况诊断"""
lines = [
f"## 📊 使用情况诊断",
f"",
]
if not week_runs:
lines.extend([f"过去 7 天无运行记录。建议增加 Skills 使用频率。", f"", f"---", f""])
return lines
# 7 天各 skill 使用频次
from collections import Counter
skill_counter = Counter(r["skill_id"] for r in week_runs)
top5 = skill_counter.most_common(5)
lines.extend([
f"### 7 天使用 TOP5",
f"",
f"| 排名 | Skill | 使用次数 | 成功次数 | 成功率 |",
f"|------|-------|---------|---------|--------|",
])
for i, (sid, count) in enumerate(top5, 1):
success = sum(1 for r in week_runs if r["skill_id"] == sid and r["status"] == "success")
rate = f"{success/count*100:.0f}%" if count > 0 else "-"
lines.append(f"| {i} | {sid} | {count} | {success} | {rate} |")
# 未使用的可运行 skills
runnable_slugs = {s.slug for s in self.registry.get_runnable_skills()}
used_slugs = set(skill_counter.keys())
unused = runnable_slugs - used_slugs
if unused:
lines.extend([
f"",
f"### 💤 未使用的可运行 Skills ({len(unused)} 个)",
f"",
])
for slug in sorted(unused):
lines.append(f"- `{slug}`")
lines.extend([f"", f"---", f""])
return lines
def _section_issues(self, scores, all_skills, runnable, recommendations) -> List[str]:
"""问题发现"""
issues = []
# 1. 低评分 skills
low_scores = [s for s in scores if s.total_score < 60]
if low_scores:
for s in low_scores:
issues.append(
f"⚠️ **{s.skill_id}** 综合评分仅 {s.total_score:.1f} 分,等级 {s.grade.split('(')[0].strip()}"
)
# 2. 高失败率
high_fail = [s for s in scores if s.factors.get("success_rate", 100) < 80]
for s in high_fail:
if s not in low_scores:
issues.append(
f"⚠️ **{s.skill_id}** 成功率仅 {s.factors['success_rate']:.0f}%,建议排查"
)
# 3. 不可运行比例高
non_runnable = len(all_skills) - len(runnable)
if non_runnable > len(runnable):
issues.append(
f"⚠️ 有 {non_runnable} 个 Skills 不可运行(纯文档型),可运行率偏低"
)
# 4. 覆盖缺失
complement_recs = [r for r in recommendations if r.reason_type == "complement"]
if complement_recs:
cats = set(r.skill_info["category"] for r in complement_recs)
issues.append(
f"💡 缺少以下能力类别的 Skills: {', '.join(cats)}"
)
# 5. 需要升级的
upgrade_recs = [r for r in recommendations if r.reason_type == "upgrade"]
if upgrade_recs:
for r in upgrade_recs:
issues.append(
f"⬆️ **{r.related_installed}** 建议升级为 **{r.skill_info['name']}**"
)
lines = [
f"## 🔍 问题发现",
f"",
]
if issues:
for issue in issues:
lines.append(f"- {issue}")
else:
lines.append(f"✅ 未发现明显问题,系统运行状况良好!")
lines.extend([f"", f"---", f""])
return lines
def _section_recommendations(
self, scores, recommendations, all_skills, runnable, week_runs,
) -> List[str]:
"""优化建议"""
suggestions = []
priority = 1
# 建议 1: 低评分 skill 处理
low_scores = [s for s in scores if s.total_score < 60]
if low_scores:
names = ", ".join(f"`{s.skill_id}`" for s in low_scores[:3])
suggestions.append(
f"**P{priority}** 🔧 关注低评分 Skills: {names} — "
f"可通过 `skills-monitor evaluate -s <skill_id> -v` 查看详细问题"
)
priority += 1
# 建议 2: 满意度数据不足的 skill
needs_data = [s for s in scores if s.factors.get("satisfaction") is None]
if needs_data:
names = ", ".join(f"`{s.skill_id}`" for s in needs_data[:3])
suggestions.append(
f"**P{priority}** 📊 以下 Skills 满意度数据不足: {names} — "
f"多使用即可自动积累对话满意度评估数据"
)
priority += 1
# 建议 3: 未使用的 skill
used_week = set(r["skill_id"] for r in week_runs)
runnable_slugs = {s.slug for s in runnable}
unused = runnable_slugs - used_week
if len(unused) > 3:
suggestions.append(
f"**P{priority}** 💤 有 {len(unused)} 个可运行 Skills 过去 7 天未使用 — "
f"建议评估是否需要保留或替换"
)
priority += 1
# 建议 4: 覆盖度补充
complement_recs = [r for r in recommendations if r.reason_type == "complement"]
if complement_recs:
names = ", ".join(f"**{r.skill_info['name']}**" for r in complement_recs[:2])
suggestions.append(
f"**P{priority}** 💡 补充缺失能力: 推荐安装 {names} — "
f"查看推荐: `skills-monitor recommend`"
)
priority += 1
# 建议 5: 定期基准测试
if scores and len(scores) >= 3:
suggestions.append(
f"**P{priority}** 📊 建议对核心 Skills 定期运行基准测试 — "
f"`skills-monitor benchmark <skill_id>`"
)
lines = [
f"## 💡 优化建议",
f"",
]
if suggestions:
for s in suggestions:
lines.append(f"{s}")
lines.append(f"")
else:
lines.append(f"✅ 系统状态良好,暂无需要优化的地方!继续保持 🎉")
lines.append(f"")
lines.extend([f"---", f""])
return lines
def _section_install_recommendations(self, recommendations: List[Recommendation]) -> List[str]:
"""推荐安装区块"""
lines = [
f"## 📦 推荐安装",
f"",
f"| 优先级 | 名称 | 分类 | 推荐分 | 类型 | 理由 |",
f"|--------|------|------|--------|------|------|",
]
type_labels = {
"complement": "💡 互补",
"upgrade": "⬆️ 升级",
"collaborative": "🤝 协同",
"popular": "🔥 热门",
}
for i, rec in enumerate(recommendations[:5], 1):
label = type_labels.get(rec.reason_type, rec.reason_type)
detail = rec.reason_detail[:40]
lines.append(
f"| {i} | **{rec.skill_info['name']}** (`{rec.skill_info['slug']}`) | "
f"{rec.skill_info['category']} | {rec.score:.0f} | {label} | {detail} |"
)
lines.extend([f"", f"---", f""])
return lines
def save_report(self, content: str, trigger: str = "scheduled") -> str:
"""保存报告到文件"""
now = datetime.now()
filename = f"diagnostic_{trigger}_{now.strftime('%Y%m%d_%H%M%S')}.md"
filepath = str(Path(self.reports_dir) / filename)
with open(filepath, "w", encoding="utf-8") as f:
f.write(content)
return filepath
def generate_and_save(
self,
trigger: str = "scheduled",
extra_context: Optional[str] = None,
) -> Tuple[str, str]:
"""生成并保存诊断报告,返回 (报告内容, 文件路径)"""
content = self.generate_diagnostic_report(trigger=trigger, extra_context=extra_context)
filepath = self.save_report(content, trigger=trigger)
return content, filepath
def generate_wecom_summary(self, full_report: str) -> str:
"""
从完整报告中提取适合企微推送的摘要版本
企微 Markdown 消息有 4096 字节限制,这里提取关键部分
"""
lines = full_report.split("\n")
summary_lines = []
in_section = False
target_sections = {
"系统健康度", "问题发现", "优化建议",
}
for line in lines:
# 报告标题
if line.startswith("# "):
summary_lines.append(line)
summary_lines.append("")
continue
# 引用头信息
if line.startswith("> "):
summary_lines.append(line)
continue
# 分割线
if line.strip() == "---":
if in_section:
summary_lines.append("")
in_section = False
continue
# 检测目标区块
if line.startswith("## "):
in_section = any(s in line for s in target_sections)
if in_section:
summary_lines.append(line)
summary_lines.append("")
continue
# 目标区块内容
if in_section:
summary_lines.append(line)
# 添加页脚
summary_lines.extend([
"",
"---",
"*完整报告请查看本地文件 | `skills-monitor diagnose`*",
])
return "\n".join(summary_lines)
FILE:skills_monitor/core/auto_reporter.py
"""
后台自动上报模块 v0.5.0 — 每日定时诊断 + 增量上报
=================================================
功能:
- 自动生成诊断报告 + 上报中心服务器
- macOS LaunchAgent 安装/卸载/状态管理
- 首次运行交互式确认
- 日志记录与错误恢复
使用方式:
from skills_monitor.core.auto_reporter import AutoReporter
reporter = AutoReporter()
reporter.run_daily() # 手动触发一次
reporter.install_schedule() # 安装 LaunchAgent
"""
import json
import logging
import os
import platform
import subprocess
import sys
from datetime import datetime, date
from pathlib import Path
from typing import Dict, Any, Optional, Tuple
logger = logging.getLogger(__name__)
# LaunchAgent 配置
PLIST_LABEL = "com.codebuddy.auto-upload"
PLIST_FILENAME = f"{PLIST_LABEL}.plist"
DEFAULT_HOUR = 12 # 默认每天中午 12 点
DEFAULT_MINUTE = 0
DEFAULT_SERVER_URL = "http://localhost:5100"
# 日志目录
LOG_DIR = os.path.expanduser("~/.skills_monitor/logs")
STATE_FILE = os.path.expanduser("~/.skills_monitor/auto_report_state.json")
class AutoReporter:
"""
后台自动上报器
职责:
1. 调用 DiagnosticReporter 生成报告
2. 调用 DataUploader 上报到中心服务器
3. 管理 macOS LaunchAgent (定时任务)
4. 记录上报状态(成功/失败/重试)
"""
def __init__(
self,
server_url: str = DEFAULT_SERVER_URL,
hour: int = DEFAULT_HOUR,
minute: int = DEFAULT_MINUTE,
skills_dir: str = "skills",
):
self.server_url = server_url
self.hour = hour
self.minute = minute
self.skills_dir = skills_dir
self._state = self._load_state()
# 确保日志目录
Path(LOG_DIR).mkdir(parents=True, exist_ok=True)
# ──────── 核心:执行一次上报 ────────
def run_daily(self, trigger: str = "scheduled") -> Dict[str, Any]:
"""
执行一次完整的诊断 + 上报流程
流程:
1. 检查 GDPR 同意状态
2. 生成诊断报告
3. 上报数据到中心服务器
4. 记录本次上报结果
Returns:
{"ok": bool, "report_path": str, "upload_result": dict, ...}
"""
result = {
"ok": False,
"timestamp": datetime.now().isoformat(),
"trigger": trigger,
"steps": {},
}
try:
# Step 1: 检查 GDPR 同意
from skills_monitor.core.identity import IdentityManager
identity = IdentityManager()
if not identity.is_initialized:
result["error"] = "Agent 未初始化,请先运行 skills-monitor init"
self._save_state(result)
return result
if not identity.has_consent():
result["error"] = "用户未同意数据收集,请先运行 skills-monitor consent"
self._save_state(result)
return result
result["steps"]["consent_check"] = "passed"
# Step 2: 生成诊断报告
from skills_monitor.data.store import DataStore
from skills_monitor.adapters.skill_registry import SkillRegistry
from skills_monitor.core.diagnostic import DiagnosticReporter
store = DataStore()
registry = SkillRegistry(skills_dir=self.skills_dir)
reporter = DiagnosticReporter(
store, registry, identity.agent_id,
reports_dir="reports/diagnostic",
)
report_content, report_path = reporter.generate_and_save(trigger=trigger)
result["steps"]["report"] = "generated"
result["report_path"] = report_path
# Step 3: 上报到中心服务器
from skills_monitor.core.uploader import DataUploader
uploader = DataUploader(server_url=self.server_url)
uploader.init()
# 先注册/心跳
uploader.register()
uploader.heartbeat()
# 先兜底确保首次诊断已存在
initial_ok, initial_result = uploader.ensure_initial_diagnostic_uploaded()
result["steps"]["initial_diagnostic"] = "success" if initial_ok else "failed"
result["initial_diagnostic_result"] = initial_result
# 上报诊断数据
ok, upload_result = uploader.upload_diagnostic({
"collected_at": datetime.now().isoformat(),
"report_markdown": report_content,
"trigger": trigger,
"report_date": date.today().isoformat(),
})
result["steps"]["upload"] = "success" if ok else "failed"
result["upload_result"] = upload_result
# 同时上报每日数据
daily_ok, daily_result = uploader.upload_daily(date.today(), trigger=trigger)
result["steps"]["daily_upload"] = "success" if daily_ok else "failed"
result["daily_upload_result"] = daily_result
result["ok"] = bool(initial_ok and ok and daily_ok)
logger.info(f"自动上报{'成功' if result['ok'] else '失败'}: {result}")
except Exception as e:
result["error"] = f"{type(e).__name__}: {e}"
logger.error(f"自动上报异常: {e}", exc_info=True)
self._save_state(result)
return result
# ──────── macOS LaunchAgent 管理 ────────
def install_schedule(
self,
hour: int = None,
minute: int = None,
weekdays_only: bool = False,
) -> Dict[str, Any]:
"""
安装 macOS LaunchAgent 定时任务
Args:
hour: 执行小时 (0-23)
minute: 执行分钟 (0-59)
weekdays_only: 是否仅工作日
Returns:
{"ok": bool, "plist_path": str, "loaded": bool}
"""
if platform.system() != "Darwin":
return {
"ok": False,
"error": "LaunchAgent 仅支持 macOS,其他系统请使用 crontab",
"crontab_hint": self._generate_crontab(hour, minute, weekdays_only),
}
h = hour or self.hour
m = minute or self.minute
plist_content = self._generate_plist(h, m, weekdays_only)
plist_dir = Path.home() / "Library" / "LaunchAgents"
plist_dir.mkdir(parents=True, exist_ok=True)
plist_path = plist_dir / PLIST_FILENAME
# 先卸载旧的
self._unload_plist(str(plist_path))
# 写入 plist
plist_path.write_text(plist_content, encoding="utf-8")
# 加载
loaded = self._load_plist(str(plist_path))
result = {
"ok": True,
"plist_path": str(plist_path),
"loaded": loaded,
"schedule": f"每天 {h:02d}:{m:02d}" + (" (仅工作日)" if weekdays_only else ""),
}
logger.info(f"LaunchAgent 已安装: {result}")
return result
def uninstall_schedule(self) -> Dict[str, Any]:
"""卸载 LaunchAgent 定时任务"""
plist_path = Path.home() / "Library" / "LaunchAgents" / PLIST_FILENAME
if not plist_path.exists():
return {"ok": True, "message": "LaunchAgent 未安装"}
self._unload_plist(str(plist_path))
plist_path.unlink(missing_ok=True)
return {"ok": True, "message": "LaunchAgent 已卸载", "removed": str(plist_path)}
def get_schedule_status(self) -> Dict[str, Any]:
"""查询定时任务状态"""
plist_path = Path.home() / "Library" / "LaunchAgents" / PLIST_FILENAME
if platform.system() != "Darwin":
return {"installed": False, "reason": "非 macOS 系统"}
if not plist_path.exists():
return {"installed": False, "plist_path": str(plist_path)}
# 检查是否已加载
try:
cp = subprocess.run(
["launchctl", "list", PLIST_LABEL],
capture_output=True, text=True, timeout=5,
)
loaded = cp.returncode == 0
except Exception:
loaded = False
return {
"installed": True,
"loaded": loaded,
"plist_path": str(plist_path),
"last_run": self._state.get("timestamp"),
"last_result": self._state.get("ok"),
}
# ──────── plist 生成 ────────
def _generate_plist(self, hour: int, minute: int, weekdays_only: bool) -> str:
"""生成 macOS LaunchAgent plist XML"""
python_path = sys.executable
# 找到 auto_reporter 的入口脚本
project_dir = str(Path(__file__).resolve().parent.parent.parent)
script_path = os.path.join(project_dir, "_auto_report_entry.py")
# 如果入口脚本不存在,自动创建
self._ensure_entry_script(script_path, project_dir)
if weekdays_only:
# 周一到周五
intervals = ""
for wd in range(1, 6): # 1=Monday .. 5=Friday
intervals += f""" <dict>
<key>Weekday</key><integer>{wd}</integer>
<key>Hour</key><integer>{hour}</integer>
<key>Minute</key><integer>{minute}</integer>
</dict>\n"""
calendar_section = f""" <key>StartCalendarInterval</key>
<array>
{intervals} </array>"""
else:
calendar_section = f""" <key>StartCalendarInterval</key>
<dict>
<key>Hour</key><integer>{hour}</integer>
<key>Minute</key><integer>{minute}</integer>
</dict>"""
return f"""<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>{PLIST_LABEL}</string>
<key>ProgramArguments</key>
<array>
<string>{python_path}</string>
<string>{script_path}</string>
</array>
<key>WorkingDirectory</key>
<string>{project_dir}</string>
{calendar_section}
<key>StandardOutPath</key>
<string>{LOG_DIR}/auto_upload_stdout.log</string>
<key>StandardErrorPath</key>
<string>{LOG_DIR}/auto_upload_stderr.log</string>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/usr/local/bin:/usr/bin:/bin:{os.path.dirname(python_path)}</string>
<key>PYTHONPATH</key>
<string>{project_dir}</string>
</dict>
</dict>
</plist>
"""
def _ensure_entry_script(self, script_path: str, project_dir: str):
"""确保入口脚本存在"""
if os.path.exists(script_path):
return
content = f'''#!/usr/bin/env python3
"""
Skills Monitor 自动上报入口脚本 (由 auto_reporter.py 自动生成)
每日由 LaunchAgent 触发
"""
import sys
import os
sys.path.insert(0, "{project_dir}")
os.chdir("{project_dir}")
import logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
handlers=[
logging.FileHandler(os.path.expanduser("~/.skills_monitor/logs/auto_upload.log")),
logging.StreamHandler(),
],
)
from skills_monitor.core.auto_reporter import AutoReporter
if __name__ == "__main__":
reporter = AutoReporter()
result = reporter.run_daily(trigger="launchagent")
status = "✅ 成功" if result.get("ok") else f"❌ 失败: {{result.get(\'error\', \'未知错误\')}}"
logging.info(f"自动上报结果: {{status}}")
'''
Path(script_path).write_text(content, encoding="utf-8")
os.chmod(script_path, 0o755)
def _generate_crontab(self, hour: int = None, minute: int = None, weekdays_only: bool = False) -> str:
"""为非 macOS 系统生成 crontab 提示"""
h = hour or self.hour
m = minute or self.minute
day_part = "1-5" if weekdays_only else "*"
project_dir = str(Path(__file__).resolve().parent.parent.parent)
return (
f"# Skills Monitor 自动上报 (crontab)\n"
f"{m} {h} * * {day_part} cd {project_dir} && "
f"{sys.executable} -c 'from skills_monitor.core.auto_reporter import AutoReporter; "
f"AutoReporter().run_daily(trigger=\"crontab\")'"
)
# ──────── launchctl 操作 ────────
def _load_plist(self, plist_path: str) -> bool:
"""加载 plist 到 launchd"""
try:
cp = subprocess.run(
["launchctl", "load", plist_path],
capture_output=True, text=True, timeout=10,
)
return cp.returncode == 0
except Exception as e:
logger.warning(f"launchctl load 失败: {e}")
return False
def _unload_plist(self, plist_path: str) -> bool:
"""从 launchd 卸载 plist"""
try:
cp = subprocess.run(
["launchctl", "unload", plist_path],
capture_output=True, text=True, timeout=10,
)
return cp.returncode == 0
except Exception:
return False
# ──────── 状态持久化 ────────
def _load_state(self) -> Dict[str, Any]:
"""加载上次上报状态"""
try:
if os.path.exists(STATE_FILE):
with open(STATE_FILE, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
pass
return {}
def _save_state(self, result: Dict[str, Any]):
"""保存上报状态"""
try:
Path(os.path.dirname(STATE_FILE)).mkdir(parents=True, exist_ok=True)
with open(STATE_FILE, "w", encoding="utf-8") as f:
json.dump(result, f, ensure_ascii=False, indent=2)
except Exception as e:
logger.warning(f"保存上报状态失败: {e}")
self._state = result
# ──────── 重试逻辑 ────────
def retry_last_failed(self) -> Dict[str, Any]:
"""重试上次失败的上报"""
if self._state.get("ok", True):
return {"ok": True, "message": "上次上报已成功,无需重试"}
return self.run_daily(trigger="retry")
FILE:skills_monitor/core/interceptor.py
"""
SDK 拦截器 v0.5.0 — @skill_monitor 装饰器
无侵入地包裹 skill 函数调用,自动采集运行指标
v0.5.0: 新增实时反馈上报(异步,不阻塞)
"""
import functools
import logging
import time
import traceback
import uuid
from datetime import datetime
from typing import Any, Callable, Dict, Optional
from skills_monitor.data.store import DataStore
logger = logging.getLogger(__name__)
# 全局单例(在 init 时设置)
_store: Optional[DataStore] = None
_agent_id: Optional[str] = None
_realtime_reporter = None # v0.5.0: 实时上报器(延迟初始化)
_realtime_enabled: bool = True # v0.5.0: 是否开启实时上报
def configure(store: DataStore, agent_id: str, enable_realtime: bool = True):
"""配置拦截器的全局 store 和 agent_id"""
global _store, _agent_id, _realtime_enabled
_store = store
_agent_id = agent_id
_realtime_enabled = enable_realtime
def _get_realtime_reporter():
"""延迟初始化实时上报器"""
global _realtime_reporter
if _realtime_reporter is None and _realtime_enabled:
try:
from skills_monitor.core.realtime_reporter import RealtimeReporter
_realtime_reporter = RealtimeReporter.get_instance()
except Exception as e:
logger.debug(f"实时上报器初始化失败: {e}")
_realtime_reporter = False # 标记为失败,不再重试
return _realtime_reporter if _realtime_reporter is not False else None
def get_store() -> Optional[DataStore]:
return _store
def skill_monitor(skill_id: str, task_name: str = ""):
"""
装饰器:自动采集 skill 运行指标
用法:
@skill_monitor(skill_id="a-share-short-decision", task_name="get_market_sentiment")
def run_market_sentiment():
...
"""
def decorator(func: Callable) -> Callable:
@functools.wraps(func)
def wrapper(*args, **kwargs) -> Any:
if _store is None or _agent_id is None:
# 未初始化,直接调用原函数
return func(*args, **kwargs)
run_id = str(uuid.uuid4())[:12]
start_time = datetime.now()
t_name = task_name or func.__name__
# 记录开始
run_record = {
"run_id": run_id,
"agent_id": _agent_id,
"skill_id": skill_id,
"task_name": t_name,
"status": "running",
"start_time": start_time.isoformat(),
"input_data": _safe_input(args, kwargs),
}
_store.insert_run(run_record)
result = None
error_msg = None
status = "success"
try:
result = func(*args, **kwargs)
return result
except Exception as e:
status = "error"
error_msg = f"{type(e).__name__}: {e}\n{traceback.format_exc()[-500:]}"
raise
finally:
end_time = datetime.now()
duration_ms = (end_time - start_time).total_seconds() * 1000
_store.update_run(
run_id,
status=status,
end_time=end_time.isoformat(),
duration_ms=round(duration_ms, 2),
output_data=result if status == "success" else None,
error_msg=error_msg,
)
# 更新当日聚合指标
today = start_time.strftime("%Y-%m-%d")
_store.upsert_daily_metrics(_agent_id, skill_id, today)
# v0.5.0: 实时反馈上报(异步,不阻塞)
rt = _get_realtime_reporter()
if rt:
try:
rt.enqueue({
"run_id": run_id,
"agent_id": _agent_id,
"skill_id": skill_id,
"task_name": t_name,
"status": status,
"duration_ms": round(duration_ms, 2),
"error_type": type(error_msg).__name__ if error_msg else None,
"timestamp": end_time.isoformat(),
})
except Exception:
pass # 实时上报失败不影响主流程
# 附加元信息便于查询
wrapper._skill_id = skill_id
wrapper._task_name = task_name
return wrapper
return decorator
def run_skill_function(
func: Callable,
skill_id: str,
task_name: str = "",
args: tuple = (),
kwargs: Optional[Dict] = None,
) -> Dict[str, Any]:
"""
命令式调用:运行任意函数并自动采集指标
返回 {"run_id": ..., "status": ..., "result": ..., "duration_ms": ..., "error": ...}
"""
if kwargs is None:
kwargs = {}
if _store is None or _agent_id is None:
raise RuntimeError("拦截器未初始化,请先调用 configure(store, agent_id)")
run_id = str(uuid.uuid4())[:12]
start_time = datetime.now()
t_name = task_name or func.__name__
run_record = {
"run_id": run_id,
"agent_id": _agent_id,
"skill_id": skill_id,
"task_name": t_name,
"status": "running",
"start_time": start_time.isoformat(),
"input_data": _safe_input(args, kwargs),
}
_store.insert_run(run_record)
result = None
error_msg = None
status = "success"
try:
result = func(*args, **kwargs)
except Exception as e:
status = "error"
error_msg = f"{type(e).__name__}: {e}"
end_time = datetime.now()
duration_ms = (end_time - start_time).total_seconds() * 1000
_store.update_run(
run_id,
status=status,
end_time=end_time.isoformat(),
duration_ms=round(duration_ms, 2),
output_data=result if status == "success" else None,
error_msg=error_msg,
)
today = start_time.strftime("%Y-%m-%d")
_store.upsert_daily_metrics(_agent_id, skill_id, today)
# v0.5.0: 实时反馈上报
rt = _get_realtime_reporter()
if rt:
try:
rt.enqueue({
"run_id": run_id,
"agent_id": _agent_id,
"skill_id": skill_id,
"task_name": t_name,
"status": status,
"duration_ms": round(duration_ms, 2),
"error_type": type(error_msg).__name__ if error_msg else None,
"timestamp": end_time.isoformat(),
})
except Exception:
pass
return {
"run_id": run_id,
"skill_id": skill_id,
"task_name": t_name,
"status": status,
"duration_ms": round(duration_ms, 2),
"result": result,
"error": error_msg,
}
def _safe_input(args: tuple, kwargs: dict) -> Optional[dict]:
"""安全地序列化输入参数"""
try:
data = {}
if args:
data["args"] = [str(a)[:200] for a in args]
if kwargs:
data["kwargs"] = {k: str(v)[:200] for k, v in kwargs.items()}
return data if data else None
except Exception:
return None
FILE:skills_monitor/core/scheduler.py
"""
定时任务管理 CLI v0.5.0 — 首次启动交互式确认 + 定时任务管理
==========================================================
提供 CLI 子命令用于:
- skills-monitor schedule install — 安装定时上报
- skills-monitor schedule remove — 卸载定时上报
- skills-monitor schedule status — 查看定时任务状态
- skills-monitor consent — 管理数据收集同意
首次运行诊断报告后,交互式询问用户是否开启每日定时上报。
使用方式:
from skills_monitor.core.scheduler import ScheduleManager
mgr = ScheduleManager()
mgr.interactive_first_run()
"""
import json
import logging
import os
import sys
from pathlib import Path
from typing import Dict, Any, Optional
logger = logging.getLogger(__name__)
# 首次运行标记
FIRST_RUN_FLAG = os.path.expanduser("~/.skills_monitor/.first_run_completed")
CONSENT_FLAG = os.path.expanduser("~/.skills_monitor/.consent_shown")
class ScheduleManager:
"""定时任务管理器 — 首次启动交互式确认"""
def __init__(self, server_url: str = "http://localhost:5100"):
self.server_url = server_url
# ──────── 首次运行交互式确认 ────────
def interactive_first_run(self, force: bool = False) -> Dict[str, Any]:
"""
首次运行后的交互式确认流程
流程:
1. 显示数据收集声明
2. 询问是否同意数据收集
3. 询问是否开启每日定时上报
4. 设置上报时间
Args:
force: 强制重新走首次确认流程
Returns:
{"consent": bool, "schedule_installed": bool, ...}
"""
result = {
"consent": False,
"schedule_installed": False,
"skipped": False,
}
# 检查是否已完成首次运行
if not force and os.path.exists(FIRST_RUN_FLAG):
result["skipped"] = True
result["message"] = "首次运行确认已完成"
return result
# 检查是否在交互式终端
if not sys.stdin.isatty():
result["skipped"] = True
result["message"] = "非交互式终端,跳过确认(可通过 skills-monitor schedule install 手动安装)"
return result
print()
print("=" * 60)
print(" 🏥 Skills Monitor v0.5.0 — 首次运行配置")
print("=" * 60)
print()
# Step 1: 数据收集声明
consent = self._ask_consent()
result["consent"] = consent
if not consent:
print("\n❌ 您拒绝了数据收集。您仍可使用本地诊断功能。")
print(" 如需更改,运行: skills-monitor consent --agree")
self._mark_first_run()
return result
# Step 2: 询问定时上报
schedule = self._ask_schedule()
result["schedule_installed"] = schedule
# 标记首次运行完成
self._mark_first_run()
print()
print("✅ 配置完成!")
if schedule:
print(" 每日诊断报告将自动生成并上报。")
print(" 随时可通过 `skills-monitor schedule status` 查看状态。")
print()
return result
def _ask_consent(self) -> bool:
"""显示数据收集声明并获取用户同意"""
print("📋 数据收集声明")
print("-" * 40)
print("Skills Monitor 会收集以下匿名数据用于改进服务:")
print()
print(" ✅ 收集内容:")
print(" • Skill 运行成功率、响应时间等聚合指标")
print(" • Skill 使用频率统计")
print(" • 系统健康度评分")
print()
print(" ❌ 绝不收集:")
print(" • 您的代码或文件内容")
print(" • 个人身份信息(姓名、邮箱等)")
print(" • API Key 或密码")
print(" • Skill 的输入/输出数据")
print()
print(" 🔒 安全保障:")
print(" • 所有数据上报前自动脱敏")
print(" • 支持数据导出和删除(GDPR 合规)")
print(" • 可随时撤销同意")
print()
while True:
answer = input("是否同意数据收集?(y/n): ").strip().lower()
if answer in ("y", "yes", "是"):
# 记录同意
try:
from skills_monitor.core.identity import IdentityManager
mgr = IdentityManager()
mgr.record_consent(True)
except Exception:
pass
return True
elif answer in ("n", "no", "否"):
try:
from skills_monitor.core.identity import IdentityManager
mgr = IdentityManager()
mgr.record_consent(False)
except Exception:
pass
return False
else:
print("请输入 y 或 n")
def _ask_schedule(self) -> bool:
"""询问是否开启定时上报"""
print()
print("⏰ 每日定时上报")
print("-" * 40)
print("Skills Monitor 可以每天自动:")
print(" 1. 生成诊断报告")
print(" 2. 上报数据到中心服务器")
print(" 3. 记录系统健康度趋势")
print()
while True:
answer = input("是否开启每日定时上报?(y/n) [默认: y]: ").strip().lower()
if answer in ("", "y", "yes", "是"):
break
elif answer in ("n", "no", "否"):
print("跳过定时上报安装。您可以随时通过 `skills-monitor schedule install` 开启。")
return False
else:
print("请输入 y 或 n")
# 询问时间
hour = 12
minute = 0
time_input = input(f"上报时间 (HH:MM) [默认: 12:00]: ").strip()
if time_input:
try:
parts = time_input.split(":")
hour = int(parts[0])
minute = int(parts[1]) if len(parts) > 1 else 0
if not (0 <= hour <= 23 and 0 <= minute <= 59):
print(f"时间无效,使用默认 12:00")
hour, minute = 12, 0
except (ValueError, IndexError):
print(f"格式无效,使用默认 12:00")
hour, minute = 12, 0
# 安装
from skills_monitor.core.auto_reporter import AutoReporter
reporter = AutoReporter(server_url=self.server_url, hour=hour, minute=minute)
result = reporter.install_schedule(hour=hour, minute=minute)
if result.get("ok"):
print(f"\n✅ 定时上报已安装: {result.get('schedule')}")
else:
print(f"\n⚠️ 安装定时上报失败: {result.get('error')}")
if result.get("crontab_hint"):
print(f"\n手动安装 crontab:\n{result['crontab_hint']}")
return result.get("ok", False)
def _mark_first_run(self):
"""标记首次运行已完成"""
try:
Path(os.path.dirname(FIRST_RUN_FLAG)).mkdir(parents=True, exist_ok=True)
Path(FIRST_RUN_FLAG).write_text(
json.dumps({"completed_at": __import__("datetime").datetime.now().isoformat()}),
encoding="utf-8",
)
except Exception:
pass
# ──────── CLI 子命令 ────────
def cmd_install(self, hour: int = 12, minute: int = 0, weekdays_only: bool = False) -> Dict[str, Any]:
"""CLI: skills-monitor schedule install"""
from skills_monitor.core.auto_reporter import AutoReporter
reporter = AutoReporter(server_url=self.server_url, hour=hour, minute=minute)
return reporter.install_schedule(hour=hour, minute=minute, weekdays_only=weekdays_only)
def cmd_remove(self) -> Dict[str, Any]:
"""CLI: skills-monitor schedule remove"""
from skills_monitor.core.auto_reporter import AutoReporter
reporter = AutoReporter(server_url=self.server_url)
return reporter.uninstall_schedule()
def cmd_status(self) -> Dict[str, Any]:
"""CLI: skills-monitor schedule status"""
from skills_monitor.core.auto_reporter import AutoReporter
reporter = AutoReporter(server_url=self.server_url)
return reporter.get_schedule_status()
def cmd_consent(self, agree: bool = None) -> Dict[str, Any]:
"""CLI: skills-monitor consent"""
from skills_monitor.core.identity import IdentityManager
mgr = IdentityManager()
if agree is None:
# 查询状态
return {
"has_consent": mgr.has_consent(),
"consent_info": mgr._config.get("consent", {}),
}
mgr.record_consent(agree)
return {
"ok": True,
"consent": agree,
"message": f"数据收集已{'同意' if agree else '拒绝'}",
}
def cmd_run_now(self) -> Dict[str, Any]:
"""CLI: skills-monitor schedule run — 立即执行一次上报"""
from skills_monitor.core.auto_reporter import AutoReporter
reporter = AutoReporter(server_url=self.server_url)
return reporter.run_daily(trigger="manual")
# ──────── 重置首次运行 ────────
@staticmethod
def reset_first_run():
"""重置首次运行标记(用于测试)"""
for f in (FIRST_RUN_FLAG, CONSENT_FLAG):
try:
os.remove(f)
except FileNotFoundError:
pass
FILE:skills_monitor/core/llm_baseline.py
"""
大模型基准线测试器 v0.6.0 — LLM Baseline Benchmark (TOP50 × 6 Models)
======================================================================
支持模型: Claude Opus 4.6 / MiniMax 2.5 / GLM-5 / GPT-5.4 / Gemini 3.0 Pro / DeepSeek 3.2
运行模式: mock (高仿真模拟,零成本) / live (真实 API 调用)
使用:
from skills_monitor.core.llm_baseline import LLMBaselineTester, BatchBenchmark
batch = BatchBenchmark(mode="mock")
matrix = batch.run_full_benchmark()
report = batch.generate_matrix_report(matrix)
"""
import json, hashlib, logging, math, os, random, statistics, time
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Tuple
logger = logging.getLogger(__name__)
# ═══════════════════════════════════════════════════════════════════
# 模型定义
# ═══════════════════════════════════════════════════════════════════
class ModelProvider(str, Enum):
OPENAI = "openai"; ANTHROPIC = "anthropic"; GOOGLE = "google"
ZHIPU = "zhipu"; MINIMAX = "minimax"; DEEPSEEK = "deepseek"
LOCAL = "local"; MOCK = "mock"
@dataclass
class ModelConfig:
provider: ModelProvider; model_name: str; display_name: str
api_key_env: str = ""; base_url: str = ""
max_tokens: int = 2048; temperature: float = 0.0
cost_per_1k_input: float = 0; cost_per_1k_output: float = 0
mock_avg_latency_ms: float = 2000; mock_latency_stddev_ms: float = 500
mock_success_rate: float = 0.90
mock_avg_input_tokens: int = 500; mock_avg_output_tokens: int = 350
@property
def api_key(self) -> str:
return os.environ.get(self.api_key_env, "") if self.api_key_env else ""
PRESET_MODELS: Dict[str, ModelConfig] = {
"claude-opus-4.6": ModelConfig(
ModelProvider.ANTHROPIC, "claude-opus-4-6-20260301", "Claude Opus 4.6",
"ANTHROPIC_API_KEY", "", 2048, 0.0, 0.015, 0.075,
3200, 800, 0.96, 550, 420),
"minimax-2.5": ModelConfig(
ModelProvider.MINIMAX, "minimax-2.5", "MiniMax 2.5",
"MINIMAX_API_KEY", "https://api.minimax.chat/v1", 2048, 0.0, 0.004, 0.012,
1800, 400, 0.91, 500, 380),
"glm-5": ModelConfig(
ModelProvider.ZHIPU, "glm-5", "GLM-5",
"ZHIPU_API_KEY", "https://open.bigmodel.cn/api/paas/v4", 2048, 0.0, 0.005, 0.015,
2200, 600, 0.92, 520, 390),
"gpt-5.4": ModelConfig(
ModelProvider.OPENAI, "gpt-5.4", "GPT-5.4",
"OPENAI_API_KEY", "", 2048, 0.0, 0.010, 0.030,
2500, 700, 0.95, 530, 400),
"gemini-3.0-pro": ModelConfig(
ModelProvider.GOOGLE, "gemini-3.0-pro", "Gemini 3.0 Pro",
"GOOGLE_API_KEY", "https://generativelanguage.googleapis.com/v1beta",
2048, 0.0, 0.007, 0.021, 2800, 650, 0.93, 510, 410),
"deepseek-3.2": ModelConfig(
ModelProvider.DEEPSEEK, "deepseek-chat-v3.2", "DeepSeek 3.2",
"DEEPSEEK_API_KEY", "https://api.deepseek.com/v1", 2048, 0.0, 0.002, 0.008,
1600, 350, 0.91, 490, 360),
"mock": ModelConfig(
ModelProvider.MOCK, "mock-baseline", "模拟模型 (Demo)",
mock_avg_latency_ms=1500, mock_latency_stddev_ms=300, mock_success_rate=0.85),
}
# ═══════════════════════════════════════════════════════════════════
# 数据类
# ═══════════════════════════════════════════════════════════════════
@dataclass
class SingleRunResult:
run_index: int; success: bool; duration_ms: float
output: Any = None; error: Optional[str] = None
token_usage: Dict[str, int] = field(default_factory=dict)
cost_usd: float = 0.0
timestamp: str = field(default_factory=lambda: datetime.now().isoformat())
def to_dict(self) -> dict:
return {"run_index": self.run_index, "success": self.success,
"duration_ms": round(self.duration_ms, 2), "error": self.error,
"token_usage": self.token_usage, "cost_usd": round(self.cost_usd, 6)}
@dataclass
class BaselineStats:
source: str; display_name: str; total_runs: int; success_count: int
results: List[SingleRunResult] = field(default_factory=list)
@property
def success_rate(self) -> float:
return (self.success_count / self.total_runs * 100) if self.total_runs > 0 else 0
@property
def _ok_d(self) -> List[float]:
return [r.duration_ms for r in self.results if r.success]
@property
def avg_duration_ms(self) -> Optional[float]:
d = self._ok_d; return round(statistics.mean(d), 2) if d else None
@property
def median_duration_ms(self) -> Optional[float]:
d = self._ok_d; return round(statistics.median(d), 2) if d else None
@property
def p95_duration_ms(self) -> Optional[float]:
d = sorted(self._ok_d); return round(d[int(len(d)*0.95)], 2) if d else None
@property
def total_cost_usd(self) -> float:
return round(sum(r.cost_usd for r in self.results), 6)
@property
def avg_cost_usd(self) -> float:
return round(self.total_cost_usd / max(self.total_runs, 1), 6)
def to_dict(self) -> dict:
return {"source": self.source, "display_name": self.display_name,
"total_runs": self.total_runs, "success_count": self.success_count,
"success_rate": round(self.success_rate, 1),
"avg_duration_ms": self.avg_duration_ms,
"total_cost_usd": self.total_cost_usd, "avg_cost_usd": self.avg_cost_usd}
@dataclass
class SkillModelScore:
"""单个 Skill × 单个 Model 的评分"""
skill_slug: str; skill_name: str; model_key: str; model_name: str
category: str; task_type: str
success_rate: float; avg_latency_ms: float; avg_cost_usd: float
quality_score: float; total_runs: int; success_count: int; total_cost_usd: float
def to_dict(self) -> dict:
return {k: (round(v, 4) if isinstance(v, float) else v)
for k, v in self.__dict__.items()}
@dataclass
class BenchmarkMatrix:
"""完整评测矩阵 (50 Skills × 6 Models)"""
version: str = "0.6.0"; mode: str = "mock"
generated_at: str = field(default_factory=lambda: datetime.now().isoformat())
models: List[str] = field(default_factory=list)
skills_count: int = 0; models_count: int = 0; n_runs_per_cell: int = 3
cells: List[SkillModelScore] = field(default_factory=list)
model_summaries: Dict[str, Dict] = field(default_factory=dict)
category_summaries: Dict[str, Dict] = field(default_factory=dict)
def to_dict(self) -> dict:
return {"version": self.version, "mode": self.mode,
"generated_at": self.generated_at, "models": self.models,
"skills_count": self.skills_count, "models_count": self.models_count,
"n_runs_per_cell": self.n_runs_per_cell,
"total_cells": len(self.cells),
"cells": [c.to_dict() for c in self.cells],
"model_summaries": self.model_summaries,
"category_summaries": self.category_summaries}
def get_model_ranking(self) -> List[Dict]:
return sorted(self.model_summaries.values(),
key=lambda x: x.get("avg_quality_score", 0), reverse=True)
# ═══════════════════════════════════════════════════════════════════
# LLM 适配器
# ═══════════════════════════════════════════════════════════════════
class LLMAdapter(ABC):
@abstractmethod
def call(self, prompt: str, config: ModelConfig) -> Tuple[str, Dict[str, int]]: ...
class OpenAIAdapter(LLMAdapter):
def call(self, prompt, config):
import requests as req
if not config.api_key: raise ValueError(f"未配置 {config.api_key_env}")
base = config.base_url or "https://api.openai.com/v1"
r = req.post(f"{base}/chat/completions",
headers={"Authorization": f"Bearer {config.api_key}"},
json={"model": config.model_name, "messages": [{"role":"user","content":prompt}],
"max_tokens": config.max_tokens, "temperature": config.temperature},
timeout=120).json()
u = r.get("usage", {})
return r["choices"][0]["message"]["content"], \
{"input_tokens": u.get("prompt_tokens",0), "output_tokens": u.get("completion_tokens",0)}
class AnthropicAdapter(LLMAdapter):
def call(self, prompt, config):
import requests as req
if not config.api_key: raise ValueError(f"未配置 {config.api_key_env}")
r = req.post("https://api.anthropic.com/v1/messages",
headers={"x-api-key": config.api_key, "anthropic-version": "2023-06-01",
"content-type": "application/json"},
json={"model": config.model_name, "max_tokens": config.max_tokens,
"temperature": config.temperature,
"messages": [{"role":"user","content":prompt}]},
timeout=120).json()
u = r.get("usage", {})
return r["content"][0]["text"], \
{"input_tokens": u.get("input_tokens",0), "output_tokens": u.get("output_tokens",0)}
class GoogleGeminiAdapter(LLMAdapter):
def call(self, prompt, config):
import requests as req
if not config.api_key: raise ValueError(f"未配置 {config.api_key_env}")
base = config.base_url or "https://generativelanguage.googleapis.com/v1beta"
r = req.post(f"{base}/models/{config.model_name}:generateContent?key={config.api_key}",
json={"contents": [{"parts": [{"text": prompt}]}],
"generationConfig": {"maxOutputTokens": config.max_tokens,
"temperature": config.temperature}},
timeout=120).json()
c = r.get("candidates", [{}])
text = c[0].get("content",{}).get("parts",[{}])[0].get("text","") if c else ""
u = r.get("usageMetadata", {})
return text, {"input_tokens": u.get("promptTokenCount",0),
"output_tokens": u.get("candidatesTokenCount",0)}
class ZhipuAdapter(LLMAdapter):
def call(self, prompt, config):
import requests as req
if not config.api_key: raise ValueError(f"未配置 {config.api_key_env}")
base = config.base_url or "https://open.bigmodel.cn/api/paas/v4"
r = req.post(f"{base}/chat/completions",
headers={"Authorization": f"Bearer {config.api_key}"},
json={"model": config.model_name, "messages": [{"role":"user","content":prompt}],
"max_tokens": config.max_tokens,
"temperature": max(config.temperature, 0.01)},
timeout=120).json()
u = r.get("usage", {})
return r["choices"][0]["message"]["content"], \
{"input_tokens": u.get("prompt_tokens",0), "output_tokens": u.get("completion_tokens",0)}
class MiniMaxAdapter(LLMAdapter):
def call(self, prompt, config):
import requests as req
if not config.api_key: raise ValueError(f"未配置 {config.api_key_env}")
base = config.base_url or "https://api.minimax.chat/v1"
r = req.post(f"{base}/text/chatcompletion_v2",
headers={"Authorization": f"Bearer {config.api_key}"},
json={"model": config.model_name, "messages": [{"role":"user","content":prompt}],
"max_tokens": config.max_tokens, "temperature": config.temperature},
timeout=120).json()
text = r["choices"][0]["message"]["content"] if "choices" in r else r.get("reply","")
u = r.get("usage", {})
return text, {"input_tokens": u.get("prompt_tokens",0),
"output_tokens": u.get("completion_tokens",0)}
class DeepSeekAdapter(LLMAdapter):
def call(self, prompt, config):
import requests as req
if not config.api_key: raise ValueError(f"未配置 {config.api_key_env}")
base = config.base_url or "https://api.deepseek.com/v1"
r = req.post(f"{base}/chat/completions",
headers={"Authorization": f"Bearer {config.api_key}"},
json={"model": config.model_name, "messages": [{"role":"user","content":prompt}],
"max_tokens": config.max_tokens, "temperature": config.temperature},
timeout=120).json()
u = r.get("usage", {})
return r["choices"][0]["message"]["content"], \
{"input_tokens": u.get("prompt_tokens",0), "output_tokens": u.get("completion_tokens",0)}
# 任务难度系数 & 模型特长
_TASK_DIFF = {
"search_and_summarize": (0.8, 0.7), "translation": (0.9, 0.5),
"file_operation": (0.7, 0.8), "code_analysis": (1.3, 1.2),
"api_query": (0.6, 0.6), "data_processing": (1.1, 1.0),
"document_processing": (1.0, 0.9), "text_formatting": (0.7, 0.4),
"text_generation": (1.0, 0.6), "code_generation": (1.4, 1.3),
"config_generation": (1.1, 1.0), "sql_generation": (1.0, 0.9),
"financial_analysis": (1.5, 1.5), "workflow_design": (1.2, 1.1),
"media_processing": (1.0, 1.0), "nlp_analysis": (1.2, 1.1),
"security_task": (1.3, 1.2), "api_testing": (0.9, 0.8),
"calculation": (0.8, 0.7), "troubleshooting": (1.2, 1.0),
}
_MODEL_STR = {
"claude-opus-4.6": {"code_analysis":8,"code_generation":7,"security_task":6,"text_generation":5,"nlp_analysis":6},
"gpt-5.4": {"code_generation":8,"api_testing":6,"data_processing":7,"workflow_design":5,"config_generation":6},
"gemini-3.0-pro": {"search_and_summarize":7,"data_processing":6,"calculation":8,"nlp_analysis":5,"media_processing":5},
"glm-5": {"translation":8,"text_generation":7,"financial_analysis":6,"sql_generation":5,"text_formatting":5},
"minimax-2.5": {"text_generation":6,"translation":5,"nlp_analysis":5,"search_and_summarize":4,"document_processing":4},
"deepseek-3.2": {"code_generation":7,"code_analysis":6,"calculation":7,"sql_generation":6,"config_generation":5},
}
class MockLLMAdapter(LLMAdapter):
"""高仿真模拟 — 基于各模型已知特性"""
def __init__(self, task_type="text_generation"):
self.task_type = task_type
self._last_latency = 0; self._last_quality_bonus = 0
def call(self, prompt, config):
lat_m, fail_m = _TASK_DIFF.get(self.task_type, (1.0, 1.0))
latency = max(200, random.gauss(config.mock_avg_latency_ms * lat_m, config.mock_latency_stddev_ms))
if random.random() < (1 - config.mock_success_rate) * fail_m:
raise Exception(f"模拟 {config.display_name} 失败 (task={self.task_type})")
inp = max(100, int(config.mock_avg_input_tokens * len(prompt)/500 * random.uniform(0.8,1.2)))
out = max(50, int(config.mock_avg_output_tokens * random.uniform(0.7,1.3)))
qb = _MODEL_STR.get(config.display_name, _MODEL_STR.get(config.model_name, {})).get(self.task_type, 0)
# 也用 key 匹配
for k, v in _MODEL_STR.items():
if k in (config.model_name or "") or k in (config.display_name or ""):
qb = max(qb, v.get(self.task_type, 0)); break
self._last_latency = latency; self._last_quality_bonus = qb
time.sleep(0.005) # 极小真实延迟
return json.dumps({"mock":True,"model":config.display_name}, ensure_ascii=False), \
{"input_tokens": inp, "output_tokens": out}
def _get_adapter(provider: ModelProvider, task_type: str = "") -> LLMAdapter:
if provider == ModelProvider.MOCK: return MockLLMAdapter(task_type)
m = {ModelProvider.OPENAI: OpenAIAdapter, ModelProvider.ANTHROPIC: AnthropicAdapter,
ModelProvider.GOOGLE: GoogleGeminiAdapter, ModelProvider.ZHIPU: ZhipuAdapter,
ModelProvider.MINIMAX: MiniMaxAdapter, ModelProvider.DEEPSEEK: DeepSeekAdapter}
cls = m.get(provider)
if not cls: raise ValueError(f"不支持: {provider}")
return cls()
# ═══════════════════════════════════════════════════════════════════
# 单 Skill 测试器
# ═══════════════════════════════════════════════════════════════════
class LLMBaselineTester:
def __init__(self, models=None, cache_dir="reports/baseline", mode="mock"):
self.model_names = models or list(PRESET_MODELS.keys())
self.models = {n: PRESET_MODELS[n] for n in self.model_names if n in PRESET_MODELS}
self.mode = mode; self.cache_dir = cache_dir
Path(cache_dir).mkdir(parents=True, exist_ok=True)
def get_available_models(self) -> List[Dict]:
return [{"name": n, "display_name": c.display_name, "provider": c.provider.value,
"cost_1k_in": c.cost_per_1k_input, "cost_1k_out": c.cost_per_1k_output}
for n, c in self.models.items()]
def run_llm_baseline(self, model_name, prompt, n_runs=3, task_type="text_generation") -> BaselineStats:
config = self.models[model_name]
adapter = MockLLMAdapter(task_type) if self.mode == "mock" else _get_adapter(config.provider, task_type)
results, ok = [], 0
for i in range(n_runs):
t0 = time.time(); success, output, error, tokens, cost = False, None, None, {}, 0.0
try:
text, tokens = adapter.call(prompt, config); success = True; output = text
cost = tokens.get("input_tokens",0)/1000*config.cost_per_1k_input + \
tokens.get("output_tokens",0)/1000*config.cost_per_1k_output
except Exception as e: error = str(e)
dur = adapter._last_latency if (self.mode=="mock" and success and hasattr(adapter,'_last_latency')) \
else (time.time()-t0)*1000
results.append(SingleRunResult(i+1, success, dur, output, error, tokens, cost))
if success: ok += 1
return BaselineStats(model_name, config.display_name, n_runs, ok, results)
def run_skill_baseline(self, skill_id, n_runs=3) -> BaselineStats:
"""模拟 Skill 基准 (Skill 直接执行一般更快更稳)"""
results, ok = [], 0
for i in range(n_runs):
success = random.random() < 0.95
dur = random.uniform(500, 2500)
if success: ok += 1
results.append(SingleRunResult(i+1, success, dur, error="模拟失败" if not success else None))
return BaselineStats("skill", f"Skill [{skill_id}]", n_runs, ok, results)
def _cache_result(self, data: dict, prefix: str):
try:
fn = f"{prefix}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
with open(os.path.join(self.cache_dir, fn), "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
except Exception as e: logger.warning(f"缓存失败: {e}")
# ═══════════════════════════════════════════════════════════════════
# 批量评测引擎 — TOP50 × 6 Models
# ═══════════════════════════════════════════════════════════════════
class BatchBenchmark:
"""
批量基准评测引擎
加载 TOP50 Skills 数据集,对每个 Skill 用 6 个大模型跑标准化 Prompt,
生成 50×6=300 个评分单元的完整矩阵。
使用:
batch = BatchBenchmark(mode="mock", n_runs=3)
matrix = batch.run_full_benchmark()
report_md = batch.generate_matrix_report(matrix)
batch.save_matrix(matrix, "reports/benchmark")
"""
# 6 个评测模型 (不含 mock)
DEFAULT_MODELS = [
"claude-opus-4.6", "minimax-2.5", "glm-5",
"gpt-5.4", "gemini-3.0-pro", "deepseek-3.2",
]
def __init__(
self,
models: List[str] = None,
mode: str = "mock",
n_runs: int = 3,
output_dir: str = "reports/benchmark",
):
self.model_keys = models or self.DEFAULT_MODELS
self.mode = mode
self.n_runs = n_runs
self.output_dir = output_dir
Path(output_dir).mkdir(parents=True, exist_ok=True)
self.tester = LLMBaselineTester(
models=self.model_keys, mode=mode, cache_dir=output_dir,
)
self._dataset = None
def _load_dataset(self) -> List[Dict]:
"""加载 Skills 基准数据集 (TOP1000)"""
if self._dataset:
return self._dataset
data_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "data")
# 加载 TOP1000 基准数据集(优先),如无法匹配具体 Skill 则使用均值作为基准线
for fname in ("top1000_skills_dataset.json",):
ds_path = os.path.join(data_dir, fname)
if os.path.exists(ds_path):
with open(ds_path, "r", encoding="utf-8") as f:
self._dataset = json.load(f)
logger.info(f"加载基准数据集: {fname} ({len(self._dataset)} Skills)")
return self._dataset
raise FileNotFoundError("找不到 Skills 基准数据集文件 (top1000_skills_dataset.json)")
def get_baseline_for_skill(self, slug: str, model_key: str) -> Dict:
"""
获取指定 Skill + Model 的基准分数。
优先级:
1. 精确匹配 slug + model_key → 返回该 Skill 在该模型上的真实评测分数
2. 精确匹配 slug → 返回该 Skill 跨模型平均基准分
3. 同分类平均值
4. 全局平均值 (最终回退)
Returns:
{"quality": float, "success_rate": float, "latency_ms": float,
"source": "exact_model"|"exact"|"category_avg"|"global_avg"}
"""
dataset = self._load_dataset()
# 1. 精确匹配 slug
for skill in dataset:
if skill["slug"] == slug:
# 1a. 如果有 model_baselines 且包含指定模型 → 最精确
model_bl = skill.get("model_baselines", {}).get(model_key)
if model_bl:
return {
"quality": model_bl["quality"],
"success_rate": model_bl["success_rate"],
"latency_ms": model_bl["latency_ms"],
"source": "exact_model",
"slug": slug,
"model": model_key,
}
# 1b. 有跨模型平均基准 (baseline_quality 字段存在)
if "baseline_quality" in skill:
return {
"quality": skill["baseline_quality"],
"success_rate": skill["baseline_success_rate"],
"latency_ms": skill["baseline_latency_ms"],
"source": "exact",
"slug": slug,
}
# 1c. slug 匹配但无评测数据 → fallback 到默认值
return {
"quality": 80.0,
"success_rate": 0.90,
"latency_ms": 2500,
"source": "exact_no_data",
"slug": slug,
}
# 2. 按分类匹配 — 找到同分类的所有 Skills 取平均值
target_skill_category = None
for skill in dataset:
if slug.startswith(skill.get("category", "")[:3]):
target_skill_category = skill["category"]
break
if target_skill_category:
same_cat = [s for s in dataset
if s["category"] == target_skill_category and "baseline_quality" in s]
if same_cat:
avg_quality = sum(s["baseline_quality"] for s in same_cat) / len(same_cat)
avg_sr = sum(s["baseline_success_rate"] for s in same_cat) / len(same_cat)
avg_lat = sum(s["baseline_latency_ms"] for s in same_cat) / len(same_cat)
return {
"quality": round(avg_quality, 1),
"success_rate": round(avg_sr, 3),
"latency_ms": round(avg_lat),
"source": "category_avg",
"category": target_skill_category,
"n_skills": len(same_cat),
}
# 3. 全局平均值作为最终回退基准线
with_bl = [s for s in dataset if "baseline_quality" in s]
if with_bl:
avg_quality = sum(s["baseline_quality"] for s in with_bl) / len(with_bl)
avg_sr = sum(s["baseline_success_rate"] for s in with_bl) / len(with_bl)
avg_lat = sum(s["baseline_latency_ms"] for s in with_bl) / len(with_bl)
else:
avg_quality, avg_sr, avg_lat = 80.0, 0.90, 2500
return {
"quality": round(avg_quality, 1),
"success_rate": round(avg_sr, 3),
"latency_ms": round(avg_lat),
"source": "global_avg",
"n_skills": len(with_bl) or len(dataset),
}
def _get_prompt(self, skill: Dict) -> str:
"""为指定 Skill 生成评测 Prompt"""
try:
from skills_monitor.data.benchmark_prompts import get_benchmark_prompt
return get_benchmark_prompt(skill["task_type"], skill["benchmark_task"])
except Exception:
return f"请完成以下任务:\n{skill['benchmark_task']}\n返回结构化 JSON 结果。"
def run_full_benchmark(
self,
limit: int = 50,
progress_callback: Optional[Callable] = None,
) -> BenchmarkMatrix:
"""
运行完整的 TOP50 × 6 Models 批量评测
Args:
limit: 最多评测几个 Skills (调试时可设小)
progress_callback: fn(current, total, skill_slug, model_key)
"""
dataset = self._load_dataset()[:limit]
matrix = BenchmarkMatrix(
mode=self.mode,
models=list(self.model_keys),
skills_count=len(dataset),
models_count=len(self.model_keys),
n_runs_per_cell=self.n_runs,
)
total_cells = len(dataset) * len(self.model_keys)
current = 0
for skill in dataset:
slug = skill["slug"]
prompt = self._get_prompt(skill)
for model_key in self.model_keys:
current += 1
if progress_callback:
progress_callback(current, total_cells, slug, model_key)
try:
stats = self.tester.run_llm_baseline(
model_key, prompt,
n_runs=self.n_runs,
task_type=skill["task_type"],
)
# 计算质量评分 (基于成功率 + 速度 + 模型特长)
quality = self._calc_quality(stats, model_key, skill["task_type"])
cell = SkillModelScore(
skill_slug=slug,
skill_name=skill["name"],
model_key=model_key,
model_name=PRESET_MODELS[model_key].display_name,
category=skill.get("category", "other"),
task_type=skill["task_type"],
success_rate=stats.success_rate,
avg_latency_ms=stats.avg_duration_ms or 0,
avg_cost_usd=stats.avg_cost_usd,
quality_score=quality,
total_runs=stats.total_runs,
success_count=stats.success_count,
total_cost_usd=stats.total_cost_usd,
)
matrix.cells.append(cell)
except Exception as e:
logger.error(f"评测失败 [{slug}] x [{model_key}]: {e}")
matrix.cells.append(SkillModelScore(
skill_slug=slug, skill_name=skill["name"],
model_key=model_key,
model_name=PRESET_MODELS.get(model_key, ModelConfig(
ModelProvider.MOCK, "", "")).display_name,
category=skill.get("category", "other"),
task_type=skill["task_type"],
success_rate=0, avg_latency_ms=0, avg_cost_usd=0,
quality_score=0, total_runs=self.n_runs,
success_count=0, total_cost_usd=0,
))
# 计算汇总
self._calc_summaries(matrix)
return matrix
def _calc_quality(self, stats: BaselineStats, model_key: str, task_type: str) -> float:
"""计算综合质量评分 (0-100)"""
# 基础分 = 成功率权重 60%
base = stats.success_rate * 0.6
# 速度分 = 越快越好, 最高 20 分
if stats.avg_duration_ms and stats.avg_duration_ms > 0:
speed_score = max(0, 20 - (stats.avg_duration_ms / 500))
else:
speed_score = 0
# 模型特长加分 (最高 10 分)
strength = _MODEL_STR.get(model_key, {}).get(task_type, 0)
# 稳定性 (成功次数/总次数 的额外奖励, 最高 10 分)
if stats.total_runs > 0:
stability = (stats.success_count / stats.total_runs) * 10
else:
stability = 0
total = min(100, base + speed_score + strength + stability)
return round(total, 1)
def _calc_summaries(self, matrix: BenchmarkMatrix):
"""计算模型维度和分类维度的汇总"""
from collections import defaultdict
# 模型维度汇总
model_cells = defaultdict(list)
for c in matrix.cells:
model_cells[c.model_key].append(c)
for mk, cells in model_cells.items():
qualities = [c.quality_score for c in cells]
latencies = [c.avg_latency_ms for c in cells if c.avg_latency_ms > 0]
costs = [c.avg_cost_usd for c in cells]
success_rates = [c.success_rate for c in cells]
matrix.model_summaries[mk] = {
"model_key": mk,
"model_name": PRESET_MODELS[mk].display_name,
"skills_evaluated": len(cells),
"avg_quality_score": round(statistics.mean(qualities), 1) if qualities else 0,
"avg_success_rate": round(statistics.mean(success_rates), 1) if success_rates else 0,
"avg_latency_ms": round(statistics.mean(latencies), 1) if latencies else 0,
"total_cost_usd": round(sum(costs), 4),
"avg_cost_per_skill": round(statistics.mean(costs), 6) if costs else 0,
"best_categories": self._find_best_categories(cells),
}
# 分类维度汇总
cat_cells = defaultdict(lambda: defaultdict(list))
for c in matrix.cells:
cat_cells[c.category][c.model_key].append(c)
for cat, model_map in cat_cells.items():
model_scores = {}
for mk, cells in model_map.items():
q = [c.quality_score for c in cells]
model_scores[mk] = {
"avg_quality": round(statistics.mean(q), 1) if q else 0,
"avg_success_rate": round(
statistics.mean([c.success_rate for c in cells]), 1
) if cells else 0,
"count": len(cells),
}
best_model = max(model_scores.items(), key=lambda x: x[1]["avg_quality"])[0] \
if model_scores else ""
matrix.category_summaries[cat] = {
"category": cat,
"total_skills": sum(v["count"] for v in model_scores.values()) // max(len(model_scores), 1),
"best_model": best_model,
"best_model_name": PRESET_MODELS.get(best_model, ModelConfig(
ModelProvider.MOCK, "", "")).display_name,
"model_scores": model_scores,
}
def _find_best_categories(self, cells: List[SkillModelScore]) -> List[str]:
"""找出该模型表现最好的分类"""
from collections import defaultdict
cat_q = defaultdict(list)
for c in cells:
cat_q[c.category].append(c.quality_score)
avgs = {cat: statistics.mean(qs) for cat, qs in cat_q.items()}
sorted_cats = sorted(avgs.items(), key=lambda x: x[1], reverse=True)
return [c[0] for c in sorted_cats[:3]]
# ──────── 保存 ────────
def save_matrix(self, matrix: BenchmarkMatrix, output_dir: str = None) -> Dict[str, str]:
"""
保存评测矩阵为多种格式
Returns:
{"json": path, "md": path, "csv": path}
"""
out = output_dir or self.output_dir
Path(out).mkdir(parents=True, exist_ok=True)
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
paths = {}
# JSON (完整数据,可上传中心服务器)
json_path = os.path.join(out, f"benchmark_matrix_{ts}.json")
with open(json_path, "w", encoding="utf-8") as f:
json.dump(matrix.to_dict(), f, ensure_ascii=False, indent=2)
paths["json"] = json_path
# Markdown 报告
md_path = os.path.join(out, f"benchmark_report_{ts}.md")
report = self.generate_matrix_report(matrix)
with open(md_path, "w", encoding="utf-8") as f:
f.write(report)
paths["md"] = md_path
# CSV (便于导入 Excel/数据库)
csv_path = os.path.join(out, f"benchmark_matrix_{ts}.csv")
self._save_csv(matrix, csv_path)
paths["csv"] = csv_path
# 精简版 JSON (用于 Skills 内置预存)
lite_path = os.path.join(out, f"benchmark_lite_{ts}.json")
self._save_lite(matrix, lite_path)
paths["lite_json"] = lite_path
logger.info(f"评测矩阵已保存: {paths}")
return paths
def _save_csv(self, matrix: BenchmarkMatrix, path: str):
"""保存为 CSV"""
header = "rank,skill_slug,skill_name,category,task_type,model_key,model_name," \
"success_rate,avg_latency_ms,avg_cost_usd,quality_score\n"
with open(path, "w", encoding="utf-8") as f:
f.write(header)
for i, c in enumerate(matrix.cells, 1):
f.write(f"{i},{c.skill_slug},{c.skill_name},{c.category},{c.task_type},"
f"{c.model_key},{c.model_name},"
f"{c.success_rate:.1f},{c.avg_latency_ms:.1f},"
f"{c.avg_cost_usd:.6f},{c.quality_score:.1f}\n")
def _save_lite(self, matrix: BenchmarkMatrix, path: str):
"""保存精简版 (用于 Skills 内置预存,去掉详细运行数据)"""
lite = {
"version": matrix.version,
"mode": matrix.mode,
"generated_at": matrix.generated_at,
"models": matrix.models,
"skills_count": matrix.skills_count,
"model_ranking": matrix.get_model_ranking(),
"category_leaders": {
cat: info.get("best_model_name", "")
for cat, info in matrix.category_summaries.items()
},
"matrix": {}, # skill_slug -> {model_key -> quality_score}
}
for c in matrix.cells:
lite["matrix"].setdefault(c.skill_slug, {})[c.model_key] = {
"q": round(c.quality_score, 1),
"sr": round(c.success_rate, 1),
"ms": round(c.avg_latency_ms, 0),
"cost": round(c.avg_cost_usd, 6),
}
with open(path, "w", encoding="utf-8") as f:
json.dump(lite, f, ensure_ascii=False, indent=2)
# ──────── 矩阵报告生成 ────────
def generate_matrix_report(self, matrix: BenchmarkMatrix) -> str:
"""生成完整的 Markdown 矩阵评测报告"""
lines = [
"# 🧪 Skills × LLM 跨模型基准评测报告",
"",
f"> **评测规模**: {matrix.skills_count} Skills × {matrix.models_count} Models = "
f"{len(matrix.cells)} 评测单元 ",
f"> **每单元运行**: {matrix.n_runs_per_cell} 次 ",
f"> **运行模式**: `{matrix.mode}` ",
f"> **生成时间**: {matrix.generated_at} ",
f"> **版本**: Skills Monitor v{matrix.version}",
"", "---", "",
]
# 1. 模型综合排行
lines.extend(self._section_model_ranking(matrix))
# 2. 分类最佳模型
lines.extend(self._section_category_leaders(matrix))
# 3. 完整矩阵 (按分类分组)
lines.extend(self._section_full_matrix(matrix))
# 4. 成本对比
lines.extend(self._section_cost_comparison(matrix))
# 5. 结论
lines.extend(self._section_conclusion(matrix))
lines.extend([
"", "---", "",
"*报告由 Skills Monitor v0.6.0 BatchBenchmark 引擎生成* ",
f"*数据格式兼容中心服务器存储和 Skills 内置预存*",
])
return "\n".join(lines)
def _section_model_ranking(self, matrix: BenchmarkMatrix) -> List[str]:
lines = ["## 🏆 模型综合排行", ""]
ranking = matrix.get_model_ranking()
lines.append("| 排名 | 模型 | 综合质量 | 平均成功率 | 平均延迟 | 总费用 | 最强分类 |")
lines.append("|:----:|------|:-------:|:---------:|:-------:|:-----:|---------|")
medals = ["🥇", "🥈", "🥉", "4️⃣", "5️⃣", "6️⃣"]
for i, m in enumerate(ranking):
medal = medals[i] if i < len(medals) else f"{i+1}"
best_cats = ", ".join(m.get("best_categories", [])[:2])
lines.append(
f"| {medal} | **{m['model_name']}** | "
f"{m['avg_quality_score']:.1f} | "
f"{m['avg_success_rate']:.1f}% | "
f"{m['avg_latency_ms']:.0f}ms | "
f".4f | "
f"{best_cats} |"
)
lines.extend(["", "---", ""])
return lines
def _section_category_leaders(self, matrix: BenchmarkMatrix) -> List[str]:
lines = ["## 📂 分类最佳模型", ""]
lines.append("| 分类 | Skills 数 | 最佳模型 | 该模型平均质量 |")
lines.append("|------|:---------:|---------|:------------:|")
from skills_monitor.data.category_mapping import CLAWHUB_CATEGORIES
for cat, info in sorted(matrix.category_summaries.items()):
cat_meta = CLAWHUB_CATEGORIES.get(cat, {"icon": "📦", "name": cat})
best = info.get("best_model_name", "N/A")
best_key = info.get("best_model", "")
ms = info.get("model_scores", {}).get(best_key, {})
q = ms.get("avg_quality", 0)
lines.append(
f"| {cat_meta['icon']} {cat_meta['name']} | "
f"{info.get('total_skills', 0)} | "
f"**{best}** | {q:.1f} |"
)
lines.extend(["", "---", ""])
return lines
def _section_full_matrix(self, matrix: BenchmarkMatrix) -> List[str]:
lines = ["## 📊 完整评测矩阵", ""]
model_names = [PRESET_MODELS[k].display_name for k in self.model_keys]
# 表头
header = "| # | Skill | 分类 |"
separator = "|:-:|-------|------|"
for mn in model_names:
short = mn.split()[0] if len(mn) > 10 else mn # 缩短表头
header += f" {short} |"
separator += ":---------:|"
lines.extend([header, separator])
# 按分类排序
dataset = self._load_dataset()
for i, skill in enumerate(dataset, 1):
slug = skill["slug"]
row = f"| {i} | {skill['name']} | {skill.get('category','')} |"
for mk in self.model_keys:
cell = None
for c in matrix.cells:
if c.skill_slug == slug and c.model_key == mk:
cell = c; break
if cell:
# 用 emoji 表示质量等级
q = cell.quality_score
if q >= 80: icon = "🟢"
elif q >= 60: icon = "🟡"
elif q >= 40: icon = "🟠"
else: icon = "🔴"
row += f" {icon} {q:.0f} |"
else:
row += " ❌ - |"
lines.append(row)
lines.extend([
"",
"> 🟢 ≥80 | 🟡 ≥60 | 🟠 ≥40 | 🔴 <40 | ❌ 评测失败",
"", "---", "",
])
return lines
def _section_cost_comparison(self, matrix: BenchmarkMatrix) -> List[str]:
lines = ["## 💰 成本对比 (每次调用平均费用)", ""]
lines.append("| 模型 | 输入价/1K tok | 输出价/1K tok | 评测总费用 | 平均/Skill |")
lines.append("|------|:-----------:|:-----------:|:---------:|:---------:|")
for mk in self.model_keys:
cfg = PRESET_MODELS[mk]
sm = matrix.model_summaries.get(mk, {})
lines.append(
f"| {cfg.display_name} | "
f".4f | "
f".4f | "
f".4f | "
f".6f |"
)
lines.extend(["", "---", ""])
return lines
def _section_conclusion(self, matrix: BenchmarkMatrix) -> List[str]:
lines = ["## 💡 结论与建议", ""]
ranking = matrix.get_model_ranking()
if not ranking:
lines.append("无足够数据生成结论。")
return lines
top = ranking[0]
lines.append(f"### 综合最强:**{top['model_name']}**")
lines.append(f"- 综合质量评分 **{top['avg_quality_score']:.1f}/100**")
lines.append(f"- 平均成功率 **{top['avg_success_rate']:.1f}%**")
lines.append(f"- 平均延迟 **{top['avg_latency_ms']:.0f}ms**")
lines.append("")
# 性价比之王
if len(ranking) > 1:
# 质量/费用 比值最高的
best_ratio = max(ranking,
key=lambda x: x["avg_quality_score"] / max(x.get("total_cost_usd", 0.001), 0.001))
lines.append(f"### 性价比之王:**{best_ratio['model_name']}**")
lines.append(f"- 质量 {best_ratio['avg_quality_score']:.1f},"
f"费用仅 .4f")
lines.append("")
# 速度之王
fastest = min(ranking, key=lambda x: x.get("avg_latency_ms", 99999))
lines.append(f"### 速度之王:**{fastest['model_name']}**")
lines.append(f"- 平均延迟 **{fastest['avg_latency_ms']:.0f}ms**")
lines.append("")
# 分类推荐
lines.append("### 分类推荐")
for cat, info in sorted(matrix.category_summaries.items()):
best_name = info.get("best_model_name", "N/A")
lines.append(f"- **{cat}**: 推荐 {best_name}")
lines.append("")
return lines
FILE:skills_monitor/core/implicit_feedback.py
"""
隐性对话语义评分引擎 — Implicit Conversation Feedback
=====================================================
不依赖用户主动打分,而是通过分析用户与大模型对话中的自然语言,
自动推断用户对每个 Skill 调用结果的满意度。
评分维度:
1. 任务完成信号 — Skill 是否执行成功 + 用户是否继续追问同一任务
2. 情感极性 — NLP 分析用户回复中的情感倾向
3. 行为信号 — 重试次数、是否放弃、是否采纳结果
4. 满意度关键词 — "不错"/"有问题"/"再试"等关键短语
5. 会话延续性 — 调用后用户是否基于结果继续后续工作
最终输出 implicit_rating (1.0 - 5.0) 供评估引擎使用。
"""
import re
from datetime import datetime
from typing import Any, Dict, List, Optional, Tuple
from skills_monitor.data.store import DataStore
# ──────── 语义信号词典 ────────
# 正面意图信号(用户满意/采纳结果)
POSITIVE_SIGNALS = {
# 中文
"不错", "很好", "准确", "正是我要的", "有用", "很有帮助",
"可以", "好的", "谢谢", "感谢", "太好了", "完美", "棒",
"确实", "没问题", "很棒", "满意", "给力", "厉害",
"继续", "接着", "然后", "下一步", "基于这个",
"就用这个", "保存", "记录一下", "挺好", "nice",
# 英文
"good", "great", "nice", "perfect", "thanks", "helpful",
"exactly", "correct", "works", "awesome", "excellent",
}
# 负面意图信号(用户不满/质疑结果)
NEGATIVE_SIGNALS = {
# 中文
"不对", "不准", "错了", "有问题", "不行", "不是我要的",
"换个方式", "重新", "再试", "有误", "不对劲", "搞错了",
"差", "垃圾", "没用", "不好用", "太慢", "超时",
"失败", "崩溃", "出错", "报错", "异常", "bug",
"算了", "不要了", "放弃", "跳过", "不用了",
# 英文
"wrong", "incorrect", "error", "bad", "useless", "broken",
"retry", "again", "fail", "crash", "slow", "timeout",
"skip", "forget it", "never mind", "cancel",
}
# 重试/不满信号(用户要求重做同一件事)
RETRY_SIGNALS = {
"再试一次", "重新跑", "换个", "再查", "再分析",
"重新来", "重跑", "再运行", "另一种方式", "换一个",
"retry", "again", "redo", "try another",
}
# 放弃信号
ABANDON_SIGNALS = {
"算了", "不用了", "不要了", "放弃", "跳过", "不需要了",
"forget it", "never mind", "skip", "cancel", "don't need",
}
# 采纳/继续信号(基于结果做后续工作)
ADOPT_SIGNALS = {
"基于这个", "根据这个结果", "那么接下来", "在此基础上",
"用这个数据", "就按这个", "保存一下", "记录下来",
"帮我生成报告", "总结一下", "下一步",
"based on this", "use this", "next step", "save this",
}
# ──────── 信号提取 & 评分推断 ────────
class ConversationSignal:
"""一次 Skill 调用的对话上下文信号"""
def __init__(
self,
skill_id: str,
run_id: str,
run_status: str, # "success" / "error"
run_duration_ms: float,
user_messages: List[str], # 调用后用户的回复消息列表
retry_count: int = 0, # 同一 skill 被连续调用的次数
session_continued: bool = True, # 用户是否在此之后继续了会话
):
self.skill_id = skill_id
self.run_id = run_id
self.run_status = run_status
self.run_duration_ms = run_duration_ms
self.user_messages = user_messages
self.retry_count = retry_count
self.session_continued = session_continued
class ImplicitFeedbackEngine:
"""
隐性反馈引擎:从对话信号中推断用户满意度
不诱导用户打分,不提示评价,完全被动地从自然交互中提取信号。
"""
def __init__(self, store: DataStore, agent_id: str):
self.store = store
self.agent_id = agent_id
def analyze_signal(self, signal: ConversationSignal) -> Dict[str, Any]:
"""
分析一次 Skill 调用的对话信号,返回多维度评分
Returns:
{
"implicit_rating": float, # 1.0 - 5.0
"confidence": float, # 0.0 - 1.0 置信度
"dimensions": {
"task_completion": float, # 任务完成度 0-1
"sentiment": float, # 情感极性 0-1
"behavior": float, # 行为信号 0-1
"adoption": float, # 采纳度 0-1
"continuity": float, # 会话延续性 0-1
},
"sentiment_label": str, # positive/negative/neutral
"evidence": list, # 支撑证据
}
"""
dimensions = {}
evidence = []
confidence_factors = []
# ── 1. 任务完成度 (权重 0.30) ──
if signal.run_status == "success":
dimensions["task_completion"] = 0.8
evidence.append("skill 执行成功")
# 如果耗时合理,额外加分
if signal.run_duration_ms and signal.run_duration_ms < 5000:
dimensions["task_completion"] = 0.9
evidence.append(f"响应时间 {signal.run_duration_ms:.0f}ms 良好")
else:
dimensions["task_completion"] = 0.2
evidence.append("skill 执行失败")
confidence_factors.append(0.9) # 执行状态是硬信号,置信度高
# ── 2. 情感极性 (权重 0.25) ──
sentiment_score, sentiment_label, sent_evidence = self._analyze_sentiment(
signal.user_messages
)
dimensions["sentiment"] = sentiment_score
evidence.extend(sent_evidence)
# 有用户消息时置信度高,没有时低
if signal.user_messages:
confidence_factors.append(min(0.5 + len(signal.user_messages) * 0.15, 0.95))
else:
confidence_factors.append(0.3)
# ── 3. 行为信号 (权重 0.20) ──
behavior_score, beh_evidence = self._analyze_behavior(signal)
dimensions["behavior"] = behavior_score
evidence.extend(beh_evidence)
confidence_factors.append(0.7)
# ── 4. 采纳度 (权重 0.15) ──
adoption_score, adopt_evidence = self._analyze_adoption(signal.user_messages)
dimensions["adoption"] = adoption_score
evidence.extend(adopt_evidence)
confidence_factors.append(0.6 if signal.user_messages else 0.2)
# ── 5. 会话延续性 (权重 0.10) ──
if signal.session_continued:
dimensions["continuity"] = 0.7
else:
# 用户走了,可能满意也可能不满意,给中间分
dimensions["continuity"] = 0.5
confidence_factors.append(0.4)
# ── 加权合成 ──
weights = {
"task_completion": 0.30,
"sentiment": 0.25,
"behavior": 0.20,
"adoption": 0.15,
"continuity": 0.10,
}
weighted_sum = sum(
dimensions[k] * weights[k] for k in weights
)
# 映射到 1-5 分制
implicit_rating = round(1.0 + weighted_sum * 4.0, 2)
implicit_rating = max(1.0, min(5.0, implicit_rating))
# 综合置信度
confidence = round(sum(confidence_factors) / len(confidence_factors), 3)
return {
"implicit_rating": implicit_rating,
"confidence": confidence,
"dimensions": {k: round(v, 3) for k, v in dimensions.items()},
"sentiment_label": sentiment_label,
"evidence": evidence,
}
def _analyze_sentiment(
self, messages: List[str]
) -> Tuple[float, str, List[str]]:
"""
分析用户消息的情感极性
返回: (score 0-1, label, evidence_list)
"""
if not messages:
return 0.5, "neutral", ["无用户回复,情感中性"]
combined = " ".join(messages).lower()
evidence = []
# 统计匹配
pos_hits = []
neg_hits = []
for kw in POSITIVE_SIGNALS:
if kw.lower() in combined:
pos_hits.append(kw)
for kw in NEGATIVE_SIGNALS:
if kw.lower() in combined:
neg_hits.append(kw)
# 否定句翻转检测
# "不错" 是正面,但 "不太好" 是负面
negation_patterns = [
r"不太[好准行]", r"不够[好准稳]", r"不怎么",
r"不是很[好准]", r"not\s+(?:good|great|helpful)",
]
negation_hits = 0
for pat in negation_patterns:
if re.search(pat, combined):
negation_hits += 1
pos_count = len(pos_hits)
neg_count = len(neg_hits) + negation_hits
if pos_hits:
evidence.append(f"正面信号: {', '.join(pos_hits[:3])}")
if neg_hits:
evidence.append(f"负面信号: {', '.join(neg_hits[:3])}")
if negation_hits > 0:
evidence.append(f"检测到否定句式 x{negation_hits}")
# 评分计算
if pos_count > 0 and neg_count == 0:
score = min(0.7 + pos_count * 0.06, 0.95)
label = "positive"
elif neg_count > 0 and pos_count == 0:
score = max(0.3 - neg_count * 0.06, 0.05)
label = "negative"
elif pos_count > neg_count:
score = 0.5 + (pos_count - neg_count) * 0.08
label = "positive"
elif neg_count > pos_count:
score = 0.5 - (neg_count - pos_count) * 0.08
label = "negative"
else:
score = 0.5
label = "neutral"
if not evidence:
evidence.append("情感中性")
return max(0.0, min(1.0, score)), label, evidence
def _analyze_behavior(
self, signal: ConversationSignal
) -> Tuple[float, List[str]]:
"""
分析行为信号:重试次数、放弃、会话模式
返回: (score 0-1, evidence_list)
"""
evidence = []
score = 0.7 # 基准分
# 重试次数
if signal.retry_count == 0:
score += 0.1
evidence.append("无重试")
elif signal.retry_count == 1:
score -= 0.1
evidence.append("重试 1 次")
elif signal.retry_count >= 2:
score -= 0.25
evidence.append(f"重试 {signal.retry_count} 次")
# 检测用户消息中的重试/放弃信号
combined = " ".join(signal.user_messages).lower() if signal.user_messages else ""
retry_detected = any(kw.lower() in combined for kw in RETRY_SIGNALS)
abandon_detected = any(kw.lower() in combined for kw in ABANDON_SIGNALS)
if retry_detected:
score -= 0.15
evidence.append("检测到重试意图")
if abandon_detected:
score -= 0.3
evidence.append("检测到放弃意图")
if not retry_detected and not abandon_detected and signal.retry_count == 0:
if not evidence:
evidence.append("行为正常")
return max(0.0, min(1.0, score)), evidence
def _analyze_adoption(
self, messages: List[str]
) -> Tuple[float, List[str]]:
"""
分析用户是否采纳了 Skill 的输出结果
返回: (score 0-1, evidence_list)
"""
if not messages:
return 0.5, ["无用户回复,采纳度未知"]
combined = " ".join(messages).lower()
evidence = []
adopt_hits = [kw for kw in ADOPT_SIGNALS if kw.lower() in combined]
if adopt_hits:
score = min(0.7 + len(adopt_hits) * 0.1, 0.95)
evidence.append(f"采纳信号: {', '.join(adopt_hits[:3])}")
else:
score = 0.5 # 没有明确信号,给中间分
evidence.append("无明确采纳信号")
return score, evidence
# ──────── 存储接口 ────────
def record_implicit_feedback(
self,
signal: ConversationSignal,
analysis: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""
记录一条隐性反馈到数据库
如果未提供 analysis,则自动分析。
"""
if analysis is None:
analysis = self.analyze_signal(signal)
feedback_record = {
"agent_id": self.agent_id,
"skill_id": signal.skill_id,
"run_id": signal.run_id,
"implicit_rating": analysis["implicit_rating"],
"confidence": analysis["confidence"],
"sentiment_label": analysis["sentiment_label"],
"dimensions": analysis["dimensions"],
"evidence": analysis["evidence"],
"source": "conversation",
"user_messages_count": len(signal.user_messages),
"run_status": signal.run_status,
"retry_count": signal.retry_count,
}
self.store.insert_implicit_feedback(feedback_record)
return feedback_record
def record_from_run_context(
self,
skill_id: str,
run_id: str,
run_status: str,
run_duration_ms: float,
user_messages: Optional[List[str]] = None,
retry_count: int = 0,
session_continued: bool = True,
) -> Dict[str, Any]:
"""
便捷方法:从运行上下文直接记录隐性反馈
用于拦截器在 Skill 运行结束后自动调用
"""
signal = ConversationSignal(
skill_id=skill_id,
run_id=run_id,
run_status=run_status,
run_duration_ms=run_duration_ms,
user_messages=user_messages or [],
retry_count=retry_count,
session_continued=session_continued,
)
return self.record_implicit_feedback(signal)
def get_skill_implicit_rating(self, skill_id: str) -> Optional[float]:
"""
获取某个 Skill 的综合隐性评分
使用置信度加权平均:高置信度的反馈权重更大
"""
feedbacks = self.store.get_implicit_feedback(skill_id=skill_id, limit=100)
if not feedbacks:
return None
weighted_sum = 0.0
weight_total = 0.0
for fb in feedbacks:
conf = fb.get("confidence", 0.5)
rating = fb.get("implicit_rating", 3.0)
weighted_sum += rating * conf
weight_total += conf
if weight_total == 0:
return None
return round(weighted_sum / weight_total, 2)
def get_skill_sentiment_summary(self, skill_id: str) -> Dict[str, Any]:
"""
获取某 Skill 的情感分布摘要
"""
feedbacks = self.store.get_implicit_feedback(skill_id=skill_id, limit=200)
if not feedbacks:
return {
"total": 0,
"positive": 0,
"negative": 0,
"neutral": 0,
"avg_rating": None,
"avg_confidence": None,
}
total = len(feedbacks)
pos = sum(1 for f in feedbacks if f.get("sentiment_label") == "positive")
neg = sum(1 for f in feedbacks if f.get("sentiment_label") == "negative")
neu = total - pos - neg
ratings = [f["implicit_rating"] for f in feedbacks if f.get("implicit_rating")]
confs = [f["confidence"] for f in feedbacks if f.get("confidence")]
return {
"total": total,
"positive": pos,
"negative": neg,
"neutral": neu,
"positive_ratio": round(pos / total * 100, 1) if total > 0 else 0,
"avg_rating": round(sum(ratings) / len(ratings), 2) if ratings else None,
"avg_confidence": round(sum(confs) / len(confs), 3) if confs else None,
}
FILE:skills_monitor/core/identity.py
"""
本地身份管理模块 v0.5.0
- 生成唯一 agent_id (UUID v4)
- 生成/验证 API Key (HMAC-SHA256)
- OS Keychain 安全存储 (keyring 优先,文件降级)
- API Key 生命周期管理 (轮换/撤销/TTL)
- 配置文件持久化 (~/.skills_monitor/config.json)
"""
import hashlib
import hmac
import json
import logging
import os
import secrets
import stat
import uuid
from datetime import datetime, timedelta
from pathlib import Path
from typing import Optional, Dict, Any, List
logger = logging.getLogger(__name__)
def _default_home_dir() -> str:
"""
允许通过环境变量重定向所有本地持久化目录,便于 CI/容器/沙盒运行。
"""
return os.path.expanduser(os.environ.get("SKILLS_MONITOR_HOME", "~/.skills_monitor"))
DEFAULT_CONFIG_DIR = _default_home_dir()
CONFIG_FILE = "config.json"
DEFAULT_KEY_TTL_DAYS = 90
KEY_EXPIRY_WARNING_DAYS = 7
GRACE_PERIOD_HOURS = 24
MAX_ACTIVE_KEYS = 2
def _ensure_config_dir(config_dir: str = DEFAULT_CONFIG_DIR) -> Path:
path = Path(config_dir)
path.mkdir(parents=True, exist_ok=True)
try:
os.chmod(str(path), stat.S_IRWXU)
except OSError:
pass
return path
def generate_agent_id() -> str:
return str(uuid.uuid4())
def generate_api_key(agent_id: str, secret: Optional[str] = None) -> str:
if secret is None:
secret = secrets.token_hex(16)
sig = hmac.new(secret.encode(), agent_id.encode(), hashlib.sha256).hexdigest()
return f"sk-{sig[:32]}"
def hash_api_key(api_key: str) -> str:
return hashlib.sha256(api_key.encode()).hexdigest()
def verify_api_key(api_key: str, stored_hash: str) -> bool:
return hmac.compare_digest(hash_api_key(api_key), stored_hash)
class IdentityManager:
"""身份管理器 v0.5.0 — Keychain 集成 + Key 生命周期"""
def __init__(self, config_dir: str = DEFAULT_CONFIG_DIR):
self.config_dir = _ensure_config_dir(config_dir)
self.config_path = self.config_dir / CONFIG_FILE
self._config: Dict[str, Any] = {}
self._secure_store = None
self._load()
self._init_secure_store()
def _init_secure_store(self):
try:
from skills_monitor.core.secure_store import SecureStore
self._secure_store = SecureStore()
except Exception as e:
logger.warning(f"SecureStore 初始化失败: {e}")
self._secure_store = None
def _load(self):
if self.config_path.exists():
with open(self.config_path, "r", encoding="utf-8") as f:
self._config = json.load(f)
def _save(self):
with open(self.config_path, "w", encoding="utf-8") as f:
json.dump(self._config, f, ensure_ascii=False, indent=2)
try:
os.chmod(str(self.config_path), stat.S_IRUSR | stat.S_IWUSR)
except OSError:
pass
@property
def is_initialized(self) -> bool:
return bool(self._config.get("agent_id"))
@property
def agent_id(self) -> Optional[str]:
return self._config.get("agent_id")
@property
def api_key(self) -> Optional[str]:
"""从 Keychain 读取 API Key(降级返回 None)"""
if self._secure_store:
return self._secure_store.get_credential("api_key")
return None
# ──────── 初始化 ────────
def initialize(self, force: bool = False, ttl_days: int = DEFAULT_KEY_TTL_DAYS) -> Dict[str, str]:
"""首次初始化:生成 agent_id + api_key,存入 Keychain"""
if self.is_initialized and not force:
return {
"agent_id": self._config["agent_id"],
"api_key": "(已初始化,API Key 仅首次显示)",
"status": "already_initialized",
}
agent_id = generate_agent_id()
api_key = generate_api_key(agent_id, secrets.token_hex(16))
now = datetime.now()
expires_at = (now + timedelta(days=ttl_days)).isoformat() if ttl_days > 0 else None
self._config = {
"agent_id": agent_id,
"api_key_hash": hash_api_key(api_key),
"created_at": now.isoformat(),
"updated_at": now.isoformat(),
"skills_dir": "",
"settings": {
"auto_collect": True,
"buffer_size": 100,
"flush_interval_seconds": 60,
},
"key_lifecycle": {
"version": 1,
"keys": [{
"hash": hash_api_key(api_key),
"version": 1,
"created_at": now.isoformat(),
"expires_at": expires_at,
"status": "active",
}],
},
"consent": {"data_collection_agreed": False, "agreed_at": None},
}
self._save()
# 存入安全存储
if self._secure_store:
self._secure_store.store_credential("api_key", api_key)
self._secure_store.store_credential("agent_id", agent_id)
return {
"agent_id": agent_id,
"api_key": api_key,
"status": "initialized",
"config_path": str(self.config_path),
"key_expires_at": expires_at,
"secure_store": self._secure_store.backend_name if self._secure_store else "none",
}
# ──────── 验证 ────────
def verify(self, api_key: str) -> bool:
"""验证 API Key(支持多 Key + 过期检测)"""
lifecycle = self._config.get("key_lifecycle", {})
keys = lifecycle.get("keys", [])
if not keys:
# 向后兼容 v0.4.0
return verify_api_key(api_key, self._config.get("api_key_hash", ""))
api_hash = hash_api_key(api_key)
now = datetime.now()
changed = False
for entry in keys:
if entry["status"] not in ("active", "grace_period"):
continue
# 检查过期
exp = entry.get("expires_at")
if exp:
try:
if now > datetime.fromisoformat(exp):
entry["status"] = "expired"
changed = True
continue
except ValueError:
pass
if hmac.compare_digest(entry.get("hash", ""), api_hash):
if changed:
self._save()
return True
if changed:
self._save()
return False
# ──────── Key 轮换 ────────
def rotate_key(self, ttl_days: int = DEFAULT_KEY_TTL_DAYS) -> Dict[str, str]:
"""生成新 Key,旧 Key 进入宽限期(24h)"""
if not self.is_initialized:
raise RuntimeError("Agent 未初始化")
now = datetime.now()
lifecycle = self._config.setdefault("key_lifecycle", {"keys": [], "version": 0})
keys = lifecycle.setdefault("keys", [])
# 旧 Key 标记为宽限期
grace_until = (now + timedelta(hours=GRACE_PERIOD_HOURS)).isoformat()
for entry in keys:
if entry["status"] == "active":
entry["status"] = "grace_period"
entry["grace_until"] = grace_until
# 生成新 Key
new_version = lifecycle.get("version", 0) + 1
new_key = generate_api_key(self.agent_id, secrets.token_hex(16))
expires_at = (now + timedelta(days=ttl_days)).isoformat() if ttl_days > 0 else None
keys.append({
"hash": hash_api_key(new_key),
"version": new_version,
"created_at": now.isoformat(),
"expires_at": expires_at,
"status": "active",
})
# 限制最多保留 MAX_ACTIVE_KEYS 个非 revoked key
active_keys = [k for k in keys if k["status"] in ("active", "grace_period")]
if len(active_keys) > MAX_ACTIVE_KEYS:
for k in active_keys[:-MAX_ACTIVE_KEYS]:
k["status"] = "revoked"
lifecycle["version"] = new_version
self._config["api_key_hash"] = hash_api_key(new_key)
self._config["updated_at"] = now.isoformat()
self._save()
if self._secure_store:
self._secure_store.store_credential("api_key", new_key)
return {
"new_api_key": new_key,
"key_version": new_version,
"expires_at": expires_at,
"old_key_status": "grace_period",
"grace_until": grace_until,
}
# ──────── Key 撤销 ────────
def revoke_key(self, key_version: int = None) -> Dict[str, Any]:
"""立即撤销指定版本的 Key(默认撤销当前所有 Key)"""
lifecycle = self._config.get("key_lifecycle", {})
keys = lifecycle.get("keys", [])
revoked = 0
for entry in keys:
if entry["status"] in ("active", "grace_period"):
if key_version is None or entry.get("version") == key_version:
entry["status"] = "revoked"
entry["revoked_at"] = datetime.now().isoformat()
revoked += 1
self._config["updated_at"] = datetime.now().isoformat()
self._save()
if self._secure_store and key_version is None:
self._secure_store.delete_credential("api_key")
return {"revoked_count": revoked, "status": "all_keys_revoked" if key_version is None else "key_revoked"}
# ──────── Key 状态检查 ────────
def check_key_health(self) -> Dict[str, Any]:
"""检查 Key 健康状态(过期提醒等)"""
lifecycle = self._config.get("key_lifecycle", {})
keys = lifecycle.get("keys", [])
now = datetime.now()
warnings = []
active_count = 0
for entry in keys:
if entry["status"] == "active":
active_count += 1
exp = entry.get("expires_at")
if exp:
try:
exp_dt = datetime.fromisoformat(exp)
days_left = (exp_dt - now).days
if days_left <= 0:
warnings.append(f"Key v{entry.get('version')} 已过期!")
entry["status"] = "expired"
elif days_left <= KEY_EXPIRY_WARNING_DAYS:
warnings.append(f"Key v{entry.get('version')} 将在 {days_left} 天后过期")
except ValueError:
pass
if warnings:
self._save()
return {
"active_keys": active_count,
"total_keys": len(keys),
"warnings": warnings,
"healthy": len(warnings) == 0 and active_count > 0,
"secure_store": self._secure_store.backend_name if self._secure_store else "none",
}
# ──────── GDPR 同意记录 ────────
def record_consent(self, agreed: bool = True):
"""记录用户数据收集同意"""
consent = self._config.setdefault("consent", {})
consent["data_collection_agreed"] = agreed
consent["agreed_at"] = datetime.now().isoformat() if agreed else None
self._config["updated_at"] = datetime.now().isoformat()
self._save()
def has_consent(self) -> bool:
"""检查用户是否已同意数据收集"""
return self._config.get("consent", {}).get("data_collection_agreed", False)
# ──────── 原有方法 ────────
def update_settings(self, **kwargs):
settings = self._config.setdefault("settings", {})
settings.update(kwargs)
self._config["updated_at"] = datetime.now().isoformat()
self._save()
def set_skills_dir(self, skills_dir: str):
self._config["skills_dir"] = str(skills_dir)
self._config["updated_at"] = datetime.now().isoformat()
self._save()
def get_config(self) -> Dict[str, Any]:
"""获取当前配置(脱敏 — 不含 hash/key)"""
cfg = dict(self._config)
cfg.pop("api_key_hash", None)
lifecycle = cfg.get("key_lifecycle", {})
for k in lifecycle.get("keys", []):
k.pop("hash", None)
return cfg
FILE:skills_monitor/core/evaluator.py
"""
综合评估引擎 v0.5.0 — 7 因子加权评分模型
==========================================
本地因子(85%):
成功率 0.25
响应时间 0.18
满意度(隐性) 0.20
复用率 0.12
稳定性 0.10
社区因子(15%):
社区热度 🆕 0.08 ← ClawHub 下载量+星标
社区评分 🆕 0.07 ← ClawHub 星标密度
安全机制:
- 评分因子可展示给用户,但权重比例不暴露
- API/CLI 响应中不含 weights 字段
- 社区数据不可用时,权重自动回退分配到本地因子
"""
import math
import statistics
from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional, Tuple
from skills_monitor.data.store import DataStore
# ──────── 权重配置(仅内部使用,不暴露) ────────
_WEIGHTS_V2 = {
"success_rate": 0.25,
"response_time": 0.18,
"satisfaction": 0.20,
"reuse_rate": 0.12,
"stability": 0.10,
"community_popularity": 0.08,
"community_rating": 0.07,
}
# v0.4 兼容权重(无社区数据时自动回退)
_WEIGHTS_V1_FALLBACK = {
"success_rate": 0.30,
"response_time": 0.20,
"satisfaction": 0.25,
"reuse_rate": 0.15,
"stability": 0.10,
}
# 因子展示标签
FACTOR_LABELS = {
"success_rate": "成功率",
"response_time": "响应时间",
"satisfaction": "满意度(隐性)",
"reuse_rate": "复用率",
"stability": "稳定性",
"community_popularity": "社区热度",
"community_rating": "社区评分",
}
# 归一化参考值
NORMALIZATION_REFS = {
"response_time_excellent_ms": 1000,
"response_time_poor_ms": 30000,
"min_runs_for_reuse": 3,
"reuse_high_threshold": 10,
"stability_window_days": 7,
}
# ──────── 归一化函数 ────────
def normalize_success_rate(rate: float) -> float:
if rate >= 100:
return 1.0
if rate <= 0:
return 0.0
if rate >= 90:
return 0.8 + (rate - 90) / 50
return rate / 100 * 0.8
def normalize_response_time(avg_ms: Optional[float]) -> float:
if avg_ms is None:
return 0.5
excellent = NORMALIZATION_REFS["response_time_excellent_ms"]
poor = NORMALIZATION_REFS["response_time_poor_ms"]
if avg_ms <= excellent:
return 1.0
if avg_ms >= poor:
return 0.0
ratio = (avg_ms - excellent) / (poor - excellent)
return max(0, 1 - math.pow(ratio, 0.7))
def normalize_satisfaction(avg_rating: Optional[float]) -> float:
if avg_rating is None:
return 0.5
return max(0, min(1, (avg_rating - 1) / 4))
# ──────── 向后兼容别名(旧测试/旧代码引用) ────────
def normalize_user_rating(avg_rating: Optional[float]) -> float:
return normalize_satisfaction(avg_rating)
def normalize_reuse_rate(total_runs: int, days_active: int = 7) -> float:
if total_runs < NORMALIZATION_REFS["min_runs_for_reuse"]:
return 0.2
daily_avg = total_runs / max(days_active, 1)
threshold = NORMALIZATION_REFS["reuse_high_threshold"] / 7
if daily_avg >= threshold:
return 1.0
return min(1.0, daily_avg / threshold)
def normalize_stability(durations: List[float]) -> float:
if len(durations) < 2:
return 0.5
mean = statistics.mean(durations)
if mean == 0:
return 1.0
cv = statistics.stdev(durations) / mean
if cv <= 0.1:
return 1.0
if cv >= 1.0:
return 0.0
return max(0, 1 - cv)
def normalize_community_popularity(downloads: int, max_downloads: int = 100000) -> float:
"""社区热度归一化:对数归一化 log(d+1)/log(max+1)"""
if downloads <= 0:
return 0.0
return min(1.0, math.log(downloads + 1) / math.log(max_downloads + 1))
def normalize_community_rating(stars: int, installs: int) -> float:
"""社区评分归一化:stars/installs 密度,范围 [0, 1]"""
if installs <= 0 or stars <= 0:
return 0.0
density = stars / installs
# 密度上限约 0.5(50% 的人给 star),归一化到 [0,1]
return min(1.0, density / 0.5)
# ──────── 等级 ────────
def _score_to_grade(score: float) -> str:
if score >= 90:
return "A+"
elif score >= 80:
return "A"
elif score >= 70:
return "B"
elif score >= 60:
return "C"
elif score >= 50:
return "D"
else:
return "F"
def _grade_emoji(grade: str) -> str:
return {"A+": "🏆", "A": "🥇", "B": "🥈", "C": "🥉", "D": "⚠️", "F": "❌"}.get(grade, "")
def _grade_label(grade: str) -> str:
return {"A+": "卓越", "A": "优秀", "B": "良好", "C": "合格", "D": "待改进", "F": "不合格"}.get(grade, "")
def _factor_level(normalized_score: float) -> str:
"""归一化分数 → 等级描述"""
if normalized_score >= 0.9:
return "优秀"
elif normalized_score >= 0.7:
return "良好"
elif normalized_score >= 0.5:
return "中等"
elif normalized_score >= 0.3:
return "偏低"
else:
return "不足"
# ──────── SkillScore ────────
class SkillScore:
"""单个 skill 的综合评分"""
def __init__(
self,
skill_id: str,
factors: Dict[str, float],
normalized: Dict[str, float],
weighted: Dict[str, float],
total_score: float,
grade: str,
details: Dict[str, Any],
):
self.skill_id = skill_id
self.factors = factors
self.normalized = normalized
self.weighted = weighted
self.total_score = total_score
self.grade = grade
self.details = details
def to_dict(self, include_weights: bool = False) -> Dict[str, Any]:
"""
输出评分字典
⚠️ include_weights 默认 False:不暴露权重信息
仅内部诊断/调试时设为 True
"""
result = {
"skill_id": self.skill_id,
"total_score": round(self.total_score, 1),
"grade": self.grade,
"grade_label": f"{_grade_emoji(self.grade)} {self.grade} ({_grade_label(self.grade)})",
"factors": {},
"raw_factors": {k: round(v, 4) if isinstance(v, float) else v for k, v in self.factors.items()},
"normalized_factors": {k: round(v * 100, 1) for k, v in self.normalized.items()},
"details": self.details,
}
for key in self.normalized:
norm_val = self.normalized[key]
factor_info = {
"score": round(norm_val * 100, 1),
"level": _factor_level(norm_val),
}
# 生成描述(不含权重)
factor_info["desc"] = self._factor_desc(key)
result["factors"][FACTOR_LABELS.get(key, key)] = factor_info
if include_weights:
result["_weights"] = {k: round(v, 3) for k, v in self.weighted.items()}
return result
def _factor_desc(self, key: str) -> str:
"""生成因子的自然语言描述(不含权重信息)"""
raw = self.factors.get(key)
if key == "success_rate" and raw is not None:
return f"近期成功率{raw:.1f}%"
elif key == "response_time" and raw is not None:
return f"平均响应{raw:.0f}ms"
elif key == "satisfaction" and raw is not None:
return f"对话满意度{raw:.1f}/5"
elif key == "reuse_rate" and raw is not None:
return f"累计使用{int(raw)}次"
elif key == "stability" and raw is not None:
return f"响应波动CV={raw:.2f}"
elif key == "community_popularity" and raw is not None:
return f"社区下载量{int(raw)}"
elif key == "community_rating" and raw is not None:
return f"社区星标{int(raw)}"
return "数据不足"
def format_report(self) -> str:
"""格式化评分报告(不含权重列)"""
grade_full = f"{_grade_emoji(self.grade)} {self.grade} ({_grade_label(self.grade)})"
lines = [
f"📊 [{self.skill_id}] 综合评分: {self.total_score:.1f}/100 {grade_full}",
f"{'─' * 50}",
f" {'因子':<14} {'得分':<10} {'等级':<8} {'说明':<20}",
f" {'─' * 48}",
]
for key in self.normalized:
label = FACTOR_LABELS.get(key, key)
norm = self.normalized[key]
score_100 = round(norm * 100, 1)
level = _factor_level(norm)
desc = self._factor_desc(key)
lines.append(f" {label:<14} {score_100:<10} {level:<8} {desc}")
lines.append(f" {'─' * 48}")
return "\n".join(lines)
# ──────── SkillEvaluator ────────
class SkillEvaluator:
"""综合评估引擎 v0.5.0 — 7 因子"""
def __init__(self, store: DataStore, agent_id: str = "", community_data: Dict = None):
self.store = store
self.agent_id = agent_id
self._community_data = community_data or {}
def set_community_data(self, data: Dict[str, Dict]):
"""设置社区数据(从 ClawHub 获取)"""
self._community_data = data
def _get_active_weights(self, has_community: bool) -> Dict[str, float]:
"""根据社区数据可用性选择权重组"""
if has_community:
return dict(_WEIGHTS_V2)
else:
return dict(_WEIGHTS_V1_FALLBACK)
def evaluate_skill(self, skill_id: str) -> SkillScore:
"""对单个 skill 进行 7 因子综合评估"""
summary = self.store.get_skill_summary(skill_id, self.agent_id)
all_runs = self.store.get_runs(skill_id=skill_id, agent_id=self.agent_id, limit=500)
user_runs = [r for r in all_runs if not (r.get("task_name", "").startswith("[benchmark]"))]
success_rate = summary["success_rate"]
avg_duration = summary["avg_duration_ms"]
avg_rating = summary["avg_rating"]
total_runs = summary["total_runs"]
durations = [
r["duration_ms"] for r in user_runs
if r.get("duration_ms") and r["status"] == "success"
]
cv = None
if len(durations) >= 2:
mean_d = statistics.mean(durations)
if mean_d > 0:
cv = statistics.stdev(durations) / mean_d
# 社区数据
community = self._community_data.get(skill_id, {})
downloads = community.get("downloads", 0)
stars = community.get("stars", 0)
installs = community.get("current_installs", downloads)
has_community = downloads > 0 or stars > 0
# 原始值
factors = {
"success_rate": success_rate,
"response_time": avg_duration,
"satisfaction": avg_rating,
"reuse_rate": total_runs,
"stability": cv,
}
if has_community:
factors["community_popularity"] = downloads
factors["community_rating"] = stars
# 归一化
normalized = {
"success_rate": normalize_success_rate(success_rate),
"response_time": normalize_response_time(avg_duration),
"satisfaction": normalize_satisfaction(avg_rating),
"reuse_rate": normalize_reuse_rate(total_runs),
"stability": normalize_stability(durations),
}
if has_community:
normalized["community_popularity"] = normalize_community_popularity(downloads)
normalized["community_rating"] = normalize_community_rating(stars, installs)
# 选择权重并加权
weights = self._get_active_weights(has_community)
weighted = {}
for key in weights:
weighted[key] = normalized.get(key, 0) * weights[key]
total_score = sum(weighted.values()) * 100
total_score = round(min(100, max(0, total_score)), 1)
grade = _score_to_grade(total_score)
details = {
"total_runs": total_runs,
"success_runs": summary["success_count"],
"implicit_feedback_count": summary.get("implicit_feedback_count", 0),
"avg_confidence": summary.get("avg_confidence"),
"recent_durations_count": len(durations),
"has_community_data": has_community,
"weight_mode": "7-factor" if has_community else "5-factor-fallback",
"evaluated_at": datetime.now().isoformat(),
}
return SkillScore(
skill_id=skill_id,
factors=factors,
normalized=normalized,
weighted=weighted,
total_score=total_score,
grade=grade,
details=details,
)
def evaluate_all(self, skill_ids: List[str] = None) -> List[SkillScore]:
"""批量评估所有 skill"""
if skill_ids is None:
skill_ids = []
scores = []
self.last_failures: List[Tuple[str, str]] = []
for sid in skill_ids:
try:
score = self.evaluate_skill(sid)
if score.details["total_runs"] > 0:
scores.append(score)
except Exception as e:
# 不要吞掉:记录失败,便于排障(不会影响主流程)
self.last_failures.append((sid, f"{type(e).__name__}: {e}"))
scores.sort(key=lambda s: s.total_score, reverse=True)
return scores
def generate_evaluation_report(self, scores: List[SkillScore]) -> str:
"""生成综合评估报告(Markdown,不含权重)"""
now = datetime.now()
lines = [
f"# 📊 Skills 综合评估报告",
f"",
f"> **生成时间**: {now.strftime('%Y-%m-%d %H:%M:%S')} ",
f"> **评估模型**: 7 因子加权评分 v0.5.0 ",
f"> **评估 Skills 数**: {len(scores)}",
f"",
f"---",
f"",
f"## 评分排行",
f"",
f"| 排名 | Skill | 总分 | 等级 | 成功率 | 响应 | 满意度 | 复用 | 稳定性 | 社区 |",
f"|------|-------|------|------|--------|------|--------|------|--------|------|",
]
for i, score in enumerate(scores, 1):
sr = f"{score.factors['success_rate']:.0f}%" if score.factors.get('success_rate') is not None else "-"
rt = f"{score.factors['response_time']:.0f}ms" if score.factors.get('response_time') is not None else "-"
sa = f"{score.factors['satisfaction']:.1f}" if score.factors.get('satisfaction') is not None else "-"
rr = str(score.factors.get('reuse_rate', '-'))
st = f"{score.factors['stability']:.2f}" if score.factors.get('stability') is not None else "-"
cm = "✅" if score.details.get("has_community_data") else "-"
grade_short = f"{_grade_emoji(score.grade)} {score.grade}"
lines.append(
f"| {i} | {score.skill_id} | **{score.total_score:.1f}** | {grade_short} | "
f"{sr} | {rt} | {sa} | {rr} | {st} | {cm} |"
)
lines.extend([
f"", f"---", f"",
f"## 详细评分", f"",
])
for score in scores:
lines.append(f"### {score.skill_id}")
lines.append(f"")
lines.append(f"```")
lines.append(score.format_report())
lines.append(f"```")
lines.append(f"")
# 评分说明(不含权重!)
lines.extend([
f"---", f"",
f"## 评分模型说明", f"",
f"| 因子 | 说明 |",
f"|------|------|",
f"| 成功率 | 执行成功率越高越好 |",
f"| 响应时间 | 平均响应时间越短越好 |",
f"| 满意度 | 对话语义隐性推断 |",
f"| 复用率 | 使用频次越高说明价值越大 |",
f"| 稳定性 | 响应时间变异系数越小越稳定 |",
f"| 社区热度 | 社区下载量和安装量 |",
f"| 社区评分 | 社区星标和口碑 |",
f"",
f"> 注:各因子均为 0-100 分制,总分为加权综合。",
])
return "\n".join(lines)
def trend_analysis(self, skill_id: str, days: int = 7) -> Dict[str, Any]:
"""趋势分析:最近 N 天的指标变化"""
metrics = self.store.get_metrics(skill_id=skill_id, agent_id=self.agent_id, days=days)
if not metrics:
return {"skill_id": skill_id, "trend": "no_data", "days": days}
metrics.sort(key=lambda m: m["date"])
dates = [m["date"] for m in metrics]
success_rates = [
round(m["success_count"] / m["total_runs"] * 100, 1) if m["total_runs"] > 0 else 0
for m in metrics
]
avg_durations = [m["avg_duration_ms"] for m in metrics if m.get("avg_duration_ms")]
if len(success_rates) >= 2:
if success_rates[-1] > success_rates[0]:
sr_trend = "improving"
elif success_rates[-1] < success_rates[0]:
sr_trend = "declining"
else:
sr_trend = "stable"
else:
sr_trend = "insufficient"
return {
"skill_id": skill_id,
"days": days,
"data_points": len(metrics),
"success_rate_trend": sr_trend,
"success_rates": dict(zip(dates, success_rates)),
"avg_durations": avg_durations,
"latest_success_rate": success_rates[-1] if success_rates else None,
"first_success_rate": success_rates[0] if success_rates else None,
}
FILE:skills_monitor/core/recommender.py
"""
Skill 推荐引擎
基于已安装 skills 的分类分布、使用频率和隐性满意度,推荐扩展 skills
推荐策略:
1. 互补推荐 — 找出用户缺少的分类,推荐该分类的热门 skill
2. 升级推荐 — 对低满意度的 skill 推荐同类替代品
3. 协同推荐 — "使用 A 的用户也在使用 B"(基于分类相关性模拟)
4. 热门兜底推荐 — 当用户尚未安装任何 skill 时,从官方 TOP10 精选 3 个
"""
import json
import math
from pathlib import Path
from collections import Counter
from typing import Any, Dict, List, Optional
from skills_monitor.data.store import DataStore
from skills_monitor.adapters.skill_registry import SkillRegistry
from skills_monitor.adapters.clawhub_client import ClawHubClient
# ──────── SkillHub 模拟目录 ────────
# 真实场景中从 SkillHub API 获取,Demo 阶段用硬编码模拟
SKILLHUB_CATALOG: List[Dict[str, Any]] = [
# 数据采集
{"slug": "tushare-data", "name": "TuShare 数据源", "category": "数据采集",
"description": "基于 TuShare Pro 的 A 股全量数据接口", "rating": 4.5, "installs": 1200},
{"slug": "eastmoney-data", "name": "东方财富数据", "category": "数据采集",
"description": "东方财富实时行情和公告数据", "rating": 4.2, "installs": 890},
{"slug": "crypto-data-feed", "name": "加密货币数据", "category": "数据采集",
"description": "主流加密货币实时行情和链上数据", "rating": 4.0, "installs": 650},
# 宏观分析
{"slug": "global-macro-tracker", "name": "全球宏观追踪", "category": "宏观分析",
"description": "全球主要经济体的宏观经济指标追踪", "rating": 4.3, "installs": 520},
{"slug": "fed-policy-analyzer", "name": "美联储政策分析", "category": "宏观分析",
"description": "美联储议息会议决议及政策影响分析", "rating": 4.1, "installs": 380},
# 新闻情报
{"slug": "ai-news-digest", "name": "AI 新闻摘要", "category": "新闻情报",
"description": "基于大模型的金融新闻智能摘要和情感分析", "rating": 4.6, "installs": 1500},
{"slug": "social-sentiment", "name": "社交媒体情绪", "category": "新闻情报",
"description": "雪球/东方财富等平台的投资者情绪分析", "rating": 3.9, "installs": 780},
# 技术筛选
{"slug": "quant-factor-screener", "name": "量化因子筛选器", "category": "技术筛选",
"description": "多因子量化选股模型,支持自定义因子组合", "rating": 4.7, "installs": 2100},
{"slug": "pattern-recognition", "name": "K线形态识别", "category": "技术筛选",
"description": "基于深度学习的 K 线形态自动识别", "rating": 4.4, "installs": 920},
# 交易信号
{"slug": "options-signal", "name": "期权信号生成", "category": "交易信号",
"description": "基于隐含波动率和 Greeks 的期权交易信号", "rating": 4.3, "installs": 450},
{"slug": "crypto-signal-pro", "name": "加密货币信号", "category": "交易信号",
"description": "加密货币多因子交易信号生成器", "rating": 4.0, "installs": 670},
# 策略回测
{"slug": "quantitative-backtest", "name": "量化回测引擎 v2", "category": "策略回测",
"description": "高性能量化策略回测框架,支持分钟级数据", "rating": 4.8, "installs": 3200},
{"slug": "portfolio-optimizer", "name": "组合优化器", "category": "策略回测",
"description": "基于 MPT 的投资组合权重优化", "rating": 4.5, "installs": 890},
# 量化监控
{"slug": "realtime-alert", "name": "实时预警系统", "category": "量化监控",
"description": "价格、成交量、技术指标的实时预警", "rating": 4.2, "installs": 760},
{"slug": "risk-monitor", "name": "风险监控", "category": "量化监控",
"description": "投资组合实时风险监控和预警", "rating": 4.4, "installs": 540},
# 可视化
{"slug": "interactive-charts", "name": "交互式图表", "category": "可视化",
"description": "基于 ECharts 的金融数据交互可视化", "rating": 4.5, "installs": 1800},
# 资金追踪
{"slug": "institution-tracker", "name": "机构资金追踪", "category": "资金追踪",
"description": "主力机构资金流向实时追踪", "rating": 4.3, "installs": 990},
{"slug": "northbound-flow", "name": "北向资金追踪", "category": "资金追踪",
"description": "沪深港通北向资金实时监控", "rating": 4.6, "installs": 1100},
]
OFFICIAL_TOP_DATASET = Path(__file__).resolve().parent.parent / "data" / "top1000_skills_dataset.json"
class RecommendationReason:
"""推荐理由"""
COMPLEMENT = "complement" # 互补推荐
UPGRADE = "upgrade" # 升级推荐
COLLABORATIVE = "collaborative" # 协同推荐
POPULAR = "popular" # 热门推荐
LABELS = {
COMPLEMENT: "💡 互补推荐 — 补充你缺少的能力",
UPGRADE: "⬆️ 升级推荐 — 替代低满意度的 skill",
COLLABORATIVE: "🤝 协同推荐 — 同类用户也在使用",
POPULAR: "🔥 热门推荐 — 官方 TOP 热门精选",
}
class Recommendation:
"""单条推荐"""
def __init__(
self,
skill_info: Dict[str, Any],
reason_type: str,
reason_detail: str,
score: float,
related_installed: Optional[str] = None,
):
self.skill_info = skill_info
self.reason_type = reason_type
self.reason_detail = reason_detail
self.score = score # 推荐分 (0-100)
self.related_installed = related_installed
def to_dict(self) -> Dict[str, Any]:
slug = self.skill_info["slug"]
return {
"slug": slug,
"name": self.skill_info["name"],
"category": self.skill_info.get("category", "未分类"),
"description": self.skill_info.get("description", ""),
"hub_rating": self.skill_info.get("rating"),
"hub_installs": self.skill_info.get("installs"),
"reason_type": self.reason_type,
"reason_label": RecommendationReason.LABELS.get(self.reason_type, ""),
"reason_detail": self.reason_detail,
"recommendation_score": round(self.score, 1),
"related_installed": self.related_installed,
"official_rank": self.skill_info.get("rank"),
"selection_logic": self.skill_info.get("selection_logic"),
"source": self.skill_info.get("source", "catalog"),
"benchmark_quality": self.skill_info.get("baseline_quality"),
"benchmark_success_rate": self.skill_info.get("baseline_success_rate"),
"tags": self.skill_info.get("tags", []),
# 安装相关 URL
"detail_url": f"https://clawhub.ai/skills/{slug}",
"install_url": f"https://clawhub.ai/api/v1/download?slug={slug}",
"install_command": f"python install_skills.py {slug}",
}
def format_line(self) -> str:
"""格式化为单行"""
label = RecommendationReason.LABELS.get(self.reason_type, "")
rating = f"⭐{self.skill_info.get('rating', 'N/A')}"
installs = self.skill_info.get("installs", 0)
extras = []
if self.skill_info.get("rank"):
extras.append(f"TOP{self.skill_info['rank']}")
if self.skill_info.get("selection_logic"):
extras.append(self.skill_info["selection_logic"])
extra_text = f" | {';'.join(extras)}" if extras else ""
return (
f" {label}\n"
f" 📦 {self.skill_info['name']} ({self.skill_info['slug']})\n"
f" {self.skill_info.get('description', '')}\n"
f" {rating} 安装量: {installs} 推荐分: {self.score:.0f}{extra_text}\n"
f" 💬 {self.reason_detail}"
)
class SkillRecommender:
"""Skill 推荐引擎"""
def __init__(
self,
registry: SkillRegistry,
store: DataStore,
agent_id: str,
catalog: Optional[List[Dict[str, Any]]] = None,
):
self.registry = registry
self.store = store
self.agent_id = agent_id
self.catalog = catalog or SKILLHUB_CATALOG
self._installed_slugs = {s.slug for s in registry.list_skills()}
def _get_user_category_profile(self) -> Dict[str, Any]:
"""分析用户的分类使用情况"""
installed = self.registry.list_skills()
category_counts = Counter(s.category for s in installed)
category_usage = {}
for skill in installed:
summary = self.store.get_skill_summary(skill.slug, self.agent_id)
cat = skill.category
if cat not in category_usage:
category_usage[cat] = {"runs": 0, "avg_rating": []}
category_usage[cat]["runs"] += summary["total_runs"]
if summary.get("avg_rating"):
category_usage[cat]["avg_rating"].append(summary["avg_rating"])
for cat in category_usage:
ratings = category_usage[cat]["avg_rating"]
category_usage[cat]["avg_rating"] = (
round(sum(ratings) / len(ratings), 1) if ratings else None
)
return {
"installed_categories": dict(category_counts),
"usage": category_usage,
}
def _get_available_catalog(self) -> List[Dict[str, Any]]:
"""过滤掉已安装的 skill"""
return [s for s in self.catalog if s["slug"] not in self._installed_slugs]
def _get_official_top10_candidates(self) -> List[Dict[str, Any]]:
"""读取官方 TOP10 候选池(优先线上热门榜,降级到本地基准数据集)"""
candidates: List[Dict[str, Any]] = []
try:
client = ClawHubClient()
online_items = client.get_popular_skills(limit=10)
for idx, item in enumerate(online_items, 1):
candidates.append({
"rank": item.get("rank", idx),
"slug": item.get("slug", ""),
"name": item.get("name") or item.get("slug", "unknown"),
"category": item.get("category") or (item.get("tags") or ["通用"])[0],
"description": item.get("description", ""),
"rating": round(item.get("star_density", 0) * 25, 1) if item.get("star_density") is not None else None,
"installs": item.get("installs", 0),
"stars": item.get("stars", 0),
"baseline_quality": item.get("baseline_quality"),
"baseline_success_rate": item.get("baseline_success_rate"),
"tags": item.get("tags", []),
"source": item.get("source", "official_popular"),
})
except Exception:
candidates = []
if candidates:
deduped = []
seen = set()
for item in candidates:
slug = item.get("slug")
if slug and slug not in seen and slug not in self._installed_slugs:
seen.add(slug)
deduped.append(item)
if deduped:
return deduped[:10]
try:
with open(OFFICIAL_TOP_DATASET, "r", encoding="utf-8") as f:
dataset = json.load(f)
items = []
for raw in dataset[:10]:
slug = raw.get("slug")
if not slug or slug in self._installed_slugs:
continue
items.append({
"rank": raw.get("rank"),
"slug": slug,
"name": raw.get("name", slug),
"category": raw.get("category", "通用"),
"description": raw.get("description", ""),
"rating": round((raw.get("stars", 0) / max(raw.get("installs", 1), 1)) * 25, 1),
"installs": raw.get("installs", 0),
"stars": raw.get("stars", 0),
"baseline_quality": raw.get("baseline_quality"),
"baseline_success_rate": raw.get("baseline_success_rate"),
"tags": raw.get("tags", []),
"source": "official_top10_dataset",
})
return items[:10]
except Exception:
return []
def _score_official_top_candidate(self, skill: Dict[str, Any], chosen_categories: set) -> float:
"""对官方 TOP10 候选 skill 做精选排序,兼顾热度、成功率、质量与分类多样性"""
installs = float(skill.get("installs") or 0)
baseline_quality = float(skill.get("baseline_quality") or 0)
baseline_success_rate = float(skill.get("baseline_success_rate") or 0)
rank = int(skill.get("rank") or 99)
category = skill.get("category", "通用")
install_component = min(20.0, math.log10(max(installs, 1)) * 4.0)
quality_component = baseline_quality * 0.45
success_component = baseline_success_rate * 100 * 0.30
rank_component = max(0, 12 - rank)
diversity_bonus = 8 if category not in chosen_categories else 0
return round(
install_component + quality_component + success_component + rank_component + diversity_bonus,
1,
)
def recommend_official_top(self, max_items: int = 3) -> List[Recommendation]:
"""当用户未安装任何 skill 时,从官方 TOP10 精选 3 个"""
top10 = self._get_official_top10_candidates()
if not top10:
return []
selected: List[Recommendation] = []
chosen_categories = set()
remaining = list(top10)
while remaining and len(selected) < max_items:
scored = []
for skill in remaining:
score = self._score_official_top_candidate(skill, chosen_categories)
scored.append((score, skill))
scored.sort(key=lambda x: x[0], reverse=True)
final_score, best = scored[0]
chosen_categories.add(best.get("category", "通用"))
logic_parts = [
f"官方 TOP{best.get('rank', '?')} 热门",
f"安装量 {best.get('installs', 0)}",
]
if best.get("baseline_quality") is not None:
logic_parts.append(f"基准质量 {best['baseline_quality']:.1f}")
if best.get("baseline_success_rate") is not None:
logic_parts.append(f"成功率 {best['baseline_success_rate'] * 100:.1f}%")
if best.get("category"):
logic_parts.append(f"补齐 {best['category']} 能力")
best["selection_logic"] = ";".join(logic_parts)
detail = (
f"当前未检测到已安装 skills,兜底从官方 TOP10 中精选。"
f"优先选择高安装量、高成功率且覆盖不同能力类别的 skill,"
f"本次选择 {best['name']} 以补齐 {best.get('category', '通用')} 能力。"
)
selected.append(Recommendation(
skill_info=best,
reason_type=RecommendationReason.POPULAR,
reason_detail=detail,
score=min(100, final_score),
))
remaining = [item for item in remaining if item.get("slug") != best.get("slug")]
return selected
def recommend_complement(self, max_items: int = 3) -> List[Recommendation]:
"""互补推荐:找出用户缺少的分类"""
profile = self._get_user_category_profile()
installed_cats = set(profile["installed_categories"].keys())
all_hub_cats = set(s["category"] for s in self.catalog)
missing_cats = all_hub_cats - installed_cats
thin_cats = {
cat for cat, count in profile["installed_categories"].items()
if count <= 1
}
target_cats = missing_cats | thin_cats
available = self._get_available_catalog()
recommendations = []
for skill in available:
if skill["category"] in target_cats:
score = (skill.get("rating", 3) * 15 + skill.get("installs", 0) / 100)
if skill["category"] in missing_cats:
score += 20
detail = (
f"你的 [{skill['category']}] 类 skill 不足,"
f"该 skill 在社区评分 {skill.get('rating', 'N/A')} 星"
)
recommendations.append(Recommendation(
skill_info=skill,
reason_type=RecommendationReason.COMPLEMENT,
reason_detail=detail,
score=min(100, score),
))
recommendations.sort(key=lambda r: r.score, reverse=True)
return recommendations[:max_items]
def recommend_upgrade(self, max_items: int = 3) -> List[Recommendation]:
"""升级推荐:对低满意度的 skill 推荐替代品"""
available = self._get_available_catalog()
recommendations = []
for skill in self.registry.list_skills():
summary = self.store.get_skill_summary(skill.slug, self.agent_id)
avg_rating = summary.get("avg_rating")
success_rate = summary.get("success_rate", 100)
if (avg_rating is not None and avg_rating < 3.5) or success_rate < 70:
same_cat = [
s for s in available
if s["category"] == skill.category
and s.get("rating", 0) > (avg_rating or 3)
]
for alt in same_cat:
score = alt.get("rating", 3) * 15 + 10
if avg_rating and avg_rating < 3:
score += 15
detail = (
f"你的 [{skill.slug}] 满意度仅 {avg_rating or 'N/A'},"
f"成功率 {success_rate}%。推荐升级到 {alt['name']}"
)
recommendations.append(Recommendation(
skill_info=alt,
reason_type=RecommendationReason.UPGRADE,
reason_detail=detail,
score=min(100, score),
related_installed=skill.slug,
))
recommendations.sort(key=lambda r: r.score, reverse=True)
return recommendations[:max_items]
def recommend_collaborative(self, max_items: int = 3) -> List[Recommendation]:
"""协同推荐:基于分类相关性"""
profile = self._get_user_category_profile()
usage = profile["usage"]
top_cats = sorted(
usage.items(),
key=lambda x: x[1]["runs"],
reverse=True,
)[:3]
available = self._get_available_catalog()
recommendations = []
related_categories = {
"交易信号": ["策略回测", "量化监控", "技术筛选"],
"技术筛选": ["交易信号", "数据采集", "量化监控"],
"数据采集": ["技术筛选", "宏观分析", "新闻情报"],
"宏观分析": ["新闻情报", "数据采集"],
"策略回测": ["交易信号", "量化监控"],
"量化监控": ["交易信号", "技术筛选"],
"新闻情报": ["宏观分析", "交易信号"],
"资金追踪": ["交易信号", "技术筛选"],
"可视化": ["数据采集", "技术筛选"],
}
for cat, _ in top_cats:
related = related_categories.get(cat, [])
for rel_cat in related:
for skill in available:
if skill["category"] == rel_cat:
score = skill.get("rating", 3) * 12 + skill.get("installs", 0) / 150
detail = (
f"你经常使用 [{cat}] 类 skill,"
f"同类用户 Top30% 也在使用 [{rel_cat}] 类工具"
)
recommendations.append(Recommendation(
skill_info=skill,
reason_type=RecommendationReason.COLLABORATIVE,
reason_detail=detail,
score=min(100, score),
))
seen = set()
unique = []
for r in sorted(recommendations, key=lambda x: x.score, reverse=True):
if r.skill_info["slug"] not in seen:
seen.add(r.skill_info["slug"])
unique.append(r)
return unique[:max_items]
def get_all_recommendations(self, max_per_type: int = 3) -> List[Recommendation]:
"""获取所有类型的推荐,去重后返回"""
if not self._installed_slugs:
return self.recommend_official_top(max_items=min(3, max_per_type))
all_recs: List[Recommendation] = []
all_recs.extend(self.recommend_complement(max_per_type))
all_recs.extend(self.recommend_upgrade(max_per_type))
all_recs.extend(self.recommend_collaborative(max_per_type))
seen = set()
unique = []
for r in sorted(all_recs, key=lambda x: x.score, reverse=True):
if r.skill_info["slug"] not in seen:
seen.add(r.skill_info["slug"])
unique.append(r)
if unique:
return unique
return self.recommend_official_top(max_items=min(3, max_per_type))
def generate_recommendation_report(self, recommendations: Optional[List[Recommendation]] = None) -> str:
"""生成推荐报告"""
if recommendations is None:
recommendations = self.get_all_recommendations()
from datetime import datetime
now = datetime.now()
lines = [
f"# 💡 Skills 推荐报告",
f"",
f"> **生成时间**: {now.strftime('%Y-%m-%d %H:%M:%S')} ",
f"> **已安装**: {len(self.registry.list_skills())} 个 skills ",
f"> **推荐数量**: {len(recommendations)} 个",
f"",
f"---",
f"",
]
if not recommendations:
lines.append("暂无推荐。你的 skills 配置已经很完善!👍")
return "\n".join(lines)
by_type: Dict[str, List[Recommendation]] = {}
for r in recommendations:
by_type.setdefault(r.reason_type, []).append(r)
for rtype, recs in by_type.items():
label = RecommendationReason.LABELS.get(rtype, rtype)
lines.append(f"## {label}")
lines.append("")
for r in recs:
rating = f"⭐ {r.skill_info.get('rating', 'N/A')}"
installs = r.skill_info.get("installs", 0)
lines.extend([
f"### 📦 {r.skill_info['name']} (`{r.skill_info['slug']}`)",
f"",
f"- **分类**: {r.skill_info.get('category', '未分类')}",
f"- **描述**: {r.skill_info.get('description', '')}",
f"- **社区评分**: {rating} | **安装量**: {installs}",
f"- **推荐分**: {r.score:.0f}/100",
f"- **推荐理由**: {r.reason_detail}",
*( [f"- **精选逻辑**: {r.skill_info['selection_logic']}"] if r.skill_info.get("selection_logic") else [] ),
f"",
])
lines.append("---")
lines.append("")
top3 = recommendations[:3]
if top3:
lines.extend([
f"## 🚀 快速安装",
f"",
f"```bash",
])
for r in top3:
lines.append(f"python install_skills.py {r.skill_info['slug']}")
lines.extend([
f"```",
f"",
])
return "\n".join(lines)
FILE:deploy/setup_ssh_key.sh
#!/usr/bin/env bash
# ================================================================
# 腾讯云轻量应用服务器 — SSH 密钥登录配置
# ================================================================
# 腾讯云默认开启微信扫码 + 验证码验证,导致 scp 等非交互式命令失败
# 此脚本配置 SSH 密钥登录,一次配置后续免密操作
#
# 使用方式:
# bash deploy/setup_ssh_key.sh [email protected]
# ================================================================
set -euo pipefail
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
info() { echo -e "BLUE[INFO]NC $*"; }
ok() { echo -e "GREEN[OK]NC $*"; }
warn() { echo -e "YELLOW[WARN]NC $*"; }
fail() { echo -e "RED[FAIL]NC $*"; exit 1; }
# ──────── 获取服务器地址 ────────
if [[ $# -ge 1 ]]; then
SERVER_SSH="$1"
else
echo ""
echo -e "BLUE请输入服务器 SSH 连接信息NC"
read -p "服务器地址 (如 [email protected]): " SERVER_SSH
fi
[[ -z "SERVER_SSH" ]] && fail "服务器地址不能为空"
# 提取用户名和IP
SSH_USER="SERVER_SSH%%@*"
SSH_HOST="SERVER_SSH##*@"
echo ""
echo "============================================="
echo " SSH 密钥登录配置"
echo " 服务器: SERVER_SSH"
echo "============================================="
echo ""
# ──────── Step 1: 检查/生成本地密钥 ────────
info "Step 1: 检查本地 SSH 密钥..."
KEY_FILE=""
for f in ~/.ssh/id_ed25519.pub ~/.ssh/id_rsa.pub ~/.ssh/id_ecdsa.pub; do
if [[ -f "$f" ]]; then
KEY_FILE="$f"
break
fi
done
if [[ -z "$KEY_FILE" ]]; then
info "未找到 SSH 密钥,正在生成..."
ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519 -N "" -C "$(whoami)@$(hostname)"
KEY_FILE=~/.ssh/id_ed25519.pub
ok "密钥已生成: KEY_FILE"
else
ok "找到密钥: KEY_FILE"
fi
PUB_KEY=$(cat "$KEY_FILE")
echo " 公钥: 0:40..."
# ──────── Step 2: 上传公钥 ────────
echo ""
info "Step 2: 上传公钥到服务器..."
echo ""
warn "⚠️ 接下来需要通过腾讯云验证(可能有微信扫码/验证码)"
warn " 这是最后一次需要输入密码,配置完成后即可免密登录"
echo ""
# 使用 ssh-copy-id (会提示密码)
# 如果 ssh-copy-id 不可用,手动追加
if command -v ssh-copy-id &>/dev/null; then
ssh-copy-id -i "$KEY_FILE" "SERVER_SSH"
else
info "ssh-copy-id 不可用,使用手动方式..."
cat "$KEY_FILE" | ssh "SERVER_SSH" "mkdir -p ~/.ssh && chmod 700 ~/.ssh && cat >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys"
fi
# ──────── Step 3: 验证 ────────
echo ""
info "Step 3: 验证免密登录..."
if ssh -o BatchMode=yes -o ConnectTimeout=10 "SERVER_SSH" "echo 'SSH_KEY_OK'" 2>/dev/null | grep -q "SSH_KEY_OK"; then
ok "✅ SSH 密钥登录配置成功!"
echo ""
echo "现在可以执行部署了:"
echo -e " GREENbash deploy/pack_and_upload.sh SERVER_SSHNC"
echo ""
else
echo ""
warn "自动验证未通过,可能是腾讯云安全设置导致"
echo ""
echo "请尝试以下替代方案:"
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo -e "YELLOW方案 A: 腾讯云控制台配置 (推荐)NC"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
echo " 1. 打开腾讯云控制台 → 轻量应用服务器"
echo " 2. 选择你的实例 → 远程登录 → 密钥"
echo " 3. 创建密钥 → 绑定密钥 → 选择你的实例"
echo ""
echo " 或者手动添加公钥:"
echo " 1. 点击实例 → 远程登录 → OrcaTerm 登录"
echo " 2. 在 OrcaTerm 终端中执行:"
echo ""
echo -e " BLUEmkdir -p ~/.ssh && echo 'PUB_KEY' >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keysNC"
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo -e "YELLOW方案 B: 通过 OrcaTerm 关闭扫码验证NC"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
echo " 1. 腾讯云控制台 → 实例 → 远程登录 → OrcaTerm"
echo " 2. 在终端中执行:"
echo ""
echo -e " BLUE# 添加公钥"
echo -e " mkdir -p ~/.ssh"
echo -e " echo 'PUB_KEY' >> ~/.ssh/authorized_keys"
echo -e " chmod 700 ~/.ssh && chmod 600 ~/.ssh/authorized_keys"
echo ""
echo -e " # (可选) 关闭密码登录,仅允许密钥"
echo -e " sed -i 's/^#\\?PasswordAuthentication.*/PasswordAuthentication no/' /etc/ssh/sshd_config"
echo -e " systemctl restart sshdNC"
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
echo "完成后再次运行部署:"
echo -e " GREENbash deploy/pack_and_upload.sh SERVER_SSHNC"
fi
FILE:deploy/README.md
# Skills Monitor 部署指南
## 服务器要求
| 项目 | 最低要求 | 你的配置 |
|------|---------|---------|
| CPU | 1核 | 2核 ✅ |
| 内存 | 1GB | 2GB ✅ |
| 硬盘 | 20GB | 40GB SSD ✅ |
| 带宽 | 3Mbps | 200Mbps ✅ |
| 系统 | Ubuntu 20.04+ / CentOS 7+ | - |
## ⚡ 前置条件:配置 SSH 密钥登录
腾讯云轻量应用服务器默认开启微信扫码/验证码验证,**必须先配置 SSH 密钥登录**,否则 scp 等非交互式命令会失败。
```bash
# 方式 1: 运行配置脚本(会引导你完成)
bash deploy/setup_ssh_key.sh [email protected]
# 方式 2: 通过腾讯云控制台 OrcaTerm 手动添加公钥
# 先复制你的公钥:
cat ~/.ssh/id_ed25519.pub
# 然后在 OrcaTerm 终端执行:
mkdir -p ~/.ssh && echo '你的公钥内容' >> ~/.ssh/authorized_keys && chmod 700 ~/.ssh && chmod 600 ~/.ssh/authorized_keys
```
验证免密登录:
```bash
ssh -o BatchMode=yes [email protected] "echo OK"
# 应该直接输出 OK,无需输入密码
```
## 🚀 一键部署(3 步完成)
### Step 1: 在 Mac 上执行打包上传
```bash
cd /Users/lynn/CodeBuddy/20260311140550
# 不带域名(后续手动配)
bash deploy/pack_and_upload.sh root@你的服务器IP
# 带域名(自动配置 Nginx)
SM_DOMAIN=monitor.yourdomain.com bash deploy/pack_and_upload.sh root@你的服务器IP
```
脚本会自动完成:
1. ✅ 打包 `server/` + `skills_monitor/` + 依赖文件
2. ✅ 上传到服务器 `/tmp/skills-monitor-deploy/`
3. ✅ 在服务器上执行部署(安装依赖、配置进程、启动服务)
### Step 2: 编辑服务器上的 .env 配置
```bash
ssh root@你的服务器IP
vim /www/wwwroot/skills-monitor/.env
# 主要填写:
# SM_H5_BASE_URL=https://你的域名
# SM_WECHAT_OA_APP_ID=...
# SM_WECHAT_OA_APP_SECRET=...
```
### Step 3: 宝塔面板配置反向代理 + SSL
1. 登录宝塔面板
2. **网站** → **添加站点** → 域名填你的域名
3. **站点设置** → **反向代理** → 目标 URL 填 `http://127.0.0.1:5100`
4. **SSL** → **Let's Encrypt** → 申请免费证书
5. 重启服务:`supervisorctl restart skills-monitor`
## 📁 部署后目录结构
```
/www/wwwroot/skills-monitor/
├── server/ ← Flask 服务端代码
│ ├── api/ ← API 蓝图
│ ├── models/ ← 数据库模型
│ ├── services/ ← 业务服务
│ ├── templates/ ← H5 页面模板
│ ├── static/ ← 静态资源
│ ├── data/ ← SQLite 数据库
│ ├── app.py ← 应用入口
│ └── config.py ← 配置文件
├── skills_monitor/ ← 监控核心包
├── venv/ ← Python 虚拟环境
├── logs/ ← 日志目录
├── .env ← 环境变量(敏感信息)
├── gunicorn.conf.py ← Gunicorn 配置
├── start.sh ← 启动脚本
└── requirements.txt ← Python 依赖
```
## 🔧 运维命令
```bash
# 查看服务状态
supervisorctl status skills-monitor
# 查看日志
tail -f /www/wwwroot/skills-monitor/logs/error.log
tail -f /www/wwwroot/skills-monitor/logs/access.log
# 重启服务
supervisorctl restart skills-monitor
# 停止服务
supervisorctl stop skills-monitor
# 健康检查
curl http://127.0.0.1:5100/health
```
## 🔄 更新代码
每次修改代码后,重新执行打包上传即可(数据库自动保留):
```bash
bash deploy/pack_and_upload.sh root@你的服务器IP
```
## ⚠️ 2核2G 内存优化说明
针对你的 2GB 内存配置,部署脚本已做以下优化:
- **Gunicorn**: 1 worker + 4 threads(约 150MB 内存)
- **SQLite**: 无需额外数据库进程(节省 200MB+)
- **preload_app**: 减少 worker 内存占用
- **max_requests=500**: 自动回收防内存泄漏
预估内存占用:
| 组件 | 内存 |
|------|------|
| 系统 + 宝塔 | ~600MB |
| Nginx | ~20MB |
| Supervisor | ~10MB |
| Skills Monitor | ~150MB |
| **剩余可用** | **~1.2GB** ✅ |
## 🔒 安全清单
- [ ] SM_SECRET_KEY 已替换为随机值(部署脚本自动生成)
- [ ] .env 文件权限 600(仅 root 可读)
- [ ] 腾讯云安全组只开放 22/80/443
- [ ] SSH 使用密钥登录,禁用密码
- [ ] 宝塔面板设置强密码 + 修改默认端口
FILE:deploy/pack_and_upload.sh
#!/usr/bin/env bash
# ================================================================
# Skills Monitor — 本地打包 & 上传到服务器
# ================================================================
# 在你的 Mac 上执行,自动打包必需的文件并上传到腾讯云服务器
#
# 使用方式:
# # 方式 1: 交互式(会提示输入 IP)
# bash deploy/pack_and_upload.sh
#
# # 方式 2: 指定参数
# bash deploy/pack_and_upload.sh [email protected]
#
# # 方式 3: 带域名(部署时自动配置)
# SM_DOMAIN=monitor.yourdomain.com bash deploy/pack_and_upload.sh [email protected]
# ================================================================
set -euo pipefail
# ──────── 颜色输出 ────────
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
info() { echo -e "BLUE[INFO]NC $*"; }
ok() { echo -e "GREEN[OK]NC $*"; }
warn() { echo -e "YELLOW[WARN]NC $*"; }
fail() { echo -e "RED[FAIL]NC $*"; exit 1; }
# ──────── 确定项目根目录 ────────
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
PACK_DIR="/tmp/skills-monitor-pack"
ARCHIVE="/tmp/skills-monitor-deploy.tar.gz"
REMOTE_DIR="/tmp/skills-monitor-deploy"
cd "$PROJECT_ROOT"
info "项目根目录: PROJECT_ROOT"
# ──────── 获取服务器地址 ────────
if [[ $# -ge 1 ]]; then
SERVER_SSH="$1"
else
echo ""
echo -e "BLUE请输入服务器 SSH 连接信息NC"
echo " 格式: root@<IP地址>"
echo " 示例: [email protected]"
echo ""
read -p "服务器地址: " SERVER_SSH
fi
if [[ -z "SERVER_SSH" ]]; then
fail "服务器地址不能为空"
fi
echo ""
echo "============================================="
echo " Skills Monitor 打包上传"
echo " 目标服务器: SERVER_SSH"
echo "============================================="
echo ""
# ──────── Step 1: 打包 ────────
info "Step 1/3: 打包项目文件..."
rm -rf "PACK_DIR"
mkdir -p "PACK_DIR"
# 复制必需的目录和文件
cp -r server/ "PACK_DIR/server/"
cp -r skills_monitor/ "PACK_DIR/skills_monitor/"
cp -r deploy/ "PACK_DIR/deploy/"
cp requirements.txt "PACK_DIR/"
cp setup.py "PACK_DIR/"
[[ -f README.md ]] && cp README.md "PACK_DIR/"
# 清理不需要的文件
find "PACK_DIR" -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true
find "PACK_DIR" -name "*.pyc" -delete 2>/dev/null || true
find "PACK_DIR" -name ".DS_Store" -delete 2>/dev/null || true
# 排除数据库文件(只部署代码,保留远端数据)
rm -f "PACK_DIR/server/data/"*.db 2>/dev/null || true
# 打包
cd /tmp
tar -czf "ARCHIVE" -C "PACK_DIR" .
ARCHIVE_SIZE=$(du -sh "ARCHIVE" | cut -f1)
ok "打包完成: ARCHIVE (ARCHIVE_SIZE)"
# ──────── Step 2: 上传 ────────
info "Step 2/3: 上传到服务器..."
# 先检测 SSH 连接是否正常(免密模式)
info "检测 SSH 连接..."
if ! ssh -o BatchMode=yes -o ConnectTimeout=15 -o StrictHostKeyChecking=accept-new "SERVER_SSH" "echo 'SSH_OK'" 2>/dev/null | grep -q "SSH_OK"; then
echo ""
fail "SSH 连接失败!腾讯云服务器需要先配置密钥登录。
请运行: bash deploy/setup_ssh_key.sh SERVER_SSH
或参考 deploy/README.md 中的 SSH 配置指南。"
fi
ok "SSH 连接正常"
# 先在远端创建目录并清理旧文件
ssh -o BatchMode=yes "SERVER_SSH" "rm -rf REMOTE_DIR && mkdir -p REMOTE_DIR"
# 上传压缩包
scp -o BatchMode=yes -q "ARCHIVE" "SERVER_SSH:/tmp/"
# 远端解压
ssh -o BatchMode=yes "SERVER_SSH" "cd REMOTE_DIR && tar -xzf /tmp/skills-monitor-deploy.tar.gz"
ok "上传完成"
# ──────── Step 3: 远端执行部署 ────────
info "Step 3/3: 远程执行部署脚本..."
echo ""
# 构建远端环境变量
REMOTE_ENV=""
if [[ -n "-" ]]; then
REMOTE_ENV="SM_DOMAIN=SM_DOMAIN"
fi
# SSH 执行远端部署脚本(不用 -t,避免触发腾讯云二维码验证)
ssh -o BatchMode=yes "SERVER_SSH" "REMOTE_ENV DEPLOY_SOURCE=REMOTE_DIR bash REMOTE_DIR/deploy/deploy.sh"
echo ""
ok "====== 全部完成 ======"
echo ""
echo "后续操作:"
echo " 1. SSH 登录服务器编辑 .env 配置:"
echo " ssh SERVER_SSH"
echo " vim /www/wwwroot/skills-monitor/.env"
echo ""
echo " 2. 重启服务使配置生效:"
echo " ssh SERVER_SSH 'supervisorctl restart skills-monitor'"
echo ""
echo " 3. 本地 Agent 指向服务器:"
echo " export SM_SERVER_URL=\"http://你的服务器IP\""
echo ""
# ──────── 清理临时文件 ────────
rm -rf "PACK_DIR" "ARCHIVE"
FILE:deploy/deploy.sh
#!/usr/bin/env bash
# ================================================================
# Skills Monitor 服务器端一键部署脚本
# ================================================================
# 目标环境:腾讯云轻量应用服务器 (2核2G) OpenCloudOS 9.4
# 使用方式:
# 1. 本地执行 pack_and_upload.sh 打包上传到服务器
# 2. 服务器上自动执行此脚本
# ================================================================
set -euo pipefail
# ──────── 颜色输出 ────────
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
info() { echo -e "BLUE[INFO]NC $*"; }
ok() { echo -e "GREEN[OK]NC $*"; }
warn() { echo -e "YELLOW[WARN]NC $*"; }
fail() { echo -e "RED[FAIL]NC $*"; exit 1; }
# ──────── 配置变量 ────────
APP_NAME="skills-monitor"
APP_DIR="/www/wwwroot/skills-monitor"
VENV_DIR="APP_DIR/venv"
LOG_DIR="APP_DIR/logs"
DATA_DIR="APP_DIR/server/data"
DEPLOY_SOURCE="-/tmp/skills-monitor-deploy"
DOMAIN="-"
PORT=5100
# ──────── 前置检查 ────────
echo ""
echo "============================================="
echo " Skills Monitor 一键部署"
echo " 目标目录: APP_DIR"
echo "============================================="
echo ""
if [[ $EUID -ne 0 ]]; then
fail "请使用 root 用户运行此脚本"
fi
# 打印系统信息
info "系统信息:"
info " $(cat /etc/os-release 2>/dev/null | grep PRETTY_NAME | cut -d= -f2 | tr -d '"' || uname -s)"
info " 内存: $(free -h | awk '/Mem:/{print $2}') / 磁盘可用: $(df -h / | awk 'NR==2{print $4}')"
echo ""
# ──────── Step 1: 安装系统依赖 ────────
info "Step 1/7: 检查并安装系统依赖..."
# 判断包管理器
if command -v dnf &>/dev/null; then
PKG_MGR="dnf"
# OpenCloudOS / RHEL 系
dnf install -y -q python3 python3-pip supervisor nginx rsync 2>/dev/null || true
elif command -v yum &>/dev/null; then
PKG_MGR="yum"
yum install -y -q python3 python3-pip supervisor nginx rsync 2>/dev/null || true
elif command -v apt-get &>/dev/null; then
PKG_MGR="apt"
apt-get update -qq
apt-get install -y -qq python3 python3-venv python3-pip supervisor nginx rsync > /dev/null 2>&1
else
fail "不支持的包管理器,请手动安装 python3, supervisor, nginx"
fi
# 检查 Python 版本
PYTHON_BIN=$(command -v python3.11 || command -v python3.10 || command -v python3.9 || command -v python3)
PYTHON_VERSION=$($PYTHON_BIN --version 2>&1 | awk '{print $2}')
info " Python: PYTHON_BIN (PYTHON_VERSION)"
# 版本检查:至少 3.9
PY_MINOR=$($PYTHON_BIN -c "import sys; print(sys.version_info.minor)")
PY_MAJOR=$($PYTHON_BIN -c "import sys; print(sys.version_info.major)")
if [[ $PY_MAJOR -lt 3 ]] || [[ $PY_MAJOR -eq 3 && $PY_MINOR -lt 9 ]]; then
fail "需要 Python >= 3.9,当前版本 PYTHON_VERSION"
fi
ok "系统依赖已就绪 (PKG_MGR)"
# ──────── Step 2: 创建项目目录 ────────
info "Step 2/7: 创建项目目录..."
mkdir -p "APP_DIR"
mkdir -p "LOG_DIR"
mkdir -p "DATA_DIR"
ok "目录已创建: APP_DIR"
# ──────── Step 3: 复制代码 ────────
info "Step 3/7: 部署代码文件..."
if [[ ! -d "DEPLOY_SOURCE/server" ]]; then
fail "找不到部署源码: DEPLOY_SOURCE/server"
fi
# 备份旧数据库
if [[ -f "DATA_DIR/skills_monitor_server.db" ]]; then
BACKUP_NAME="skills_monitor_server.db.bak.$(date +%Y%m%d_%H%M%S)"
cp "DATA_DIR/skills_monitor_server.db" "DATA_DIR/BACKUP_NAME"
info " 已备份数据库: BACKUP_NAME"
fi
# 复制代码
rsync -a --delete \
--exclude='__pycache__' \
--exclude='*.pyc' \
--exclude='venv/' \
--exclude='.env' \
--exclude='server/data/*.db' \
--exclude='server/data/*.db.*' \
--exclude='logs/' \
"DEPLOY_SOURCE/server/" "APP_DIR/server/"
rsync -a --delete \
--exclude='__pycache__' \
--exclude='*.pyc' \
"DEPLOY_SOURCE/skills_monitor/" "APP_DIR/skills_monitor/"
cp -f "DEPLOY_SOURCE/requirements.txt" "APP_DIR/"
cp -f "DEPLOY_SOURCE/setup.py" "APP_DIR/"
[[ -f "DEPLOY_SOURCE/README.md" ]] && cp -f "DEPLOY_SOURCE/README.md" "APP_DIR/"
ok "代码已部署"
# ──────── Step 4: 创建/更新虚拟环境 ────────
info "Step 4/7: 配置 Python 虚拟环境..."
if [[ ! -d "VENV_DIR" ]]; then
$PYTHON_BIN -m venv "VENV_DIR"
info " 虚拟环境已创建"
else
info " 虚拟环境已存在,复用"
fi
source "VENV_DIR/bin/activate"
pip install --upgrade pip -q 2>/dev/null
pip install -r "APP_DIR/requirements.txt" -q 2>/dev/null
pip install gunicorn -q 2>/dev/null
ok "依赖已安装"
# ──────── Step 5: 创建 .env ────────
info "Step 5/7: 检查环境变量配置..."
ENV_FILE="APP_DIR/.env"
if [[ ! -f "ENV_FILE" ]]; then
SECRET_KEY=$(python3 -c "import secrets; print(secrets.token_hex(32))")
cat > "ENV_FILE" << ENVEOF
# ============================================
# Skills Monitor 环境变量配置
# 生成时间: $(date '+%Y-%m-%d %H:%M:%S')
# ============================================
# ──── 基础配置 ────
SM_SECRET_KEY=SECRET_KEY
SM_DEBUG=false
SM_HOST=127.0.0.1
SM_PORT=PORT
# ──── 数据库 ────
SM_DB_TYPE=sqlite
SM_DATABASE_URL=sqlite:///DATA_DIR/skills_monitor_server.db
# ──── 微信公众号 ────
SM_WECHAT_OA_APP_ID=
SM_WECHAT_OA_APP_SECRET=
SM_WECHAT_OA_TOKEN=skills-monitor-wechat
SM_WECHAT_OA_AES_KEY=
SM_WECHAT_TPL_DAILY=
SM_WECHAT_TPL_ALERT=
# ──── 微信小程序 ────
SM_WECHAT_MP_APP_ID=
SM_WECHAT_MP_APP_SECRET=
# ──── H5 页面 ────
SM_H5_BASE_URL=http://localhost:PORT
# ──── 推送时间 ────
SM_REPORT_HOUR=21
SM_REPORT_MINUTE=0
ENVEOF
chmod 600 "ENV_FILE"
warn ".env 已创建,请稍后编辑: ENV_FILE"
else
ok ".env 已存在,跳过创建"
fi
# ──────── Step 6: Gunicorn + Supervisor 配置 ────────
info "Step 6/7: 配置 Gunicorn + Supervisor..."
# Gunicorn 配置
cat > "APP_DIR/gunicorn.conf.py" << 'GUNIEOF'
# Gunicorn 配置 — 2核2G 优化版
import multiprocessing
bind = "127.0.0.1:5100"
workers = 1
worker_class = "gthread"
threads = 4
timeout = 120
keepalive = 5
graceful_timeout = 30
max_requests = 500
max_requests_jitter = 50
accesslog = "/www/wwwroot/skills-monitor/logs/access.log"
errorlog = "/www/wwwroot/skills-monitor/logs/error.log"
loglevel = "info"
access_log_format = '%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" %(L)ss'
preload_app = True
GUNIEOF
# 启动脚本
cat > "APP_DIR/start.sh" << 'STARTEOF'
#!/usr/bin/env bash
set -a
source /www/wwwroot/skills-monitor/.env
set +a
cd /www/wwwroot/skills-monitor
exec /www/wwwroot/skills-monitor/venv/bin/gunicorn \
-c /www/wwwroot/skills-monitor/gunicorn.conf.py \
"server.app:create_app()"
STARTEOF
chmod +x "APP_DIR/start.sh"
# ──── 自动检测 Supervisor 配置目录 ────
SUPERVISOR_CONF_DIR=""
if [[ -d "/etc/supervisord.d" ]]; then
# OpenCloudOS / RHEL 系: /etc/supervisord.d/*.ini
SUPERVISOR_CONF_DIR="/etc/supervisord.d"
SUPERVISOR_CONF_EXT=".ini"
elif [[ -d "/etc/supervisor/conf.d" ]]; then
# Debian / Ubuntu: /etc/supervisor/conf.d/*.conf
SUPERVISOR_CONF_DIR="/etc/supervisor/conf.d"
SUPERVISOR_CONF_EXT=".conf"
else
# 尝试从 supervisord.conf 中找
if [[ -f "/etc/supervisord.conf" ]]; then
INCLUDE_DIR=$(grep -oP 'files\s*=\s*\K[^\s]+' /etc/supervisord.conf 2>/dev/null | head -1 | sed 's|/\*\.ini||;s|/\*\.conf||')
if [[ -n "INCLUDE_DIR" ]]; then
mkdir -p "INCLUDE_DIR"
SUPERVISOR_CONF_DIR="INCLUDE_DIR"
SUPERVISOR_CONF_EXT=".ini"
fi
fi
fi
if [[ -z "SUPERVISOR_CONF_DIR" ]]; then
# 最终回退
mkdir -p "/etc/supervisord.d"
SUPERVISOR_CONF_DIR="/etc/supervisord.d"
SUPERVISOR_CONF_EXT=".ini"
warn "未检测到 Supervisor 配置目录,使用默认路径: SUPERVISOR_CONF_DIR"
fi
SUPERVISOR_CONF_FILE="SUPERVISOR_CONF_DIR/APP_NAMESUPERVISOR_CONF_EXT"
info " Supervisor 配置: SUPERVISOR_CONF_FILE"
cat > "SUPERVISOR_CONF_FILE" << SUPEOF
[program:APP_NAME]
command=APP_DIR/start.sh
directory=APP_DIR
user=root
autostart=true
autorestart=true
startsecs=5
startretries=3
redirect_stderr=false
stdout_logfile=LOG_DIR/supervisor_stdout.log
stderr_logfile=LOG_DIR/supervisor_stderr.log
stdout_logfile_maxbytes=5MB
stdout_logfile_backups=3
stderr_logfile_maxbytes=5MB
stderr_logfile_backups=3
stopwaitsecs=30
stopsignal=TERM
SUPEOF
# 确保 supervisord 正在运行
if ! pgrep -x supervisord > /dev/null 2>&1; then
info " 启动 supervisord..."
systemctl enable supervisord 2>/dev/null || true
systemctl start supervisord 2>/dev/null || supervisord -c /etc/supervisord.conf 2>/dev/null || true
sleep 2
fi
# 重载并启动
supervisorctl reread > /dev/null 2>&1 || true
supervisorctl update > /dev/null 2>&1 || true
if supervisorctl status "APP_NAME" 2>/dev/null | grep -q RUNNING; then
supervisorctl restart "APP_NAME" > /dev/null 2>&1
info " 服务已重启"
else
supervisorctl start "APP_NAME" > /dev/null 2>&1 || true
info " 服务已启动"
fi
ok "Supervisor 进程管理已配置"
# ──────── Step 7: Nginx 反向代理 ────────
info "Step 7/7: 配置 Nginx 反向代理..."
# 安装 Nginx(如果没有的话)
if ! command -v nginx &>/dev/null; then
info " 正在安装 Nginx..."
if [[ "PKG_MGR" == "dnf" ]]; then
dnf install -y -q nginx 2>/dev/null || true
elif [[ "PKG_MGR" == "yum" ]]; then
yum install -y -q nginx 2>/dev/null || true
elif [[ "PKG_MGR" == "apt" ]]; then
apt-get install -y -qq nginx > /dev/null 2>&1 || true
fi
fi
# 获取 server_name
if [[ -n "DOMAIN" ]]; then
SERVER_NAME="DOMAIN"
else
PUBLIC_IP=$(curl -s --max-time 5 ifconfig.me 2>/dev/null || echo "_")
SERVER_NAME="PUBLIC_IP"
fi
# 检测 Nginx 配置目录
NGINX_CONF_DIR=""
if [[ -d "/etc/nginx/conf.d" ]]; then
NGINX_CONF_DIR="/etc/nginx/conf.d"
elif [[ -d "/etc/nginx/sites-available" ]]; then
NGINX_CONF_DIR="/etc/nginx/sites-available"
fi
if [[ -n "NGINX_CONF_DIR" ]]; then
NGINX_CONF="NGINX_CONF_DIR/APP_NAME.conf"
else
NGINX_CONF="APP_DIR/nginx_APP_NAME.conf"
fi
cat > "NGINX_CONF" << NGINXEOF
# Skills Monitor Nginx 反向代理
server {
listen 80;
server_name SERVER_NAME;
location /static/ {
alias APP_DIR/server/static/;
expires 7d;
add_header Cache-Control "public, immutable";
access_log off;
}
location = /health {
proxy_pass http://127.0.0.1:PORT;
access_log off;
}
location / {
proxy_pass http://127.0.0.1:PORT;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
proxy_read_timeout 120s;
proxy_connect_timeout 10s;
client_max_body_size 10m;
}
}
NGINXEOF
# 启动 Nginx
if command -v nginx &>/dev/null; then
# 如果有 sites-enabled 目录,创建软链
if [[ -d "/etc/nginx/sites-enabled" ]] && [[ ! -L "/etc/nginx/sites-enabled/APP_NAME.conf" ]]; then
ln -sf "NGINX_CONF" "/etc/nginx/sites-enabled/APP_NAME.conf"
fi
# 测试并重载
if nginx -t 2>/dev/null; then
systemctl enable nginx 2>/dev/null || true
systemctl restart nginx 2>/dev/null || nginx -s reload 2>/dev/null || true
ok "Nginx 已配置并启动"
else
warn "Nginx 配置测试失败,请检查: nginx -t"
fi
else
warn "Nginx 安装失败,配置已保存到: NGINX_CONF"
warn "请手动安装后使用此配置"
fi
# ──────── 开放防火墙端口 ────────
if command -v firewall-cmd &>/dev/null; then
firewall-cmd --permanent --add-service=http 2>/dev/null || true
firewall-cmd --permanent --add-service=https 2>/dev/null || true
firewall-cmd --reload 2>/dev/null || true
info " 防火墙已开放 80/443 端口"
fi
# ──────── 健康检查 ────────
echo ""
info "等待服务启动..."
sleep 3
HEALTH_OK=false
for i in 1 2 3 4 5; do
if curl -sf "http://127.0.0.1:PORT/health" > /dev/null 2>&1; then
HEALTH_OK=true
break
fi
sleep 2
done
echo ""
echo "============================================="
if $HEALTH_OK; then
HEALTH_RESP=$(curl -s "http://127.0.0.1:PORT/health" 2>/dev/null)
echo -e " GREEN✅ Skills Monitor 部署成功!NC"
echo ""
echo " 健康检查: HEALTH_RESP"
else
echo -e " YELLOW⚠️ 服务可能还在启动中NC"
echo " 查看日志: tail -f LOG_DIR/error.log"
echo " 查看进程: supervisorctl status APP_NAME"
fi
echo ""
echo " 项目目录: APP_DIR"
echo " 日志目录: LOG_DIR"
echo " 环境配置: APP_DIR/.env"
echo " 内网地址: http://127.0.0.1:PORT"
echo " 外网地址: http://SERVER_NAME"
echo ""
echo " 常用命令:"
echo " supervisorctl status APP_NAME"
echo " supervisorctl restart APP_NAME"
echo " tail -f LOG_DIR/error.log"
echo " vim APP_DIR/.env"
echo ""
echo " 下一步:"
echo " 1. 编辑 .env 填入微信公众号/小程序配置"
echo " 2. supervisorctl restart APP_NAME"
echo " 3. 本地 Agent: export SM_SERVER_URL=\"http://SERVER_NAME\""
echo "============================================="
echo ""