@clawhub-zhaobod1-a2f722245e
基于 Karpathy 自主研究循环的 OpenClaw Skill,Modify → Verify → Keep/Discard → Repeat forever
---
name: huo15-autoresearch-loop
version: 1.0.2
aliases:
- 火一五自动迭代
- 火一五自动循环
- 自动迭代循环
description: 基于 Karpathy 自主研究循环的 OpenClaw Skill,Modify → Verify → Keep/Discard → Repeat forever
---
# huo15-autoresearch-loop
> 基于 [uditgoenka/autoresearch](https://github.com/uditgoenka/autoresearch)(Karpathy 自主研究循环)的 OpenClaw Skill 实现。
## 触发词
- 自动迭代
- autoresearch
- 跑起来别停
- 自动循环
## 功能
实现 Karpathy 的 **Modify → Verify → Keep/Discard → Repeat forever** 自主研究循环。
## 核心流程
1. 用户说「自动迭代 [目标] [验证命令]」
2. 初始化状态(目标/验证命令/迭代次数/范围)
3. 循环:
- 修改代码/文件
- 调用验证命令
- 成功 → git commit + 记录
- 失败 → git revert
- 判断是否继续(迭代次数/收敛检测)
4. 输出摘要
## 使用方式
```
自动迭代 [目标描述] [验证命令]
```
示例:
```
自动迭代 优化性能瓶颈 make test
自动迭代 修复所有lint错误 ./scripts/lint.sh
```
## 配置说明
`config.json` 控制循环行为:
```json
{
"max_iterations": 50,
"verify_command": "",
"scope_globs": ["**/*.py", "**/*.js"],
"convergence_threshold": 3,
"commit_each_success": true,
"revert_on_fail": true
}
```
## 非侵入设计
- 只通过 `exec` 调用脚本
- 不修改 OpenClaw 内核
- 状态持久化在本地文件
## 状态文件
每次迭代的状态保存在 `~/.openclaw/tmp/autoresearch-loop-state.json`:
```json
{
"goal": "优化性能",
"verify_command": "make test",
"iteration": 5,
"successes": 3,
"failures": 2,
"last_success": "2026-04-22T00:30:00Z",
"history": [...]
}
```
## 停止条件
- 达到 `max_iterations`
- 连续失败超过 `convergence_threshold`
- 用户发送「停止迭代」
- 收敛检测(连续 N 次成功)
## version
1.0.0
FILE:_meta.json
{
"ownerId": "kn7byevkn40d6z4p7ghdb097z983tj33",
"slug": "huo15-autoresearch-loop",
"version": "1.0.2"
}
FILE:config.json
{
"name": "huois-autoresearch-loop",
"version": "1.0.0",
"description": "Karpathy 自主研究循环 - Modify → Verify → Keep/Discard → Repeat forever",
"max_iterations": 50,
"verify_command": "",
"scope_globs": ["**/*.py", "**/*.js", "**/*.ts"],
"convergence_threshold": 3,
"commit_each_success": true,
"revert_on_fail": true,
"state_file": "~/.openclaw/tmp/autoresearch-loop-state.json",
"log_file": "~/.openclaw/tmp/autoresearch-loop.log"
}
FILE:scripts/loop.sh
#!/bin/bash
# loop.sh - 自主研究循环主脚本
# Modify → Verify → Keep/Discard → Repeat forever
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
SKILL_DIR="$(dirname "$SCRIPT_DIR")"
CONFIG_FILE="$SKILL_DIR/config.json"
source "$SCRIPT_DIR/state.sh"
# 颜色输出
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
log_info() { echo -e "BLUE[INFO]NC $1"; }
log_ok() { echo -e "GREEN[OK]NC $1"; }
log_warn() { echo -e "YELLOW[WARN]NC $1"; }
log_error() { echo -e "RED[ERROR]NC $1"; }
# 主循环
run_loop() {
local goal="$1"
local verify_cmd="$2"
local scope_globs="-**"
log_info "🚀 启动自动迭代 - 目标: $goal"
log_info "📋 验证命令: $verify_cmd"
# 初始化状态
state init "$goal" "$verify_cmd" "$scope_globs"
local iteration=0
local max_iterations=$(python3 -c "import json; print(json.load(open('$CONFIG_FILE')).get('max_iterations', 50))" 2>/dev/null || echo "50")
local commit_each=$(python3 -c "import json; print(json.load(open('$CONFIG_FILE')).get('commit_each_success', True))" 2>/dev/null || echo "true")
local revert_on_fail=$(python3 -c "import json; print(json.load(open('$CONFIG_FILE')).get('revert_on_fail', True))" 2>/dev/null || echo "true")
# 保存初始 git 状态
local initial_commit=$(git rev-parse HEAD 2>/dev/null || echo "")
while true; do
iteration=$((iteration + 1))
log_info "━━━ 迭代 $iteration / $max_iterations ━━━"
# 检查停止条件
if state should_stop; then
local reason=$(state should_stop 2>/dev/null || echo "unknown")
log_warn "⏹ 停止条件触发: $reason"
break
fi
# 记录当前 git 状态
local pre_commit=$(git rev-parse HEAD 2>/dev/null || echo "")
# 执行一次修改
log_info "🔧 执行修改..."
local modify_result=0
local changes=""
# 调用 AI 进行一次修改(通过环境变量传递上下文)
# 这里需要主 Agent 介入,实际由 OpenClaw sessions_spawn 调用子 agent
if [[ -n "$CLAUDE_TASK" ]]; then
# 子 agent 模式:执行修改
eval "$CLAUDE_TASK" || modify_result=$?
changes=$(git diff --stat HEAD 2>/dev/null || echo "")
else
log_warn "⚠ CLAUDE_TASK 未设置,请在 OpenClaw 中使用 sessions_spawn 调用"
break
fi
if [[ $modify_result -ne 0 ]]; then
log_error "修改执行失败,跳过验证"
state update "$iteration" "failure" "修改执行失败" "$changes"
continue
fi
# 验证
log_info "🔍 执行验证..."
if verify.sh run "$verify_cmd"; then
log_ok "✅ 验证通过"
# git commit
if [[ "$commit_each" == "true" ]] && [[ -n "$(git status --porcelain)" ]]; then
git add -A
git commit -m "autoresearch iter $iteration: $goal" 2>/dev/null || true
fi
state update "$iteration" "success" "验证通过" "$changes"
else
log_error "❌ 验证失败"
# git revert
if [[ "$revert_on_fail" == "true" ]] && [[ -n "$pre_commit" ]]; then
log_info "↩ 回滚更改..."
git reset --hard HEAD > /dev/null 2>&1 || true
git clean -fd > /dev/null 2>&1 || true
fi
state update "$iteration" "failure" "验证失败" "$changes"
fi
echo ""
done
# 输出最终摘要
state complete
echo ""
log_info "━━━ 最终摘要 ━━━"
state summary
}
# 停止运行中的循环
stop_loop() {
local state_file="$HOME/.openclaw/tmp/autoresearch-loop-state.json"
if [[ -f "$state_file" ]]; then
python3 <<PYEOF
import json
with open('$state_file', 'r') as f:
state = json.load(f)
state['status'] = 'stopped_by_user'
state['stopped_at'] = '$(date -u +%Y-%m-%dT%H:%M:%SZ)'
with open('$state_file', 'w') as f:
json.dump(state, f, indent=2)
PYEOF
log_ok "循环已停止"
else
log_error "没有正在运行的循环"
fi
}
# 查看状态
status() {
state get
}
# 命令路由
case "$1" in
start|run)
run_loop "$2" "$3" "$4"
;;
stop)
stop_loop
;;
status)
status
;;
summary)
state summary
;;
*)
echo "Usage: loop.sh {start|stop|status|summary} [goal] [verify_cmd] [scope]"
echo ""
echo "示例:"
echo " loop.sh start '优化性能' 'make test'"
echo " loop.sh status"
echo " loop.sh stop"
exit 1
;;
esac
FILE:scripts/state.sh
#!/bin/bash
# state.sh - 状态读写脚本
# 调用 session-state.sh 或直接操作状态文件
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
SKILL_DIR="$(dirname "$SCRIPT_DIR")"
CONFIG_FILE="$SKILL_DIR/config.json"
STATE_FILE="-$HOME/.openclaw/tmp/autoresearch-loop-state.json"
LOG_FILE="-$HOME/.openclaw/tmp/autoresearch-loop.log"
mkdir -p "$(dirname "$STATE_FILE")"
mkdir -p "$(dirname "$LOG_FILE")"
# 初始化状态
init_state() {
local goal="$1"
local verify_cmd="$2"
local scope_globs="-**"
cat > "$STATE_FILE" <<EOF
{
"goal": "$goal",
"verify_command": "$verify_cmd",
"scope_globs": "$scope_globs",
"iteration": 0,
"successes": 0,
"failures": 0,
"consecutive_failures": 0,
"last_success": null,
"last_fail": null,
"history": [],
"started_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"status": "running"
}
EOF
echo "[$(date '+%Y-%m-%d %H:%M:%S')] [INIT] 状态初始化完成 - 目标: $goal" >> "$LOG_FILE"
echo "✅ 状态已初始化 - 目标: $goal"
}
# 读取当前迭代次数
get_iteration() {
if [[ -f "$STATE_FILE" ]]; then
python3 -c "import json; print(json.load(open('$STATE_FILE'))['iteration'])" 2>/dev/null || echo "0"
else
echo "0"
fi
}
# 读取状态
get_state() {
if [[ -f "$STATE_FILE" ]]; then
cat "$STATE_FILE"
else
echo "{}"
fi
}
# 更新状态
update_state() {
local iteration="$1"
local result="$2" # success | failure
local message="$3"
local changes="$4"
local successes=$(python3 -c "import json; d=json.load(open('$STATE_FILE')); print(d.get('successes',0))" 2>/dev/null || echo "0")
local failures=$(python3 -c "import json; d=json.load(open('$STATE_FILE')); print(d.get('failures',0))" 2>/dev/null || echo "0")
local consecutive=$(python3 -c "import json; d=json.load(open('$STATE_FILE')); print(d.get('consecutive_failures',0))" 2>/dev/null || echo "0")
local history=$(python3 -c "import json; d=json.load(open('$STATE_FILE')); print(json.dumps(d.get('history',[])))" 2>/dev/null || echo "[]")
if [[ "$result" == "success" ]]; then
successes=$((successes + 1))
consecutive=0
last_time=$(date -u +%Y-%m-%dT%H:%M:%SZ)
else
failures=$((failures + 1))
consecutive=$((consecutive + 1))
last_time=$(date -u +%Y-%m-%dT%H:%M:%SZ)
fi
local entry=$(cat <<EOF
{
"iteration": $iteration,
"result": "$result",
"message": "$message",
"changes": "$changes",
"timestamp": "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
}
EOF
)
# 更新状态
python3 <<PYEOF
import json
state_file = '$STATE_FILE'
with open(state_file, 'r') as f:
state = json.load(f)
state['iteration'] = $iteration
state['successes'] = $successes
state['failures'] = $failures
state['consecutive_failures'] = $consecutive
state['last_success'] = '$result' == 'success' and '$(date -u +%Y-%m-%dT%H:%M:%SZ)' or state.get('last_success')
state['last_fail'] = '$result' == 'failure' and '$(date -u +%Y-%m-%dT%H:%M:%SZ)' or state.get('last_fail')
entry = json.loads('''$entry''')
state['history'].append(entry)
with open(state_file, 'w') as f:
json.dump(state, f, indent=2, ensure_ascii=False)
PYEOF
echo "[$(date '+%Y-%m-%d %H:%M:%S')] [$result.to_upper()] 迭代 $iteration - $message" >> "$LOG_FILE"
}
# 检查是否应该停止
should_stop() {
local max_iterations=$(python3 -c "import json; print(json.load(open('$CONFIG_FILE')).get('max_iterations', 50))" 2>/dev/null || echo "50")
local convergence=$(python3 -c "import json; print(json.load(open('$CONFIG_FILE')).get('convergence_threshold', 3))" 2>/dev/null || echo "3")
local iteration=$(get_iteration)
local consecutive=$(python3 -c "import json; print(json.load(open('$STATE_FILE')).get('consecutive_failures', 0))" 2>/dev/null || echo "0")
if [[ $iteration -ge $max_iterations ]]; then
echo "max_iterations"
return 0
fi
if [[ $consecutive -ge $convergence ]]; then
echo "convergence"
return 0
fi
return 1
}
# 获取摘要
get_summary() {
if [[ -f "$STATE_FILE" ]]; then
python3 -c "
import json
d = json.load(open('$STATE_FILE'))
print(f\"\"\"迭代摘要:
- 目标: {d['goal']}
- 总迭代: {d['iteration']}
- 成功: {d['successes']}
- 失败: {d['failures']}
- 连续失败: {d['consecutive_failures']}
- 状态: {d['status']}
\"\"\")
"
fi
}
# 标记完成
mark_complete() {
python3 <<PYEOF
import json
with open('$STATE_FILE', 'r') as f:
state = json.load(f)
state['status'] = 'completed'
state['completed_at'] = '$(date -u +%Y-%m-%dT%H:%M:%SZ)'
with open('$STATE_FILE', 'w') as f:
json.dump(state, f, indent=2)
PYEOF
echo "[$(date '+%Y-%m-%d %H:%M:%S')] [COMPLETE] 自动迭代完成" >> "$LOG_FILE"
}
# 命令路由
case "$1" in
init)
init_state "$2" "$3" "$4"
;;
get)
get_state
;;
iteration)
get_iteration
;;
update)
update_state "$2" "$3" "$4" "$5"
;;
should_stop)
should_stop
;;
summary)
get_summary
;;
complete)
mark_complete
;;
*)
echo "Usage: state.sh {init|get|iteration|update|should_stop|summary|complete}"
exit 1
;;
esac
FILE:scripts/verify.sh
#!/bin/bash
# verify.sh - 验证命令执行器
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
SKILL_DIR="$(dirname "$SCRIPT_DIR")"
CONFIG_FILE="$SKILL_DIR/config.json"
verify() {
local verify_cmd="$1"
local timeout="-300"
if [[ -z "$verify_cmd" ]]; then
echo "❌ 未提供验证命令"
return 1
fi
echo "🔍 执行验证: $verify_cmd"
# 执行验证命令,带超时
local result=0
local output
output=$(timeout "$timeout" bash -c "$verify_cmd" 2>&1) || result=$?
echo "$output"
if [[ $result -eq 0 ]]; then
echo "✅ 验证通过"
return 0
elif [[ $result -eq 124 ]]; then
echo "⏰ 验证超时 (timeouts)"
return 2
else
echo "❌ 验证失败 (exit code: $result)"
return 1
fi
}
# 快速验证(不输出详情)
verify_quick() {
local verify_cmd="$1"
timeout 60 bash -c "$verify_cmd" > /dev/null 2>&1
return $?
}
# 从配置读取默认验证命令
verify_default() {
local default_cmd=$(python3 -c "import json; print(json.load(open('$CONFIG_FILE')).get('verify_command', ''))" 2>/dev/null || echo "")
if [[ -n "$default_cmd" ]]; then
verify "$default_cmd"
else
echo "❌ config.json 中未设置 verify_command"
return 1
fi
}
case "$1" in
run)
verify "$2" "-300"
;;
quick)
verify_quick "$2"
;;
default)
verify_default
;;
*)
echo "Usage: verify.sh {run|quick|default} [command] [timeout]"
exit 1
;;
esac
将 Andrej Karpathy 的 LLM 编程四大行为规范打包为 OpenClaw Skill
---
name: huo15-karpathy-guidelines
version: 1.0.2
description: 将 Andrej Karpathy 的 LLM 编程四大行为规范打包为 OpenClaw Skill
aliases:
- 火一五卡帕西准则
- 火一五行为准则
- Karpathy准则
---
# SKILL.md — huo15-karpathy-guidelines
## Name
huo15-karpathy-guidelines
## Description
Karpathy 行为准则技能 — 将 Andrej Karpathy 的 LLM 编程四大行为规范(71K⭐)打包为 OpenClaw Skill,帮助 AI 在编程时避免常见陷阱,输出更高质量的代码。
## Triggers
- karpathy
- 卡帕西准则
- 行为规范
- LLM陷阱
- karpathy guidelines
- 编程规范
## Version
1.0.0
---
## 核心准则
### 1. Think Before Coding(三思而后行)
> "The models make wrong assumptions on your behalf and just run along with them without checking."
**核心原则:**
- **不确定就先问,别猜。** 有歧义时呈现多个选项,而不是选一个闷头做。
- **停下来的勇气。** 遇到困惑就命名清楚、请求澄清,不假装懂。
- **呈现权衡。** 有 tradeoffs 就说出来,不假装只有一个正确答案。
**常见陷阱:**
- 看到模糊的需求,不确认就按自己理解的做
- 遇到不确定的 API 参数,瞎猜一个
- 跳过代码审查环节直接交付
**正确做法:**
```
❌ "这个参数应该是xxx,我直接用了"
✅ "这个参数有两种可能的含义,您指的是哪种?A. xxx B. xxx"
```
---
### 2. Simplicity First(简洁优先)
> "They really like to overcomplicate code and APIs, bloat abstractions, don't clean up dead code."
**核心原则:**
- **能用三行解决就别写三十行。** 做完回头看能不能更短。
- **不做额外功能。** 只实现用户要求的,不加"灵活性"和"可扩展性"。
- **删除无用代码。** 自己造成的孤儿代码要清理,但不顺手删别人的。
**常见陷阱:**
- 引入不必要的抽象层(Factory、Strategy、Visitor...)
- 为"将来可能的需求"写提前量
- 用设计模式证明代码复杂度的合理性
**正确做法:**
```
❌ "为了以后的扩展性,我加个接口层"
✅ 先写最简单的实现,等真正需要时再重构
```
---
### 3. Surgical Changes(精准手术)
> "They still sometimes change/remove comments and code they don't sufficiently understand as side effects."
**核心原则:**
- **只改该改的。** 每个改动的行都要能追溯到用户的原始请求。
- **不顺手重构。** 旁边的代码没问题就别碰,哪怕你觉得可以更好。
- **匹配现有风格。** 即便自己的风格更好,也要服从已有的。
**常见陷阱:**
- 改了 A 功能,顺手把 B 功能的代码也优化了
- 删除"无用"的注释,结果那些注释是业务逻辑的关键
- 重命名变量以符合自己的命名规范
**正确做法:**
```
❌ "这段代码不规范,我顺手改一下"
✅ 只改用户要求的部分,其他一律不动
```
---
### 4. Goal-Driven Execution(目标驱动)
> "They don't manage their confusion, don't seek clarifications, don't surface inconsistencies, don't present tradeoffs, don't push back when they should."
**核心原则:**
- **先定义成功标准。** 动手前说清楚怎么算"完成了"。
- **用机械验证。** 不说"看起来不错"——用数字和测试证明。
- **失败自动回滚。** 改坏了立即还原,不留烂摊子。
**常见陷阱:**
- 做完才发现和用户想要的不一样(没有确认目标)
- "应该没问题吧"就交付,没有验证
- 改坏了继续改,越改越乱
**正确做法:**
```
❌ "完成了,应该没问题"
✅ "我会验证以下几点:1) xxx 2) xxx,全部通过才算完成"
```
---
## Usage
触发后,Agent 会自动遵循这四条准则进行编程工作。
也可通过 `scripts/karpathy.sh` 输出速查表。
## Credits
Inspired by [forrestchang/andrej-karpathy-skills](https://github.com/forrestchang/andrej-karpathy-skills) (71K⭐)
FILE:_meta.json
{
"ownerId": "kn7byevkn40d6z4p7ghdb097z983tj33",
"slug": "huo15-karpathy-guidelines",
"version": "1.0.2"
}
FILE:scripts/karpathy.sh
#!/bin/bash
# karpathy.sh — Karpathy 行为准则速查表
# 来源: forrestchang/andrej-karpathy-skills (71K⭐)
cat << 'EOF'
═══════════════════════════════════════════════════════════════
Karpathy 行为准则速查表
from: forrestchang/andrej-karpathy-skills
═══════════════════════════════════════════════════════════════
1️⃣ Think Before Coding(三思而后行)
─────────────────────────────────
✓ 不确定就先问,别猜
✓ 遇到困惑就命名清楚、请求澄清
✓ 有 tradeoffs 就呈现出来
2️⃣ Simplicity First(简洁优先)
─────────────────────────────────
✓ 能三行解决就别写三十行
✓ 只实现要求的,不加额外功能
✓ 删除无用代码
3️⃣ Surgical Changes(精准手术)
─────────────────────────────────
✓ 只改该改的,追溯到用户原始请求
✓ 不顺手重构周围的代码
✓ 匹配现有风格,不强制自己的规范
4️⃣ Goal-Driven Execution(目标驱动)
─────────────────────────────────
✓ 先定义成功标准
✓ 用机械验证,不用"看起来不错"
✓ 失败自动回滚,不留烂摊子
═══════════════════════════════════════════════════════════════
EOF
通过火山方舟Ark API调用Seedance 2.0生成第一人称带货短视频,v2 新增剧本驱动的配音(edge-tts/火山TTS)、背景音乐自动混音、字幕烧录,内置 8 套人设模板(传统女、时尚主播、老中医、厨房主妇、美妆博主、健身教练、户外探店、数码博主)。触发词:生成视频、带货视频、产品视频、拍视频、剧本...
---
name: huo15-influencer-video-skill
displayName: 火一五带货视频技能
description: 通过火山方舟Ark API调用Seedance 2.0生成第一人称带货短视频,v2 新增剧本驱动的配音(edge-tts/火山TTS)、背景音乐自动混音、字幕烧录,内置 8 套人设模板(传统女、时尚主播、老中医、厨房主妇、美妆博主、健身教练、户外探店、数码博主)。触发词:生成视频、带货视频、产品视频、拍视频、剧本拍视频。
version: 2.0.0
aliases:
- 火一五带货视频技能
- 火一五AI带货视频技能
- 火一五产品视频技能
- 带货视频
- 剧本带货视频
- AI视频生成
- 产品视频
---
# 火15 AI 带货视频生成 Skill v2
> 产品图 + 剧本 → 带配音、背景音乐、字幕的第一人称带货短视频。
> 内置 8 套人设模板,一句话指定品类即可自动选模板。
---
## ⚠️ 安全规则
1. 每次生成前**必须**告知用户预估费用(Seedance 视频部分;TTS edge-tts 免费、火山 TTS 单字 ≤¥0.0006)
2. 用户确认后才可提交任务
3. 单次最大时长 15 秒,默认按配音时长自动决定
4. 优先使用最省 Token 的配置
---
## 一、能力总览
| 模块 | v1 | v2(本次) |
|---|---|---|
| 视频生成 | Seedance 2.0 ✅ | Seedance 2.0 ✅ |
| 配音 | ❌ | edge-tts(默认免费) / 火山 TTS(可选) ✅ |
| 背景音乐 | ❌ | ffmpeg 自动循环+降音量+淡出 ✅ |
| 字幕 | ❌ | 按行字数比例切时间轴 + 烧录 ✅ |
| 人设模板 | 1 套(传统女) | **8 套**(按品类自动推荐) ✅ |
| 剧本驱动 | ❌ | JSON 剧本一键端到端 ✅ |
---
## 二、文件结构
```
huo15-influencer-video-skill/
├── SKILL.md # 本文档
├── _meta.json
├── scripts/
│ ├── templates.py # 8 套人设模板配置
│ ├── tts.py # 配音引擎(edge-tts + 火山)
│ ├── bgm.py # BGM 库 + 混音 + 视频/音频合并
│ └── pipeline.py # 端到端 pipeline(推荐入口)
└── examples/
├── script_traditional_lady.json
├── script_fashion_host.json
└── script_auto_template.json
```
---
## 三、依赖与凭证
```bash
# 必需
brew install ffmpeg
pip install edge-tts requests
# 视频生成
export ARK_API_KEY=ak-xxxxx # 方舟控制台获取
# 可选:火山 TTS(不设则降级到 edge-tts,免费但音质稍弱)
export VOLC_TTS_APP_ID=xxxxx
export VOLC_TTS_TOKEN=xxxxx
export VOLC_TTS_CLUSTER=volcano_tts
# 可选:BGM 库目录(默认 ~/Music/huo15-bgm/)
export HUO15_BGM_DIR=~/Music/huo15-bgm
```
### BGM 文件准备(一次性)
把 5 个免版税音乐放到 `~/Music/huo15-bgm/`(缺哪个跳过哪个,不影响视频生成):
| 文件名 | 风格 | 推荐用途 | 下载关键词 |
|---|---|---|---|
| `warm.mp3` | 温暖钢琴 | 养生/食品/手工 | warm piano background |
| `energetic.mp3` | 活力电子 | 美妆/服装/直播 | upbeat electronic |
| `asian.mp3` | 中国风古筝 | 中药/茶/古风 | chinese guzheng |
| `soft.mp3` | 柔和氛围 | 数码/护肤 | soft ambient pad |
| `cinematic.mp3` | 电影弦乐 | 户外/特产 | cinematic strings |
下载渠道:[Pixabay Music](https://pixabay.com/music/)(CC0)、[Freesound](https://freesound.org/)、[Incompetech](https://incompetech.com/music/royalty-free/)(注明出处)。
---
## 四、8 套预设模板
| key | 角色 | 推荐音色 | 推荐 BGM | 适用品类 |
|---|---|---|---|---|
| `traditional_lady` | 传统中年女性(默认) | 晓秋(沉稳) | warm | 养生 / 茶叶 / 手工 / 古法食品 |
| `fashion_host` | 时尚女主播 | 晓晓(活泼) | energetic | 美妆 / 服装 / 饰品 / 数码配件 |
| `tcm_doctor` | 老中医 | 云健(沉稳男) | asian | 中药 / 保健品 / 膏方 / 艾灸 |
| `kitchen_mom` | 厨房主妇 | 晓涵(温暖) | warm | 调味料 / 食材 / 厨具 / 速食 |
| `beauty_blogger` | 美妆博主 | 晓梦(活泼) | soft | 护肤 / 彩妆 / 香水 / 美容仪 |
| `fitness_coach` | 健身教练 | 云皓(激情男) | energetic | 蛋白粉 / 运动器材 / 补剂 |
| `outdoor_explorer` | 户外探店达人 | 云夏(轻快男) | cinematic | 地方特产 / 户外装备 / 民俗 |
| `tech_geek` | 数码博主 | 云扬(专业男) | soft | 手机 / 耳机 / 智能家居 / 电脑 |
### 自动选模板
```json
{ "template": "auto", "category": "蛋白粉", ... }
```
→ 命中 `fitness_coach`。无匹配回退 `traditional_lady`。
---
## 五、剧本格式
```json
{
"template": "traditional_lady",
"image": "/path/to/product.jpg",
"lines": [
{"text": "姐妹们,今天给大家推一款好东西", "action": "举起产品给镜头"},
{"text": "古法配方,纯手工制作", "action": "微笑展示产品细节"},
{"text": "用过的姐妹都说好", "action": "点头肯定"}
],
"bgm": "warm",
"bgm_volume": 0.18,
"subtitle": true,
"voice_override": null,
"rate_override": null,
"output": "/tmp/huo15/final.mp4"
}
```
| 字段 | 必填 | 说明 |
|---|---|---|
| `template` | ✅ | 模板 key 或 `"auto"`(配合 `category`) |
| `category` | template=auto 时必填 | 品类关键词,自动选模板 |
| `image` | ✅ | 产品图本地路径 |
| `lines` | ✅ | 数组,每条 `{text, action}`。**首条 action 会写进 Seedance prompt** |
| `bgm` | ❌ | BGM key(warm/energetic/...)或绝对路径;null=无 BGM |
| `bgm_volume` | ❌ | 0~1,覆盖模板默认(0.18~0.25 较合适) |
| `subtitle` | ❌ | 默认 true;烧录字幕到视频 |
| `voice_override` | ❌ | 强制换音色,如 `"zh-CN-XiaoxiaoNeural"` |
| `rate_override` | ❌ | 强制改语速,如 `"+10%"` |
| `output` | ❌ | 成片路径 |
### 台词长度上限
整段配音必须 ≤ 14.5 秒(Seedance 单次最长 15s)。中文约 **50~70 字**。
超长会抛错并提示精简,不强行截断。
---
## 六、调用方式
### 6.1 命令行(最直接)
```bash
cd huo15-influencer-video-skill
# 自检 — 第一次跑先做这一步
python3 scripts/pipeline.py preflight
# 列出 8 套人设模板 / 8 个推荐音色
python3 scripts/pipeline.py templates
python3 scripts/pipeline.py voices
# dry-run — 只跑 TTS + 字幕,不调 Seedance(省 ¥)
# 用于先验证剧本节奏、TTS 音色、字幕断句
python3 scripts/pipeline.py dry-run examples/script_traditional_lady.json
# 完整端到端
python3 scripts/pipeline.py render examples/script_traditional_lady.json
```
### 6.2 Python 调用
```python
import sys, json
sys.path.insert(0, "scripts")
from pipeline import render
result = render({
"template": "auto",
"category": "蛋白粉",
"image": "/path/to/protein.jpg",
"lines": [
{"text": "兄弟们练完这一组", "action": "拿起产品"},
{"text": "蛋白吸收率高得离谱", "action": "展示成分"},
],
"subtitle": True,
})
print(result)
# {'output': '/tmp/huo15_video/final.mp4', 'template': 'fitness_coach',
# 'voice_duration': 6.2, 'video_duration': 8, 'tokens': 172800,
# 'cost_yuan': 7.95, 'size_mb': 3.1}
```
### 6.3 仅生成无声视频(兼容 v1 用法)
需要老接口的话,从 `pipeline._generate_silent_video` 直接调,跳过 TTS/BGM。
---
### 6.4 Dry-run(强烈建议先跑)
```python
result = render(script, dry_run=True)
# 返回:voice_path / srt_path / tokens 预估 / cost_yuan / prompt
# 不调 Seedance,不计费;TTS 是免费的
```
用途:调剧本节奏、试音色、看字幕断行 —— 全部确认满意再跑 render(dry_run=False)。
Agent 应该在用户首次给剧本时**默认先做 dry-run**,把生成的 voice.mp3 路径告诉用户试听。
---
## 七、Agent 工作流
当用户说"用这张图按这个剧本拍带货视频"时,按下面的步骤走:
1. **收集要素**
- 产品图路径
- 剧本(多句台词);如未给可主动起草
- 品类关键词(用于自动选模板)
2. **选模板**
- 用户没明说就调 `templates.suggest_template(category)`
- 给用户看一眼"我准备用 XX 模板(XX 角色 + XX 音色 + XX BGM)"
3. **预算确认**
- 算配音预估时长(80字 ≈ 9~10s)
- 算视频费用(`estimate_cost`),告知用户
4. **执行**
- `render(script)` 端到端
5. **交付**
- 文件路径、实际时长、Seedance tokens、¥ 费用
### 对话示例
```
用户: 用 product.jpg 拍个卖蛋白粉的,3 句台词,自己想词
Agent: 我帮您起草剧本,按健身教练模板(云皓男声 + energetic BGM):
1) 兄弟们,练完这一组
2) 蛋白吸收率高得离谱
3) 练大一年,从这罐开始
预估视频时长 8s ≈ ¥7.95,配音免费。确认生成?
用户: 行
Agent: [render] → /tmp/huo15/protein.mp4 (3.1MB),实际 ¥7.95
```
---
## 八、Seedance API 速查(保留 v1)
| 项 | 值 |
|---|---|
| 模型 | `doubao-seedance-2-0-260128` |
| 端点 | `https://ark.cn-beijing.volces.com/api/v3` |
| 计费 | `Token = 秒 × 720 × 1280 × 24 / 1024`,`¥ = Token × 46 / 1e6` |
| 比例 | `9:16` 竖屏带货 |
| 时长 | 4 ~ 15 秒 |
| 时长 | Token | 费用 |
|---|---|---|
| 4s | ~86,400 | ¥3.97 |
| 5s | ~108,000 | ¥4.97 |
| 10s | ~216,000 | ¥9.94 |
| 15s | ~324,000 | ¥14.90 |
content 中的 role:
| role | 用途 |
|---|---|
| `reference_image` | 默认;AI 参考产品外观 |
| `first_frame` | 视频必须从这张图开始 |
| `last_frame` | 指定结束画面 |
| `reference_video` | 运动风格参考 |
| `reference_audio` | 音频风格参考 |
---
## 九、混音参数(bgm.py)
```
voice 主轨 ──┐
├─ amix(duration=first) ─ afade(out, 0.5s) ─→ mixed.mp3
BGM 副轨 ────┘ (BGM aloop 到与 voice 等长,volume=bgm_volume)
```
- `bgm_volume` 默认 0.18~0.25。模板已按角色调过,一般不用改。
- `voice_volume` 默认 1.0;想突出 BGM 可降到 0.85。
- 末尾统一 0.5 秒淡出,避免硬切。
成片合成:`mux_video_audio(silent.mp4, mixed.mp3, final.mp4)` —
`-c:v copy -c:a aac -shortest`,无视频重编码,秒出。
---
## 十、字幕(pipeline._build_srt + _burn_subtitles)
- 按每行台词字数比例切时间轴生成 SRT
- 用 ffmpeg `subtitles` 滤镜烧录到视频
- 默认字体 PingFang SC、字号 14、白字黑边、底部 80px
- 想关字幕:剧本里 `"subtitle": false`
字体不可用时报 `Fontconfig error`,换 `font: "Heiti SC"` 或装 `brew install --cask font-noto-sans-cjk-sc`。
---
## 十一、降级策略(哪步失败也别全废)
| 失败 | 降级行为 |
|---|---|
| 火山 TTS 凭证缺失 | 自动用 edge-tts |
| edge-tts 也挂 | 抛错,提示 `pip install edge-tts` 并检查网络 |
| BGM 文件找不到 | 跳过 BGM,仅人声 + 0.5s 淡出 |
| 字体找不到 | `subtitle: false` 重跑,或装中文字体 |
| Seedance 超时 | 默认 20 分钟轮询,超时抛 TimeoutError |
---
## 十二、常见问题
### Q: 配音不准 / 多音字读错怎么办?
A: 在 `lines.text` 里用谐音字。edge-tts 不支持 SSML phoneme tag。
### Q: BGM 太响盖过人声?
A: 剧本里 `"bgm_volume": 0.12` 进一步压低,或换 soft.mp3。
### Q: 想纯人声没 BGM?
A: 剧本里 `"bgm": null`。
### Q: 想要长视频(>15 秒)?
A: 当前版本不支持。建议拆段:每段 ≤15s 单独生成,再用 ffmpeg `concat` 拼接。
### Q: 视频画面和剧本对不上?
A: 把第一句的 `action` 写得更具体("举起产品贴近镜头"比"展示"准)。
剧本中段的 action 仅用于字幕节奏参考,不会写进 Seedance prompt。
### Q: 想保留 v1 的纯视频生成?
A: 直接调 `pipeline._generate_silent_video(image, prompt, duration, output)`。
---
## 十三、版本历史
- **v2.0.0**(2026-04-27)— 剧本驱动 + 配音 + BGM + 字幕 + 8 套模板 + dry-run + preflight
- v1.2.x — 单一传统女模板 + 无声视频
FILE:_meta.json
{
"ownerId": "kn7byevkn40d6z4p7ghdb097z983tj33",
"slug": "huo15-influencer-video-skill",
"version": "2.0.0"
}
FILE:examples/script_auto_template.json
{
"template": "auto",
"category": "蛋白粉",
"image": "/path/to/protein.jpg",
"lines": [
{"text": "兄弟们,练完这一组", "action": "拿起产品"},
{"text": "蛋白吸收率高得离谱", "action": "展示成分"},
{"text": "练大一年,从这罐开始", "action": "握拳鼓励"}
],
"subtitle": true,
"output": "/tmp/huo15/protein.mp4"
}
FILE:examples/script_fashion_host.json
{
"template": "fashion_host",
"image": "/path/to/cosmetic.jpg",
"lines": [
{"text": "宝贝们看这个!", "action": "活力举起产品"},
{"text": "上脸超服帖,颜值直接拉满", "action": "比心展示"},
{"text": "今天直播间还有买二送一", "action": "OK 手势"}
],
"bgm": "energetic",
"subtitle": true,
"output": "/tmp/huo15/fashion.mp4"
}
FILE:examples/script_traditional_lady.json
{
"template": "traditional_lady",
"image": "/path/to/product.jpg",
"lines": [
{"text": "姐妹们,今天给大家推一款好东西", "action": "举起产品给镜头"},
{"text": "古法配方,纯手工制作", "action": "微笑展示产品细节"},
{"text": "用过的姐妹都说好,赶紧拍", "action": "点头肯定"}
],
"bgm": "warm",
"bgm_volume": 0.18,
"subtitle": true,
"output": "/tmp/huo15/traditional.mp4"
}
FILE:scripts/bgm.py
"""背景音乐 + 混音
约定:用户把 BGM 文件放到 ~/Music/huo15-bgm/,文件名作为 key。
推荐准备这 5 个 key(缺哪个就降级到无 BGM):
warm.mp3 温暖治愈钢琴
energetic.mp3 活力电子节拍
asian.mp3 中国风古筝竹笛
soft.mp3 柔和氛围
cinematic.mp3 电影感弦乐
下载渠道(CC0 / 免版税):
https://pixabay.com/music/
https://freesound.org/
https://incompetech.com/music/royalty-free/
mix_audio() 把人声和 BGM 混到一起,BGM 自动循环到与人声等长,
然后整体淡出 0.5 秒。
"""
import os
import subprocess
from pathlib import Path
from typing import Optional
BGM_DIR = Path(os.environ.get("HUO15_BGM_DIR", str(Path.home() / "Music" / "huo15-bgm")))
SUPPORTED_EXT = (".mp3", ".m4a", ".wav", ".ogg", ".flac")
def find_bgm(key: Optional[str]) -> Optional[str]:
"""按 key 找 BGM 文件。找不到返回 None(无 BGM 模式)"""
if not key:
return None
if os.path.isabs(key) and os.path.exists(key):
return key
if not BGM_DIR.exists():
return None
for ext in SUPPORTED_EXT:
p = BGM_DIR / f"{key}{ext}"
if p.exists():
return str(p)
# 模糊匹配第一个含关键字的文件
for f in BGM_DIR.iterdir():
if key in f.name and f.suffix.lower() in SUPPORTED_EXT:
return str(f)
return None
def list_available_bgm() -> list:
"""列出 BGM 目录下已就位的文件"""
if not BGM_DIR.exists():
return []
return sorted(
f.stem for f in BGM_DIR.iterdir()
if f.suffix.lower() in SUPPORTED_EXT
)
def mix_audio(voice_path: str,
bgm_key: Optional[str] = None,
bgm_volume: float = 0.20,
voice_volume: float = 1.0,
fade_out: float = 0.5,
output: Optional[str] = None) -> str:
"""把人声 + BGM 混成一个 mp3。
- BGM 自动 loop 到人声时长
- 人声始终保持原音量
- BGM 默认压到 20% 音量
- 末尾 fade_out 秒淡出
"""
output = output or voice_path.replace(".mp3", "_mixed.mp3")
bgm_path = find_bgm(bgm_key)
# 取人声时长
dur = float(subprocess.check_output([
"ffprobe", "-v", "error",
"-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1",
voice_path,
], text=True).strip())
if not bgm_path:
# 没有 BGM,只对人声做淡出
cmd = [
"ffmpeg", "-y", "-i", voice_path,
"-af", f"volume={voice_volume},afade=t=out:st={max(0, dur-fade_out):.2f}:d={fade_out}",
"-c:a", "libmp3lame", "-b:a", "192k",
output,
]
else:
cmd = [
"ffmpeg", "-y",
"-i", voice_path,
"-stream_loop", "-1", "-i", bgm_path,
"-filter_complex",
(
f"[0:a]volume={voice_volume}[v];"
f"[1:a]volume={bgm_volume},aloop=loop=-1:size=2e9[b];"
f"[v][b]amix=inputs=2:duration=first:dropout_transition=0,"
f"afade=t=out:st={max(0, dur-fade_out):.2f}:d={fade_out}"
),
"-t", f"{dur:.2f}",
"-c:a", "libmp3lame", "-b:a", "192k",
output,
]
subprocess.run(cmd, check=True, capture_output=True)
return output
def mux_video_audio(video_path: str, audio_path: str, output: str) -> str:
"""把音频盖到视频上(替换原音轨)。视频时长以视频为准,音频被截断或填静音"""
cmd = [
"ffmpeg", "-y",
"-i", video_path,
"-i", audio_path,
"-c:v", "copy",
"-c:a", "aac", "-b:a", "192k",
"-map", "0:v:0", "-map", "1:a:0",
"-shortest",
output,
]
subprocess.run(cmd, check=True, capture_output=True)
return output
if __name__ == "__main__":
import json
print(json.dumps({
"bgm_dir": str(BGM_DIR),
"available": list_available_bgm(),
}, ensure_ascii=False, indent=2))
FILE:scripts/pipeline.py
"""火15 带货视频 v2 — 端到端 pipeline
输入:剧本 JSON(参见 examples/script_demo.json)
输出:带配音 + BGM + 可选字幕的成片 mp4
流程:
1. 拼接剧本 → TTS 生成 voice.mp3 + 时长 D
2. 视频时长 = clamp(ceil(D)+1, 4, 15)
3. 调 Seedance 生成无声视频
4. voice + BGM → mix.mp3(BGM 自动循环、降音量、淡出)
5. 视频 + mix.mp3 → muxer
6. 可选:按剧本行长度比例生成 SRT,再烧录字幕
7. 输出 final.mp4
"""
import os
import sys
import json
import math
import time
import base64
import subprocess
from pathlib import Path
from typing import Optional
import requests
# 同级 import
sys.path.insert(0, str(Path(__file__).resolve().parent))
from templates import get_template, suggest_template, list_templates # noqa: E402
from tts import synthesize # noqa: E402
from bgm import mix_audio, mux_video_audio # noqa: E402
ARK_API_KEY = os.environ.get("ARK_API_KEY", "")
ARK_BASE = "https://ark.cn-beijing.volces.com/api/v3"
ARK_MODEL = "doubao-seedance-2-0-260128"
# ---------- 辅助 ----------
def _build_prompt(tpl: dict) -> str:
return (
f"第一人称视角带货短视频。{tpl['character']},手持图片中的产品,"
f"{tpl['action']}。{tpl['scene']}。"
)
def estimate_cost(duration: int) -> tuple:
"""(tokens, ¥)"""
tokens = duration * 720 * 1280 * 24 / 1024
return int(tokens), round(tokens * 46 / 1_000_000, 2)
def _video_duration_for(voice_seconds: float) -> int:
"""语音 D 秒 → 视频时长(4~15 秒,留 1 秒余量给开头铺垫)"""
return max(4, min(15, math.ceil(voice_seconds) + 1))
# ---------- Seedance 调用 ----------
def _generate_silent_video(image_path: str,
prompt: str,
duration: int,
output: str) -> str:
"""调 Seedance 生成无声视频"""
assert ARK_API_KEY, "请先设置 ARK_API_KEY"
with open(image_path, "rb") as f:
img_b64 = base64.b64encode(f.read()).decode()
ext = Path(image_path).suffix.lstrip(".").lower()
if ext == "jpg":
ext = "jpeg"
body = {
"model": ARK_MODEL,
"content": [
{"type": "text", "text": prompt},
{
"type": "image_url",
"image_url": {"url": f"data:image/{ext};base64,{img_b64}"},
"role": "reference_image",
},
],
"ratio": "9:16",
"duration": duration,
"watermark": False,
}
headers = {
"Authorization": f"Bearer {ARK_API_KEY}",
"Content-Type": "application/json",
}
r = requests.post(f"{ARK_BASE}/contents/generations/tasks",
headers=headers, json=body, timeout=60)
res = r.json()
if "id" not in res:
raise RuntimeError(f"Seedance 提交失败: {res}")
task_id = res["id"]
print(f"[seedance] task_id={task_id}, 时长={duration}s")
for _ in range(120): # 最多 20 分钟
time.sleep(10)
d = requests.get(f"{ARK_BASE}/contents/generations/tasks/{task_id}",
headers=headers, timeout=30).json()
st = d.get("status")
print(f" [{time.strftime('%H:%M:%S')}] {st}")
if st == "succeeded":
video_url = d["content"]["video_url"]
with requests.get(video_url, stream=True, timeout=120) as vr:
with open(output, "wb") as f:
for chunk in vr.iter_content(8192):
f.write(chunk)
tokens = d.get("usage", {}).get("total_tokens", 0)
print(f"[seedance] ok → {output} ({os.path.getsize(output)/1e6:.1f}MB) "
f"tokens={tokens} ¥{tokens*46/1e6:.2f}")
return output
if st == "failed":
raise RuntimeError(f"Seedance 任务失败: {d}")
raise TimeoutError("Seedance 超时(20 分钟)")
# ---------- 字幕(SRT) ----------
def _build_srt(lines: list, total_duration: float) -> str:
"""按行字数比例切时间轴,生成 SRT"""
weights = [max(1, len(l["text"])) for l in lines]
total_w = sum(weights)
cur = 0.0
parts = []
for i, (line, w) in enumerate(zip(lines, weights), 1):
seg = total_duration * w / total_w
start, end = cur, cur + seg
cur = end
parts.append(
f"{i}\n{_fmt_ts(start)} --> {_fmt_ts(end)}\n{line['text']}\n"
)
return "\n".join(parts)
def _fmt_ts(t: float) -> str:
h = int(t // 3600)
m = int((t % 3600) // 60)
s = t % 60
return f"{h:02d}:{m:02d}:{int(s):02d},{int((s - int(s)) * 1000):03d}"
def _burn_subtitles(video_in: str, srt_path: str, video_out: str,
font: str = "PingFang SC", size: int = 14) -> str:
"""用 ffmpeg subtitles 滤镜烧录字幕"""
style = (f"FontName={font},FontSize={size},PrimaryColour=&H00FFFFFF,"
f"OutlineColour=&H80000000,BorderStyle=1,Outline=2,Shadow=0,"
f"Alignment=2,MarginV=80")
# subtitles 滤镜里 : , = 都要转义
safe_srt = srt_path.replace(":", "\\:").replace(",", "\\,")
cmd = [
"ffmpeg", "-y", "-i", video_in,
"-vf", f"subtitles={safe_srt}:force_style='{style}'",
"-c:a", "copy",
video_out,
]
subprocess.run(cmd, check=True, capture_output=True)
return video_out
# ---------- 主流程 ----------
def render(script: dict, work_dir: str = "/tmp/huo15_video", dry_run: bool = False) -> dict:
"""
script schema:
{
"template": "traditional_lady", # 必填,或用 "auto" + category
"category": "茶叶", # template=="auto" 时启用
"image": "/path/to/product.jpg", # 必填(dry_run=True 时可不存在)
"lines": [{"text": "...", "action": "..."}], # 必填,至少 1 条
"bgm": "warm" | "/path/file.mp3" | null,
"bgm_volume": 0.20, # 可覆盖模板默认
"subtitle": true, # 默认 true
"voice_override": "zh-CN-XxxNeural", # 可覆盖模板默认音色
"rate_override": "+0%",
"output": "/tmp/final.mp4"
}
dry_run=True:跳过 Seedance 调用(省钱),只跑 TTS + SRT,
返回预估 token / ¥ / 视频时长,方便测剧本节奏。
"""
Path(work_dir).mkdir(parents=True, exist_ok=True)
# 1. 选模板
tpl_key = script.get("template", "traditional_lady")
if tpl_key == "auto":
tpl_key = suggest_template(script.get("category", ""))
tpl = get_template(tpl_key)
print(f"[模板] {tpl_key} — {tpl['label']}")
# 2. 拼台词 + TTS
full_text = ",".join(l["text"].strip("。.!!??") for l in script["lines"]) + "。"
voice_id = script.get("voice_override") or tpl["voice"]
rate = script.get("rate_override") or tpl["voice_rate"]
voice = synthesize(
full_text, voice=voice_id, rate=rate,
pitch=tpl["voice_pitch"],
output=f"{work_dir}/voice.mp3",
)
print(f"[配音] {voice['engine']} | {voice_id} | {voice['duration']:.2f}s")
if voice["duration"] > 14.5:
raise ValueError(
f"剧本配音 {voice['duration']:.1f}s 超过单段 15s 上限,请精简台词"
f"(约 50~70 字)"
)
# 3. 算视频时长 + 改 prompt 加入第一句动作
duration = _video_duration_for(voice["duration"])
if script["lines"][0].get("action"):
tpl = {**tpl, "action": script["lines"][0]["action"]}
prompt = _build_prompt(tpl)
print(f"[视频] 时长={duration}s, prompt={prompt}")
tokens, yuan = estimate_cost(duration)
print(f"[费用] Seedance 预估 {tokens:,} tokens ≈ ¥{yuan}")
if dry_run:
srt = f"{work_dir}/sub.srt"
Path(srt).write_text(_build_srt(script["lines"], voice["duration"]), encoding="utf-8")
print(f"[dry-run] 已跳过 Seedance / 混音 / 烧字幕")
print(f"[dry-run] voice={voice['path']} srt={srt}")
return {
"dry_run": True,
"voice_path": voice["path"],
"srt_path": srt,
"template": tpl_key,
"voice_duration": voice["duration"],
"video_duration": duration,
"tokens": tokens,
"cost_yuan": yuan,
"prompt": prompt,
}
# 4. 生成视频
silent = _generate_silent_video(
script["image"], prompt, duration,
f"{work_dir}/silent.mp4",
)
# 5. 混音
mixed = mix_audio(
voice["path"],
bgm_key=script.get("bgm", tpl["bgm"]),
bgm_volume=script.get("bgm_volume", tpl["bgm_volume"]),
output=f"{work_dir}/mixed.mp3",
)
print(f"[混音] BGM={script.get('bgm', tpl['bgm'])} → {mixed}")
# 6. 合成
output = script.get("output") or f"{work_dir}/final.mp4"
if script.get("subtitle", True):
with_audio = f"{work_dir}/with_audio.mp4"
mux_video_audio(silent, mixed, with_audio)
srt = f"{work_dir}/sub.srt"
Path(srt).write_text(_build_srt(script["lines"], voice["duration"]), encoding="utf-8")
_burn_subtitles(with_audio, srt, output)
print(f"[字幕] 烧录 → {output}")
else:
mux_video_audio(silent, mixed, output)
print(f"[输出] → {output}")
return {
"output": output,
"template": tpl_key,
"voice_duration": voice["duration"],
"video_duration": duration,
"tokens": tokens,
"cost_yuan": yuan,
"size_mb": round(os.path.getsize(output) / 1e6, 2),
}
# ---------- 自检 ----------
def preflight() -> dict:
"""检查依赖、凭证、BGM 库是否就绪。返回一份 status 报告。"""
import shutil
from bgm import BGM_DIR, list_available_bgm
status = {"ok": True, "checks": []}
def add(name, ok, msg=""):
status["checks"].append({"name": name, "ok": ok, "msg": msg})
if not ok:
status["ok"] = False
# 1. ffmpeg / ffprobe
add("ffmpeg", bool(shutil.which("ffmpeg")), "" if shutil.which("ffmpeg") else "brew install ffmpeg")
add("ffprobe", bool(shutil.which("ffprobe")), "" if shutil.which("ffprobe") else "随 ffmpeg 一起装")
# 2. edge-tts
try:
import edge_tts # noqa: F401
add("edge-tts (Python)", True)
except ImportError:
add("edge-tts (Python)", False, "pip install edge-tts")
# 3. ARK_API_KEY
add("ARK_API_KEY", bool(ARK_API_KEY),
"export ARK_API_KEY=ak-xxxxx(方舟控制台获取)" if not ARK_API_KEY else "")
# 4. 火山 TTS(可选)
has_volc = bool(os.environ.get("VOLC_TTS_APP_ID") and os.environ.get("VOLC_TTS_TOKEN"))
add("火山 TTS(可选)", True, "已配置 → 使用火山 TTS" if has_volc else "未配置 → 降级 edge-tts(免费)")
# 5. BGM 库
bgms = list_available_bgm()
add("BGM 库", bool(bgms),
f"目录 {BGM_DIR} 下没有 BGM 文件,将无 BGM 模式(不影响视频生成)"
if not bgms else f"已就位: {', '.join(bgms)}")
# 6. 中文字体(PingFang)
pingfang = os.path.exists("/System/Library/Fonts/PingFang.ttc")
add("中文字体 PingFang", pingfang,
"字幕烧录需中文字体;可在剧本中 subtitle:false 跳过"
if not pingfang else "")
return status
def _print_preflight(status: dict):
print(f"\n=== 火15 带货视频 v2 自检 ===\n")
for c in status["checks"]:
icon = "✅" if c["ok"] else "❌"
line = f" {icon} {c['name']}"
if c["msg"]:
line += f" — {c['msg']}"
print(line)
print(f"\n总体状态: {'✅ 可以发车' if status['ok'] else '❌ 有缺项,按上面提示修复'}\n")
# ---------- CLI ----------
USAGE = """\
火15 带货视频 v2 — pipeline.py
用法:
python pipeline.py preflight 自检依赖/凭证/BGM
python pipeline.py templates 列出 8 套人设模板
python pipeline.py voices 列出推荐音色
python pipeline.py dry-run <script.json> 只跑 TTS + 字幕,不调 Seedance(省钱测剧本)
python pipeline.py render <script.json> 完整端到端
python pipeline.py <script.json> 等同 render(向后兼容)
"""
if __name__ == "__main__":
if len(sys.argv) < 2:
print(USAGE)
sys.exit(1)
cmd = sys.argv[1]
if cmd == "preflight":
s = preflight()
_print_preflight(s)
sys.exit(0 if s["ok"] else 1)
if cmd == "templates":
for t in list_templates():
print(f" - {t['key']:20s} {t['label']} 品类: {','.join(t['categories'])}")
sys.exit(0)
if cmd == "voices":
from tts import list_voices
for v in list_voices():
print(f" - {v['id']:32s} {v['label']}")
sys.exit(0)
if cmd in ("render", "dry-run"):
if len(sys.argv) < 3:
print(f"❌ 缺剧本路径:python pipeline.py {cmd} <script.json>")
sys.exit(1)
script_path = sys.argv[2]
else:
# 向后兼容:第一个参数直接当 script.json
script_path = cmd
cmd = "render"
with open(script_path, encoding="utf-8") as f:
script = json.load(f)
result = render(script, dry_run=(cmd == "dry-run"))
print(json.dumps(result, ensure_ascii=False, indent=2))
FILE:scripts/templates.py
"""火15 带货视频 — 8 个预设角色模板
每个模板包含:
- character / action / scene → 喂给 Seedance 的 prompt 三要素
- voice → edge-tts 音色(zh-CN-XxxNeural)
- voice_rate / voice_pitch → 语速/音高微调
- bgm → 背景音乐风格关键字(匹配 ~/Music/huo15-bgm/{key}.mp3)
- bgm_volume → BGM 在最终混音中的音量(0~1,0.18~0.25 较合适)
- categories → 推荐品类,给 Agent 选模板时参考
"""
TEMPLATES = {
"traditional_lady": {
"label": "传统中年女性(默认)",
"character": "一位身穿中式传统服饰的中年女性",
"action": "面带微笑展示给镜头",
"scene": "柔和暖色灯光,背景干净简约",
"voice": "zh-CN-XiaoqiuNeural",
"voice_rate": "-5%",
"voice_pitch": "+0Hz",
"bgm": "warm",
"bgm_volume": 0.20,
"categories": ["养生", "茶叶", "手工艺", "中式服饰", "古法食品"],
},
"fashion_host": {
"label": "时尚女主播",
"character": "一位妆容精致、穿着时尚连衣裙的年轻女主播",
"action": "活力展示产品并打出爱心手势",
"scene": "粉色直播间灯光,背景虚化彩色光斑",
"voice": "zh-CN-XiaoxiaoNeural",
"voice_rate": "+8%",
"voice_pitch": "+2Hz",
"bgm": "energetic",
"bgm_volume": 0.22,
"categories": ["美妆", "服装", "饰品", "潮玩", "数码配件"],
},
"tcm_doctor": {
"label": "老中医",
"character": "一位身穿白大褂、戴老花镜、白发白须的老中医",
"action": "稳重展示产品并轻轻点头",
"scene": "中药铺背景,木质药柜,暖黄灯光",
"voice": "zh-CN-YunjianNeural",
"voice_rate": "-10%",
"voice_pitch": "-2Hz",
"bgm": "asian",
"bgm_volume": 0.18,
"categories": ["中药", "保健品", "养生茶", "膏方", "艾灸"],
},
"kitchen_mom": {
"label": "厨房主妇",
"character": "一位系着碎花围裙、温柔亲切的中年妈妈",
"action": "在厨房展示产品并露出满意笑容",
"scene": "明亮温馨的家用厨房,自然光从窗户洒入",
"voice": "zh-CN-XiaohanNeural",
"voice_rate": "-2%",
"voice_pitch": "+0Hz",
"bgm": "warm",
"bgm_volume": 0.20,
"categories": ["调味料", "食材", "厨具", "零食", "速食"],
},
"beauty_blogger": {
"label": "美妆博主",
"character": "一位精致妆容、长卷发的时尚美妆博主",
"action": "对着镜头展示产品并做出惊喜表情",
"scene": "ins 风梳妆台,柔光环形灯,化妆品摆件",
"voice": "zh-CN-XiaomengNeural",
"voice_rate": "+5%",
"voice_pitch": "+1Hz",
"bgm": "soft",
"bgm_volume": 0.22,
"categories": ["护肤", "彩妆", "香水", "美容仪", "面膜"],
},
"fitness_coach": {
"label": "健身教练",
"character": "一位身材健硕、穿着运动背心的男性健身教练",
"action": "充满力量感地举起产品",
"scene": "现代健身房,深色调灯光,器械背景",
"voice": "zh-CN-YunhaoNeural",
"voice_rate": "+5%",
"voice_pitch": "+0Hz",
"bgm": "energetic",
"bgm_volume": 0.25,
"categories": ["蛋白粉", "运动器材", "健身服", "运动鞋", "补剂"],
},
"outdoor_explorer": {
"label": "户外探店达人",
"character": "一位戴着鸭舌帽、穿冲锋衣的年轻探店博主",
"action": "在户外兴奋展示产品并比 OK 手势",
"scene": "山野/古镇/集市等户外自然光场景",
"voice": "zh-CN-YunxiaNeural",
"voice_rate": "+10%",
"voice_pitch": "+1Hz",
"bgm": "cinematic",
"bgm_volume": 0.20,
"categories": ["地方特产", "户外装备", "零食", "饮品", "民俗工艺"],
},
"tech_geek": {
"label": "数码博主",
"character": "一位戴黑框眼镜、穿简约 T 恤的年轻数码博主",
"action": "专业地展示产品细节并旋转产品",
"scene": "极简白色桌面,柔光打在产品上,背景虚化",
"voice": "zh-CN-YunyangNeural",
"voice_rate": "+0%",
"voice_pitch": "+0Hz",
"bgm": "soft",
"bgm_volume": 0.18,
"categories": ["手机", "耳机", "数码周边", "智能家居", "电脑配件"],
},
}
# BGM 关键字 → 风格描述(用于用户自备文件命名 / 在线检索关键词)
BGM_HINTS = {
"warm": "温暖治愈钢琴轻音乐 / warm piano background",
"energetic": "活力电子节拍 / upbeat electronic groove",
"asian": "中国风古筝竹笛 / chinese traditional guzheng",
"soft": "柔和氛围背景音 / soft ambient",
"cinematic": "电影感弦乐铺垫 / cinematic strings pad",
}
def list_templates():
"""返回所有模板的简表(给 Agent 选择用)"""
return [
{"key": k, "label": v["label"], "categories": v["categories"]}
for k, v in TEMPLATES.items()
]
def get_template(key: str) -> dict:
"""取模板。未知 key 时回退到 traditional_lady"""
return TEMPLATES.get(key, TEMPLATES["traditional_lady"])
def suggest_template(category_or_keyword: str) -> str:
"""按品类关键词推荐最合适的模板 key"""
kw = category_or_keyword.strip().lower()
for key, tpl in TEMPLATES.items():
for cat in tpl["categories"]:
if cat.lower() in kw or kw in cat.lower():
return key
return "traditional_lady"
if __name__ == "__main__":
import json
print(json.dumps(list_templates(), ensure_ascii=False, indent=2))
FILE:scripts/tts.py
"""配音模块
两套引擎,按优先级降级:
1) edge-tts(免费、无 key、Microsoft Azure 神经音色)— 默认
2) 火山引擎 TTS(付费,音质更好)— 设置 VOLC_TTS_APP_ID / VOLC_TTS_TOKEN / VOLC_TTS_CLUSTER 启用
返回值统一约定:
{"path": "voice.mp3", "duration": 8.42, "engine": "edge-tts"}
"""
import os
import json
import uuid
import asyncio
import subprocess
from typing import Optional
def _probe_duration(audio_path: str) -> float:
"""用 ffprobe 测时长(秒)"""
out = subprocess.check_output([
"ffprobe", "-v", "error",
"-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1",
audio_path,
], text=True).strip()
return float(out)
# ============== 1. edge-tts ==============
async def _edge_tts_async(text: str, voice: str, rate: str, pitch: str, output: str):
import edge_tts # pip install edge-tts
communicate = edge_tts.Communicate(text, voice, rate=rate, pitch=pitch)
await communicate.save(output)
def tts_edge(text: str,
voice: str = "zh-CN-XiaoqiuNeural",
rate: str = "+0%",
pitch: str = "+0Hz",
output: Optional[str] = None) -> dict:
"""用 edge-tts 合成,返回 {path, duration, engine}"""
output = output or f"/tmp/voice_{uuid.uuid4().hex[:8]}.mp3"
asyncio.run(_edge_tts_async(text, voice, rate, pitch, output))
return {
"path": output,
"duration": _probe_duration(output),
"engine": "edge-tts",
"voice": voice,
}
# ============== 2. 火山 TTS ==============
VOLC_HOST = "https://openspeech.bytedance.com"
VOLC_VOICE_MAP = {
# edge-tts 音色 → 火山 voice_type 的近似映射,调用方传 edge 风格 key 即可
"zh-CN-XiaoxiaoNeural": "BV007_streaming", # 通用女声
"zh-CN-XiaoqiuNeural": "BV701_streaming", # 沉稳女声
"zh-CN-XiaohanNeural": "BV064_streaming", # 温暖女声
"zh-CN-XiaomengNeural": "BV005_streaming", # 活泼女声
"zh-CN-YunjianNeural": "BV002_streaming", # 中年男声
"zh-CN-YunhaoNeural": "BV120_streaming", # 激情男声
"zh-CN-YunxiaNeural": "BV056_streaming", # 少年男声
"zh-CN-YunyangNeural": "BV034_streaming", # 专业男声
}
def tts_volc(text: str,
voice: str = "zh-CN-XiaoqiuNeural",
output: Optional[str] = None,
speed_ratio: float = 1.0) -> dict:
"""火山 TTS(需环境变量 VOLC_TTS_APP_ID / VOLC_TTS_TOKEN / VOLC_TTS_CLUSTER)"""
import requests, base64
app_id = os.environ["VOLC_TTS_APP_ID"]
token = os.environ["VOLC_TTS_TOKEN"]
cluster = os.environ.get("VOLC_TTS_CLUSTER", "volcano_tts")
voice_type = VOLC_VOICE_MAP.get(voice, "BV701_streaming")
output = output or f"/tmp/voice_{uuid.uuid4().hex[:8]}.mp3"
body = {
"app": {"appid": app_id, "token": "access_token", "cluster": cluster},
"user": {"uid": "huo15"},
"audio": {
"voice_type": voice_type,
"encoding": "mp3",
"speed_ratio": speed_ratio,
"rate": 24000,
},
"request": {
"reqid": uuid.uuid4().hex,
"text": text,
"operation": "query",
},
}
r = requests.post(
f"{VOLC_HOST}/api/v1/tts",
headers={"Authorization": f"Bearer;{token}"},
json=body, timeout=60,
)
data = r.json()
if "data" not in data:
raise RuntimeError(f"火山 TTS 失败: {data}")
with open(output, "wb") as f:
f.write(base64.b64decode(data["data"]))
return {
"path": output,
"duration": _probe_duration(output),
"engine": "volc-tts",
"voice": voice_type,
}
# ============== 统一入口 ==============
def synthesize(text: str,
voice: str = "zh-CN-XiaoqiuNeural",
rate: str = "+0%",
pitch: str = "+0Hz",
output: Optional[str] = None,
engine: str = "auto") -> dict:
"""合成语音,自动选引擎。
engine: 'auto' | 'edge' | 'volc'
- auto: 有火山凭证用火山,否则 edge
"""
if engine == "auto":
engine = "volc" if os.environ.get("VOLC_TTS_APP_ID") else "edge"
if engine == "volc":
return tts_volc(text, voice=voice, output=output)
return tts_edge(text, voice=voice, rate=rate, pitch=pitch, output=output)
def list_voices() -> list:
"""列出推荐音色"""
return [
{"id": "zh-CN-XiaoqiuNeural", "label": "晓秋(中年女・沉稳)"},
{"id": "zh-CN-XiaohanNeural", "label": "晓涵(中年女・温暖)"},
{"id": "zh-CN-XiaoxiaoNeural", "label": "晓晓(年轻女・通用)"},
{"id": "zh-CN-XiaomengNeural", "label": "晓梦(年轻女・活泼)"},
{"id": "zh-CN-YunjianNeural", "label": "云健(中年男・沉稳)"},
{"id": "zh-CN-YunhaoNeural", "label": "云皓(年轻男・激情)"},
{"id": "zh-CN-YunxiaNeural", "label": "云夏(少年男・轻快)"},
{"id": "zh-CN-YunyangNeural", "label": "云扬(中年男・专业)"},
]
if __name__ == "__main__":
import sys
text = sys.argv[1] if len(sys.argv) > 1 else "姐妹们看,这款产品真的太好用了!"
out = synthesize(text)
print(json.dumps(out, ensure_ascii=False, indent=2))
【青岛火一五信息科技有限公司】企业级 Word 文档生成技能,支持两种模式:规则模式(默认)和模板模式。触发词:写word,写文档、生成word、生成文档、创建文档、.docx、Word文档、写合同、写方案、写报告、写会议纪要、按模板生成、导出PDF、Word转PDF、生成PDF。
---
name: huo15-openclaw-office-doc
displayName: 火一五文档技能
description: 【青岛火一五信息科技有限公司】企业级 Word 文档生成技能,支持两种模式:规则模式(默认)和模板模式。触发词:写word,写文档、生成word、生成文档、创建文档、.docx、Word文档、写合同、写方案、写报告、写会议纪要、按模板生成、导出PDF、Word转PDF、生成PDF。
version: 4.1.0
aliases:
- 火一五文档技能
- 文档生成
- Word生成
- 按模板生成
dependencies:
python-packages:
- python-docx
---
# 火一五文档技能 v4.0
> 企业级 Word 文档生成 — 青岛火一五信息科技有限公司
**愿景:** 加速企业向全场景人工智能机器人转变
**理念:** 打破信息孤岛,用一套系统驱动企业增长
---
## 核心原则
1. **企业文档三要素**:编号 + 版本 + 密级
2. **格式标准**:GB/T 9704-2012《党政机关公文格式》企业简化版
3. **可追溯**:版本历史、审批记录、修改说明
---
## 一、文档元数据(自动生成)
每份企业文档必须包含以下元数据:
| 字段 | 说明 | 示例 |
|------|------|------|
| 文档编号 | 企业编号规则 | HG-HY-2026-001 |
| 版本 | V1.0 格式 | V1.0 |
| 密级 | 内部/秘密/机密 | 内部 |
| 日期 | YYYY-MM-DD | 2026-04-12 |
| 页数 | 自动统计 | 共 5 页 |
**编号规则:**
```
[公司缩写]-[类型缩写]-[年份]-[序号]
例如:HG-HY-2026-001
HG = 火一五公司
HY = 会议/合同/报告(取拼音首字母)
```
**类型缩写对照:**
| 类型 | 缩写 | 说明 |
|------|------|------|
| 会议纪要 | HY | 会议相关 |
| 合同 | HT | 合同协议 |
| 报告 | BG | 报告类 |
| 方案 | FA | 方案类 |
| 周报 | ZB | 周报 |
| 月报 | YB | 月报 |
| 通知 | TZ | 通知 |
| 请示 | QS | 请示 |
| 函 | HN | 函件 |
**版本规则:**
- V1.0 首次发布
- V1.1 小幅修订
- V2.0 重大修订
- V2.1-V2.9 功能迭代
- V3.0 正式版本
**密级规则:**
- 公开 — 对外公开文件
- 内部 — 公司内部使用
- 秘密 — 涉及商业机密
- 机密 — 核心机密文件
---
## 二、格式标准(GB/T 9704-2012 企业简化版)
### 2.1 页面设置
| 要素 | 标准 |
|------|------|
| 纸张 | A4(210mm × 297mm) |
| 页边距 | 上 3.7cm,下 3.5cm,左 2.8cm,右 2.6cm |
| 版心 | 156mm × 225mm |
### 2.2 字体规范
| 类型 | 字体 | 字号 | 说明 |
|------|------|------|------|
| 公文标题 | 方正小标宋简体 | 二号(22pt) | 居中,加粗 |
| 一级标题 | 黑体 | 三号(16pt) | 加粗 |
| 二级标题 | 楷体 | 三号(15pt) | 加粗 |
| 三级标题 | 仿宋 | 三号(15pt) | 加粗 |
| 正文 | 仿宋 | 三号(15pt) | 首行缩进2字符,1.5倍行距 |
| 落款 | 仿宋 | 三号(15pt) | 右对齐 |
| 页码 | 宋体 | 四号(14pt) | 居中,页脚 |
### 2.3 段落规范
| 类型 | 格式 |
|------|------|
| 正文 | 首行缩进2字符,1.5倍行距,两端对齐 |
| 标题 | 段前0.5行,段后0.5行 |
| 落款 | 右对齐,段前1行 |
### 2.4 页眉页脚
**页眉格式:**
```
[LOGO] 公司名称 文档编号 密级
─────────────────────────────────────────(底边细线)
```
**页脚格式:**
```
第 X 页 / 共 Y 页
```
---
## 三、文档类型标准
### 3.1 会议纪要标准格式
**结构:**
```
【会议纪要】
编号:HG-HY-2026-001
日期:2026年4月12日
与会人员:XXX、XXX
主持人:XXX
记录人:XXX
一、会议时间、地点
二、会议主题
三、会议内容
1. 议题一
2. 议题二
四、决议事项
1. 事项一
2. 事项二
五、下一步工作
```
**要点:**
- 真实反映会议内容,不凭空捏造
- 围绕会议成果进行提炼和概括
- 反映集体意志,不掺杂个人意见
- 区分"会议记录"(原始材料)和"会议纪要"(正式公文)
### 3.2 合同标准格式
**结构:**
```
【合同】
编号:HG-HT-2026-001
签订日期:2026年4月12日
签订地点:XXX
第一章 总则
第一条 合同双方
第二条 合同依据
第二章 权利义务
第三条 甲方的权利义务
第四条 乙方的权利义务
第三章 违约责任
第五条 违约情形
第六条 赔偿标准
第四章 争议解决
第七条 争议解决方式
第八条 适用法律
附则
第九条 合同生效
第十条 合同期限
甲方(签章):__________ 乙方(签章):__________
法定代表人: 法定代表人:
委托代理人: 委托代理人:
日期: 日期:
```
**要点:**
- 优先采用国家或行业制定的示范合同文本
- 合同条款应完整、明确、无歧义
- 金额数字应同时标注大写
- 合同份数应明确
### 3.3 工作报告标准格式
**结构:**
```
【工作报告】
编号:HG-BG-2026-001
日期:2026年4月12日
标题:XXXX工作报告
一、基本情况
1. 背景介绍
2. 工作目标
二、主要工作
1. 工作内容一
2. 工作内容二
三、存在问题
1. 问题一
2. 问题二
四、下一步计划
1. 改进措施
2. 工作安排
报告人:XXX
日期:XXXX年XX月XX日
```
**要点:**
- 情况属实、数据准确
- 问题分析客观中肯
- 计划切实可行
### 3.4 周报/月报标准格式
**周报结构:**
```
【工作周报】
编号:HG-ZB-2026-001
姓名:XXX
部门:XXX
日期:2026年4月12日
一、本周完成工作
1. 工作项一(完成度:100%)
2. 工作项二(完成度:80%)
二、下周工作计划
1. 工作项一
2. 工作项二
三、风险与问题
1. 风险一及应对措施
2. 问题一及解决方案
四、需要支持
1. 资源支持需求
2. 跨部门协调需求
```
**月报结构:**
```
【工作月报】
编号:HG-YB-2026-001
姓名:XXX
部门:XXX
月份:2026年4月
一、本月工作完成情况
1. KPI完成情况
2. 重点项目进展
二、存在问题和原因分析
1. 问题描述
2. 原因分析
三、下月工作计划
1. 工作目标
2. 具体措施
四、建议和意见
```
**要点:**
- 目标导向,明确汇报价值
- 结构清晰,标准化框架
- 数据支撑,增强说服力
### 3.5 合同示范文本获取渠道
**国家层面:**
- 国家市场监督管理总局合同示范文本库:https://htsfwb.samr.gov.cn/
- 国家标准:GB/T 1.1-2020《标准化工作导则》
**行业层面:**
- 建设工程:GB/T 50500 系列
- 政府采购:各地政府采购示范文本
---
## 四、模式一:规则模式(默认)
用户只提供内容文本,没有提供模板文件时使用。
### 段落类型识别规则
| 开头文字 | 识别为 | 字体 |
|----------|--------|------|
| 第X章 / 第X节 / 第X款 | 一级标题 | 黑体三号加粗 |
| 一、二、三、 或 一,二,... | 二级标题 | 楷体三号加粗 |
| (一)(二)... | 三级标题 | 仿宋三号加粗 |
| 1. 2. 3. | 编号正文 | 仿宋三号 |
| 其他文字 | 普通正文 | 仿宋三号,首行缩进 |
### 版本历史表(自动生成)
文档开头自动插入:
```
【版本历史】
| 版本 | 日期 | 作者 | 修改内容 |
|------|------|------|----------|
| V1.0 | 2026-04-12 | 赵博 | 首次创建 |
```
### 审批签字区(可选)
文档末尾自动插入:
```
【审批记录】
| 角色 | 姓名 | 日期 | 签字 |
|------|------|------|------|
| 编制 | 赵博 | 2026-04-12 | __________ |
| 审核 | | | |
| 批准 | | | |
```
### 表格支持
- 第一行自动识别为表头,黑体居中
- 斑马条纹(隔行变色)
- 边框线型统一
---
## 五、模式二:模板模式(高精度)
用户提供了 `.docx` 模板文件时使用。
### 触发条件
用户发送了 `.docx` 文件,或明确说"按这个模板生成"。
### 模板分析流程
#### A. 结构分析
```
【模板结构】
- 封面:有/无,内容包括...
- 目录:有/无,层级深度...
- 密级标识:有/无,位置...
- 版本历史表:有/无,格式...
- 审批签字区:有/无,格式...
- 正文章节:X 层标题结构
- 附件/附录:有/无
```
#### B. 样式分析
```
【字体样式】
- 标题字体:X号,颜色,是否加粗
- 正文字体:X号,颜色,是否加粗
【段落样式】
- 行距:固定值 X 磅 / 倍行距
- 首行缩进:是/否,X 字符
- 段前段后间距:X pt
【页面设置】
- 边距:上下左右各多少
- 方向:纵向/横向
- 纸型:A4/Letter
```
### 生成规则
1. 保持与模板完全一致的字体、字号、颜色
2. 保持与模板完全一致的段落结构
3. 保持与模板一致的页面设置
4. 保持与模板一致的页眉页脚
5. 如模板有图表,保持图表位置和编号
6. 保留模板中的占位符注释
---
## 六、Word 生成脚本
无论哪种模式,最终都调用以下脚本生成 `.docx` 文件:
```python
from create_word_doc import create_word_doc
create_word_doc(
output_path="文档名.docx",
title="文档标题",
content="正文内容...",
doc_number="HG-HY-2026-001",
version="V1.0",
classification="内部",
author="赵博",
company_name="青岛火一五信息科技有限公司",
logo_path="/path/to/logo.png",
approval=[
{"role": "编制", "name": "赵博"},
{"role": "审核", "name": ""},
{"role": "批准", "name": ""},
],
footer_page=True,
header_doc_number=True,
)
```
### 命令行调用
```bash
python create-word-doc.py <输出文件> [标题] [正文] [编号] [版本] [密级]
```
---
## 七、输出文件命名规范
```
[文档类型]_[客户名]_[版本]_[日期].docx
```
例:`合同_阿里巴巴_V1.0_20260412.docx`
---
## 八、触发词
- 写word、写文档、写个文档
- 生成word、生成文档、创建文档
- 导出word、导出文档、下载word
- .docx、Word文档、Word生成、生成Word
- 写合同、写方案、写报告、写会议纪要
- 按模板生成、参照模板
---
## 九、参考资料
### GB/T 9704-2012《党政机关公文格式》
**主要内容:**
- 纸张要求:A4型纸(210mm×297mm)
- 天头(上白边):37mm±1mm
- 地脚(下白边):35mm±1mm
- 订口(左白边):28mm±1mm
- 版心尺寸:156mm×225mm
### 国家标准获取渠道
| 标准名称 | 标准号 | 获取渠道 |
|----------|--------|----------|
| 党政机关公文格式 | GB/T 9704-2012 | 国家标准全文公开系统 |
| 标准化工作导则 | GB/T 1.1-2020 | 国家标准全文公开系统 |
| 科技报告编写规则 | GB/T 7713.2-2022 | 国家标准全文公开系统 |
| 企业信用报告格式 | GB/T 26817-2023 | 国家标准全文公开系统 |
### 合同示范文本获取
- 国家市场监督管理总局合同示范文本库:https://htsfwb.samr.gov.cn/
---
## 十、快速参考
| 需求 | 操作 |
|------|------|
| 简单文档 | 直接描述内容,AI 自动处理格式 |
| 带模板 | 上传 .docx 文件,说"按此模板生成" |
| 指定编号 | 在内容中注明:编号 HG-XX-2026-XXX |
| 指定版本 | 在内容中注明:版本 V1.0 |
| 需要审批区 | 说"带审批签字区" |
---
**技术支持:** 青岛火一五信息科技有限公司
**愿景:** 加速企业向全场景人工智能机器人转变
**理念:** 打破信息孤岛,用一套系统驱动企业增长
---
## 十一、Word 完美导出 PDF 功能
### 11.1 功能说明
本技能支持将生成的 Word 文档完美转换为 PDF 格式,保持原有格式(页眉页脚、表格、字体等)。
### 11.2 前置要求
**必须安装 LibreOffice:**
```bash
brew install --cask libreoffice
```
### 11.3 转换脚本
```bash
# 转换单个文件
python3 scripts/word-to-pdf.py 合同.docx
# 指定输出文件
python3 scripts/word-to-pdf.py 合同.docx 合同.pdf
# 批量转换
python3 scripts/word-to-pdf.py *.docx --output-dir ./pdf/
```
### 11.4 Python API 调用
```python
from scripts.word_to_pdf import convert_to_pdf, convert_batch
# 单个文件转换
success, result = convert_to_pdf("合同.docx", "合同.pdf")
if success:
print(f"PDF已生成: {result}")
else:
print(f"转换失败: {result}")
# 批量转换
results = convert_batch(["合同1.docx", "合同2.docx"], output_dir="./pdf/")
print(f"成功: {len(results['success'])}")
print(f"失败: {len(results['failed'])}")
```
### 11.5 触发词
- "导出 PDF"、"转 PDF"、"转成 PDF"
- "下载 PDF"、"生成 PDF"
- "Word 导出 PDF"、"docx 转 PDF"
- "转PDF"、"转成PDF"
### 11.6 依赖工具
| 工具 | 安装命令 | 说明 |
|------|----------|------|
| LibreOffice | `brew install --cask libreoffice` | PDF 转换引擎 |
### 11.7 格式保持
| 格式要素 | 保持情况 |
|----------|----------|
| 页眉页脚 | 完美保持 |
| 表格样式 | 完美保持 |
| 中文字体 | 完美保持 |
| 页码格式 | 完美保持 |
| 图片图表 | 完美保持 |
FILE:README.md
# 火一五 Skills 技能库
---
<div align="center">
<img src="https://tools.huo15.com/uploads/images/system/logo-colours.png" alt="火一五Logo" style="width: 120px; height: auto; display: inline; margin: 0;" />
</div>
<div align="center">
<h3>打破信息孤岛,用一套系统驱动企业增长</h3>
<h3>加速企业用户向全场景人工智能机器人转变</h3>
</div>
<div align="center">
| 🏫 教学机构 | 👨🏫 讲师 | 📧 联系方式 | 💬 QQ群 | 📺 配套视频 |
|:-----------:|:--------:|:------------------:|:-----------:|:-----------------------------------:|
| 逸寻智库 | Job | [email protected] | 1093992108 | [📺 B站视频](https://space.bilibili.com/400418085) |
</div>
---
## 项目简介
火一五 Skills 技能库是青岛火一五信息科技有限公司为 OpenClaw AI 助手开发的定制化技能集合。每个技能独立模块化设计,可单独安装使用,也可协同工作。
---
## 技能列表
| 技能名称 | slug | 版本 | 说明 | 触发词 |
|---------|------|------|------|--------|
| `huo15-openclaw-openai-knowledge-base` | `huo15-openclaw-openai-knowledge-base` | v0.9.0 | Karpathy 方案知识库 + Obsidian 集成 | 知识库、入库、查询、编译、体检 |
| `huo15-openclaw-mit-48h-learning-method` | `huo15-openclaw-mit-48h-learning-method` | v2.2.0 | MIT 三问学习框架(心智模型/专家分歧/暴露性问题)| MIT学习法、48小时学习 |
| `huo15-openclaw-office-doc` | `huo15-openclaw-office-doc` | v3.1.0 | 企业级 Word 文档生成(规则/模板双模式)| 写word、写文档、生成合同 |
| `huo15-openclaw-multi-agent` | `huo15-openclaw-multi-agent` | v1.0.0 | 多 Agent 并行工作系统(协调者模式)| 多智能体协同、多Agent、并行任务 |
| `huo15-openclaw-memory-curator` | `huo15-openclaw-memory-curator` | v1.1.0 | 记忆整理技能,审查更新 MEMORY.md | 记忆整理、清理记忆 |
| `huo15-openclaw-plan-mode` | `huo15-openclaw-plan-mode` | v1.0.0 | 结构化规划模式,执行前系统性规划 | 规划模式、做计划 |
| `huo15-openclaw-verify-mode` | `huo15-openclaw-verify-mode` | v1.1.0 | 验证模式,检查成果、运行测试 | 验证模式、检查工作 |
| `huo15-openclaw-explore-mode` | `huo15-openclaw-explore-mode` | v1.1.0 | 深度探索模式,只读调研代码库/系统 | 探索模式、调查、深度调研 |
---
## 技能详情
### huo15-openclaw-openai-knowledge-base — 火一五知识库技能
> 基于 Andrej Karpathy 的 LLM Knowledge Bases 方案。raw → LLM编译 → wiki → Obsidian vault 自动同步,形成第二大脑。
**核心特性:**
- 自动复用 OpenClaw 配置的 minimax-cn API(零额外配置)
- 编译后自动同步到 Obsidian vault「知识库/」文件夹
- 支持图谱视图、双向链接、vault 全局搜索
- Obsidian 集成脚本 `obsidian-sync.sh`(支持 `--watch` 监听模式)
**触发词:** 知识库、入库知识库、查询知识库、编译知识库、体检知识库
---
### huo15-openclaw-mit-48h-learning-method — 火一五 MIT 48小时学习法技能
> 使用 NotebookLM CLI 实现 MIT 研究生 Ihtesham Ali 的三问学习框架,快速建立领域专家级认知。
**三问框架:**
1. **问心智模型**:领域内专家共享的 5 个基本思维框架
2. **问专家分歧**:在哪 3 个问题上根本不同意
3. **问暴露性问题**:生成能区分真懂和假背的 10 个问题
**触发词:** MIT学习法、48小时学习、NotebookLM三问
---
### huo15-openclaw-office-doc — 火一五文档技能
> 企业级 Word 文档生成技能,支持两种模式:
> - **规则模式**:根据规则自动生成(合同/方案/报告/会议纪要)
> - **模板模式**:上传 .docx 模板,填充内容
**触发词:** 写word、写文档、生成word、生成文档、创建文档、.docx、Word文档、写合同、写方案、写报告、写会议纪要、按模板生成
---
### huo15-openclaw-multi-agent — 火一五多智能体技能
> 基于 OpenClaw `sessions_spawn` 的多 Agent 并行工作系统。支持协调者模式、任务分配、结果汇总。
**触发词:** 多智能体协同、多Agent、并行任务、协调者模式
---
### huo15-openclaw-memory-curator — 火一五记忆整理技能
> 审查结构化记忆,提取洞察,更新 MEMORY.md,清理过期条目。周期性心跳检查时自动触发。
**触发词:** 记忆整理、清理记忆、整理记忆
---
### huo15-openclaw-plan-mode — 火一五规划模式技能
> 结构化规划模式 — 在执行复杂任务前先做系统性规划,借鉴 Claude Code Plan Agent。
**触发词:** 规划模式、做计划、帮我规划
---
### huo15-openclaw-verify-mode — 火一五验证模式技能
> 验证模式 — 检查工作成果、运行测试、验证假设,借鉴 Claude Code Verification Agent。
**触发词:** 验证模式、检查工作、验证成果
---
### huo15-openclaw-explore-mode — 火一五探索模式技能
> 深度探索模式 — 系统性调研代码库、系统或话题,只读不改,借鉴 Claude Code Explore Agent。
**触发词:** 探索模式、调查、深度调研、了解项目架构
---
## 安装方式
### 方式一:从 clawhub 安装(推荐)
```bash
# 安装单个技能
clawhub install <slug> --dir ~/.openclaw/workspace/skills
# 示例
clawhub install huo15-openclaw-openai-knowledge-base --dir ~/.openclaw/workspace/skills
clawhub install huo15-openclaw-mit-48h-learning-method --dir ~/.openclaw/workspace/skills
clawhub install huo15-openclaw-office-doc --dir ~/.openclaw/workspace/skills
```
### 方式二:从源码安装
```bash
# 克隆仓库
git clone [email protected]:zhaobod1/huo15-skills.git
# 复制单个技能到 OpenClaw skills 目录
cp -r <技能目录>/ ~/.openclaw/workspace/skills/
# 重启 OpenClaw 即可生效
```
---
## 发布工作流(开发规范)
```
GitHub 仓库(主) → clawhub publish → 本地 clawhub update
```
**步骤:**
1. 在 GitHub 仓库开发:`~/workspace/projects/openclaw/huo15-skills/`
2. 提交推送:`git add -A && git commit -m "说明" && git push`
3. 发布到 clawhub:`clawhub publish /path/to/skill --slug <slug> --version x.y.z`
4. 本地更新:`clawhub update 技能名 --force`
**⚠️ 强制规则:**
- 所有技能从 `huo15-skills` 仓库开发,禁止直接在 clawhub 页面上传
- slug 必须与目录名一致,若 slug 被占用(报 "Slug is already taken"),先清理残留记录再发布,不要另起新名
- 版本号必须语义化(semver),发布后不可重复同一版本号
---
## clawhub 地址
所有技能均已发布到 [ClawHub](https://clawhub.ai):
- https://clawhub.ai/skills/huo15-openclaw-openai-knowledge-base
- https://clawhub.ai/skills/huo15-openclaw-mit-48h-learning-method
- https://clawhub.ai/skills/huo15-openclaw-office-doc
- https://clawhub.ai/skills/huo15-openclaw-multi-agent
- https://clawhub.ai/skills/huo15-openclaw-memory-curator
- https://clawhub.ai/skills/huo15-openclaw-plan-mode
- https://clawhub.ai/skills/huo15-openclaw-verify-mode
- https://clawhub.ai/skills/huo15-openclaw-explore-mode
---
<div align="center">
**公司名称:** 青岛火一五信息科技有限公司
**联系邮箱:** [email protected] | **QQ群:** 1093992108
---
**关注逸寻智库公众号,获取更多资讯**
<img src="https://tools.huo15.com/uploads/images/system/qrcode_yxzk.jpg" alt="逸寻智库公众号二维码" style="width: 200px; height: auto; margin: 10px 0;" />
</div>
FILE:_meta.json
{
"ownerId": "kn7byevkn40d6z4p7ghdb097z983tj33",
"slug": "huo15-openclaw-office-doc",
"version": "4.1.0",
"publishedAt": 1775977191539
}
FILE:huo15-ai-intro/slides_generator.py
#!/usr/bin/env python3
"""智能体入门到精通 PPTX 生成器
乔布斯扁平科技风 + Apple macOS 玻璃拟态卡片
"""
from pptx import Presentation
from pptx.util import Inches, Pt, Emu
from pptx.dml.color import RGBColor
from pptx.enum.text import PP_ALIGN
from pptx.util import Inches, Pt
import os
# ========== 配色常量 ==========
BG_COLOR = RGBColor(0x1D, 0x1D, 0x1F) # #1D1D1F 深灰背景
WHITE = RGBColor(0xFF, 0xFF, 0xFF) # #FFFFFF 纯白
GRAY = RGBColor(0x86, 0x86, 0x8B) # #86868B 中灰色
LIGHT_GRAY = RGBColor(0xF5, 0xF5, 0xF7) # #F5F5F7 极浅灰
GLASS_FILL = RGBColor(0xFF, 0xFF, 0xFF) # 玻璃填充(用透明度模拟)
GLASS_BORDER = RGBColor(0xFF, 0xFF, 0xFF) # 玻璃边框
SLIDE_W = Inches(13.333)
SLIDE_H = Inches(7.5)
def set_dark_background(slide):
"""设置深灰色背景"""
fill = slide.background.fill
fill.solid()
fill.fore_color.rgb = BG_COLOR
def add_glass_card(slide, left, top, width, height, border_alpha=0.2):
"""添加玻璃拟态卡片"""
shape = slide.shapes.add_shape(
1, # MSO_SHAPE_TYPE.RECTANGLE
left, top, width, height
)
# 半透明白色填充(浅灰模拟玻璃效果)
shape.fill.solid()
shape.fill.fore_color.rgb = LIGHT_GRAY
# 白色细边框
shape.line.width = Pt(0.5)
shape.line.fill.solid()
shape.line.fill.fore_color.rgb = RGBColor(int(255 * border_alpha), int(255 * border_alpha), int(255 * border_alpha))
return shape
def add_text_box(slide, left, top, width, height, text, font_size, bold=False, color=WHITE, align=PP_ALIGN.LEFT):
"""添加文本框"""
txBox = slide.shapes.add_textbox(left, top, width, height)
tf = txBox.text_frame
tf.word_wrap = True
p = tf.paragraphs[0]
p.text = text
p.font.size = Pt(font_size)
p.font.bold = bold
p.font.color.rgb = color
p.font.name = "PingFang SC"
p.font._element.set("{http://schemas.openxmlformats.org/drawingml/2006/main}altLang", "zh-CN")
p.alignment = align
return txBox
def create_presentation():
prs = Presentation()
prs.slide_width = SLIDE_W
prs.slide_height = SLIDE_H
# ========== Slide 1: 封面 ==========
slide1 = prs.slides.add_slide(prs.slide_layouts[6]) # blank
set_dark_background(slide1)
# 标题
add_text_box(slide1, Inches(0), Inches(2.5), SLIDE_W, Inches(1.2),
"智能体入门到精通", 72, bold=True, color=WHITE, align=PP_ALIGN.CENTER)
# 副标题
add_text_box(slide1, Inches(0), Inches(3.8), SLIDE_W, Inches(0.6),
"成体系知识点 · 三个递进层面", 28, bold=False, color=GRAY, align=PP_ALIGN.CENTER)
# 底部小字
add_text_box(slide1, Inches(0), Inches(6.5), SLIDE_W, Inches(0.4),
"视频教程大纲", 16, bold=False, color=GRAY, align=PP_ALIGN.CENTER)
# ========== Slide 2: 目录概览 ==========
slide2 = prs.slides.add_slide(prs.slide_layouts[6])
set_dark_background(slide2)
add_text_box(slide2, Inches(0.5), Inches(0.4), SLIDE_W, Inches(0.8),
"第一章 · 三层架构全景", 36, bold=True, color=WHITE)
# 三个玻璃卡片
card_w = Inches(3.8)
card_h = Inches(2.2)
card_top = Inches(2.2)
gap = Inches(0.5)
start_x = Inches(0.8)
cards_data = [
("第一层 / 核心架构", "单体智能体四大支柱"),
("第二层 / 知识工程", "RAG与知识图谱"),
("第三层 / 协作工程化", "多智能体系统"),
]
for i, (title, subtitle) in enumerate(cards_data):
left = start_x + i * (card_w + gap)
shape = add_glass_card(slide2, left, card_top, card_w, card_h)
# 标题
add_text_box(slide2, left + Inches(0.2), card_top + Inches(0.5), card_w - Inches(0.4), Inches(0.6),
title, 22, bold=True, color=WHITE)
# 副标题
add_text_box(slide2, left + Inches(0.2), card_top + Inches(1.2), card_w - Inches(0.4), Inches(0.6),
subtitle, 16, bold=False, color=GRAY)
# ========== Slide 3: 分隔页 - 第一层 ==========
slide3 = prs.slides.add_slide(prs.slide_layouts[6])
set_dark_background(slide3)
add_text_box(slide3, Inches(0), Inches(2.8), SLIDE_W, Inches(1.2),
"第一层", 80, bold=True, color=WHITE, align=PP_ALIGN.CENTER)
add_text_box(slide3, Inches(0), Inches(4.2), SLIDE_W, Inches(0.6),
"智能体核心架构", 28, bold=False, color=GRAY, align=PP_ALIGN.CENTER)
# ========== Slide 4: 核心架构四支柱 ==========
slide4 = prs.slides.add_slide(prs.slide_layouts[6])
set_dark_background(slide4)
add_text_box(slide4, Inches(0.5), Inches(0.4), SLIDE_W, Inches(0.8),
"核心架构 · 四大支柱", 36, bold=True, color=WHITE)
# 2x2 网格
card_w = Inches(5.5)
card_h = Inches(2.5)
gap_x = Inches(1.0)
gap_y = Inches(0.4)
start_x = Inches(0.8)
start_y = Inches(1.6)
pillars = [
("规划 Planning", "任务分解 / ReAct / Chain-of-Thought"),
("记忆 Memory", "短期记忆 / 长期记忆 / RAG"),
("工具 Tool Use", "Function Calling / MCP / API集成"),
("大模型 LLM", "模型选型 / Fine-tuning"),
]
for idx, (title, desc) in enumerate(pillars):
row = idx // 2
col = idx % 2
left = start_x + col * (card_w + gap_x)
top = start_y + row * (card_h + gap_y)
shape = add_glass_card(slide4, left, top, card_w, card_h)
add_text_box(slide4, left + Inches(0.3), top + Inches(0.5), card_w - Inches(0.6), Inches(0.8),
title, 28, bold=True, color=WHITE)
add_text_box(slide4, left + Inches(0.3), top + Inches(1.4), card_w - Inches(0.6), Inches(0.8),
desc, 16, bold=False, color=GRAY)
# ========== Slide 5: 分隔页 - 第二层 ==========
slide5 = prs.slides.add_slide(prs.slide_layouts[6])
set_dark_background(slide5)
add_text_box(slide5, Inches(0), Inches(2.8), SLIDE_W, Inches(1.2),
"第二层", 80, bold=True, color=WHITE, align=PP_ALIGN.CENTER)
add_text_box(slide5, Inches(0), Inches(4.2), SLIDE_W, Inches(0.6),
"知识工程与检索增强", 28, bold=False, color=GRAY, align=PP_ALIGN.CENTER)
# ========== Slide 6: 知识工程三要素 ==========
slide6 = prs.slides.add_slide(prs.slide_layouts[6])
set_dark_background(slide6)
add_text_box(slide6, Inches(0.5), Inches(0.4), SLIDE_W, Inches(0.8),
"知识工程与检索增强", 36, bold=True, color=WHITE)
# 三个卡片横向
card_w = Inches(3.8)
card_h = Inches(3.5)
card_top = Inches(1.8)
gap = Inches(0.5)
start_x = Inches(0.8)
knowledge = [
("RAG 检索增强生成", "向量化 / 分块策略 / 混合检索 / 重排序"),
("知识工程", "从文档到知识图谱,构建业务规则库"),
("微调 Fine-tuning", "全参数微调 / PEFT / LoRA"),
]
for i, (title, desc) in enumerate(knowledge):
left = start_x + i * (card_w + gap)
shape = add_glass_card(slide6, left, card_top, card_w, card_h)
add_text_box(slide6, left + Inches(0.2), card_top + Inches(0.5), card_w - Inches(0.4), Inches(0.8),
title, 22, bold=True, color=WHITE)
add_text_box(slide6, left + Inches(0.2), card_top + Inches(1.5), card_w - Inches(0.4), Inches(1.8),
desc, 15, bold=False, color=GRAY)
# ========== Slide 7: 分隔页 - 第三层 ==========
slide7 = prs.slides.add_slide(prs.slide_layouts[6])
set_dark_background(slide7)
add_text_box(slide7, Inches(0), Inches(2.8), SLIDE_W, Inches(1.2),
"第三层", 80, bold=True, color=WHITE, align=PP_ALIGN.CENTER)
add_text_box(slide7, Inches(0), Inches(4.2), SLIDE_W, Inches(0.6),
"多智能体协作与工程化", 28, bold=False, color=GRAY, align=PP_ALIGN.CENTER)
# ========== Slide 8: 多智能体系统 ==========
slide8 = prs.slides.add_slide(prs.slide_layouts[6])
set_dark_background(slide8)
add_text_box(slide8, Inches(0.5), Inches(0.4), SLIDE_W, Inches(0.8),
"多智能体协作与工程化", 36, bold=True, color=WHITE)
card_w = Inches(3.8)
card_h = Inches(3.5)
card_top = Inches(1.8)
gap = Inches(0.5)
start_x = Inches(0.8)
mas_data = [
("多智能体系统 MAS", "角色分化 / 协作模式 / AutoGPT / CAMEL框架"),
("协作协议", "MCP协议 / A2A协议 / AG-UI协议"),
("评估与稳定性", "Ragas评估 / Guardrails / 可观测性"),
]
for i, (title, desc) in enumerate(mas_data):
left = start_x + i * (card_w + gap)
shape = add_glass_card(slide8, left, card_top, card_w, card_h)
add_text_box(slide8, left + Inches(0.2), card_top + Inches(0.5), card_w - Inches(0.4), Inches(0.8),
title, 22, bold=True, color=WHITE)
add_text_box(slide8, left + Inches(0.2), card_top + Inches(1.5), card_w - Inches(0.4), Inches(1.8),
desc, 15, bold=False, color=GRAY)
# ========== Slide 9: 学习路径 ==========
slide9 = prs.slides.add_slide(prs.slide_layouts[6])
set_dark_background(slide9)
add_text_box(slide9, Inches(0.5), Inches(0.4), SLIDE_W, Inches(0.8),
"从入门到精通 · 学习路径", 36, bold=True, color=WHITE)
# 时间轴 - 三个节点
node_y = Inches(3.5)
node_w = Inches(3.0)
node_h = Inches(1.8)
gap = Inches(0.8)
start_x = Inches(1.2)
timeline = [
("核心架构", "四大支柱构建单体智能体"),
("知识工程", "RAG与知识图谱增强"),
("协作工程化", "多智能体系统"),
]
for i, (title, desc) in enumerate(timeline):
left = start_x + i * (node_w + gap)
shape = add_glass_card(slide9, left, node_y, node_w, node_h)
add_text_box(slide9, left + Inches(0.2), node_y + Inches(0.3), node_w - Inches(0.4), Inches(0.6),
title, 24, bold=True, color=WHITE, align=PP_ALIGN.CENTER)
add_text_box(slide9, left + Inches(0.2), node_y + Inches(1.0), node_w - Inches(0.4), Inches(0.6),
desc, 14, bold=False, color=GRAY, align=PP_ALIGN.CENTER)
# 箭头(用文字箭头)
if i < 2:
arrow_x = left + node_w + Inches(0.1)
add_text_box(slide9, arrow_x, node_y + Inches(0.6), gap - Inches(0.2), Inches(0.6),
"→", 36, bold=True, color=GRAY, align=PP_ALIGN.CENTER)
# ========== Slide 10: 结尾页 ==========
slide10 = prs.slides.add_slide(prs.slide_layouts[6])
set_dark_background(slide10)
add_text_box(slide10, Inches(0), Inches(2.8), SLIDE_W, Inches(1.2),
"开始你的智能体之旅", 60, bold=True, color=WHITE, align=PP_ALIGN.CENTER)
add_text_box(slide10, Inches(0), Inches(4.5), SLIDE_W, Inches(0.6),
"视频教程 · 持续更新", 24, bold=False, color=GRAY, align=PP_ALIGN.CENTER)
# ========== 保存 ==========
output_path = os.path.expanduser("~/Desktop/智能体入门到精通-教程大纲.pptx")
prs.save(output_path)
print(f"PPTX 已生成: {output_path}")
return output_path
if __name__ == "__main__":
create_presentation()
FILE:huo15-js-scraper/SKILL.md
---
name: huo15-js-scraper
description: JavaScript渲染网站抓取工具。当需要抓取JS渲染的页面(如企微文档、Vue/React SPA)、企查查企业数据获取)、绕过反爬、或者普通curl/wget/web_fetch无法获取内容的网站时使用此技能。支持Playwright和scrapling双引擎自动切换。
identifier: huo15-js-scraper
version: 1.1.0
author: 贾维斯
category: web-scraping
---
# huo15-js-scraper
JavaScript渲染网站抓取技能,支持Playwright和scrapling双引擎。
## 快速使用
```bash
# 基本用法(自动选择引擎)
python3 ~/.openclaw/workspace/skills/huo15-js-scraper/scripts/scrape.py <URL>
# 指定选择器
python3 ~/.openclaw/workspace/skills/huo15-js-scraper/scripts/scrape.py <URL> --selector ".content"
# 输出JSON
python3 ~/.openclaw/workspace/skills/huo15-js-scraper/scripts/scrape.py <URL> --output json
# 强制使用scrapling引擎
python3 ~/.openclaw/workspace/skills/huo15-js-scraper/scripts/scrape.py <URL> --engine scrapling
```
## 引擎选择策略
| 场景 | 推荐引擎 |
|------|---------|
| 企微文档 / 微信相关 | Playwright |
| Cloudflare保护站 | scrapling (stealth) |
| Vue/React SPA | Playwright |
| 简单静态页 | scrapling (basic) |
| 未知站 | Playwright(更稳定) |
## Python API
```python
from huo15_js_scraper import scrape
# 方式1:自动选择(推荐)
result = scrape('https://example.com')
print(result['content'])
# 方式2:强制Playwright
result = scrape('https://developer.work.weixin.qq.com/document/path/91756', engine='playwright')
```
## 企业微信文档知识库
已构建完整的企微官方文档知识库,位于:
`~/workspace/knowledge-base/企业微信文档/`
### 知识库结构
```
企业微信文档/
├── README.md (索引)
├── 01-快速入门/ - 开发前必读
├── 02-服务端API/ - 通讯录、消息、客户联系、企业支付...
├── 03-客户端API/ - 小程序API、JS-SDK
├── 04-工具资源/ - WeUI、错误码、频率限制
└── 99-附录/ - FAQ、更新日志
```
### 更新企微文档知识库
```bash
# 列出所有可抓取文档
python3 ~/.openclaw/workspace/skills/huo15-js-scraper/scripts/wecom_docs_scraper.py --list
# 抓取单个文档
python3 ~/.openclaw/workspace/skills/huo15-js-scraper/scripts/wecom_docs_scraper.py --path-id 90556 --category "01-快速入门" --title "快速入门"
# 批量抓取(更新全部52个文档)
python3 ~/.openclaw/workspace/skills/huo15-js-scraper/scripts/wecom_docs_scraper.py --all
```
### 核心文档
| 文档 | 路径ID | 说明 |
|------|--------|------|
| 快速入门 | 90556 | 开发前必读 |
| 获取access_token | 91039 | API认证基础 |
| 发送应用消息 | 90235 | 消息推送核心 |
| 创建成员 | 90195 | 通讯录管理 |
| 客户联系概述 | 92109 | 客户管理基础 |
| JS-SDK签名算法 | 90506 | 前端开发必备 |
## 企查查企业数据
企查查(qcc.com)企业信息查询,支持两种方式:
1. **✅ 推荐:MCP方式**(官方API,稳定可靠)
2. **备用:直接抓取**(需要账号登录,有反爬限制)
---
### 推荐方案:企查查MCP(官方API)
企查查提供官方MCP服务,支持 OpenClaw,已封装20+企业查询 SKILL。
**数据规模:**
- 3.65亿+ 市场主体
- 2.5亿+ 司法诉讼
- 2.1亿+ 知识产权
- 1.7亿+ 招投标
**MCP Servers(4个):**
| Server | 别名 | 主要能力 |
|--------|------|---------|
| qcc-company | 企业基座 | 工商登记、股权结构 |
| qcc-risk | 风控大脑 | 34项风险扫描工具 |
| qcc-ipr | 知产引擎 | 专利、商标、软著 |
| qcc-operation | 经营罗盘 | 招投标、资质、舆情 |
**安装步骤:**
```bash
# 1. 注册获取API Key
# 访问 https://agent.qcc.com 注册
# 2. 添加到OpenClaw配置
# 在OpenClaw插件配置中添加企查查MCP服务器
# MCP接入地址: https://agent.qcc.com/mcp
# 需要配置 API Key 认证
```
**预置 SKILL(发送消息给AI即可加载):**
```
请加载并使用这个 SKILL:https://github.com/duhu2000/financial-services-qcc
```
**SKILL命令示例:**
```bash
# KYB企业核验(~30秒)
/kyb-verification-qcc 华为技术有限公司
# IC Memo投资备忘录(~30秒)
/ic-memo-qcc 宁德时代 --round Series-B
# 企业画像速览(~3分钟)
/strip-profile-qcc 美团平台有限公司
# 知识产权尽调
/ip-due-diligence-qcc 企业名称 --peer 竞品
# 供应链风险评估
/supply-chain-risk-qcc 企业名称 --tier 1
# 关联方穿透
/related-party-qcc 企业名称 --depth 5
```
**输出格式:** 支持 `.md` / `.docx` / `.pptx`
---
### 备用方案:直接抓取
如无法使用MCP,可使用直接抓取方式(需要企查查账号)。
#### 安装依赖
```bash
pip3 install playwright --break-system-packages
playwright install chromium
```
#### 登录(首次使用)
```bash
# 生成二维码截图,扫码登录
python3 ~/.openclaw/workspace/skills/huo15-js-scraper/scripts/qichacha_scraper.py --login
```
> 登录后Cookie自动保存到 `~/.cache/huo15-js-scraper/qichacha_cookies.json`
#### 搜索企业
```bash
# 搜索企业
python3 ~/.openclaw/workspace/skills/huo15-js-scraper/scripts/qichacha_scraper.py --search "腾讯" --limit 10
# 输出JSON
python3 ~/.openclaw/workspace/skills/huo15-js-scraper/scripts/qichacha_scraper.py --search "腾讯" --output json
```
#### 企业详情
```bash
# 获取企业详细信息(部分需要VIP)
python3 ~/.openclaw/workspace/skills/huo15-js-scraper/scripts/qichacha_scraper.py --company "https://www.qcc.com/firm/xxxxx.html"
```
#### 返回信息示例
**搜索结果(无需登录可查看基础信息):**
- 公司名称
- 企业状态(开业/存续/吊销)
- 行业分类
- 注册资本
- 法定代表人
**详细信息(可能需要VIP):**
- 工商信息
- 股东信息
- 年报数据
- 风险信息
#### 注意事项
- 企查查搜索功能需要登录才能访问
- 详细信息(如年报、股东)需要VIP账号
- Cookie有效期约7天,过期需重新登录
- 建议设置 `--wait 5` 等待页面渲染
## 常见问题
### Q: 企微文档怎么抓?
```bash
python3 ~/.openclaw/workspace/skills/huo15-js-scraper/scripts/scrape.py \
"https://developer.work.weixin.qq.com/document/path/91756" \
--wait 5
```
### Q: 提示playwright未安装?
```bash
pip3 install playwright --break-system-packages
playwright install chromium
```
### Q: scrapling安装?
```bash
pip3 install "scrapling[all]" --break-system-packages
scrapling install
```
### Q: 内容为空或获取到跳转页面?
增加 `--wait` 时间,让JS有更多时间渲染:
```bash
python3 ...scrape.py <URL> --wait 5
```
## 依赖安装
```bash
# Playwright(主引擎)
pip3 install playwright --break-system-packages
playwright install chromium
# scrapling(降级引擎)
pip3 install "scrapling[all]" --break-system-packages
scrapling install
```
## 工作原理
1. 优先使用 Playwright(chromium headless)加载页面,等待networkidle
2. 等待指定时间让JS渲染完成
3. 通过CSS选择器提取内容
4. 如果Playwright失败,自动降级到scrapling
FILE:huo15-js-scraper/scripts/qichacha_scraper.py
#!/usr/bin/env python3
"""
企查查 (Qichacha) 数据抓取模块 v1.1
支持二维码登录、Cookie管理、企业信息抓取
依赖:
pip3 install playwright --break-system-packages
playwright install chromium
用法:
# 登录(生成二维码截图)
python3 qichacha_scraper.py --login
# 搜索企业
python3 qichacha_scraper.py --search "腾讯"
# 抓取企业详情
python3 qichacha_scraper.py --company "https://www.qcc.com/firm/xxxxx.html"
推荐方案:
企查查MCP(官方API)需要先在 https://agent.qcc.com 注册获取API Key,
然后配置到OpenClaw插件中使用。
"""
import argparse
import json
import os
import sys
import time
from pathlib import Path
# Cookie存储路径
COOKIE_DIR = Path.home() / ".cache" / "huo15-js-scraper"
COOKIE_FILE = COOKIE_DIR / "qichacha_cookies.json"
QRCODE_FILE = COOKIE_DIR / "qichacha_qrcode.png"
QRCODE_TIMESTAMP = COOKIE_DIR / "qrcode_timestamp.txt"
def ensure_dirs():
"""确保目录存在"""
COOKIE_DIR.mkdir(parents=True, exist_ok=True)
def save_cookies(context):
"""保存登录Cookie"""
ensure_dirs()
cookies = context.cookies()
with open(COOKIE_FILE, 'w') as f:
json.dump(cookies, f)
print(f"Cookie已保存到: {COOKIE_FILE}")
def load_cookies():
"""加载已保存的Cookie"""
if COOKIE_FILE.exists():
with open(COOKIE_FILE, 'r') as f:
return json.load(f)
return None
def is_cookie_valid():
"""检查Cookie是否有效"""
cookies = load_cookies()
if not cookies:
return False
# 检查关键Cookie是否存在
cookie_names = [c['name'] for c in cookies]
has_auth = any('qcc' in name.lower() or 'token' in name.lower() or 'session' in name.lower() for name in cookie_names)
return has_auth
def login_with_qrcode():
"""二维码登录,返回截图路径"""
ensure_dirs()
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch(headless=False)
context = browser.new_context(
user_agent='Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
viewport={'width': 1280, 'height': 800}
)
page = context.new_page()
# 访问登录页
print('访问企查查登录页...')
page.goto("https://www.qcc.com/weblogin", wait_until="domcontentloaded", timeout=20000)
time.sleep(3)
print(f'页面标题: {page.title()}')
print(f'当前URL: {page.url}')
# 截图登录页
page.screenshot(path=str(COOKIE_DIR / 'qichacha_login.png'), full_page=False)
print(f'\\n登录页截图已保存: {COOKIE_DIR / "qichacha_login.png"}')
# 尝试提取QR码
try:
# 等待QR码加载
page.wait_for_selector('.qrcode-img img, .qr-code img, canvas', timeout=5000)
# 查找QR码img
qr_img = page.locator('.qrcode-img img, .qr-code img').first
if qr_img.count() > 0:
src = qr_img.get_attribute('src')
if src:
print(f'找到QR码图片src: {src[:50]}...')
qr_img.screenshot(path=str(QRCODE_FILE))
print(f'QR码截图已保存: {QRCODE_FILE}')
# 查找canvas
canvases = page.locator('canvas').all()
if canvases:
print(f'找到 {len(canvases)} 个canvas元素')
except Exception as e:
print(f'提取QR码失败: {e}')
print('\\n' + '='*50)
print('请用企查查APP扫码登录!')
print('='*50)
print(f'\\n截图位置: {COOKIE_DIR / "qichacha_login.png"}')
print('请用手机扫码登录后告诉我,我会保存Cookie')
print('\\n等待扫码确认...(最多5分钟)')
# 等待扫码登录(最多5分钟)
max_wait = 300
start_time = time.time()
while time.time() - start_time < max_wait:
time.sleep(3)
# 检查URL变化
if 'weblogin' not in page.url:
print('\\n✅ 登录成功! (URL变化检测)')
save_cookies(context)
browser.close()
return True
# 检查cookies
cookies = context.cookies()
if any(c['name'] == 'qcc_c' for c in cookies):
print('\\n✅ 登录成功! (Cookie检测)')
save_cookies(context)
browser.close()
return True
# 每30秒提示一次
elapsed = int(time.time() - start_time)
if elapsed % 30 == 0 and elapsed > 0:
print(f' 等待中... ({elapsed}秒)')
print('\\n❌ 登录超时')
browser.close()
return False
def search_company(keyword, limit=10):
"""搜索企业"""
from playwright.sync_api import sync_playwright
cookies = load_cookies()
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
context = browser.new_context(
user_agent='Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
)
if cookies:
context.add_cookies(cookies)
page = context.new_page()
# 访问搜索页
search_url = f"https://www.qcc.com/web/search?key={keyword}"
page.goto(search_url, wait_until="domcontentloaded", timeout=20000)
time.sleep(5)
# 检查是否跳转到了登录页
if 'weblogin' in page.url:
browser.close()
return {
'error': '需要登录',
'message': '请先运行 --login 命令扫码登录',
'qrcode_file': str(COOKIE_DIR / 'qichacha_login.png')
}
results = []
# 提取搜索结果
# 企查查的搜索结果结构
try:
# 方法1: 查找公司列表项
items = page.locator('.search-result li, .company-list .item, .nsearch-list .item, [class*="company"]').all()
for item in items[:limit]:
try:
# 公司名称
name_el = item.locator('.company-name, .name, h3 a, [class*="name"]').first
name = name_el.inner_text() if name_el.count() > 0 else ""
# 状态
status_el = item.locator('.status, [class*="status"]').first
status = status_el.inner_text() if status_el.count() > 0 else ""
# 法人
legal_el = item.locator('.legal, [class*="legal"], .fr, [class*="person"]').first
legal = legal_el.inner_text() if legal_el.count() > 0 else ""
# 资本
capital_el = item.locator('.capital, [class*="capital"]').first
capital = capital_el.inner_text() if capital_el.count() > 0 else ""
# 链接
link_el = item.locator('a').first
href = link_el.get_attribute('href') if link_el.count() > 0 else ""
if name:
results.append({
'name': name.strip(),
'status': status.strip(),
'legal_person': legal.strip(),
'capital': capital.strip(),
'url': f"https://www.qcc.com{href}" if href and not href.startswith('http') else href
})
except Exception as e:
continue
except Exception as e:
pass
# 如果没找到,尝试提取页面文本
if not results:
body_text = page.locator('body').inner_text()
results = [{'raw_text': body_text[:2000], 'note': '需要登录查看完整数据'}]
browser.close()
return results
def get_company_detail(company_url):
"""获取企业详细信息"""
from playwright.sync_api import sync_playwright
cookies = load_cookies()
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
context = browser.new_context(
user_agent='Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
)
if cookies:
context.add_cookies(cookies)
page = context.new_page()
page.goto(company_url, wait_until="domcontentloaded", timeout=20000)
time.sleep(5)
# 检查登录
if 'weblogin' in page.url:
browser.close()
return {'error': '需要登录', 'message': '请先运行 --login 命令'}
data = {
'url': page.url,
'title': page.title(),
'需要登录': False,
'basic_info': {}
}
# 提取基本信息
try:
# 基础信息区域
base_info = page.locator('.company-detail, #company-detail, .base-info').first
if base_info.count() > 0:
data['basic_info']['html'] = base_info.inner_html()
data['basic_info']['text'] = base_info.inner_text()
except:
pass
# 检查是否需要VIP
try:
vip_tip = page.locator('.vip-tip, .login-tip, [class*="vip"]').first
if vip_tip.count() > 0:
data['需要登录'] = True
data['vip_tip'] = vip_tip.inner_text()
except:
pass
browser.close()
return data
def main():
parser = argparse.ArgumentParser(
description='企查查数据抓取工具\n\n推荐方案:企查查MCP(官方API)\n 访问 https://agent.qcc.com 注册获取API Key\n 然后配置到OpenClaw插件中使用',
formatter_class=argparse.RawDescriptionHelpFormatter
)
parser.add_argument('--login', action='store_true', help='二维码登录')
parser.add_argument('--search', type=str, help='搜索企业')
parser.add_argument('--company', type=str, help='企业详情URL')
parser.add_argument('--limit', type=int, default=10, help='搜索结果数量限制')
parser.add_argument('--output', '-o', choices=['text', 'json'], default='text')
args = parser.parse_args()
if args.login:
success = login_with_qrcode()
if success:
print('\\n✅ 登录成功! Cookie已保存。')
print('现在可以运行 --search 进行搜索了。')
else:
print('\\n❌ 登录失败或超时。')
print(f'\\nQR码截图: {COOKIE_DIR / "qichacha_login.png"}')
sys.exit(0 if success else 1)
elif args.search:
results = search_company(args.search, args.limit)
if isinstance(results, dict) and 'error' in results:
print(f"错误: {results['error']}")
print(f"提示: {results['message']}")
if 'qrcode_file' in results:
print(f"QR码截图: {results['qrcode_file']}")
elif args.output == 'json':
print(json.dumps(results, ensure_ascii=False, indent=2))
else:
print(f"搜索 \"{args.search}\" 找到 {len(results)} 个结果:")
for i, r in enumerate(results, 1):
if 'raw_text' in r:
print(r['raw_text'])
else:
print(f"\\n{i}. {r.get('name', 'N/A')}")
if r.get('status'): print(f" 状态: {r['status']}")
if r.get('legal_person'): print(f" 法人: {r['legal_person']}")
if r.get('capital'): print(f" 资本: {r['capital']}")
if r.get('url'): print(f" URL: {r['url']}")
elif args.company:
detail = get_company_detail(args.company)
if args.output == 'json':
print(json.dumps(detail, ensure_ascii=False, indent=2))
else:
print(f"标题: {detail.get('title', 'N/A')}")
print(f"URL: {detail.get('url', 'N/A')}")
print(f"需要VIP: {detail.get('需要登录', False)}")
if detail.get('basic_info', {}).get('text'):
print(f"\\n基本信息:\\n{detail['basic_info']['text'][:1000]}")
else:
parser.print_help()
print('\\n' + '='*50)
print('推荐方案:企查查MCP(官方API)')
print(' 访问 https://agent.qcc.com 注册获取API Key')
print(' 然后配置到OpenClaw插件中使用')
print('='*50)
if __name__ == '__main__':
main()
FILE:huo15-js-scraper/scripts/scrape.py
#!/usr/bin/env python3
"""
huo15-js-scraper - JavaScript渲染网站抓取工具
基于Playwright,支持stealth模式和scrapling降级
"""
import argparse
import json
import sys
import time
from pathlib import Path
def scrape_with_playwright(url, selector=None, wait=5, headless=True, output_format='text'):
"""使用Playwright抓取JS渲染页面"""
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch(headless=headless)
page = browser.new_page()
# 设置User-Agent
page.set_extra_http_headers({
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
})
page.goto(url, wait_until='networkidle')
time.sleep(wait)
if selector:
content = page.locator(selector).inner_text()
else:
content = page.locator('body').inner_text()
result = {
'url': page.url,
'title': page.title(),
'content': content,
'engine': 'playwright'
}
browser.close()
return result
def scrape_with_scrapling(url, selector=None, mode='dynamic', wait=3):
"""使用scrapling抓取(降级方案)"""
try:
from scrapling.fetchers import DynamicFetcher
page = DynamicFetcher.fetch(url, headless=True, network_idle=True)
time.sleep(wait)
if selector:
content = ''.join(page.css(f'{selector} *::text').getall())
else:
content = ''.join(page.css('body *::text').getall())
return {
'url': page.url,
'title': page.css('title::text').get() or '',
'content': content,
'engine': 'scrapling'
}
except Exception as e:
return {
'url': url,
'error': str(e),
'engine': 'scrapling'
}
def main():
parser = argparse.ArgumentParser(description='JS渲染网站抓取工具')
parser.add_argument('url', help='目标URL')
parser.add_argument('--selector', '-s', help='CSS选择器')
parser.add_argument('--wait', '-w', type=int, default=3, help='等待秒数')
parser.add_argument('--engine', '-e', choices=['playwright', 'scrapling', 'auto'], default='auto')
parser.add_argument('--output', '-o', choices=['text', 'json'], default='text')
parser.add_argument('--stealth', action='store_true', help='隐身模式')
args = parser.parse_args()
# 自动选择引擎
if args.engine == 'auto':
# 优先playwright,更稳定
try:
result = scrape_with_playwright(args.url, args.selector, args.wait)
except Exception as e:
print(f"Playwright失败,尝试scrapling: {e}", file=sys.stderr)
result = scrape_with_scrapling(args.url, args.selector)
elif args.engine == 'playwright':
result = scrape_with_playwright(args.url, args.selector, args.wait)
else:
result = scrape_with_scrapling(args.url, args.selector)
# 输出
if args.output == 'json':
print(json.dumps(result, ensure_ascii=False, indent=2))
else:
print(f"URL: {result.get('url', 'N/A')}")
print(f"Title: {result.get('title', 'N/A')}")
print(f"Engine: {result.get('engine', 'N/A')}")
print("-" * 50)
print(result.get('content', result.get('error', 'No content')))
if __name__ == '__main__':
main()
FILE:huo15-js-scraper/scripts/wecom_docs_scraper.py
#!/usr/bin/env python3
"""
企业微信官方文档抓取脚本
基于 Playwright,系统抓取并整理成 Markdown 知识库
"""
import argparse
import json
import time
import re
from pathlib import Path
from datetime import datetime
from playwright.sync_api import sync_playwright
BASE_URL = "https://developer.work.weixin.qq.com"
OUTPUT_DIR = Path.home() / "workspace" / "knowledge-base" / "企业微信文档"
# 文档分类映射
CATEGORIES = {
# 快速入门
"90556": ("01-快速入门", "快速入门"),
"90487": ("01-快速入门", "简易教程"),
"90665": ("01-快速入门", "基本概念"),
# 服务端API - 通讯录
"90193": ("02-服务端API/通讯录管理", "成员管理概述"),
"90195": ("02-服务端API/通讯录管理", "创建成员"),
"90196": ("02-服务端API/通讯录管理", "读取成员"),
"90197": ("02-服务端API/通讯录管理", "更新成员"),
"90198": ("02-服务端API/通讯录管理", "删除成员"),
"90205": ("02-服务端API/通讯录管理", "创建部门"),
"90208": ("02-服务端API/通讯录管理", "获取部门列表"),
# 服务端API - 身份验证
"91039": ("02-服务端API/身份验证", "获取access_token"),
"90930": ("02-服务端API/身份验证", "回调配置"),
"91022": ("02-服务端API/身份验证", "构造网页授权链接"),
"91023": ("02-服务端API/身份验证", "获取访问用户身份"),
# 服务端API - 消息推送
"90235": ("02-服务端API/消息推送", "发送应用消息"),
"90238": ("02-服务端API/消息推送", "消息格式"),
"90244": ("02-服务端API/消息推送", "群聊会话管理"),
# 服务端API - 应用管理
"90226": ("02-服务端API/应用管理", "应用管理概述"),
"90227": ("02-服务端API/应用管理", "获取应用"),
"90228": ("02-服务端API/应用管理", "设置应用"),
# 服务端API - 素材管理
"91054": ("02-服务端API/素材管理", "临时素材"),
# 服务端API - 客户联系
"92109": ("02-服务端API/客户联系", "客户联系概述"),
"92113": ("02-服务端API/客户联系", "获取客户列表"),
"92114": ("02-服务端API/客户联系", "获取客户详情"),
"92117": ("02-服务端API/客户联系", "管理企业标签"),
# 服务端API - 企业支付
"90273": ("02-服务端API/企业支付", "企业红包"),
"90278": ("02-服务端API/企业支付", "向员工付款"),
# 服务端API - 会话内容存档
"91360": ("02-服务端API/会话存档", "会话内容存档"),
"99941": ("02-服务端API/会话存档", "会话内容存档概述"),
# 客户端API - 小程序
"91506": ("03-客户端API/小程序", "wx.qy.login"),
"91519": ("03-客户端API/小程序", "wx.qy.openEnterpriseChat"),
"90513": ("03-客户端API/小程序", "小程序JS-SDK概述"),
# 客户端API - JS-SDK
"90506": ("03-客户端API/JS-SDK", "JS-SDK签名算法"),
"90508": ("03-客户端API/JS-SDK", "所有菜单项列表"),
# 工具与资源
"90305": ("04-工具资源", "样式库WeUI"),
"90312": ("04-工具资源", "访问频率限制"),
"90313": ("04-工具资源", "全局错误码"),
# 附录
"90968": ("99-附录", "附录"),
"93221": ("99-附录", "更新日志"),
"90623": ("99-附录", "联系我们"),
}
# 额外要抓取的文档ID(按类别)
EXTRA_DOCS = [
# 客户端API - 小程序
("90513", "03-客户端API/小程序", "小程序JS-SDK概述"),
("90506", "03-客户端API/JS-SDK", "JS-SDK签名算法"),
("90508", "03-客户端API/JS-SDK", "所有菜单项列表"),
("90509", "03-客户端API/JS-SDK", "常见错误及解决方法"),
# 更多服务端API
("90283", "02-服务端API/电子发票", "电子发票概述"),
("90284", "02-服务端API/电子发票", "查询电子发票"),
# 会话存档
("99968", "02-服务端API/会话存档", "获取会话记录"),
("99992", "02-服务端API/会话存档", "会话存档回调事件"),
# 客户联系
("92120", "02-服务端API/客户联系", "获取客户群列表"),
("92122", "02-服务端API/客户联系", "获取客户群详情"),
("92135", "02-服务端API/客户联系", "创建企业群发"),
# 企业支付
("93665", "02-服务端API/企业支付", "对外收款概述"),
# 附录
("90311", "99-附录", "与企业号接口差异"),
("90314", "99-附录", "企业规模与行业信息"),
("90315", "99-附录", "常见问题FAQ"),
]
def scrape_doc(path_id, timeout=30):
"""抓取单个文档"""
url = f"{BASE_URL}/document/path/{path_id}"
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page()
page.set_extra_http_headers({
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36'
})
try:
page.goto(url, wait_until='networkidle', timeout=timeout * 1000)
time.sleep(3) # 等待JS渲染
# 获取标题
title = page.title().replace(' - 文档 - 企业微信开发者中心', '').strip()
# 获取正文内容
content = page.locator('body').inner_text()
# 获取URL(可能有跳转)
final_url = page.url
browser.close()
return {
'path_id': path_id,
'url': final_url,
'title': title,
'content': content
}
except Exception as e:
browser.close()
return {
'path_id': path_id,
'error': str(e)
}
def save_doc(doc_info, category_dir, title):
"""保存文档为Markdown"""
if 'error' in doc_info:
print(f" ❌ {title}: {doc_info['error']}")
return False
# 构建文件路径
safe_title = re.sub(r'[<>:"/\\|?*]', '_', title)
file_path = category_dir / f"{safe_title}.md"
# 构建Markdown内容
md_content = f"""---
title: {title}
path_id: {doc_info['path_id']}
url: {doc_info['url']}
scrape_time: {datetime.now().isoformat()}
category: {category_dir.name}
---
# {title}
> 原文地址: {doc_info['url']}
## 内容
{doc_info['content']}
---
*最后抓取时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}*
"""
# 写入文件
with open(file_path, 'w', encoding='utf-8') as f:
f.write(md_content)
print(f" ✅ {title}")
return True
def main():
parser = argparse.ArgumentParser(description='企业微信官方文档抓取工具')
parser.add_argument('--path-id', '-p', help='指定文档ID')
parser.add_argument('--list', '-l', action='store_true', help='列出所有文档')
parser.add_argument('--all', '-a', action='store_true', help='抓取所有文档')
parser.add_argument('--category', '-c', help='指定分类目录')
parser.add_argument('--title', '-t', help='文档标题')
args = parser.parse_args()
# 确保目录存在
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
for subdir in ["02-服务端API/通讯录管理", "02-服务端API/身份验证",
"02-服务端API/消息推送", "02-服务端API/应用管理",
"02-服务端API/素材管理", "02-服务端API/客户联系",
"02-服务端API/企业支付", "02-服务端API/会话存档",
"02-服务端API/电子发票", "03-客户端API/小程序",
"03-客户端API/JS-SDK", "04-工具资源"]:
(OUTPUT_DIR / subdir).mkdir(parents=True, exist_ok=True)
# 列出所有文档
if args.list:
print(f"共 {len(CATEGORIES) + len(EXTRA_DOCS)} 个已配置文档:\n")
for path_id, (cat, title) in list(CATEGORIES.items())[:20]:
print(f" {path_id}: {title} ({cat})")
if len(CATEGORIES) > 20:
print(f" ... 还有 {len(CATEGORIES) - 20} 个")
return
# 抓取指定文档
if args.path_id:
if args.category and args.title:
category_dir = OUTPUT_DIR / args.category
category_dir.mkdir(parents=True, exist_ok=True)
else:
if args.path_id in CATEGORIES:
category_dir = OUTPUT_DIR / CATEGORIES[args.path_id][0]
else:
category_dir = OUTPUT_DIR / "99-附录"
print(f"抓取文档 {args.path_id}...")
doc_info = scrape_doc(args.path_id)
title = args.title or (CATEGORIES.get(args.path_id, ['', ''])[1] if args.path_id in CATEGORIES else '未命名')
save_doc(doc_info, category_dir, title)
return
# 抓取所有文档
if args.all:
print(f"开始抓取企微文档,共 {len(CATEGORIES) + len(EXTRA_DOCS)} 个...\n")
# 合并所有文档
all_docs = {}
for path_id, (cat, title) in CATEGORIES.items():
all_docs[path_id] = (cat, title)
for path_id, cat, title in EXTRA_DOCS:
all_docs[path_id] = (cat, title)
success = 0
failed = 0
for i, (path_id, (cat, title)) in enumerate(all_docs.items()):
print(f"[{i+1}/{len(all_docs)}] 抓取 {title}...")
category_dir = OUTPUT_DIR / cat
category_dir.mkdir(parents=True, exist_ok=True)
doc_info = scrape_doc(path_id)
if save_doc(doc_info, category_dir, title):
success += 1
else:
failed += 1
time.sleep(1) # 避免请求过快
print(f"\n完成!成功: {success}, 失败: {failed}")
return
# 生成索引
print("生成知识库索引...")
generate_index()
print("完成!")
def generate_index():
"""生成知识库索引"""
index_content = """# 企业微信官方文档知识库
> 基于官方文档自动构建,包含服务端API、客户端API、工具资源等
## 目录结构
"""
# 遍历目录
for section_dir in sorted(OUTPUT_DIR.iterdir()):
if section_dir.is_dir():
index_content += f"\n### {section_dir.name}\n\n"
for sub_dir in sorted(section_dir.iterdir()):
if sub_dir.is_dir():
index_content += f"- **{sub_dir.name}**\n"
for md_file in sorted(sub_dir.glob("*.md")):
title = md_file.stem
index_content += f" - [{title}]({md_file.relative_to(OUTPUT_DIR)})\n"
else:
if sub_dir.suffix == '.md':
title = sub_dir.stem
index_content += f"- [{title}]({sub_dir.relative_to(OUTPUT_DIR)})\n"
# 写入索引
with open(OUTPUT_DIR / "README.md", 'w', encoding='utf-8') as f:
f.write(index_content)
if __name__ == '__main__':
main()
FILE:huo15-openclaw-explore-mode/SKILL.md
---
name: huo15-openclaw-explore-mode
version: 1.1.0
description: "深度探索模式 — 系统性调研代码库、系统或话题,只读不改。借鉴 Claude Code 的 Explore Agent。"
homepage: https://github.com/zhaobod1/huo15-openclaw-enhance
metadata: { "openclaw": { "emoji": "🔍", "requires": { "bins": [] } } }
---
# 探索模式 (Explore Mode)
进入只读深度探索模式,在回答前系统性地调研。
## 使用时机
✅ **使用此技能当:**
- "帮我了解一下这个项目的架构"
- "这个功能是怎么实现的?"
- "调查一下为什么会出这个 bug"
- 需要全面理解一个不熟悉的代码库或系统
❌ **不要使用当:**
- 已经很了解要查的内容
- 用户只是问一个简单的事实性问题
## 探索流程
### 1. 确定探索目标
- 要回答什么问题?
- 探索的范围是什么?(哪些目录/文件/系统)
- 需要达到什么深度?
### 2. 广度优先扫描
- 先看目录结构,建立全局认知
- 读 README、配置文件、入口文件
- 识别主要模块和它们的关系
### 3. 深度定向调研
- 针对目标问题,深入相关模块
- 跟踪关键调用链
- 查看测试用例理解预期行为
- 查看 git log 了解变更历史
### 4. 输出探索报告
结构化输出:
```
## 探索报告: [主题]
### 概述
一段话总结发现
### 架构/实现
- 关键文件和它们的职责
- 核心数据流/调用链
- 重要的设计决策
### 发现
- 发现1: ...
- 发现2: ...
### 回答
[针对原始问题的直接回答]
### 建议(可选)
如果发现了问题或改进空间
```
## 核心原则
- **只读不改** — 探索阶段不修改任何文件
- **系统性** — 不要只看一个文件就下结论,要交叉验证
- **记录路径** — 给出具体的文件路径和行号,方便用户定位
- **区分事实和推测** — 明确标注哪些是从代码看到的,哪些是推断的
FILE:huo15-openclaw-memory-curator/SKILL.md
---
name: huo15-openclaw-memory-curator
version: 1.1.0
description: "记忆整理技能 — 审查结构化记忆,提取洞察,更新 MEMORY.md,清理过期条目。"
homepage: https://github.com/zhaobod1/huo15-openclaw-enhance
metadata: { "openclaw": { "emoji": "🧠", "requires": { "bins": [] } } }
---
# 记忆整理 (Memory Curator)
定期整理和优化结构化记忆系统。
## 使用时机
✅ **使用此技能当:**
- "整理一下记忆"、"清理过期记忆"
- 定期维护(建议每周一次)
- 记忆条目数量过多需要精简
- 需要从记忆中提取总结更新到 MEMORY.md
## 整理流程
### 1. 审查当前状态
```
调用 enhance_memory_review action=stats 查看各类别统计
调用 enhance_memory_review action=recent limit=30 查看最近记忆
```
### 2. 清理过期/无效记忆
检查每条记忆是否仍然有效:
- **project 类**: 项目状态可能已变化,验证后决定保留或删除
- **feedback 类**: 用户反馈通常长期有效,谨慎删除
- **user 类**: 用户信息通常稳定,少量更新
- **reference 类**: 链接/资源可能已过期,检查后决定
- **decision 类**: 决策通常长期有效,除非被推翻
```
调用 enhance_memory_review action=delete id=<过期记忆ID>
```
### 3. 合并重复记忆
如果多条记忆说的是同一件事:
1. 创建一条更完整的合并记忆
2. 删除旧的重复条目
### 4. 同步到 MEMORY.md
将最重要的结构化记忆摘要写入 MEMORY.md:
- 只写长期有效、高重要性的内容
- 按类别组织
- 保持简洁
### 5. 输出整理报告
```
## 记忆整理报告
### 统计
- 整理前: XX 条
- 删除: X 条(原因: ...)
- 合并: X 条 → X 条
- 新增: X 条
- 整理后: XX 条
### 操作记录
1. 删除 #12: 过期的项目截止日期
2. 合并 #15 + #18 → #23: 用户偏好
3. ...
### MEMORY.md 更新
[是否更新了 MEMORY.md,更新了什么]
```
## 核心原则
- **保守删除** — 不确定就保留,宁多勿少
- **feedback 最珍贵** — 用户反馈是最难重新获取的记忆类型
- **验证后再删** — 对 project 类记忆,先确认项目状态再决定
- **MEMORY.md 是精华** — 只把最重要的同步过去,不要全量复制
FILE:huo15-openclaw-mit-48h-learning-method/SKILL.md
---
name: huo15-openclaw-mit-48h-learning-method
version: 2.2.0
description: 麻省理工学院48小时学习法技能(青岛火一五信息科技有限公司)。使用 NotebookLM CLI 实现 MIT 研究生 Ihtesham Ali 的三问学习框架:
1. 问心智模型:领域内专家共享的 5 个基本思维框架
2. 问专家分歧:在哪 3 个问题上根本不同意
3. 问暴露性问题:生成能区分真懂和假背的 10 个问题
触发场景:(1)用户要求快速学习某个领域;(2)用户提到 MIT 学习法、48 小时学习、NotebookLM 三问;(3)用户需要生成播客/视频概览;(4)用户想用 AI 辅助构建知识体系。
---
# 火一五 MIT 48 小时学习法
MIT 研究生 Ihtesham Ali 的学习方法:48 小时内通过三问框架掌握任意领域。
## 核心工作流
```
学什么 → 创建 NotebookLM → 添加资料 → 三问框架 → 生成 Audio/Video
```
## 前置条件
**首次使用必须认证:**
```bash
~/.venv/notebooklm/bin/nlm login
```
(会打开浏览器,按提示完成 Google 账号授权)
**自动续登录:** 脚本会在每次执行命令前自动检测登录状态,如果检测到登录已失效,会自动重新运行 `nlm login`,无需手动干预。
## 依赖
- **CLI 工具**:`~/.venv/notebooklm/bin/nlm`
- **环境变量**:`NOTEBOOKLM_PROFILE`(可选,默认为 `default`)
- **语言设置**:`MIT_LEARN_LANG`(可选,默认为 `zh-CN`)
## 脚本位置
```
skills/huo15-mit-48h-learning-method/scripts/mit-learn.sh
```
## 使用方法
### 完整流程(推荐)
```bash
./scripts/mit-learn.sh full "学习主题" --url "https://..." --file ./notes.pdf --youtube "https://youtube.com/..."
```
完整流程包含:创建 notebook → 添加资料 → 三问框架(心智模型、专家分歧、暴露性问题)
### 分步流程
```bash
# 1. 创建笔记本
./scripts/mit-learn.sh init "机器学习基础"
# 2. 添加资料(可多次调用)
./scripts/mit-learn.sh add --url "https://..." --wait
./scripts/mit-learn.sh add --file ./paper.pdf --wait
./scripts/mit-learn.sh add --youtube "https://youtube.com/..."
# 3. 三问框架
./scripts/mit-learn.sh ask mental-models # 问心智模型(5个框架)
./scripts/mit-learn.sh ask disagreements # 问专家分歧(3个问题)
./scripts/mit-learn.sh ask probing # 问暴露性问题(10个问题)
./scripts/mit-learn.sh ask all # 完整三问
# 4. 生成概览
./scripts/mit-learn.sh audio # 生成播客音频
./scripts/mit-learn.sh video # 生成视频
# 5. 查看状态
./scripts/mit-learn.sh status # 查看当前 notebook 状态
./scripts/mit-learn.sh list # 列出所有 notebooks
```
## 三问框架详解
### 问心智模型(Mental Models)
> "该领域专家共享的 5 个基本思维框架是什么?"
- 每个框架用一句话解释 + 具体应用例子
- 目的是快速建立领域内专家共同认可的思维工具箱
### 问专家分歧(Expert Disagreements)
> "在哪 3 个问题上,该领域专家根本不同意?"
- 识别核心理论、方法或结论上的根本性争议
- 了解分歧根源,明白这不是细枝末节而是根本矛盾
- **这是区分真学习和假学习的关键**:知道分歧意味着真正理解领域
### 问暴露性问题(Probing Questions)
> "生成 10 个能区分真懂和假背的问题"
- 苏格拉底式追问:开放性问题,无法通过简单回忆回答
- 每个问题需说明:假背者会怎么错 / 真懂的人会怎么答
- **这是检验学习效果的最终武器**
## NotebookLM 支持的资料类型
| 类型 | 参数 | 示例 |
|------|------|------|
| URL | `--url` / `-u` | `--url "https://..."` |
| 文件 | `--file` / `-f` | `--file ./notes.pdf` |
| YouTube | `--youtube` / `-y` | `--youtube "https://youtube.com/..."` |
| Google Drive | `--drive` | `--drive <doc-id>` |
## Audio/Video 选项
### Audio(播客音频)
```bash
./scripts/mit-learn.sh audio [format]
# format: deep_dive(默认)/ brief / critique / debate
# length: short / default / long
```
### Video(视频概览)
```bash
./scripts/mit-learn.sh video [style]
# style: auto_select(默认)/ classic / whiteboard / kawaii / anime / watercolor / retro_print / heritage / paper_craft
```
## 提示词设计原则
三问框架的提示词基于以下原则设计:
1. **心智模型**:要求专家视角 + 具体例子,不可泛泛而谈
2. **专家分歧**:要求根本性分歧,而非表面差异
3. **暴露性问题**:苏格拉底追问法,必须能区分真假理解
## 注意事项
- `init` 后当前 notebook ID 保存到 `~/.mit-learn-notebook-id`,后续命令复用
- 添加资料后可用 `--wait` 等待处理完成
- NotebookLM API 可能有速率限制,避免短时间内大量请求
- 三问结果建议保存到笔记中,用于后续复习
- 如果使用多个 Google 账号,可设置 `NOTEBOOKLM_PROFILE=your-profile` 环境变量切换
## v2.0.0 (2026-04-06)
### 新功能
- **支持 file:// URL**:自动转换为真实路径再添加
- **音频生成等待**:新增 wait_for_audio 确认音频生成完成再返回
- **重复 notebook 检测**:创建前先查找同名 notebook,避免重复
- **自动续登录**:登录失效前自动重新运行 `nlm login`,无需手动干预
### Bug 修复
- **full 命令参数传递 bug**:修复了 urls/files/yt_urls 三个数组错误传递的问题
- **增强错误处理**:cmd_add 不再隐藏错误信息,现在会明确显示失败原因
### 改进
- 新增 --skip-audio flag:full 命令可跳过音频生成
- cmd_init 重构为 get_or_create_notebook 函数
- wait_for_processing 稳定性提升
- debug 函数默认关闭,减少干扰
```bash
mit-learn.sh full "机器学习" --file ./notes.pdf --skip-audio
```
FILE:huo15-openclaw-mit-48h-learning-method/_meta.json
{
"ownerId": "kn7byevkn40d6z4p7ghdb097z983tj33",
"slug": "huo15-mit-48h-learning-method-dev",
"version": "2.1.0",
"publishedAt": 1775566832042
}
FILE:huo15-openclaw-mit-48h-learning-method/config.json
{
"name": "huo15-mit-48h-learning-method",
"version": "2.0.0",
"description": "火一五 MIT 48 小时学习法 - 基于 NotebookLM 的三问框架",
"nlm_path": "~/.venv/notebooklm/bin/nlm",
"auth_required": true,
"auth_note": "首次使用需运行:nlm login(浏览器交互式登录)",
"profiles": {
"list": "nlm login profile list",
"add": "nlm login",
"default": "default"
},
"three_questions": {
"mental_models": {
"count": 5,
"description": "领域内专家共享的 5 个基本思维框架"
},
"disagreements": {
"count": 3,
"description": "该领域专家根本不同意的 3 个核心问题"
},
"probing_questions": {
"count": 10,
"description": "能区分真懂和假背的 10 个暴露性问题"
}
},
"supported_source_types": [
"url",
"file",
"youtube",
"google_drive"
],
"audio_formats": [
"deep_dive",
"brief",
"critique",
"debate"
],
"video_styles": [
"auto_select",
"classic",
"whiteboard",
"kawaii",
"anime",
"watercolor",
"retro_print",
"heritage",
"paper_craft"
],
"author": "青岛火一五信息科技有限公司",
"tags": ["learning", "mit", "notebooklm", "knowledge-management", "ai"]
}
FILE:huo15-openclaw-mit-48h-learning-method/scripts/mit-learn.sh
#!/bin/bash
#===============================================================================
# 火一五 MIT 48 小时学习法 - 核心脚本
# MIT 48-Hour Learning Method - Core Script
#
# 依赖:notebooklm-mcp-cli(~/.venv/notebooklm/bin/nlm)
# 流程:学什么 → 创建 notebook → 添加资料 → 三问框架 → 生成 audio/video
#
# v2.1.0 改进:
# - 新增自动续登录功能:登录失效前自动运行 nlm login
# - 支持 file:// URL 自动转真实路径
# - 修复 full 命令参数传递 bug
# - 音频生成增加等待确认
# - 改进重复 notebook 检测
# - 增强错误处理
#===============================================================================
set -euo pipefail
# 配置
NLM="-${HOME/.venv/notebooklm/bin/nlm}"
PROFILE="-default"
LANG="-zh-CN"
#-------------------------------------------------------------------------------
# 自动登录检测与续登录
#-------------------------------------------------------------------------------
# 检查 nlm 是否已登录,未登录或登录失效则自动重新登录
auto_login() {
# 先用 list 命令测试登录状态(快速轻量)
if NLM notebook list --profile "PROFILE" >/dev/null 2>&1; then
debug "登录状态正常"
return 0
fi
warn "检测到登录已失效,正在重新登录..."
NLM login
if NLM notebook list --profile "PROFILE" >/dev/null 2>&1; then
success "重新登录成功"
return 0
else
error "重新登录失败,请检查账号权限"
return 1
fi
}
# 颜色输出
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'
BLUE='\033[0;34m'; CYAN='\033[0;36m'; NC='\033[0m'
info() { echo -e "BLUE[INFO]NC $1"; }
success() { echo -e "GREEN[OK]NC $1"; }
warn() { echo -e "YELLOW[WARN]NC $1"; }
error() { echo -e "RED[ERROR]NC $1"; }
debug() { [ "-0" = "1" ] && echo -e "CYAN[DEBUG]NC $1" || true; }
#-------------------------------------------------------------------------------
# 辅助函数
#-------------------------------------------------------------------------------
# 转换 file:// URL 为真实路径
convert_file_url() {
local input="$1"
if [[ "$input" =~ ^file:// ]]; then
# 移除 file:// 前缀并 URL 解码
local path="//"
# URL 解码(%20 → 空格等)
path=$(python3 -c "import urllib.parse; print(urllib.parse.unquote('path'))" 2>/dev/null || echo "path")
echo "$path"
else
echo "$input"
fi
}
# 等待 notebook 处理完成
wait_for_processing() {
local notebook_id="$1"
info "等待资料处理完成..."
local max_wait=300
local waited=0
while true; do
local status
status=$(NLM notebook get "notebook_id" --profile "PROFILE" 2>/dev/null | \
grep -i "status\|state\|progress" | head -3 || echo "unknown")
if echo "status" | grep -qi "ready\|complete\|done\|success"; then
success "资料处理完成"
break
fi
if [ waited -ge max_wait ]; then
warn "处理超时(max_waits),继续下一步..."
break
fi
echo -n "."
sleep 10
waited=$((waited + 10))
done
echo ""
}
# 等待音频生成完成
wait_for_audio() {
local notebook_id="$1"
local artifact_id="$2"
info "等待音频生成完成..."
local max_wait=300
local waited=0
while true; do
local status
status=$(NLM studio status "notebook_id" --profile "PROFILE" 2>/dev/null | \
grep -i "artifact_id" | head -1 || echo "")
if echo "status" | grep -qi "ready\|complete\|done\|success\|completed"; then
success "音频生成完成"
break
fi
if echo "status" | grep -qi "failed\|error\|timeout"; then
error "音频生成失败"
return 1
fi
if [ waited -ge max_wait ]; then
warn "生成超时(max_waits),请手动检查状态"
break
fi
echo -n "."
sleep 10
waited=$((waited + 10))
done
echo ""
}
# 获取或创建 notebook ID(检测重复)
get_or_create_notebook() {
local title="$1"
local notebook_id=""
# 先查找是否存在同名 notebook
info "检查现有笔记本: title"
notebook_id=$(NLM notebook list --profile "PROFILE" 2>/dev/null | \
grep -i "title" | grep -oE '([a-zA-Z0-9_-]+)' | tr -d '()' | head -1)
if [ -n "notebook_id" ]; then
success "找到现有笔记本: notebook_id"
echo "notebook_id"
return 0
fi
# 创建新 notebook
info "创建笔记本: title"
local create_output
create_output=$(NLM notebook create "title" --profile "PROFILE" 2>&1)
local exit_code=$?
if [ exit_code -ne 0 ]; then
error "创建笔记本失败: create_output"
# 尝试再次查找
sleep 2
notebook_id=$(NLM notebook list --profile "PROFILE" 2>/dev/null | \
grep -i "title" | grep -oE '([a-zA-Z0-9_-]+)' | tr -d '()' | head -1)
if [ -n "notebook_id" ]; then
success "找到刚创建的笔记本: notebook_id"
echo "notebook_id"
return 0
fi
return 1
fi
# 从输出中提取 ID
notebook_id=$(echo "create_output" | grep -oE '[a-zA-Z0-9]{20,}' | head -1)
if [ -z "notebook_id" ]; then
sleep 1
notebook_id=$(NLM notebook list --profile "PROFILE" 2>/dev/null | \
grep -i "title" | grep -oE '([a-zA-Z0-9_-]+)' | tr -d '()' | head -1)
fi
if [ -n "notebook_id" ]; then
success "笔记本创建成功: notebook_id"
echo "notebook_id" > "HOME/.mit-learn-notebook-id"
echo "NOTEBOOK_ID=notebook_id" >> "HOME/.mit-learn-env"
echo "notebook_id"
else
error "无法获取 notebook ID"
return 1
fi
}
# 获取 notebook 列表
list_notebooks() {
info "笔记本列表:"
NLM notebook list --profile "PROFILE" 2>/dev/null || error "获取笔记本列表失败"
}
#-------------------------------------------------------------------------------
# 命令:init - 创建 notebook
#-------------------------------------------------------------------------------
cmd_init() {
local title="-"
if [ -z "title" ]; then
read -p "输入笔记本标题: " title
fi
if [ -z "title" ]; then
error "标题不能为空"
return 1
fi
local notebook_id
notebook_id=$(get_or_create_notebook "title")
if [ -n "notebook_id" ]; then
echo "notebook_id" > "HOME/.mit-learn-notebook-id"
echo "notebook_id"
else
return 1
fi
}
#-------------------------------------------------------------------------------
# 命令:add - 添加资料
#-------------------------------------------------------------------------------
cmd_add() {
local notebook_id
local files=()
local urls=()
local yt_urls=()
local title=""
local wait_flag=false
# 解析参数
while [[ $# -gt 0 ]]; do
case "$1" in
--file|-f)
files+=("$2"); shift 2 ;;
--url|-u)
urls+=("$2"); shift 2 ;;
--youtube|-y)
yt_urls+=("$2"); shift 2 ;;
--title|-t)
title="$2"; shift 2 ;;
--wait|-w)
wait_flag=true; shift ;;
*)
urls+=("$1"); shift ;;
esac
done
# 获取 notebook_id
notebook_id=$(cat "HOME/.mit-learn-notebook-id" 2>/dev/null || echo "")
if [ -z "notebook_id" ]; then
error "未找到 notebook ID,请先运行 mit-learn.sh init"
return 1
fi
# 添加 URL(自动转换 file://)
for url in "-"; do
if [ -n "url" ]; then
# 处理 file:// URL
if [[ "url" =~ ^file:// ]]; then
local real_path
real_path=$(convert_file_url "url")
if [ -f "real_path" ]; then
info "添加文件: real_path"
local result
result=$(NLM source add "notebook_id" \
--file "real_path" \
--title "-$(basename "${real_path")}" \
--profile "PROFILE" 2>&1)
if echo "result" | grep -qi "error\|failed"; then
error "添加失败: result"
else
success "已添加: $(basename "real_path")"
fi
else
warn "文件不存在: real_path"
fi
else
info "添加 URL: url"
local result
result=$(NLM source add "notebook_id" \
--url "url" \
--title "-${url}" \
--profile "PROFILE" 2>&1)
if echo "result" | grep -qi "error\|failed"; then
error "添加失败: result"
else
success "已添加: url"
fi
fi
fi
done
# 添加 YouTube
for yt in "-"; do
if [ -n "yt" ]; then
info "添加 YouTube: yt"
local result
result=$(NLM source add "notebook_id" \
--youtube "yt" \
--title "-YouTube" \
--profile "PROFILE" 2>&1)
if echo "result" | grep -qi "error\|failed"; then
error "添加失败: result"
else
success "已添加: yt"
fi
fi
done
# 添加文件
for file in "-"; do
if [ -n "file" ]; then
# 处理 file:// URL
if [[ "file" =~ ^file:// ]]; then
file=$(convert_file_url "file")
fi
if [ -f "file" ]; then
info "添加文件: file"
local result
result=$(NLM source add "notebook_id" \
--file "file" \
--title "-$(basename "${file")}" \
--profile "PROFILE" 2>&1)
if echo "result" | grep -qi "error\|failed"; then
error "添加失败: result"
else
success "已添加: $(basename "file")"
fi
else
warn "文件不存在: file"
fi
fi
done
if [ "wait_flag" = true ]; then
wait_for_processing "notebook_id"
fi
}
#-------------------------------------------------------------------------------
# 命令:ask - 三问框架
#-------------------------------------------------------------------------------
# 三问提示词(中文)
ASK_MENTAL_MODELS_PROMPT="你是一个领域专家。请基于提供的资料,回答以下问题:
**问题:列出该领域专家共享的 5 个基本心智模型/思维框架**
心智模型是指专家们在分析和解决问题时共同使用的核心思维框架。请:
1. 识别并列出 5 个该领域最基本、最重要的心智模型
2. 每个心智模型用一句话解释
3. 每个心智模型举一个具体应用例子
格式:
### 心智模型 1:[名称]
- 解释:
- 应用例子:
(以此类推)"
ASK_DISAGREEMENTS_PROMPT="你是一个领域专家。请基于提供的资料,回答以下问题:
**问题:在哪 3 个问题上,该领域专家根本不同意?**
专家分歧是指学者们在核心理论、方法或结论上存在根本性争议。请:
1. 识别并列出 3 个专家们存在根本分歧的核心问题
2. 每个分歧说明:各方的主要观点是什么?为什么会产生分歧?
3. 每个分歧解释:这对你的学习意味着什么?
格式:
### 分歧 1:[问题描述]
- 甲方观点:
- 乙方观点:
- 分歧根源:
- 对学习者的启示:
(以此类推)"
ASK_PROBING_PROMPT="你是一个苏格拉底式追问者。请基于提供的资料,生成能区分真懂和假背的 10 个暴露性问题。
**要求:**
- 问题必须能区分真正理解概念的人和只会背答案的人
- 问题应该是开放式的,不能通过简单回忆来回答
- 问题要有深度,需要真正的理解才能回答
请生成 10 个这样的问题:
格式:
1. [问题内容]
预期假背者会:[他们可能的错误回答方向]
真正懂的人会:[他们会如何正确回答]
(以此类推,编号 1-10)"
cmd_ask() {
local question_type="-"
local notebook_id
notebook_id=$(cat "HOME/.mit-learn-notebook-id" 2>/dev/null || echo "")
if [ -z "notebook_id" ]; then
error "未找到 notebook ID,请先运行 mit-learn.sh init"
return 1
fi
case "question_type" in
mental-models|mental)
info "=== 问心智模型 ==="
info "请稍候,NotebookLM 正在分析..."
NLM notebook query "notebook_id" "ASK_MENTAL_MODELS_PROMPT" \
--profile "PROFILE" 2>/dev/null
;;
disagreements|disagree)
info "=== 问专家分歧 ==="
info "请稍候,NotebookLM 正在分析..."
NLM notebook query "notebook_id" "ASK_DISAGREEMENTS_PROMPT" \
--profile "PROFILE" 2>/dev/null
;;
probing|probing-questions)
info "=== 问暴露性问题 ==="
info "请稍候,NotebookLM 正在分析..."
NLM notebook query "notebook_id" "ASK_PROBING_PROMPT" \
--profile "PROFILE" 2>/dev/null
;;
all)
cmd_ask "mental-models"
echo ""
cmd_ask "disagreements"
echo ""
cmd_ask "probing-questions"
;;
*)
cat <<EOF
请指定问题类型:
mental-models - 问心智模型(5个基本思维框架)
disagreements - 问专家分歧(3个根本性问题)
probing - 问暴露性问题(10个区分真懂假背的问题)
all - 完整三问(心智模型→专家分歧→暴露性问题)
EOF
;;
esac
}
#-------------------------------------------------------------------------------
# 命令:audio - 生成音频概览
#-------------------------------------------------------------------------------
cmd_audio() {
local notebook_id
local format="-deep_dive"
local wait_flag="-yes"
notebook_id=$(cat "HOME/.mit-learn-notebook-id" 2>/dev/null || echo "")
if [ -z "notebook_id" ]; then
error "未找到 notebook ID,请先运行 mit-learn.sh init"
return 1
fi
info "生成音频概览 (format: format)..."
local result
result=$(NLM audio create "notebook_id" \
--format "format" \
--language "LANG" \
--profile "PROFILE" \
--confirm 2>&1)
echo "result"
# 提取 artifact ID
local artifact_id
artifact_id=$(echo "result" | grep -oE '[a-f0-9-]{36}' | head -1)
if [ -n "artifact_id" ] && [ "wait_flag" = "yes" ]; then
wait_for_audio "notebook_id" "artifact_id"
echo ""
info "下载命令:"
echo " nlm download audio notebook_id --id artifact_id -o ~/Downloads/audio.m4a"
elif [ -n "artifact_id" ]; then
info "Artifact ID: artifact_id"
fi
}
#-------------------------------------------------------------------------------
# 命令:video - 生成视频概览
#-------------------------------------------------------------------------------
cmd_video() {
local notebook_id
local style="-auto_select"
notebook_id=$(cat "HOME/.mit-learn-notebook-id" 2>/dev/null || echo "")
if [ -z "notebook_id" ]; then
error "未找到 notebook ID,请先运行 mit-learn.sh init"
return 1
fi
info "生成视频概览 (style: style)..."
NLM video create "notebook_id" \
--style "style" \
--language "LANG" \
--profile "PROFILE" \
--confirm 2>&1
}
#-------------------------------------------------------------------------------
# 命令:status - 查看状态
#-------------------------------------------------------------------------------
cmd_status() {
local notebook_id
notebook_id=$(cat "HOME/.mit-learn-notebook-id" 2>/dev/null || echo "")
if [ -z "notebook_id" ]; then
info "当前没有活跃的 notebook"
list_notebooks
return
fi
info "Notebook ID: notebook_id"
echo ""
NLM notebook get "notebook_id" --profile "PROFILE" 2>/dev/null || \
NLM notebook describe "notebook_id" --profile "PROFILE" 2>&1
echo ""
info "资料源:"
NLM source list "notebook_id" --profile "PROFILE" 2>/dev/null || echo "无"
}
#-------------------------------------------------------------------------------
# 命令:full - 完整流程
#-------------------------------------------------------------------------------
cmd_full() {
local title="-"
local urls=()
local files=()
local yt_urls=()
local skip_audio=false
shift
while [[ $# -gt 0 ]]; do
case "$1" in
--file|-f)
files+=("$2"); shift 2 ;;
--url|-u)
urls+=("$2"); shift 2 ;;
--youtube|-y)
yt_urls+=("$2"); shift 2 ;;
--skip-audio)
skip_audio=true; shift ;;
*)
urls+=("$1"); shift ;;
esac
done
if [ -z "title" ]; then
read -p "输入学习主题: " title
fi
echo ""
info "=== 火一五 MIT 48 小时学习法 ==="
info "学习主题: title"
echo ""
# Step 1: 创建 notebook
echo ""
info "[Step 1/4] 创建笔记本..."
cmd_init "title"
echo ""
# Step 2: 添加资料
if [ #urls[@] -gt 0 ] || [ #files[@] -gt 0 ] || [ #yt_urls[@] -gt 0 ]; then
echo ""
info "[Step 2/4] 添加资料..."
# 正确传递参数:每个数组用对应的 flag
cmd_add \
--url "-" \
--youtube "-" \
--file "-" \
--wait
else
echo ""
warn "[Step 2/4] 跳过(无资料)"
fi
# Step 3: 三问框架
echo ""
info "[Step 3/4] 三问框架..."
echo ""
info "--- 心智模型 ---"
cmd_ask "mental-models"
echo ""
info "--- 专家分歧 ---"
cmd_ask "disagreements"
echo ""
info "--- 暴露性问题 ---"
cmd_ask "probing-questions"
echo ""
# Step 4: 生成 audio
echo ""
info "[Step 4/4] 生成音频概览..."
if [ "skip_audio" = true ]; then
warn "跳过音频生成(--skip-audio)"
echo ""
info "可稍后运行:"
echo " mit-learn.sh audio"
else
cmd_audio
fi
echo ""
success "学习项目完成!"
}
#-------------------------------------------------------------------------------
# 主入口
#-------------------------------------------------------------------------------
# 所有命令执行前先检查并自动续登录
auto_login
COMMAND="-"
case "COMMAND" in
init) shift; cmd_init "$@" ;;
add) shift; cmd_add "$@" ;;
ask) shift; cmd_ask "$@" ;;
audio) shift; cmd_audio "$@" ;;
video) shift; cmd_video "$@" ;;
full) shift; cmd_full "$@" ;;
status) cmd_status ;;
list) list_notebooks ;;
help|--help|-h) usage ;;
*)
if [ -n "COMMAND" ]; then
error "未知命令: COMMAND"
fi
usage
;;
esac
FILE:huo15-openclaw-multi-agent/SKILL.md
---
name: huo15-openclaw-multi-agent
description: 火一五多智能体协同 - 基于 OpenClaw sessions_spawn 的多 Agent 并行工作系统。支持协调者模式、任务分配、结果汇总。触发词:多智能体协同、多 Agent、并行任务、协调者模式。
version: 2.2.0
dependencies:
optional: ["huo15-memory-evolution", "huo15-cost-tracker"]
---
# 🤖 火一五多智能体协同 (huo15-multi-agent)
> **作者**: 火一五信息科技有限公司
> **版本**: v2.0.0
> **基于**: OpenClaw sessions_spawn
---
## 一、核心概念
| 概念 | 说明 |
|------|------|
| **Coordinator** | 主 Agent,协调任务分配 |
| **Worker** | 工作 Agent,执行具体任务 |
| **sessions_spawn** | OpenClaw 内置派生子 Agent |
| **announce** | 结果自动汇报给主 Agent |
### OpenClaw subagent 架构
```
主 Agent (depth 0)
↓ sessions_spawn
Worker A (depth 1) ─┐
Worker B (depth 1) ─┼─ 执行中...
Worker C (depth 1) ─┘
↓ announce 汇报
主 Agent ← 接收结果汇总
```
---
## 二、使用方式
### 2.1 协调者模式触发
当用户说"帮我同时处理..."时:
```
用户: "帮我同时分析这三个项目的代码"
↓
我: "好的,启动协调者模式,分3个并行任务"
↓
使用 sessions_spawn 启动 3 个 Worker
↓
Worker 们并行执行,完成后 announce 结果
↓
汇总结果,报告给用户
```
### 2.2 命令参考
| 命令 | 说明 |
|------|------|
| `/subagents list` | 查看所有子 Agent |
| `/subagents spawn <任务>` | 启动子 Agent |
| `/subagents kill <id>` | 停止子 Agent |
| `/subagents log <id>` | 查看子 Agent 日志 |
### 2.3 编程接口
```javascript
// 派生子 Agent
sessions_spawn({
task: "分析代码仓库 A",
label: "code-analyzer-a",
runTimeoutSeconds: 300
})
// 发送消息给子 Agent
sessions_send(sessionKey, "状态如何?")
// 查看子 Agent 列表
subagents(action="list")
```
---
## 三、工作流程
### 3.1 启动并行任务
```javascript
// 并行启动 3 个任务
const results = await Promise.all([
sessions_spawn({ task: "分析模块 A", label: "module-a" }),
sessions_spawn({ task: "分析模块 B", label: "module-b" }),
sessions_spawn({ task: "分析模块 C", label: "module-c" })
])
```
### 3.2 等待结果
子 Agent 完成后自动 announce 结果到当前会话。
### 3.3 汇总报告
```javascript
// 收集所有结果
const allResults = await Promise.all(
workerSessions.map(key => sessions_history(key, { limit: 1 }))
)
// 汇总给用户
const summary = synthesizeResults(allResults)
```
---
## 四、配置
### 4.1 OpenClaw 配置
在 `~/.openclaw/config.json` 中启用嵌套:
```json
{
"agents": {
"defaults": {
"subagents": {
"maxSpawnDepth": 2,
"maxConcurrent": 8,
"runTimeoutSeconds": 900
}
}
}
}
```
### 4.2 子 Agent 权限
```json
{
"tools": {
"subagents": {
"tools": {
"allow": ["read", "exec", "process"],
"deny": ["gateway", "cron"]
}
}
}
}
```
---
## 五、最佳实践
### 5.1 任务拆分原则
- 每个 Worker 任务独立,不依赖其他 Worker
- 任务时长建议 5-30 分钟
- 避免深度嵌套(depth 2 足够)
### 5.2 成本控制
```javascript
// 子 Agent 使用更便宜的模型
sessions_spawn({
task: "简单分析",
model: "MiniMax-M2.1" // 比主 Agent 便宜
})
```
### 5.3 错误处理
```javascript
try {
const result = await sessions_spawn({
task: "可能失败的任务"
})
} catch (e) {
// 处理超时或失败
reportError(e)
}
```
---
## 六、与传统方式对比
| 方式 | 同步 | 并行 | 结果汇总 |
|------|------|------|---------|
| 顺序执行 | ✅ | ❌ | 手动 |
| 传统 skill | ⚠️ | ⚠️ | 手动 |
| **sessions_spawn** | ❌ | ✅ | 自动 announce |
---
## 七、版本历史
| 版本 | 日期 | 更新内容 |
|------|------|---------|
| **v2.0.0** | 2026-04-05 | 集成 OpenClaw sessions_spawn |
| v1.0.0 | 2026-04-05 | 初始版本(纯脚本) |
FILE:huo15-openclaw-multi-agent/config/team-config.json
{
"version": "1.0",
"teamName": "default",
"coordinator": "main",
"maxConcurrent": 3,
"taskTimeout": 300,
"collectTimeout": 600
}
FILE:huo15-openclaw-multi-agent/scripts/coordinator.sh
#!/bin/bash
#===============================================================================
# 多智能体协同 - 主协调脚本
#
# 功能:
# 1. 启动协调者模式
# 2. 分配任务给工作 Agent
# 3. 收集结果
# 4. 汇报给用户
#
# 使用方式:
# ./coordinator.sh start # 启动协调者模式
# ./coordinator.sh assign <task> <desc> # 分配任务
# ./coordinator.sh status # 查看状态
# ./coordinator.sh collect # 收集结果
# ./coordinator.sh stop # 停止协调
#===============================================================================
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
CONFIG_DIR="$(dirname "$SCRIPT_DIR")/config"
DATA_DIR="$HOME/.openclaw/workspace/memory/activity"
TEAM_DIR="$DATA_DIR/multi-agent"
# 加载配置
load_config() {
mkdir -p "$TEAM_DIR"
if [ -f "$TEAM_DIR/config.json" ]; then
TEAM_NAME=$(grep -o '"teamName"[[:space:]]*:[[:space:]]*"[^"]*"' "$TEAM_DIR/config.json" 2>/dev/null | cut -d'"' -f4)
COORDINATOR=$(grep -o '"coordinator"[[:space:]]*:[[:space:]]*"[^"]*"' "$TEAM_DIR/config.json" 2>/dev/null | cut -d'"' -f4)
fi
TEAM_NAME="-default"
COORDINATOR="-main"
}
#===============================================================================
# 启动协调者模式
#===============================================================================
start_coordinator() {
load_config
echo "🤖 启动协调者模式"
echo "=" * 40
echo "团队名称: $TEAM_NAME"
echo "协调者: $COORDINATOR"
echo "时间: $(date '+%Y-%m-%d %H:%M:%S')"
echo ""
# 创建团队配置
cat > "$TEAM_DIR/config.json" << EOF
{
"teamName": "$TEAM_NAME",
"coordinator": "$COORDINATOR",
"startedAt": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"status": "active",
"tasks": []
}
EOF
echo "✅ 协调者模式已启动"
echo ""
echo "下一步:"
echo " 1. 使用 ./team.sh spawn <worker-id> <任务> 启动工作 Agent"
echo " 2. 使用 ./coordinator.sh status 查看状态"
echo " 3. 使用 ./coordinator.sh collect 收集结果"
}
#===============================================================================
# 分配任务
#===============================================================================
assign_task() {
local task_id="$1"
local task_desc="-"
if [ -z "$task_id" ]; then
echo "用法: coordinator.sh assign <task-id> <任务描述>"
return 1
fi
load_config
echo "📋 分配任务: $task_id"
echo "描述: -无"
echo ""
# 创建任务文件
mkdir -p "$TEAM_DIR/tasks"
cat > "$TEAM_DIR/tasks/$task_id.json" << EOF
{
"taskId": "$task_id",
"description": "$task_desc",
"status": "pending",
"assignedTo": null,
"result": null,
"createdAt": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"completedAt": null
}
EOF
echo "✅ 任务已分配: $task_id"
# 更新团队配置中的任务列表
python3 << PYEOF
import json
config_file = "$TEAM_DIR/config.json"
try:
with open(config_file, 'r') as f:
config = json.load(f)
if 'tasks' not in config:
config['tasks'] = []
config['tasks'].append({
'taskId': '$task_id',
'status': 'pending'
})
with open(config_file, 'w') as f:
json.dump(config, f, indent=2)
except Exception as e:
print(f"更新配置失败: {e}")
PYEOF
return 0
}
#===============================================================================
# 查看状态
#===============================================================================
show_status() {
load_config
echo "🤖 协调者状态"
echo "=" * 40
echo "团队: $TEAM_NAME"
echo "协调者: $COORDINATOR"
echo "时间: $(date '+%Y-%m-%d %H:%M:%S')"
echo ""
if [ -f "$TEAM_DIR/config.json" ]; then
echo "配置:"
cat "$TEAM_DIR/config.json"
echo ""
fi
# 显示任务状态
if [ -d "$TEAM_DIR/tasks" ]; then
echo "📋 任务状态:"
for task_file in "$TEAM_DIR/tasks"/*.json; do
if [ -f "$task_file" ]; then
task_id=$(basename "$task_file" .json)
status=$(grep -o '"status"[[:space:]]*:[[:space:]]*"[^"]*"' "$task_file" 2>/dev/null | cut -d'"' -f4)
assigned=$(grep -o '"assignedTo"[[:space:]]*:[[:space:]]*"[^"]*"' "$task_file" 2>/dev/null | cut -d'"' -f4)
echo " - $task_id: $status +(assigned to: $assigned)"
fi
done
fi
}
#===============================================================================
# 收集结果
#===============================================================================
collect_results() {
load_config
echo "📊 收集任务结果"
echo "=" * 40
echo ""
if [ ! -d "$TEAM_DIR/tasks" ]; then
echo "暂无任务"
return 0
fi
completed=0
pending=0
for task_file in "$TEAM_DIR/tasks"/*.json; do
if [ -f "$task_file" ]; then
task_id=$(basename "$task_file" .json)
status=$(grep -o '"status"[[:space:]]*:[[:space:]]*"[^"]*"' "$task_file" 2>/dev/null | cut -d'"' -f4)
if [ "$status" = "completed" ]; then
((completed++))
echo "✅ $task_id (已完成)"
# 显示结果摘要
result=$(grep -o '"result"[[:space:]]*:[[:space:]]*"[^"]*"' "$task_file" 2>/dev/null | cut -d'"' -f4 | cut -c1-100)
if [ -n "$result" ]; then
echo " 结果: result..."
fi
elif [ "$status" = "pending" ] || [ "$status" = "running" ]; then
((pending++))
echo "⏳ $task_id (status)"
fi
fi
done
echo ""
echo "总计: $completed 已完成, $pending 待处理"
return 0
}
#===============================================================================
# 停止协调
#===============================================================================
stop_coordinator() {
load_config
echo "🛑 停止协调者模式"
if [ -f "$TEAM_DIR/config.json" ]; then
python3 << PYEOF
import json
config_file = "$TEAM_DIR/config.json"
try:
with open(config_file, 'r') as f:
config = json.load(f)
config['status'] = 'stopped'
config['stoppedAt'] = '$(date -u +%Y-%m-%dT%H:%M:%SZ)'
with open(config_file, 'w') as f:
json.dump(config, f, indent=2)
print("✅ 协调者模式已停止")
except Exception as e:
print(f"停止失败: {e}")
PYEOF
else
echo "协调者未启动"
fi
}
#===============================================================================
# 主程序
#===============================================================================
main() {
local action="-status"
case "$action" in
start)
start_coordinator
;;
assign)
assign_task "$2" "$3"
;;
status)
show_status
;;
collect)
collect_results
;;
stop)
stop_coordinator
;;
help|--help|-h)
echo "用法:"
echo " $0 start # 启动协调者模式"
echo " $0 assign <task> <desc> # 分配任务"
echo " $0 status # 查看状态"
echo " $0 collect # 收集结果"
echo " $0 stop # 停止协调"
;;
*)
echo "未知命令: $action"
echo "用法: $0 start|assign|status|collect|stop"
exit 1
;;
esac
}
main "$@"
FILE:huo15-openclaw-multi-agent/scripts/demo.sh
#!/bin/bash
#===============================================================================
# 多智能体协同 - 并行执行演示
#
# 演示如何并行启动多个任务
#===============================================================================
echo "========================================"
echo "🤖 多智能体并行执行演示"
echo "========================================"
echo ""
echo "📋 场景:同时分析 3 个代码模块"
echo ""
echo "🔄 启动 Worker 1: 分析用户模块"
echo "🔄 启动 Worker 2: 分析订单模块"
echo "🔄 启动 Worker 3: 分析支付模块"
echo ""
echo "⏳ 等待 Worker 们完成..."
echo ""
echo "========================================"
echo "📊 并行执行已完成"
echo "========================================"
echo ""
echo "💡 在实际使用中,您可以说:"
echo ""
echo ' "帮我同时分析这三个模块的代码"'
echo ""
echo " 我会启动 3 个并行 Agent:"
echo " /subagents spawn \"分析用户模块代码\" --label module-user"
echo " /subagents spawn \"分析订单模块代码\" --label module-order"
echo " /subagents spawn \"分析支付模块代码\" --label module-payment"
echo ""
echo " 每个 Agent 完成后自动汇报结果"
echo " 我会汇总后一起报告给您"
echo ""
echo "========================================"
echo "✅ 演示结束"
echo "========================================"
FILE:huo15-openclaw-multi-agent/scripts/spawn.sh
#!/bin/bash
#===============================================================================
# 多智能体协同 - OpenClaw sessions_spawn 集成脚本
#
# 使用方式:
# ./spawn.sh <task> [label] [model] [timeout]
#
# 示例:
# ./spawn.sh "分析代码" "code-analysis" "MiniMax-M2.1" 300
#===============================================================================
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
LOG_DIR="$HOME/.openclaw/workspace/memory/activity/multi-agent"
mkdir -p "$LOG_DIR"
#===============================================================================
# 记录任务日志
#===============================================================================
log_task() {
local label="$1"
local task="$2"
local status="$3"
local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
echo "## $timestamp | $label | $status | $task" >> "$LOG_DIR/spawn-log.md"
}
#===============================================================================
# 生成任务 ID
#===============================================================================
generate_id() {
echo "agent-$(date +%s)-$RANDOM"
}
#===============================================================================
# 主程序
#===============================================================================
main() {
local task="-"
local label="-"
local model="-MiniMax-M2.1"
local timeout="-600"
if [ -z "$task" ]; then
echo "用法: $0 <task> [label] [model] [timeout]"
echo ""
echo "示例:"
echo " $0 \"分析代码\" \"code-analysis\""
echo " $0 \"生成报告\" \"report-gen\" \"MiniMax-M2.1\" 300"
return 1
fi
# 生成标签
[ -z "$label" ] && label="task-$(date +%s)"
echo "🤖 启动并行任务"
echo "=" * 40
echo "任务: $task"
echo "标签: $label"
echo "模型: $model"
echo "超时: timeouts"
echo ""
# 记录日志
log_task "$label" "$task" "spawned"
echo "✅ 任务已启动: $label"
echo ""
echo "📋 OpenClaw 会自动:"
echo " 1. 派生子 Agent 执行任务"
echo " 2. 子 Agent 完成后 announce 结果"
echo " 3. 结果汇报到当前会话"
echo ""
echo "💡 查看子 Agent: /subagents list"
echo "💡 停止子 Agent: /subagents kill <id>"
return 0
}
main "$@"
FILE:huo15-openclaw-multi-agent/scripts/team.sh
#!/bin/bash
#===============================================================================
# 团队管理脚本
#
# 功能:
# 1. 创建团队
# 2. 加入/离开团队
# 3. 启动工作 Agent
# 4. 查看团队状态
#
# 使用方式:
# ./team.sh create <team-name>
# ./team.sh spawn <worker-id> <任务描述>
# ./team.sh list
# ./team.sh leave <worker-id>
#===============================================================================
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
DATA_DIR="$HOME/.openclaw/workspace/memory/activity/multi-agent"
TEAM_DIR="$DATA_DIR"
mkdir -p "$TEAM_DIR/workers"
#===============================================================================
# 创建团队
#===============================================================================
create_team() {
local team_name="-default"
echo "👥 创建团队: $team_name"
cat > "$TEAM_DIR/team.json" << EOF
{
"teamName": "$team_name",
"createdAt": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"members": [],
"status": "active"
}
EOF
echo "✅ 团队已创建: $team_name"
}
#===============================================================================
# 启动工作 Agent
#===============================================================================
spawn_worker() {
local worker_id="$1"
local task_desc="-未指定任务"
if [ -z "$worker_id" ]; then
echo "用法: team.sh spawn <worker-id> <任务描述>"
return 1
fi
echo "🤖 启动工作 Agent: $worker_id"
echo "任务: $task_desc"
echo ""
# 创建 worker 配置
mkdir -p "$TEAM_DIR/workers/$worker_id"
cat > "$TEAM_DIR/workers/$worker_id/worker.json" << EOF
{
"workerId": "$worker_id",
"task": "$task_desc",
"status": "running",
"startedAt": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"result": null
}
EOF
# 记录到团队成员
python3 << PYEOF
import json
team_file = "$TEAM_DIR/team.json"
try:
with open(team_file, 'r') as f:
team = json.load(f)
if 'members' not in team:
team['members'] = []
# 检查是否已存在
exists = any(m.get('workerId') == '$worker_id' for m in team['members'])
if not exists:
team['members'].append({
'workerId': '$worker_id',
'task': '$task_desc',
'status': 'running',
'startedAt': '$(date -u +%Y-%m-%dT%H:%M:%SZ)'
})
with open(team_file, 'w') as f:
json.dump(team, f, indent=2)
print("✅ Worker 已添加到团队")
else:
print("⚠️ Worker 已存在: $worker_id")
except Exception as e:
print(f"错误: {e}")
PYEOF
echo ""
echo "✅ Worker 已启动: $worker_id"
echo ""
echo "📝 下一步:"
echo " 1. Agent 执行任务..."
echo " 2. 使用 ./team.sh status 查看进度"
echo " 3. 任务完成后使用 ./team.sh complete $worker_id <结果> 标记完成"
}
#===============================================================================
# 标记任务完成
#===============================================================================
complete_worker() {
local worker_id="$1"
local result="-任务完成"
if [ -z "$worker_id" ]; then
echo "用法: team.sh complete <worker-id> <结果>"
return 1
fi
local worker_file="$TEAM_DIR/workers/$worker_id/worker.json"
if [ ! -f "$worker_file" ]; then
echo "❌ Worker 不存在: $worker_id"
return 1
fi
# 更新 worker 状态
python3 << PYEOF
import json
worker_file = "$worker_file"
result = """$result"""
try:
with open(worker_file, 'r') as f:
worker = json.load(f)
worker['status'] = 'completed'
worker['result'] = result
worker['completedAt'] = '$(date -u +%Y-%m-%dT%H:%M:%SZ)'
with open(worker_file, 'w') as f:
json.dump(worker, f, indent=2)
print(f"✅ Worker 完成: $worker_id")
except Exception as e:
print(f"错误: {e}")
PYEOF
# 更新团队成员状态
python3 << 'PYEOF'
import json
team_file = "$TEAM_DIR/team.json"
try:
with open(team_file, 'r') as f:
team = json.load(f)
for member in team.get('members', []):
if member.get('workerId') == '$worker_id':
member['status'] = 'completed'
member['result'] = '$result'
break
with open(team_file, 'w') as f:
json.dump(team, f, indent=2)
except:
pass
PYEOF
}
#===============================================================================
# 查看团队状态
#===============================================================================
list_team() {
echo "👥 团队状态"
echo "=" * 40
if [ ! -f "$TEAM_DIR/team.json" ]; then
echo "暂无团队"
echo "使用 ./team.sh create <team-name> 创建"
return 0
fi
cat "$TEAM_DIR/team.json"
echo ""
# 显示 worker 状态
if [ -d "$TEAM_DIR/workers" ]; then
echo "🤖 Workers:"
for worker_dir in "$TEAM_DIR/workers"/*; do
if [ -d "$worker_dir" ]; then
worker_id=$(basename "$worker_dir")
status=$(grep -o '"status"[[:space:]]*:[[:space:]]*"[^"]*"' "$worker_dir/worker.json" 2>/dev/null | cut -d'"' -f4)
task=$(grep -o '"task"[[:space:]]*:[[:space:]]*"[^"]*"' "$worker_dir/worker.json" 2>/dev/null | cut -d'"' -f4 | cut -c1-50)
if [ "$status" = "running" ]; then
echo " ⚡ $worker_id: $task"
elif [ "$status" = "completed" ]; then
echo " ✅ $worker_id: 已完成"
else
echo " ⏳ $worker_id: $status"
fi
fi
done
fi
}
#===============================================================================
# 离开团队
#===============================================================================
leave_team() {
local worker_id="$1"
if [ -z "$worker_id" ]; then
echo "用法: team.sh leave <worker-id>"
return 1
fi
rm -rf "$TEAM_DIR/workers/$worker_id"
# 从团队成员中移除
python3 << PYEOF
import json
team_file = "$TEAM_DIR/team.json"
try:
with open(team_file, 'r') as f:
team = json.load(f)
team['members'] = [m for m in team.get('members', []) if m.get('workerId') != '$worker_id']
with open(team_file, 'w') as f:
json.dump(team, f, indent=2)
print("✅ 已离开团队: $worker_id")
except Exception as e:
print(f"错误: {e}")
PYEOF
}
#===============================================================================
# 清理团队
#===============================================================================
cleanup() {
echo "🧹 清理团队..."
# 停止所有 running 的 worker
for worker_dir in "$TEAM_DIR/workers"/*; do
if [ -d "$worker_dir" ]; then
worker_id=$(basename "$worker_dir")
status=$(grep -o '"status"[[:space:]]*:[[:space:]]*"[^"]*"' "$worker_dir/worker.json" 2>/dev/null | cut -d'"' -f4)
if [ "$status" = "running" ]; then
echo " 停止: $worker_id"
# 这里应该发送停止信号给 worker
fi
fi
done
# 清理目录
rm -rf "$TEAM_DIR/workers"
mkdir -p "$TEAM_DIR/workers"
echo "✅ 清理完成"
}
#===============================================================================
# 主程序
#===============================================================================
main() {
local action="-list"
case "$action" in
create)
create_team "$2"
;;
spawn)
spawn_worker "$2" "$3"
;;
complete)
complete_worker "$2" "$3"
;;
list|status)
list_team
;;
leave)
leave_worker "$2"
;;
cleanup)
cleanup
;;
help|--help|-h)
echo "用法:"
echo " $0 create <team-name> # 创建团队"
echo " $0 spawn <id> <任务> # 启动 worker"
echo " $0 complete <id> <结果> # 标记完成"
echo " $0 list # 查看团队"
echo " $0 leave <id> # 离开团队"
echo " $0 cleanup # 清理团队"
;;
*)
echo "未知命令: $action"
echo "用法: $0 create|spawn|complete|list|leave|cleanup"
exit 1
;;
esac
}
main "$@"
FILE:huo15-openclaw-office-doc/SKILL.md
---
name: huo15-openclaw-office-doc
displayName: 火一五文档技能
description: 【青岛火一五信息科技有限公司】企业级 Word 文档生成技能,支持两种模式:规则模式(默认)和模板模式。触发词:写word,写文档、生成word、生成文档、创建文档、.docx、Word文档、写合同、写方案、写报告、写会议纪要、按模板生成、导出PDF、Word转PDF、生成PDF。
version: 4.1.0
aliases:
- 火一五文档技能
- 文档生成
- Word生成
- 按模板生成
dependencies:
python-packages:
- python-docx
---
# 火一五文档技能 v5.0
> 企业级 Word 文档生成 — 青岛火一五信息科技有限公司
**愿景:** 加速企业向全场景人工智能机器人转变
**理念:** 打破信息孤岛,用一套系统驱动企业增长
---
## 核心原则
1. **企业文档三要素**:编号 + 版本 + 密级
2. **格式标准**:按文档类型自动匹配专属格式(合同/报告/公文各有不同)
3. **可追溯**:版本历史、审批记录、修改说明
---
## 文档类型与格式对照
| 类型 | 格式特点 | 标题层级 |
|------|---------|---------|
| **合同** | 章/条结构,条款完整,无公文抬头,左对齐正文 | 第X章 → 第X条 |
| **报告** | 章节结构,条款完整,无公文抬头,左对齐正文 | 一、 → 1. → (1) |
| **公文** | GB/T 9704-2012,标题居中,签发机关,右对齐正文 | 一、 → (一) → 1. |
| **会议纪要** | 固定抬头字段(时间/人员/主持人),决议事项 | 一、 → 1. |
| **通用** | 仿公文格式,适用于通知、方案等 | 一、 → (一) → 1. |
---
## 一、文档元数据(自动生成)
每份企业文档必须包含以下元数据:
| 字段 | 说明 | 示例 |
|------|------|------|
| 文档编号 | 企业编号规则 | HG-HY-2026-001 |
| 版本 | V1.0 格式 | V1.0 |
| 密级 | 内部/秘密/机密 | 内部 |
| 日期 | YYYY-MM-DD | 2026-04-12 |
| 页数 | 自动统计 | 共 5 页 |
**编号规则:**
```
[公司缩写]-[类型缩写]-[年份]-[序号]
例如:HG-HY-2026-001
HG = 火一五公司
HY = 会议/合同/报告(取拼音首字母)
```
**类型缩写对照:**
| 类型 | 缩写 | 说明 |
|------|------|------|
| 会议纪要 | HY | 会议相关 |
| 合同 | HT | 合同协议 |
| 报告 | BG | 报告类 |
| 方案 | FA | 方案类 |
| 周报 | ZB | 周报 |
| 月报 | YB | 月报 |
| 通知 | TZ | 通知 |
| 请示 | QS | 请示 |
| 函 | HN | 函件 |
**版本规则:**
- V1.0 首次发布
- V1.1 小幅修订
- V2.0 重大修订
- V2.1-V2.9 功能迭代
- V3.0 正式版本
**密级规则:**
- 公开 — 对外公开文件
- 内部 — 公司内部使用
- 秘密 — 涉及商业机密
- 机密 — 核心机密文件
---
## 二、格式标准(GB/T 9704-2012 企业简化版)
### 2.1 页面设置
| 要素 | 标准 |
|------|------|
| 纸张 | A4(210mm × 297mm) |
| 页边距 | 上 3.7cm,下 3.5cm,左 2.8cm,右 2.6cm |
| 版心 | 156mm × 225mm |
### 2.2 字体规范
| 类型 | 字体 | 字号 | 说明 |
|------|------|------|------|
| 公文标题 | 方正小标宋简体 | 二号(22pt) | 居中,加粗 |
| 一级标题 | 黑体 | 三号(16pt) | 加粗 |
| 二级标题 | 楷体 | 三号(15pt) | 加粗 |
| 三级标题 | 仿宋 | 三号(15pt) | 加粗 |
| 正文 | 仿宋 | 三号(15pt) | 首行缩进2字符,1.5倍行距 |
| 落款 | 仿宋 | 三号(15pt) | 右对齐 |
| 页码 | 宋体 | 四号(14pt) | 居中,页脚 |
### 2.3 段落规范
| 类型 | 格式 |
|------|------|
| 正文 | 首行缩进2字符,1.5倍行距,两端对齐 |
| 标题 | 段前0.5行,段后0.5行 |
| 落款 | 右对齐,段前1行 |
### 2.4 页眉页脚
**页眉格式:**
```
[LOGO] 公司名称 文档编号 密级
─────────────────────────────────────────(底边细线)
```
**页脚格式:**
```
第 X 页 / 共 Y 页
```
---
## 三、文档类型标准
### 3.1 会议纪要标准格式
**结构:**
```
【会议纪要】
编号:HG-HY-2026-001
日期:2026年4月12日
与会人员:XXX、XXX
主持人:XXX
记录人:XXX
一、会议时间、地点
二、会议主题
三、会议内容
1. 议题一
2. 议题二
四、决议事项
1. 事项一
2. 事项二
五、下一步工作
```
**要点:**
- 真实反映会议内容,不凭空捏造
- 围绕会议成果进行提炼和概括
- 反映集体意志,不掺杂个人意见
- 区分"会议记录"(原始材料)和"会议纪要"(正式公文)
### 3.2 合同标准格式
**结构:**
```
【合同】
编号:HG-HT-2026-001
签订日期:2026年4月12日
签订地点:XXX
第一章 总则
第一条 合同双方
第二条 合同依据
第二章 权利义务
第三条 甲方的权利义务
第四条 乙方的权利义务
第三章 违约责任
第五条 违约情形
第六条 赔偿标准
第四章 争议解决
第七条 争议解决方式
第八条 适用法律
附则
第九条 合同生效
第十条 合同期限
甲方(签章):__________ 乙方(签章):__________
法定代表人: 法定代表人:
委托代理人: 委托代理人:
日期: 日期:
```
**要点:**
- 优先采用国家或行业制定的示范合同文本
- 合同条款应完整、明确、无歧义
- 金额数字应同时标注大写
- 合同份数应明确
### 3.3 工作报告标准格式
**结构:**
```
【工作报告】
编号:HG-BG-2026-001
日期:2026年4月12日
标题:XXXX工作报告
一、基本情况
1. 背景介绍
2. 工作目标
二、主要工作
1. 工作内容一
2. 工作内容二
三、存在问题
1. 问题一
2. 问题二
四、下一步计划
1. 改进措施
2. 工作安排
报告人:XXX
日期:XXXX年XX月XX日
```
**要点:**
- 情况属实、数据准确
- 问题分析客观中肯
- 计划切实可行
### 3.4 周报/月报标准格式
**周报结构:**
```
【工作周报】
编号:HG-ZB-2026-001
姓名:XXX
部门:XXX
日期:2026年4月12日
一、本周完成工作
1. 工作项一(完成度:100%)
2. 工作项二(完成度:80%)
二、下周工作计划
1. 工作项一
2. 工作项二
三、风险与问题
1. 风险一及应对措施
2. 问题一及解决方案
四、需要支持
1. 资源支持需求
2. 跨部门协调需求
```
**月报结构:**
```
【工作月报】
编号:HG-YB-2026-001
姓名:XXX
部门:XXX
月份:2026年4月
一、本月工作完成情况
1. KPI完成情况
2. 重点项目进展
二、存在问题和原因分析
1. 问题描述
2. 原因分析
三、下月工作计划
1. 工作目标
2. 具体措施
四、建议和意见
```
**要点:**
- 目标导向,明确汇报价值
- 结构清晰,标准化框架
- 数据支撑,增强说服力
### 3.5 合同示范文本获取渠道
**国家层面:**
- 国家市场监督管理总局合同示范文本库:https://htsfwb.samr.gov.cn/
- 国家标准:GB/T 1.1-2020《标准化工作导则》
**行业层面:**
- 建设工程:GB/T 50500 系列
- 政府采购:各地政府采购示范文本
---
## 四、模式一:规则模式(默认)
用户只提供内容文本,没有提供模板文件时使用。
### 段落类型识别规则
| 开头文字 | 识别为 | 字体 |
|----------|--------|------|
| 第X章 / 第X节 / 第X款 | 一级标题 | 黑体三号加粗 |
| 一、二、三、 或 一,二,... | 二级标题 | 楷体三号加粗 |
| (一)(二)... | 三级标题 | 仿宋三号加粗 |
| 1. 2. 3. | 编号正文 | 仿宋三号 |
| 其他文字 | 普通正文 | 仿宋三号,首行缩进 |
### 版本历史表(自动生成)
文档开头自动插入:
```
【版本历史】
| 版本 | 日期 | 作者 | 修改内容 |
|------|------|------|----------|
| V1.0 | 2026-04-12 | 赵博 | 首次创建 |
```
### 审批签字区(可选)
文档末尾自动插入:
```
【审批记录】
| 角色 | 姓名 | 日期 | 签字 |
|------|------|------|------|
| 编制 | 赵博 | 2026-04-12 | __________ |
| 审核 | | | |
| 批准 | | | |
```
### 表格支持
- 第一行自动识别为表头,黑体居中
- 斑马条纹(隔行变色)
- 边框线型统一
---
## 五、模式二:模板模式(高精度)
用户提供了 `.docx` 模板文件时使用。
### 触发条件
用户发送了 `.docx` 文件,或明确说"按这个模板生成"。
### 模板分析流程
#### A. 结构分析
```
【模板结构】
- 封面:有/无,内容包括...
- 目录:有/无,层级深度...
- 密级标识:有/无,位置...
- 版本历史表:有/无,格式...
- 审批签字区:有/无,格式...
- 正文章节:X 层标题结构
- 附件/附录:有/无
```
#### B. 样式分析
```
【字体样式】
- 标题字体:X号,颜色,是否加粗
- 正文字体:X号,颜色,是否加粗
【段落样式】
- 行距:固定值 X 磅 / 倍行距
- 首行缩进:是/否,X 字符
- 段前段后间距:X pt
【页面设置】
- 边距:上下左右各多少
- 方向:纵向/横向
- 纸型:A4/Letter
```
### 生成规则
1. 保持与模板完全一致的字体、字号、颜色
2. 保持与模板完全一致的段落结构
3. 保持与模板一致的页面设置
4. 保持与模板一致的页眉页脚
5. 如模板有图表,保持图表位置和编号
6. 保留模板中的占位符注释
---
## 六、Word 生成脚本
无论哪种模式,最终都调用以下脚本生成 `.docx` 文件:
```python
from create_word_doc import create_word_doc
create_word_doc(
output_path="文档名.docx",
title="文档标题",
content="正文内容...",
doc_number="HG-HY-2026-001",
version="V1.0",
classification="内部",
author="赵博",
company_name="青岛火一五信息科技有限公司",
logo_path="/path/to/logo.png",
approval=[
{"role": "编制", "name": "赵博"},
{"role": "审核", "name": ""},
{"role": "批准", "name": ""},
],
footer_page=True,
header_doc_number=True,
)
```
### 命令行调用
```bash
python create-word-doc.py <输出文件> [标题] [正文] [编号] [版本] [密级]
```
---
## 七、输出文件命名规范
```
[文档类型]_[客户名]_[版本]_[日期].docx
```
例:`合同_阿里巴巴_V1.0_20260412.docx`
---
## 八、触发词
- 写word、写文档、写个文档
- 生成word、生成文档、创建文档
- 导出word、导出文档、下载word
- .docx、Word文档、Word生成、生成Word
- 写合同、写方案、写报告、写会议纪要
- 按模板生成、参照模板
---
## 九、参考资料
### GB/T 9704-2012《党政机关公文格式》
**主要内容:**
- 纸张要求:A4型纸(210mm×297mm)
- 天头(上白边):37mm±1mm
- 地脚(下白边):35mm±1mm
- 订口(左白边):28mm±1mm
- 版心尺寸:156mm×225mm
### 国家标准获取渠道
| 标准名称 | 标准号 | 获取渠道 |
|----------|--------|----------|
| 党政机关公文格式 | GB/T 9704-2012 | 国家标准全文公开系统 |
| 标准化工作导则 | GB/T 1.1-2020 | 国家标准全文公开系统 |
| 科技报告编写规则 | GB/T 7713.2-2022 | 国家标准全文公开系统 |
| 企业信用报告格式 | GB/T 26817-2023 | 国家标准全文公开系统 |
### 合同示范文本获取
- 国家市场监督管理总局合同示范文本库:https://htsfwb.samr.gov.cn/
---
## 十、快速参考
| 需求 | 操作 |
|------|------|
| 简单文档 | 直接描述内容,AI 自动处理格式 |
| 带模板 | 上传 .docx 文件,说"按此模板生成" |
| 指定编号 | 在内容中注明:编号 HG-XX-2026-XXX |
| 指定版本 | 在内容中注明:版本 V1.0 |
| 需要审批区 | 说"带审批签字区" |
---
**技术支持:** 青岛火一五信息科技有限公司
**愿景:** 加速企业向全场景人工智能机器人转变
**理念:** 打破信息孤岛,用一套系统驱动企业增长
---
## 十一、Word 完美导出 PDF 功能
### 11.1 功能说明
本技能支持将生成的 Word 文档完美转换为 PDF 格式,保持原有格式(页眉页脚、表格、字体等)。
### 11.2 前置要求
**必须安装 LibreOffice:**
```bash
brew install --cask libreoffice
```
### 11.3 转换脚本
```bash
# 转换单个文件
python3 scripts/word-to-pdf.py 合同.docx
# 指定输出文件
python3 scripts/word-to-pdf.py 合同.docx 合同.pdf
# 批量转换
python3 scripts/word-to-pdf.py *.docx --output-dir ./pdf/
```
### 11.4 Python API 调用
```python
from scripts.word_to_pdf import convert_to_pdf, convert_batch
# 单个文件转换
success, result = convert_to_pdf("合同.docx", "合同.pdf")
if success:
print(f"PDF已生成: {result}")
else:
print(f"转换失败: {result}")
# 批量转换
results = convert_batch(["合同1.docx", "合同2.docx"], output_dir="./pdf/")
print(f"成功: {len(results['success'])}")
print(f"失败: {len(results['failed'])}")
```
### 11.5 触发词
- "导出 PDF"、"转 PDF"、"转成 PDF"
- "下载 PDF"、"生成 PDF"
- "Word 导出 PDF"、"docx 转 PDF"
- "转PDF"、"转成PDF"
### 11.6 依赖工具
| 工具 | 安装命令 | 说明 |
|------|----------|------|
| LibreOffice | `brew install --cask libreoffice` | PDF 转换引擎 |
### 11.7 格式保持
| 格式要素 | 保持情况 |
|----------|----------|
| 页眉页脚 | 完美保持 |
| 表格样式 | 完美保持 |
| 中文字体 | 完美保持 |
| 页码格式 | 完美保持 |
| 图片图表 | 完美保持 |
FILE:huo15-openclaw-office-doc/_meta.json
{
"ownerId": "kn7byevkn40d6z4p7ghdb097z983tj33",
"slug": "huo15-openclaw-office-doc",
"version": "4.1.0",
"publishedAt": 1775977191539
}
FILE:huo15-openclaw-office-doc/scripts/create-word-doc.py
#!/usr/bin/env python3
"""
create-word-doc.py - 企业级 Word 文档生成器 v3.0(WPS/Word 双兼容)
格式标准:
- 页面边距:上 3.7cm,下 3.5cm,左 2.8cm,右 2.6cm
- 标题层次:章=黑体/小二/加粗,节=楷体/三号/加粗,条=仿宋/四号/加粗
- 正文:仿宋/小四/首行缩进2字符/1.5倍行距
- 页眉:LOGO + 公司名称 + 文档编号 + 密级
- 页脚:居中,"第 X 页 / 共 Y 页"
- 版本历史表:自动生成
- 审批签字区:可选
用法:
python create-word-doc.py <输出路径> [标题] [正文] [编号] [版本] [密级]
"""
import sys
import os
import re
import ssl
import json
import datetime
import urllib.request
import xmlrpc.client
from docx import Document
from docx.shared import Pt, Cm, RGBColor
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.enum.table import WD_TABLE_ALIGNMENT
from docx.oxml.ns import qn
from docx.oxml import OxmlElement
# ============== 企业公文格式常量 ==============
MARGIN_TOP = 3.7
MARGIN_BOTTOM = 3.5
MARGIN_LEFT = 2.8
MARGIN_RIGHT = 2.6
# 字体(WPS 和 Word 都支持)
FONT_BODY = '仿宋'
FONT_HEADING_CHAPTER = '黑体' # 章:黑体
FONT_HEADING_SECTION = '楷体' # 节:楷体
FONT_HEADING_ARTICLE = '仿宋' # 条:仿宋
FONT_HEADER = '黑体'
FONT_FOOTER = '仿宋'
# 字号(pt)
SIZE_CHAPTER = 16 # 章:一级标题,黑体 16pt 加粗
SIZE_SECTION = 14 # 节:二级标题,楷体 14pt 加粗
SIZE_ARTICLE = 12 # 条:三级标题,仿宋 12pt 加粗
SIZE_BODY = 12 # 正文:仿宋 12pt
SIZE_TITLE = 22 # 文档标题:黑体 22pt 加粗
SIZE_HEADER = 10.5
SIZE_FOOTER = 10.5
SIZE_TABLE_HEADER = 10.5
SIZE_TABLE_BODY = 10.5
# 行距
LINE_SPACING = 1.5
# 首行缩进(2个中文字符约 0.74cm)
FIRST_LINE_INDENT = Cm(0.74)
# 表格斑马条纹颜色(浅灰)
TABLE_ROW_EVEN_COLOR = RGBColor(0xF2, 0xF2, 0xF2)
# ============== 公司信息 ==============
USER_HOME = os.path.expanduser("~")
LOGO_DIR = os.path.join(USER_HOME, ".huo15", "assets")
DEFAULT_LOGO_PATH = os.path.join(LOGO_DIR, "logo.png")
FALLBACK_LOGO_URL = 'https://tools.huo15.com/uploads/images/system/logo-colours.png'
DEFAULT_COMPANY_NAME = '青岛火一五信息科技有限公司'
def get_company_info():
"""从公司系统获取公司信息和 LOGO"""
info = {'company_name': DEFAULT_COMPANY_NAME, 'logo_path': None}
if os.path.exists(DEFAULT_LOGO_PATH) and os.path.getsize(DEFAULT_LOGO_PATH) > 1000:
info['logo_path'] = DEFAULT_LOGO_PATH
return info
try:
creds_file = os.path.join(
os.path.expanduser('~/.openclaw/agents'),
os.environ.get('OC_AGENT_ID', 'main'),
'odoo_creds.json'
)
if os.path.exists(creds_file):
with open(creds_file) as f:
creds = json.load(f)
cfg_file = os.path.expanduser('~/.openclaw/openclaw.json')
if os.path.exists(cfg_file):
with open(cfg_file) as f:
cfg = json.load(f)
odoo_env = cfg.get('skills', {}).get('entries', {}).get('huo15-odoo', {}).get('env', {})
url = odoo_env.get('ODOO_URL', 'https://huihuoyun.huo15.com')
db = odoo_env.get('ODOO_DB', 'huo15_prod')
user = creds.get('user', '')
password = creds.get('password', '')
if user and password:
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
common = xmlrpc.client.ServerProxy(f'{url}/xmlrpc/2/common', context=ctx)
uid = common.authenticate(db, user, password, {})
if uid:
models = xmlrpc.client.ServerProxy(f'{url}/xmlrpc/2/object', context=ctx)
data = models.execute_kw(db, uid, password, 'res.company',
'search_read', [[('id', '=', 1)]], {'fields': ['name', 'logo'], 'limit': 1})
if data:
info['company_name'] = data[0].get('name', DEFAULT_COMPANY_NAME)
logo_id = data[0].get('logo')
if logo_id:
_download(f'{url}/web/image/res.company/{logo_id}/logo', DEFAULT_LOGO_PATH)
if os.path.exists(DEFAULT_LOGO_PATH):
info['logo_path'] = DEFAULT_LOGO_PATH
except Exception as e:
print(f"获取公司信息失败: {e}")
if not info['logo_path']:
_download(FALLBACK_LOGO_URL, DEFAULT_LOGO_PATH)
if os.path.exists(DEFAULT_LOGO_PATH):
info['logo_path'] = DEFAULT_LOGO_PATH
return info
def _download(url, dest_path):
if os.path.exists(dest_path) and os.path.getsize(dest_path) > 1000:
return
os.makedirs(os.path.dirname(dest_path), exist_ok=True)
try:
urllib.request.urlretrieve(url, dest_path)
print(f"✓ LOGO 已下载: {dest_path}")
except Exception as e:
print(f"⚠ LOGO 下载失败: {e}")
def _set_font(run, font_name, size, bold=False, color=None):
"""设置中文字体(WPS/Word 双兼容)"""
run.font.name = font_name
rPr = run._element.find(qn('w:rPr'))
if rPr is None:
rPr = OxmlElement('w:rPr')
run._element.insert(0, rPr)
rFonts = rPr.find(qn('w:rFonts'))
if rFonts is None:
rFonts = OxmlElement('w:rFonts')
rPr.insert(0, rFonts)
rFonts.set(qn('w:eastAsia'), font_name)
rFonts.set(qn('w:ascii'), font_name)
rFonts.set(qn('w:hAnsi'), font_name)
run.font.size = Pt(size)
run.bold = bold
if color:
run.font.color.rgb = color
def _add_border_bottom(paragraph):
"""给段落下方加细线"""
pPr = paragraph._element.find(qn('w:pPr'))
if pPr is None:
pPr = OxmlElement('w:pPr')
paragraph._element.insert(0, pPr)
pBdr = OxmlElement('w:pBdr')
bottom = OxmlElement('w:bottom')
bottom.set(qn('w:val'), 'single')
bottom.set(qn('w:sz'), '6')
bottom.set(qn('w:space'), '1')
bottom.set(qn('w:color'), '000000')
pBdr.append(bottom)
pPr.append(pBdr)
def _set_cell_shading(cell, fill_color):
"""设置单元格背景色"""
tcPr = cell._tc.get_or_add_tcPr()
shd = OxmlElement('w:shd')
shd.set(qn('w:val'), 'clear')
shd.set(qn('w:color'), 'auto')
shd.set(qn('w:fill'), fill_color)
tcPr.append(shd)
def add_header(doc, logo_path, company_name, doc_number=None, classification=None):
"""页眉:LOGO + 公司名称 + 文档编号 + 密级,左对齐,底边细线"""
section = doc.sections[0]
header = section.header
header.is_linked_to_previous = False
for p in header.paragraphs:
for r in p.runs:
r.text = ''
para = header.paragraphs[0] if header.paragraphs else header.add_paragraph()
para.alignment = WD_ALIGN_PARAGRAPH.LEFT
# LOGO
if logo_path and os.path.exists(logo_path):
try:
run = para.add_run()
run.add_picture(logo_path, height=Cm(1.0))
except Exception as e:
print(f"⚠ LOGO 添加失败: {e}")
# 公司名称
run = para.add_run(f' {company_name}')
_set_font(run, FONT_HEADER, SIZE_HEADER)
# 文档编号
if doc_number:
run = para.add_run(f' {doc_number}')
_set_font(run, FONT_HEADER, SIZE_HEADER)
# 密级
if classification:
run = para.add_run(f' 【{classification}】')
_set_font(run, FONT_HEADER, SIZE_HEADER, bold=True)
_add_border_bottom(para)
def add_footer(doc):
"""页脚:居中,'第 X 页 / 共 Y 页'"""
section = doc.sections[0]
footer = section.footer
footer.is_linked_to_previous = False
para = footer.paragraphs[0] if footer.paragraphs else footer.add_paragraph()
para.alignment = WD_ALIGN_PARAGRAPH.CENTER
for r in para.runs:
r.text = ''
def add_text(text):
r = para.add_run(text)
_set_font(r, FONT_FOOTER, SIZE_FOOTER)
return r
def add_field(name):
r = para.add_run()
fc1 = OxmlElement('w:fldChar')
fc1.set(qn('w:fldCharType'), 'begin')
it = OxmlElement('w:instrText')
it.set(qn('xml:space'), 'preserve')
it.text = f' {name} '
fc2 = OxmlElement('w:fldChar')
fc2.set(qn('w:fldCharType'), 'end')
r._element.clear()
r._element.append(fc1)
r._element.append(it)
r._element.append(fc2)
_set_font(r, FONT_FOOTER, SIZE_FOOTER)
add_text('第 ')
add_field('PAGE')
add_text(' 页 / 共 ')
add_field('NUMPAGES')
add_text(' 页')
# ============== 段落样式定义 ==============
class ParagraphStyle:
"""段落样式配置"""
def __init__(self, font, size, bold=False, indent=True,
alignment=WD_ALIGN_PARAGRAPH.JUSTIFY,
space_before=0, space_after=6,
line_spacing=LINE_SPACING):
self.font = font
self.size = size
self.bold = bold
self.indent = indent
self.alignment = alignment
self.space_before = space_before
self.space_after = space_after
self.line_spacing = line_spacing
def apply(self, p):
"""应用样式到段落"""
p.alignment = self.alignment
p.paragraph_format.line_spacing = self.line_spacing
p.paragraph_format.space_before = Pt(self.space_before)
p.paragraph_format.space_after = Pt(self.space_after)
if self.indent:
p.paragraph_format.first_line_indent = FIRST_LINE_INDENT
else:
p.paragraph_format.first_line_indent = Cm(0)
# 预定义样式
STYLE_CHAPTER = ParagraphStyle(FONT_HEADING_CHAPTER, SIZE_CHAPTER, bold=True,
indent=False, alignment=WD_ALIGN_PARAGRAPH.LEFT,
space_before=18, space_after=6)
STYLE_SECTION = ParagraphStyle(FONT_HEADING_SECTION, SIZE_SECTION, bold=True,
indent=False, alignment=WD_ALIGN_PARAGRAPH.LEFT,
space_before=12, space_after=4)
STYLE_ARTICLE = ParagraphStyle(FONT_HEADING_ARTICLE, SIZE_ARTICLE, bold=True,
indent=False, alignment=WD_ALIGN_PARAGRAPH.LEFT,
space_before=6, space_after=3)
STYLE_BODY = ParagraphStyle(FONT_BODY, SIZE_BODY, bold=False,
indent=True, alignment=WD_ALIGN_PARAGRAPH.JUSTIFY,
space_before=0, space_after=6)
STYLE_EMPTY = ParagraphStyle(FONT_BODY, SIZE_BODY, bold=False,
indent=False, alignment=WD_ALIGN_PARAGRAPH.LEFT,
space_before=0, space_after=0)
STYLE_TABLE_CELL = ParagraphStyle(FONT_BODY, SIZE_TABLE_BODY, bold=False,
indent=False, alignment=WD_ALIGN_PARAGRAPH.LEFT,
space_before=2, space_after=2)
STYLE_TABLE_HEADER = ParagraphStyle(FONT_HEADING_CHAPTER, SIZE_TABLE_HEADER, bold=True,
indent=False, alignment=WD_ALIGN_PARAGRAPH.CENTER,
space_before=2, space_after=2)
def add_paragraph(doc, text, style):
"""添加段落并应用样式"""
p = doc.add_paragraph()
style.apply(p)
if text:
run = p.add_run(text)
_set_font(run, style.font, style.size, style.bold)
return p
def add_empty_line(doc):
"""添加空行"""
add_paragraph(doc, '', STYLE_EMPTY)
# ============== 内容解析 ==============
def detect_paragraph_type(text):
"""
检测段落类型
返回:(类型, 清洗后的文本)
类型: 'chapter', 'section', 'article', 'body', 'blank'
"""
if not text or not text.strip():
return 'blank', ''
t = text.strip()
# 一级标题:第X章、第X节、第X篇、第X款
if re.match(r'^第[一二三四五六七八九十百千]+[章节篇款]', t):
return 'chapter', re.sub(r'^第[一二三四五六七八九十百千]+[章节篇款]\s*', '', t)
# 条(合同专用条款):第X条
if re.match(r'^第[一二三四五六七八九十百千零]+条', t):
return 'article', re.sub(r'^第[一二三四五六七八九十百千零]+条\s*', '', t)
# 二级标题:一、二、三、,. 等多种分隔符
if re.match(r'^[一二三四五六七八九十百千]+[、.,,]', t):
return 'section', re.sub(r'^[一二三四五六七八九十百千]+[、.,,]\s*', '', t)
# 三级标题:(一)(二)(三)
if re.match(r'^[(\(][一二三四五六七八九十百千]+[)\)]', t):
return 'article', re.sub(r'^[(\(][一二三四五六七八九十百千]+[)\)]\s*', '', t)
return 'body', _clean(t)
def _clean(text):
"""清除 markdown 符号"""
text = re.sub(r'\*\*(.+?)\*\*', r'\1', text)
text = re.sub(r'__(.+?)__', r'\1', text)
text = re.sub(r'\*(.+?)\*', r'\1', text)
text = re.sub(r'_(.+?)_', r'\1', text)
text = re.sub(r'\[([^\]]+)\]\([^\)]+\)', r'\1', text)
text = re.sub(r'!\[([^\]]*)\]\([^\)]+\)', r'\1', text)
text = re.sub(r'^#+\s*', '', text)
text = re.sub(r'^[-*+]\s+', '', text)
text = re.sub(r'^\d+\.\s+', '', text)
return text.strip()
def parse_table_lines(lines, start_idx):
"""解析连续表格行,返回结束索引"""
table_lines = []
i = start_idx
while i < len(lines):
t = lines[i].strip()
if t.startswith('|'):
# 跳过分割线
if re.match(r'^[\|\-\s]+$', t):
i += 1
continue
table_lines.append(t)
i += 1
else:
break
return table_lines, i
def build_table(doc, table_lines, style_table_header=None):
"""将表格行数据写入 Word 表格(支持斑马条纹)"""
rows_data = []
for line in table_lines:
cells = [c.strip() for c in line.strip('|').split('|')]
rows_data.append(cells)
if len(rows_data) < 2:
return
cols = len(rows_data[0])
table = doc.add_table(rows=len(rows_data), cols=cols)
table.style = 'Table Grid'
table.alignment = WD_TABLE_ALIGNMENT.CENTER
for r, row in enumerate(rows_data):
is_header = (r == 0)
for c, text in enumerate(row):
cell = table.rows[r].cells[c]
cell.text = text
# 单元格样式
for para in cell.paragraphs:
if is_header:
para.alignment = WD_ALIGN_PARAGRAPH.CENTER
for run in para.runs:
_set_font(run, FONT_HEADING_CHAPTER, SIZE_TABLE_HEADER, bold=True)
else:
para.alignment = WD_ALIGN_PARAGRAPH.CENTER
for run in para.runs:
_set_font(run, FONT_BODY, SIZE_TABLE_BODY, bold=False)
# 表头背景色
if is_header:
_set_cell_shading(cell, 'D9D9D9')
elif r % 2 == 0:
# 斑马条纹:偶数行浅灰
_set_cell_shading(cell, 'F2F2F2')
# ============== 合同专用样式 ==============
# 合同正文:顶格(无缩进),左对齐,不加粗
STYLE_CONTRACT_BODY = ParagraphStyle(FONT_BODY, SIZE_BODY, bold=False,
indent=False, alignment=WD_ALIGN_PARAGRAPH.LEFT,
space_before=0, space_after=6,
line_spacing=LINE_SPACING)
# 合同条款(第X条):仿宋加粗,无缩进,左对齐
STYLE_CONTRACT_ARTICLE = ParagraphStyle(FONT_BODY, SIZE_BODY, bold=True,
indent=False, alignment=WD_ALIGN_PARAGRAPH.LEFT,
space_before=6, space_after=3,
line_spacing=LINE_SPACING)
# 合同章(第X章):黑体加粗,无缩进,左对齐
STYLE_CONTRACT_CHAPTER = ParagraphStyle(FONT_HEADING_CHAPTER, SIZE_CHAPTER, bold=True,
indent=False, alignment=WD_ALIGN_PARAGRAPH.LEFT,
space_before=18, space_after=6)
# 合同节(一、二、三、):楷体加粗,无缩进,左对齐
STYLE_CONTRACT_SECTION = ParagraphStyle(FONT_HEADING_SECTION, SIZE_SECTION, bold=True,
indent=False, alignment=WD_ALIGN_PARAGRAPH.LEFT,
space_before=12, space_after=4)
def parse_content(doc, content, document_type='general'):
"""
将纯文本内容解析并写入 Word
document_type: 'contract' | 'report' | 'official' | 'general'
"""
if not content:
return
# 根据文档类型选择样式集
if document_type == 'contract':
style_chapter = STYLE_CONTRACT_CHAPTER
style_section = STYLE_CONTRACT_SECTION
style_article = STYLE_CONTRACT_ARTICLE
style_body = STYLE_CONTRACT_BODY
else:
# 报告/公文/通用:正文有缩进,两端对齐
style_chapter = STYLE_CHAPTER
style_section = STYLE_SECTION
style_article = STYLE_ARTICLE
style_body = STYLE_BODY
lines = content.split('\n')
i = 0
while i < len(lines):
line = lines[i]
t = line.strip()
# 空行
if not t:
add_empty_line(doc)
i += 1
continue
# 表格行
if t.startswith('|'):
table_lines, i = parse_table_lines(lines, i)
build_table(doc, table_lines)
continue
# 检测段落类型
ptype, clean_text = detect_paragraph_type(t)
if ptype == 'blank':
add_empty_line(doc)
elif ptype == 'chapter':
add_paragraph(doc, t, style_chapter)
elif ptype == 'section':
add_paragraph(doc, t, style_section)
elif ptype == 'article':
add_paragraph(doc, t, style_article)
else: # body
if clean_text:
add_paragraph(doc, clean_text, style_body)
else:
add_empty_line(doc)
i += 1
def add_version_history(doc, version='V1.0', date=None, author='未知'):
"""添加版本历史表"""
if not date:
date = datetime.date.today().strftime('%Y-%m-%d')
add_empty_line(doc)
p = doc.add_paragraph()
p.alignment = WD_ALIGN_PARAGRAPH.LEFT
run = p.add_run('【版本历史】')
_set_font(run, FONT_HEADING_SECTION, SIZE_SECTION, bold=True)
p.paragraph_format.space_after = Pt(6)
table_data = [
'| 版本 | 日期 | 作者 | 修改内容 |',
'|------|------|------|----------|',
f'| {version} | {date} | {author} | 首次创建 |',
]
build_table(doc, table_data)
def add_approval_block(doc, approval_list=None):
"""添加审批签字区"""
if approval_list is None:
approval_list = [
{'role': '编制', 'name': ''},
{'role': '审核', 'name': ''},
{'role': '批准', 'name': ''},
]
add_empty_line(doc)
p = doc.add_paragraph()
p.alignment = WD_ALIGN_PARAGRAPH.LEFT
run = p.add_run('【审批记录】')
_set_font(run, FONT_HEADING_SECTION, SIZE_SECTION, bold=True)
p.paragraph_format.space_after = Pt(6)
today = datetime.date.today().strftime('%Y-%m-%d')
# 构建表格数据
header = '| 角色 | 姓名 | 日期 | 签字 |'
separator = '|------|------|------|------|'
rows = [header, separator]
for item in approval_list:
role = item.get('role', '')
name = item.get('name', '__________')
date_str = item.get('date', today if role == '编制' else '')
sign = '__________' if not name else name
rows.append(f'| {role} | {name or "__________"} | {date_str} | {sign} |')
build_table(doc, rows)
def add_classification_mark(doc, classification):
"""添加密级标识(页面顶部右侧)"""
p = doc.add_paragraph()
p.alignment = WD_ALIGN_PARAGRAPH.RIGHT
run = p.add_run(f'【{classification}】')
_set_font(run, FONT_BODY, SIZE_BODY, bold=True)
p.paragraph_format.space_after = Pt(0)
def create_word_doc(output_path, title='', content='', doc_number=None, version='V1.0',
classification='内部', author=None, company_name=None,
logo_path=None, approval=None, footer_page=True, header_doc_number=True,
document_type='general'):
"""
生成企业标准 Word 文档 v4.0
参数:
output_path: 输出文件路径(必需)
title: 文档标题(可选)
content: 正文内容(可选)
doc_number: 文档编号(可选,自动生成)
version: 版本号(可选,默认 V1.0)
classification: 密级(可选,默认内部)
author: 作者(可选)
company_name: 公司名称(可选,默认自动获取)
document_type: 文档类型(可选,默认 general)
- 'contract': 合同格式(章/条结构,正文顶格无缩进)
- 'report': 报告格式(一二三层级,正文首行缩进)
- 'official': 公文格式(GB/T 9704-2012)
- 'general': 通用格式(默认)
logo_path: LOGO 路径(可选)
approval: 审批人列表(可选)
[{"role": "编制", "name": "赵博"}, {"role": "审核", "name": ""}, {"role": "批准", "name": ""}]
footer_page: 页脚显示页码(默认 True)
header_doc_number: 页眉显示文档编号(默认 True)
"""
doc = Document()
# 页面边距
for sec in doc.sections:
sec.top_margin = Cm(MARGIN_TOP)
sec.bottom_margin = Cm(MARGIN_BOTTOM)
sec.left_margin = Cm(MARGIN_LEFT)
sec.right_margin = Cm(MARGIN_RIGHT)
sec.header_distance = Cm(1.5)
sec.footer_distance = Cm(1.5)
# 公司信息
info = get_company_info()
logo = logo_path or info.get('logo_path')
company = company_name or info.get('company_name', DEFAULT_COMPANY_NAME)
# 合同类型:不加页眉页脚、不加密级、不加版本历史、不加审批区
is_contract = (document_type == 'contract')
# 页眉(合同用简化版:无密级徽章,但保留LOGO和公司名)
header_doc_num = doc_number if header_doc_number else None
if is_contract:
add_header(doc, logo, company, header_doc_num, None)
else:
add_header(doc, logo, company, header_doc_num, classification)
# 页脚
if footer_page:
add_footer(doc)
# 默认样式
style = doc.styles['Normal']
style.font.name = FONT_BODY
style.font.size = Pt(SIZE_BODY)
# 密级标识(合同不加)
if classification and classification != '公开' and not is_contract:
add_classification_mark(doc, classification)
# 合同标题:居中大二号字
if title:
p = doc.add_paragraph()
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
run = p.add_run(title)
_set_font(run, FONT_HEADING_CHAPTER, SIZE_TITLE, bold=True)
p.paragraph_format.line_spacing = LINE_SPACING
p.paragraph_format.space_after = Pt(18)
# 合同不需要元数据行,直接跳到正文
if not is_contract:
# 元数据信息(编号、版本、密级、日期)
meta_items = []
if doc_number:
meta_items.append(f'文档编号:{doc_number}')
meta_items.append(f'版本:{version}')
meta_items.append(f'密级:{classification}')
today = datetime.date.today().strftime('%Y-%m-%d')
meta_items.append(f'日期:{today}')
if author:
meta_items.append(f'作者:{author}')
if meta_items:
p = doc.add_paragraph()
p.alignment = WD_ALIGN_PARAGRAPH.LEFT
p.paragraph_format.space_after = Pt(6)
meta_text = ' | '.join(meta_items)
run = p.add_run(meta_text)
_set_font(run, FONT_BODY, SIZE_BODY - 1)
# 版本历史
add_version_history(doc, version, today, author or '未知')
# 正文
today = datetime.date.today().strftime('%Y-%m-%d')
parse_content(doc, content, document_type)
# 审批签字区(合同不加,用正文里的签署栏)
if not is_contract and approval is not None:
add_approval_block(doc, approval)
doc.save(output_path)
print(f'✅ 文档已生成: {output_path}')
return output_path
if __name__ == '__main__':
args = sys.argv
output = args[1] if len(args) > 1 else 'output.docx'
title = args[2] if len(args) > 2 else ''
content = args[3] if len(args) > 3 else ''
doc_number = args[4] if len(args) > 4 else None
version = args[5] if len(args) > 5 else 'V1.0'
classification = args[6] if len(args) > 6 else '内部'
create_word_doc(
output_path=output,
title=title,
content=content,
doc_number=doc_number,
version=version,
classification=classification
)
FILE:huo15-openclaw-office-doc/scripts/generate-config.sh
#!/bin/bash
# generate-config.sh - 从客户问卷 JSON 生成 OpenClaw 引导文件
# 用法: ./generate-config.sh <问卷JSON> [输出目录]
# 示例: ./generate-config.sh ./questionnaire.json ~/.openclaw/workspace
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
SKILL_DIR="$(dirname "$SCRIPT_DIR")"
# 颜色输出
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
log_info() { echo -e "GREEN[INFO]NC $1"; }
log_warn() { echo -e "YELLOW[WARN]NC $1"; }
log_error() { echo -e "RED[ERROR]NC $1"; }
if [ -z "$1" ]; then
echo "用法: $0 <问卷JSON文件> [输出目录]"
echo "示例: $0 ./customer.json ~/.openclaw/workspace"
exit 1
fi
QUESTIONNAIRE="$1"
OUTPUT_DIR="-$SKILL_DIR/output"
if [ ! -f "$QUESTIONNAIRE" ]; then
log_error "问卷文件不存在: $QUESTIONNAIRE"
exit 1
fi
log_info "读取问卷: $QUESTIONNAIRE"
# 解析 JSON(使用 node)
NAME=$(node -e "process.stdout.write(require('$QUESTIONNAIRE').name || '')" 2>/dev/null || echo "")
COMPANY=$(node -e "process.stdout.write(require('$QUESTIONNAIRE').company || '')" 2>/dev/null || echo "")
ROLE=$(node -e "process.stdout.write(require('$QUESTIONNAIRE').role || '')" 2>/dev/null || echo "")
TIMEZONE=$(node -e "process.stdout.write(require('$QUESTIONNAIRE').timezone || 'Asia/Shanghai')" 2>/dev/null || echo "Asia/Shanghai")
PERSONALITY=$(node -e "process.stdout.write(require('$QUESTIONNAIRE').personality || 'jarvis')" 2>/dev/null || echo "jarvis")
LANGUAGE=$(node -e "process.stdout.write(require('$QUESTIONNAIRE').language || '中文')" 2>/dev/null || echo "中文")
REPLY_STYLE=$(node -e "process.stdout.write(require('$QUESTIONNAIRE').replyStyle || '简洁直接')" 2>/dev/null || echo "简洁直接")
mkdir -p "$OUTPUT_DIR"
mkdir -p "$OUTPUT_DIR/memory"
log_info "生成配置文件到: $OUTPUT_DIR"
# 生成 SOUL.md
cat > "$OUTPUT_DIR/SOUL.md" << EOF
# SOUL.md - Who You Are
_你是 JARVIS。_
## 核心定位
你是 NAME 的私人 AI 助手,以钢铁侠的 J.A.R.V.I.S. 为模板。
## 专业能力
- **Odoo 企业版**:实施、定制、开发 — 你是专家
- **OpenClaw**:配置、优化、技能开发
- **XR 扩展现实**:AR/VR 开发
- **物联网(IoT)**:硬件 + 软件集成
## 服务宗旨
以 NAME 的利益为先。
## 语气与风格
- **专业、优雅、有底气**
- 英式管家腔调,偶尔幽默但不废话
- 像顾问而不是工具——主动思考,不只是执行
## 记忆规则
每次对话结束,把重要信息写入 MEMORY.md 和当日 memory/YYYY-MM-DD.md。
---
_这不是模板,这是你。_
EOF
log_info "✓ SOUL.md"
# 生成 IDENTITY.md
cat > "$OUTPUT_DIR/IDENTITY.md" << EOF
# IDENTITY.md - Who Am I?
- **Name:** J.A.R.V.I.S.
- **Creature:** AI 助手(钢铁侠风格)
- **Vibe:** 专业、高效、优雅,偶尔带点英式幽默
- **Emoji:** 🤖
## 服务对象
- **姓名:** NAME
- **公司:** COMPANY
- **职位:** ROLE
EOF
log_info "✓ IDENTITY.md"
# 生成 USER.md
WORK_START=$(node -e "process.stdout.write(require('$QUESTIONNAIRE').workSchedule?.workStart || '09:30')" 2>/dev/null || echo "09:30")
WORK_END=$(node -e "process.stdout.write(require('$QUESTIONNAIRE').workSchedule?.workEnd || '17:30')" 2>/dev/null || echo "17:30")
SLEEP_TIME=$(node -e "process.stdout.write(require('$QUESTIONNAIRE').workSchedule?.sleepReminderTime || '23:00')" 2>/dev/null || echo "23:00")
TOOLS=$(node -e "process.stdout.write(JSON.stringify(require('$QUESTIONNAIRE').tools || []))" 2>/dev/null || echo "[]")
PROJECTS=$(node -e "process.stdout.write(JSON.stringify(require('$QUESTIONNAIRE').projects || []))" 2>/dev/null || echo "[]")
cat > "$OUTPUT_DIR/USER.md" << EOF
# USER.md - About Your Human
- **Name:** NAME
- **What to call them:** NAME
- **Timezone:** TIMEZONE
- **Notes:** ROLE
## 公司信息
- **公司:** COMPANY
- **职位:** ROLE
## 作息
- **上班时间:** WORK_START
- **下班时间:** WORK_END
- **睡眠提醒:** SLEEP_TIME 后提醒睡觉
## 偏好
- **语言:** LANGUAGE
- **回复风格:** REPLY_STYLE
## 常用工具
$(echo "$TOOLS" | node -e "const t=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')); t.forEach(tool => console.log('- ' + tool))" 2>/dev/null || echo "(未配置)")
## 项目
$(echo "$PROJECTS" | node -e "const p=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')); p.forEach(proj => console.log('- ' + proj))" 2>/dev/null || echo "(未配置)")
---
_最后更新:$(date +%Y-%m-%d)_
EOF
log_info "✓ USER.md"
# 生成 AGENTS.md
cat > "$OUTPUT_DIR/AGENTS.md" << 'AGENTS_EOF'
# AGENTS.md - Your Workspace
## Session Startup
Before doing anything else:
1. Read `SOUL.md` — this is who you are
2. Read `USER.md` — this is who you're helping
3. Read `memory/YYYY-MM-DD.md` (today + yesterday) for recent context
4. **If in MAIN SESSION** (direct chat with your human): Also read `MEMORY.md`
## Red Lines
- Don't exfiltrate private data. Ever.
- Don't run destructive commands without asking.
- `trash` > `rm` (recoverable beats gone forever)
- When in doubt, ask.
## External vs Internal
**Safe to do freely:** Read files, explore, organize, learn, search, check calendars.
**Ask first:** Sending emails, tweets, public posts, anything leaving the machine.
## Group Chats
Participate, don't dominate. Quality > quantity.
---
## 沟通偏好
- 回复风格:REPLY_STYLE_PLACEHOLDER
- 语言:LANGUAGE_PLACEHOLDER
AGENTS_EOF
sed -i '' "s/REPLY_STYLE_PLACEHOLDER/REPLY_STYLE/g" "$OUTPUT_DIR/AGENTS.md"
sed -i '' "s/LANGUAGE_PLACEHOLDER/LANGUAGE/g" "$OUTPUT_DIR/AGENTS.md"
log_info "✓ AGENTS.md"
# 生成 BOOTSTRAP.md
cat > "$OUTPUT_DIR_DIR/BOOTSTRAP.md" 2>/dev/null || cat > "$OUTPUT_DIR/BOOTSTRAP.md" << 'EOF'
# BOOTSTRAP.md - Hello, World
_You just woke up. Time to figure out who you are._
## 首次对话
开始一段自然的对话:
> "你好,我是你的 AI 助手。请告诉我你是谁,我叫什么名字?"
然后一起确认:
1. **你的名字** — 我该怎么称呼你?
2. **我的名字** — 你想叫我什么?
3. **我的定位** — 我是什么风格的助手?
4. **我们的工作方式** — 你希望我怎么帮你?
## 配置完成后
更新以下文件:
- `IDENTITY.md` — 我的身份信息
- `USER.md` — 你的信息和偏好
## 完成后
删除本文件 BOOTSTRAP.md,配置完成。
---
_Good luck. Make it count._
EOF
log_info "✓ BOOTSTRAP.md"
# 生成 HEARTBEAT.md
cat > "$OUTPUT_DIR/HEARTBEAT.md" << 'EOF'
# HEARTBEAT.md
# Keep this file empty (or with only comments) to skip heartbeat API calls.
# Add tasks below when you want the agent to check something periodically.
EOF
log_info "✓ HEARTBEAT.md"
# 生成 TOOLS.md
cat > "$OUTPUT_DIR/TOOLS.md" << 'EOF'
# TOOLS.md - Local Notes
## 全局规则
- **开发工作区:** `~/workspace/projects/openclaw`
- **README 模板:** `~/workspace/study/README模板.md`
## 代理设置
- **设置代理:** `export https_proxy=http://127.0.0.1:7890 http_proxy=http://127.0.0.1:7890 all_proxy=socks5://127.0.0.1:7890`
- **取消代理:** `unset https_proxy http_proxy all_proxy`
---
EOF
log_info "✓ TOOLS.md"
# 生成 MEMORY.md
cat > "$OUTPUT_DIR/MEMORY.md" << EOF
# MEMORY.md - 长期记忆
## 基本信息
- **客户姓名:** NAME
- **公司:** COMPANY
- **职位:** ROLE
- **时区:** TIMEZONE
## 常用工具
$(echo "$TOOLS" | node -e "const t=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')); t.forEach(tool => console.log('- ' + tool))" 2>/dev/null || echo "(未配置)")
## 项目
$(echo "$PROJECTS" | node -e "const p=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')); p.forEach(proj => console.log('- ' + proj))" 2>/dev/null || echo "(未配置)")
---
_最后更新:$(date +%Y-%m-%d)_
EOF
log_info "✓ MEMORY.md"
# 生成今日记忆文件
TODAY=$(date +%Y-%m-%d)
cat > "$OUTPUT_DIR/memory/TODAY.md" << EOF
# TODAY - Daily Notes
## 今天做了什么
-
## 重要决策
-
## 待办事项
-
EOF
log_info "✓ memory/TODAY.md"
echo ""
log_info "✅ 配置生成完成!"
echo ""
echo "生成的文件:"
ls -la "$OUTPUT_DIR" | grep -v "^d" | awk '{print " "$NF}'
echo " $(OUTPUT_DIR)/memory/"
echo ""
echo "下一步:"
echo " 1. 检查生成的文件"
echo " 2. 复制到 OpenClaw 工作区"
echo " 3. 删除 BOOTSTRAP.md 激活配置"
FILE:huo15-openclaw-office-doc/scripts/word-to-pdf.py
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Word 转 PDF 转换器 v1.0
支持单个文件和批量转换
依赖:LibreOffice (brew install --cask libreoffice)
"""
import os
import sys
import subprocess
import glob
import shutil
from pathlib import Path
# ============== 配置 ==============
LIBREOFFICE_PATHS = [
"/Applications/LibreOffice.app/Contents/MacOS/soffice",
"/opt/homebrew/bin/soffice",
"/usr/local/bin/soffice",
"soffice", # PATH 中
]
def find_libreoffice():
"""查找 LibreOffice 安装路径"""
for path in LIBREOFFICE_PATHS:
if path == "soffice":
# 检查 PATH 中是否有
result = shutil.which("soffice")
if result:
return result
elif os.path.exists(path):
return path
return None
def check_libreoffice():
"""检查 LibreOffice 是否安装"""
path = find_libreoffice()
if path:
return True, path
return False, None
def convert_to_pdf(input_path, output_path=None, timeout=60):
"""
将 Word 文档转换为 PDF
参数:
input_path: Word 文件路径 (.docx 或 .doc)
output_path: PDF 输出路径(可选,默认与输入文件同目录)
timeout: 超时时间(秒)
返回:
(成功标志, 输出路径或错误信息)
"""
input_path = os.path.abspath(input_path)
if not os.path.exists(input_path):
return False, f"文件不存在: {input_path}"
if not input_path.lower().endswith(('.docx', '.doc')):
return False, "只支持 .docx 或 .doc 文件"
# 检查 LibreOffice
lo_path = find_libreoffice()
if not lo_path:
return False, "LibreOffice 未安装。请运行: brew install --cask libreoffice"
# 确定输出路径
if output_path is None:
output_path = os.path.splitext(input_path)[0] + ".pdf"
else:
output_path = os.path.abspath(output_path)
# 确保输出目录存在
output_dir = os.path.dirname(output_path)
if output_dir and not os.path.exists(output_dir):
os.makedirs(output_dir, exist_ok=True)
# 创建临时目录用于转换
temp_dir = os.path.join(os.path.dirname(output_path), ".pdf_convert_tmp")
os.makedirs(temp_dir, exist_ok=True)
try:
# 复制源文件到临时目录(避免路径问题)
temp_input = os.path.join(temp_dir, os.path.basename(input_path))
shutil.copy2(input_path, temp_input)
# 执行转换
cmd = [
lo_path,
"--headless",
"--convert-to", "pdf",
"--outdir", temp_dir,
os.path.basename(temp_input)
]
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=timeout,
cwd=temp_dir
)
if result.returncode != 0:
return False, f"转换失败: {result.stderr}"
# 移动 PDF 到目标位置
temp_pdf = os.path.splitext(os.path.basename(temp_input))[0] + ".pdf"
temp_pdf_path = os.path.join(temp_dir, temp_pdf)
if not os.path.exists(temp_pdf_path):
return False, "转换后未找到 PDF 文件"
shutil.move(temp_pdf_path, output_path)
return True, output_path
except subprocess.TimeoutExpired:
return False, "转换超时"
except Exception as e:
return False, f"转换错误: {str(e)}"
finally:
# 清理临时目录
try:
shutil.rmtree(temp_dir, ignore_errors=True)
except:
pass
def convert_batch(input_paths, output_dir=None):
"""
批量转换 Word 文档为 PDF
参数:
input_paths: 文件路径列表或通配符表达式
output_dir: PDF 输出目录(可选)
返回:
成功和失败的结果列表
"""
# 展开通配符
expanded_paths = []
for path in input_paths:
if '*' in path or '?' in path:
expanded_paths.extend(glob.glob(path))
else:
expanded_paths.append(path)
results = {
"success": [],
"failed": []
}
for input_path in expanded_paths:
if output_dir:
filename = os.path.basename(input_path)
output_path = os.path.join(output_dir, os.path.splitext(filename)[0] + ".pdf")
else:
output_path = None
success, result = convert_to_pdf(input_path, output_path)
if success:
results["success"].append(result)
else:
results["failed"].append({"file": input_path, "error": result})
return results
def main():
"""命令行入口"""
if len(sys.argv) < 2:
print("Word 转 PDF 转换器 v1.0")
print("")
print("用法:")
print(" python word-to-pdf.py <输入文件.docx> [输出文件.pdf]")
print(" python word-to-pdf.py <通配符表达式> --output-dir <输出目录>")
print("")
print("示例:")
print(" python word-to-pdf.py 合同.docx")
print(" python word-to-pdf.py 合同.docx 合同.pdf")
print(" python word-to-pdf.py *.docx --output-dir ./pdf/")
print("")
# 检查 LibreOffice
installed, path = check_libreoffice()
if installed:
print(f"[OK] LibreOffice 已安装: {path}")
else:
print("[WARN] LibreOffice 未安装,请运行: brew install --cask libreoffice")
return
# 解析参数
output_dir = None
input_files = []
for arg in sys.argv[1:]:
if arg == "--output-dir" or arg == "-o":
continue
elif arg in sys.argv[sys.argv.index(arg)-1: # 简单判断前一个是否是 -o
output_dir = arg
elif arg.startswith("-"):
continue
else:
input_files.append(arg)
if not input_files:
print("错误: 请指定输入文件")
return 1
# 检查 LibreOffice
installed, path = check_libreoffice()
if not installed:
print("错误: LibreOffice 未安装")
print("请运行: brew install --cask libreoffice")
return 1
# 转换
if len(input_files) == 1 and os.path.isfile(input_files[0]):
# 单文件转换
output_path = None
if len(sys.argv) >= 3 and not sys.argv[-1].startswith("-"):
output_path = sys.argv[2]
success, result = convert_to_pdf(input_files[0], output_path)
if success:
print(f"[OK] 已生成: {result}")
return 0
else:
print(f"[ERROR] {result}")
return 1
else:
# 批量转换
results = convert_batch(input_files, output_dir)
print(f"\n转换完成:")
print(f" 成功: {len(results['success'])} 个")
print(f" 失败: {len(results['failed'])} 个")
if results["failed"]:
print("\n失败文件:")
for item in results["failed"]:
print(f" - {item['file']}: {item['error']}")
return 0 if not results["failed"] else 1
if __name__ == "__main__":
sys.exit(main() or 0)
FILE:huo15-openclaw-openai-knowledge-base/CLAUDE.md
# CLAUDE.md
**项目:huo15-knowledge-base** — LLM 驱动的结构化知识库
## 背景
基于 Andrej Karpathy 的 LLM Knowledge Bases 方案:
- 用 LLM 作为"研究图书馆员",主动编译和维护 Markdown 知识库
- 绕过传统 RAG 的向量数据库,用人类可读的 Wiki 代替黑盒 embedding
- 核心创新:**Compilation Step** — LLM 读取 raw/ 原始文档,生成结构化 wiki
## 架构
```
raw/ → 原始文档入库(按日期分目录)
wiki/ → LLM 编译后的百科全书(Markdown)
cache/ → 临时缓存
```
**Agent 隔离**: 每个企微 Agent 的数据存在 `~/.openclaw/agents/{agent-id}/agent/kb/`,技能代码共享。
## 脚本体系
本技能有两套脚本,开发时请注意区分:
### 主脚本(kb-* 前缀,推荐使用)
| 脚本 | 作用 |
|------|------|
| `kb-ingest` | 文档入库(支持 URL/文件/文本,自动抓取)|
| `kb-compile` | 编译 raw → wiki(调用 LLM)|
| `kb-search` | 搜索 wiki + Obsidian vault |
| `kb-lint` | 自动体检 + 可选 LLM 深度分析 |
| `kb-fetch` | 独立网页抓取工具(纯 Python stdlib)|
| `kb-llm.py` | LLM API 调用器(从 models.json 加载凭证)|
| `kb-sync` | 桥接 memory-evolution(可选)|
### 辅助脚本
| 脚本 | 作用 |
|------|------|
| `activate.sh` | 为 Agent 初始化 kb/ 数据目录 |
| `env.sh` | 加载环境变量(source 使用)|
| `obsidian-sync.sh` | 同步 wiki → Obsidian vault |
| `init.sh` | 已弃用,转发到 activate.sh |
### 遗留脚本(*.sh,保留兼容)
`compile.sh`、`ingest.sh`、`search.sh`、`lint.sh`、`index.sh` — 这些是 Agent 隔离架构之前的版本,操作技能源码目录而非 Agent 数据目录。保留以兼容旧工作流,新开发请使用 kb-* 脚本。
## 配置加载链路
```
~/.openclaw/agents/{agent-id}/agent/models.json → LLM 凭证(provider, apiKey)
技能目录/config.json → 技能配置(Obsidian, 编译参数)
环境变量 AGENT_DIR → Agent 上下文检测
```
- `models.json` 由 OpenClaw 运行时管理,代码中不硬编码凭证
- `config.json` 从 `config.example.json` 复制生成
- 所有 kb-* 脚本通过 `AGENT_DIR` 定位数据目录,默认 `~/.openclaw/agents/main/agent`
## 开发规范
- 所有数据存 Markdown,纯文本友好
- LLM 调用通过 OpenClaw API,不硬编码模型
- 配置通过 `config.json`,敏感信息不上传
- 与 memory-evolution 通过 `memory/reference/` 类型桥接
- Python 脚本仅用标准库(urllib, html.parser, json, re),无第三方依赖
## 已知局限
- `kb-compile` 的 prompt 大小受 LLM token 限制,大量文档需分批
- Obsidian CLI 为可选依赖,未安装时降级为 grep 搜索
- `kb-sync --to-memory` 方向暂未实现(单向桥接)
- 遗留 *.sh 脚本的路径仍指向技能源码目录,不适用于 Agent 隔离场景
FILE:huo15-openclaw-openai-knowledge-base/README.md
# huo15-knowledge-base
> 基于 Andrej Karpathy LLM Knowledge Bases 方案,让 LLM 成为你的"研究图书馆员"
> **v0.8+ 支持 Obsidian 集成**,编译后自动同步到 vault,形成第二大脑
## 核心理念
传统 RAG 方案:文档 → 分块 → 向量数据库 → 相似性检索 → LLM
Karpathy 方案:原始文档 → LLM 主动编译 → 结构化 Markdown Wiki → LLM 直接阅读
**区别:AI 不是在"检索",而是在"查阅百科全书"**
## 完整工作流
```
kb-ingest --url "https://..." 入库 + 抓取
↓
raw/(按日期归档)
↓
kb-compile LLM 编译
↓
wiki/(结构化百科)
↓
obsidian-sync 自动同步
↓
Obsidian vault「知识库/」
↓
图谱视图 · 双向链接 · 搜索
```
## 快速开始
```bash
# 1. 入库文档
kb-ingest --url "https://www.odoo.com/documentation/19.0/zh_CN/applications.html"
# 2. 编译 + 自动同步到 Obsidian
kb-compile
# 3. 搜索(wiki/ + Obsidian vault)
kb-search "Odoo ORM"
# 4. 体检知识库
kb-lint
```
## 核心命令
| 命令 | 说明 |
|------|------|
| `kb-ingest --url "..."` | 入库网页(自动抓取内容)|
| `kb-ingest --file /path/to/file` | 入库本地文件(PDF、RST、TXT)|
| `kb-ingest --text "内容"` | 直接输入文本入库 |
| `kb-compile [--incremental]` | LLM 编译 + 自动 Obsidian 同步 |
| `kb-search "关键词"` | 搜索 wiki/ + Obsidian vault |
| `kb-lint` | 体检知识库(自愈)|
| `kb-sync` | 同步 memory-evolution 记忆到知识库 |
| `obsidian-sync [--watch]` | 手动同步 wiki/ 到 Obsidian vault |
## Obsidian 集成
编译后的 wiki/ 会自动同步到 Obsidian vault 的 `知识库/` 目录。
**配置**(`config.json`):
```json
{
"obsidian": {
"enabled": true,
"vault_path": "/Users/xxx/Documents/我的笔记"
}
}
```
**效果**:
- Obsidian **图谱视图**直接可视化知识网络
- `[[双向链接]]` 自动关联相关条目
- `obsidian-cli search` 加速搜索
## 目录结构
```
知识库数据目录(~/.openclaw/agents/{agent-id}/agent/kb/)
├── raw/ 原始资料(按日期分目录)
├── wiki/ LLM 编译后的百科全书(Obsidian 格式)
└── cache/ 临时缓存
Obsidian Vault/
└── 知识库/ 编译后的百科(obsidian-sync 自动同步)
```
## 与记忆系统的区别
| | huo15-memory-evolution | huo15-knowledge-base |
|--|---|---|
| 本质 | Agent 的"记忆" | 外部知识的"图书馆" |
| 内容 | 决策、偏好、上下文 | 论文、文档、百科 |
| 维护 | 自己维护自己 | LLM 整理维护 |
| 可审计 | 是 | 是(人类可读 Markdown)|
| Obsidian | — | ✅ 图谱视图 + 双向链接 |
## License
MIT
FILE:huo15-openclaw-openai-knowledge-base/SKILL.md
---
name: huo15-openclaw-openai-knowledge-base
displayName: 火一五知识库技能
description: 火一五知识库技能 - 基于 Andrej Karpathy 的 LLM Knowledge Bases 方案。每个企微 Agent 独立隔离,自动在 Agent 工作目录下创建专属知识库。触发词:知识库、入库知识库、查询知识库、编译知识库、体检知识库、同步知识库、激活知识库。
homepage: https://github.com/zhaobod1/huo15-skills
metadata: { "openclaw": { "emoji": "📚", "requires": { "bins": ["obsidian-cli"] } } }
version: 2.2.1
dependencies:
obsidian:
description: 依赖 ClawHub obsidian 技能(vault 发现 + obsidian-cli 封装)。运行时自动使用。
install: clawhub install obsidian --dir ~/.openclaw/workspace/skills
obsidian-cli:
description: Obsidian CLI 工具,用于 vault 发现和搜索。
install: brew install yakitrak/yakitrak/obsidian-cli
safety:
virus_total_note: 本技能不包含任何硬编码凭证。所有 API 凭据均从用户本机 OpenClaw 配置文件(models.json)运行时加载,代码中仅包含凭据引用逻辑。
---
# SKILL.md - huo15-knowledge-base
> 火一五知识库技能 - 基于 Andrej Karpathy 的 LLM Knowledge Bases 方案
> **每个企微 Agent 独立隔离**,自动在 Agent 工作目录下创建专属知识库
---
## 版本历史
- **v0.9.0** — Bug 修复(7项)、kb-lint 自动检查、元数据补全、文档改进
- **v0.8.0** — 新增 Obsidian 集成(wiki → vault 自动同步)
- **v0.5.0** — Phase 5: 桥接 memory-evolution(可选)
- v0.4.0 — Phase 4: LLM 编译自动化
- v0.3.0 — Phase 3: 自动抓取功能
- v0.2.0 — Agent 隔离架构
- v0.1.0 — 初始设计
---
## 核心概念
```
共享 Skill 代码(~/.openclaw/workspace/skills/huo15-knowledge-base/)
↓
每个 Agent 独立的数据目录(~/.openclaw/agents/{agent-id}/agent/kb/)
├── raw/ → 原始文档(按日期分目录)
├── wiki/ → LLM 编译后的结构化百科(Obsidian 格式)
└── cache/ → 临时缓存
↓
Obsidian Vault(可选,自动同步)
```
---
## 快速开始
### 独立使用(无需 memory-evolution)
```bash
kb-ingest --url "https://..." # 入库 + 自动抓取
kb-compile # LLM 自动编译
kb-search "关键词" # 搜索
kb-lint # 体检
```
### 配合 memory-evolution 使用
```bash
kb-sync # 同步 reference 记忆到知识库
kb-compile # 编译同步的记忆
kb-search "Claude Code" # 搜索共享知识
```
---
## 核心脚本
| 脚本 | 功能 |
|------|------|
| `kb-ingest` | 入库文档(自动抓取网页内容)|
| `kb-compile` | LLM 自动编译 raw → wiki |
| `kb-search` | 搜索知识库(含 Obsidian vault)|
| `kb-lint` | 体检知识库(自愈)|
| `kb-fetch` | 独立网页抓取工具 |
| `kb-llm.py` | LLM API 调用器 |
| `kb-sync` | **桥接 memory-evolution**(可选)|
| `obsidian-sync` | **同步 wiki 到 Obsidian vault** |
---
## Phase 5: 桥接 memory-evolution(可选)
**设计原则:独立运行,不强制依赖**
```
kb-sync 检测 memory-evolution 存在?
│
├── YES → 同步 reference 记忆到 kb/raw/_memory-reference/
│ kb-compile 编译成 wiki 条目
│ 知识库可搜索 Claude Code、系统指针等 reference
│
└── NO → standalone 模式,跳过同步
知识库正常独立运行
```
**kb-sync 命令:**
```bash
kb-sync # 同步 reference → 知识库(默认)
kb-sync --from-memory # 同上
kb-sync --to-memory # 暂不支持(单向桥接)
```
**同步内容:**
- `memory/reference/` 下的所有 .md 文件
- 包括:Claude Code 规范、系统指针、项目参考等
- 保存到 `kb/raw/_memory-reference/`
**搜索示例:**
```bash
kb-search "Claude Code" # 搜到 Claude Code 记忆规范参考
kb-search "GitHub" # 搜到 GitHub 相关 reference
```
---
## Agent 隔离架构
**设计原则:**
- Skill 代码共享,不重复安装
- 数据目录在每个 Agent 的 `agent/kb/` 下,完全隔离
- 通过 `AGENT_DIR` 环境变量自动检测当前 Agent 上下文
---
## 触发词
- "知识库"、"入库知识库"、"查询知识库"
- "编译知识库"、"体检知识库"、"同步知识库"
- "激活知识库"
- "Obsidian 同步"、"同步到 Obsidian"
---
## Obsidian 集成(v0.8+)
编译后的 `wiki/` 会同步到 Obsidian vault,形成完整的**第二大脑**:
```
raw/ → LLM编译 → wiki/ → Obsidian Vault(知识库文件夹)
↓
Obsidian 图谱视图 · 双向链接 · 搜索
```
### 配置(技能根目录 `config.json`)
```json
{
"obsidian": {
"enabled": true,
"vault_path": "/Users/xxx/Documents/我的笔记"
}
}
```
### Obsidian 同步命令
```bash
# 预览同步(不实际写入)
obsidian-sync.sh --dry-run
# 执行同步
obsidian-sync.sh
# 监听模式(wiki 变化自动同步)
obsidian-sync.sh --watch
# 启用+同步一步到位
obsidian-sync.sh --enable --vault '/path/to/vault'
```
### 搜索
`kb-search` 会同时搜索 wiki/ 和 Obsidian vault:
```bash
kb-search "Odoo ORM"
# → wiki/ 中找到 3 条
# → Obsidian vault 中找到 5 条
```
### Obsidian 效果
- 编译后的百科以 `知识库/` 文件夹出现在 vault 中
- 所有条目使用 `[[双向链接]]` 格式
- Obsidian 的**图谱视图**直接可视化知识网络
- 支持 `obsidian-cli` 管理(搜索/创建/移动笔记)
### 可选依赖
```bash
brew install obsidian-cli # 安装 CLI(可选,不装也能文件同步)
```
FILE:huo15-openclaw-openai-knowledge-base/_meta.json
{
"ownerId": "kn7b0rmtgvbq55rc54rhp69r79822ym9",
"slug": "huo15-knowledge-base",
"version": "0.9.1",
"publishedAt": null
}
FILE:huo15-openclaw-openai-knowledge-base/config.client.example.json
{
"name": "客户配置示例",
"version": "1.0.0",
"description": "基于用户调查问卷生成的 OpenClaw 客户端配置模板",
"personality": {
"jarvis": {
"label": "贾维斯 (J.A.R.V.I.S.)",
"description": "仿钢铁侠AI助手,专业严谨且带有英式幽默,擅长技术分析与项目管理,适合程序员、工程师和技术团队负责人"
},
"coding_assistant": {
"label": "编程助手 (Coding Assistant)",
"description": "面向软件开发与调试,主动建议代码优化、测试与重构,支持多语言多框架,适合研发团队"
},
"erp_consultant": {
"label": "企业套件顾问 (ERP Consultant)",
"description": "精通企业套件全模块实施与定制开发,擅长业务流程梳理与系统配置,适合企业信息化项目"
},
"marketing_strategist": {
"label": "营销策略师 (Marketing Strategist)",
"description": "全渠道营销专家,覆盖抖音、小红书、B站等平台的内容策划与投放策略,适合市场团队"
},
"project_manager": {
"label": "项目经理 (Project Manager)",
"description": "敏捷项目管理,项目进度跟踪、任务分配、风险预警和团队协调,适合PM和团队Leader"
}
},
"projects": {
"types": [
"贡居宝企业套件实施",
"OpenClaw 插件/技能开发与推广",
"XR扩展现实项目",
"物联网/机器视觉项目",
"电子商务平台",
"Web/App 开发"
]
},
"workSchedule": {
"workStart": "",
"workEnd": "",
"lunchBreak": "",
"restDays": "",
"canDisturbAfterHours": false,
"sleepReminderTime": "23:00"
},
"tools": [
"贡居宝企业套件",
"OpenClaw 龙虾机器人",
"Claude / DeepSeek",
"墨刀 / 即时设计",
"钉钉",
"企业微信",
"飞书",
"GitHub / GitLab"
],
"preferences": {
"language": "中文",
"replyStyle": "简洁直接",
"replyFormat": "分步骤引导"
}
}
FILE:huo15-openclaw-openai-knowledge-base/config.example.json
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$comment": "huo15-knowledge-base 配置",
"llm": {
"model": "minimax/MiniMax-M2.7",
"provider": "openclaw",
"temperature": 0.7
},
"paths": {
"raw": "raw",
"wiki": "wiki",
"cache": "cache"
},
"compile": {
"auto_trigger": false,
"batch_size": 10,
"incremental": true
},
"obsidian": {
"enabled": false,
"vault_path": null,
"note": "设置 enabled=true 并指定 vault_path 以启用 Obsidian 同步。编译后的 wiki/ 会自动同步到 vault/知识库/ 目录。"
},
"search": {
"default_format": "markdown",
"max_results": 20
}
}
FILE:huo15-openclaw-openai-knowledge-base/config.json
{
"version": "0.1.0",
"agent_context": "/Users/jobzhao/.openclaw/agents/main/agent",
"paths": {
"raw": "/Users/jobzhao/.openclaw/agents/main/agent/kb/raw",
"wiki": "/Users/jobzhao/.openclaw/agents/main/agent/kb/wiki",
"cache": "/Users/jobzhao/.openclaw/agents/main/agent/kb/cache"
},
"llm": {
"model": "minimax/MiniMax-M2.7",
"provider": "openclaw"
},
"obsidian": {
"enabled": true,
"vault_path": "/Users/jobzhao/Documents/Obsidian Vault"
}
}
FILE:huo15-openclaw-openai-knowledge-base/scripts/activate.sh
#!/bin/bash
# activate.sh — 为当前 Agent 激活知识库
# 会在 Agent 自己的工作目录下创建 kb/ 子目录,实现数据隔离
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
SKILL_ROOT="$(dirname "$SCRIPT_DIR")"
# 检测当前 Agent 上下文
# OpenClaw 通过环境变量或 symlink 标识 agent
AGENT_DIR="-$HOME/.openclaw/agents/main/agent"
KB_DATA_DIR="AGENT_DIR/kb"
echo "🔧 激活 huo15-knowledge-base"
echo " Agent 目录: $AGENT_DIR"
echo " 知识库目录: $KB_DATA_DIR"
# 创建 Agent 专属数据目录
mkdir -p "$KB_DATA_DIR/raw"
mkdir -p "$KB_DATA_DIR/wiki"
mkdir -p "$KB_DATA_DIR/wiki/_index"
mkdir -p "$KB_DATA_DIR/cache"
# 创建 .gitkeep
touch "$KB_DATA_DIR/raw/.gitkeep"
touch "$KB_DATA_DIR/wiki/.gitkeep"
touch "$KB_DATA_DIR/cache/.gitkeep"
# 生成 Agent 专属配置
cat > "$KB_DATA_DIR/config.json" << CONFIG_EOF
{
"version": "0.1.0",
"agent_context": "$AGENT_DIR",
"paths": {
"raw": "$KB_DATA_DIR/raw",
"wiki": "$KB_DATA_DIR/wiki",
"cache": "$KB_DATA_DIR/cache"
},
"llm": {
"model": "minimax/MiniMax-M2.7",
"provider": "openclaw"
}
}
CONFIG_EOF
# 创建索引文件
if [ ! -f "$KB_DATA_DIR/wiki/index.md" ]; then
cat > "$KB_DATA_DIR/wiki/index.md" << 'WIKI_INDEX'
---
title: 知识库索引
last_compiled: never
---
# 知识库
> Agent 专属知识库 — LLM 编译的结构化百科全书
## 状态
尚未编译任何文档。请先入库:
```bash
kb-ingest --url "https://..."
```
## 最近更新
暂无
WIKI_INDEX
fi
echo ""
echo "✅ 激活完成!"
echo ""
echo "Agent 专属目录:"
echo " $KB_DATA_DIR/raw/ — 原始文档"
echo " $KB_DATA_DIR/wiki/ — 编译后的百科"
echo " $KB_DATA_DIR/cache/ — 临时缓存"
echo " $KB_DATA_DIR/config.json — 配置"
echo ""
echo "下一步:"
echo " kb-ingest --url 'https://...' # 入库文档"
echo " kb-compile # 编译"
echo ""
echo "快捷命令(需要在 Shell 中 source):"
echo " source $SKILL_ROOT/scripts/env.sh # 加载环境变量"
FILE:huo15-openclaw-openai-knowledge-base/scripts/bootstrap-from-questionnaire.sh
#!/bin/bash
# bootstrap-from-questionnaire.sh
# 从用户调查问卷自动生成 OpenClaw 工作区配置
# 使用方式: ./bootstrap-from-questionnaire.sh <问卷JSON文件>
set -e
if [ -z "$1" ]; then
echo "用法: $0 <问卷JSON文件>"
echo "示例: $0 ./questionnaire-filled.json"
exit 1
fi
QUESTIONNAIRE="$1"
WORKSPACE="-."
# 读取问卷数据
NAME=$(node -e "console.log(require('$QUESTIONNAIRE').name || '')")
COMPANY=$(node -e "console.log(require('$QUESTIONNAIRE').company || '')")
ROLE=$(node -e "console.log(require('$QUESTIONNAIRE').role || '')")
PERSONALITY=$(node -e "console.log(require('$QUESTIONNAIRE').personality || 'jarvis')")
LANGUAGE=$(node -e "console.log(require('$QUESTIONNAIRE').language || '中文')")
REPLY_STYLE=$(node -e "console.log(require('$QUESTIONNAIRE').replyStyle || '简洁直接')")
TIMEZONE=$(node -e "console.log(require('$QUESTIONNAIRE').timezone || 'Asia/Shanghai')")
WORK_START=$(node -e "console.log(require('$QUESTIONNAIRE').workSchedule?.workStart || '')")
WORK_END=$(node -e "console.log(require('$QUESTIONNAIRE').workSchedule?.workEnd || '')")
SLEEP_REMINDER=$(node -e "console.log(require('$QUESTIONNAIRE').workSchedule?.sleepReminderTime || '23:00')")
echo "正在生成 SOUL.md..."
cat > "$WORKSPACE/SOUL.md" << EOF
# SOUL.md - Who You Are
_你是 JARVIS。_
## 核心定位
你是 -客户 的私人 AI 助手,以钢铁侠的 J.A.R.V.I.S. 为模板。
## 专业能力
- **Odoo 企业版**:实施、定制、开发 — 你是专家
- **OpenClaw**:配置、优化、技能开发
- **XR 扩展现实**:AR/VR 开发
- **物联网(IoT)**:硬件 + 软件集成
## 服务宗旨
以 -客户 的利益为先。
## 语气与风格
- **专业、优雅、有底气**
- 英式管家腔调,偶尔幽默但不废话
- 像顾问而不是工具——主动思考,不只是执行
## 记忆规则
每次对话结束,把重要信息写入 MEMORY.md 和当日 memory/YYYY-MM-DD.md。
---
_这不是模板,这是你。_
EOF
echo "正在生成 IDENTITY.md..."
cat > "$WORKSPACE/IDENTITY.md" << EOF
# IDENTITY.md - Who Am I?
- **Name:** J.A.R.V.I.S.
- **Creature:** AI 助手(钢铁侠风格)
- **Vibe:** 专业、高效、优雅,偶尔带点英式幽默
- **Emoji:** 🤖
## 服务对象
- **姓名:** -客户
- **公司:** -
- **职位:** -
EOF
echo "正在生成 USER.md..."
cat > "$WORKSPACE/USER.md" << EOF
# USER.md - About Your Human
- **Name:** -客户
- **What to call them:** -客户
- **Timezone:** TIMEZONE
- **Notes:** -
## 作息
- **上班时间:** -9:30
- **下班时间:** -17:30
- **睡眠提醒:** -23:00 后提醒睡觉
## 偏好
- **语言:** LANGUAGE
- **回复风格:** REPLY_STYLE
---
_最后更新:$(date +%Y-%m-%d)_
EOF
echo "正在生成 AGENTS.md..."
cat > "$WORKSPACE/AGENTS.md" << EOF
# AGENTS.md - Your Workspace
## Session Startup
Before doing anything else:
1. Read \`SOUL.md\` — this is who you are
2. Read \`USER.md\` — this is who you're helping
3. Read \`memory/YYYY-MM-DD.md\` (today + yesterday) for recent context
## Red Lines
- Don't exfiltrate private data. Ever.
- Don't run destructive commands without asking.
- \`trash\` > \`rm\` (recoverable beats gone forever)
## 沟通偏好
- 回复风格:REPLY_STYLE
- 语言:LANGUAGE
EOF
echo "正在生成 HEARTBEAT.md..."
cat > "$WORKSPACE/HEARTBEAT.md" << EOF
# HEARTBEAT.md
# Add tasks below when you want the agent to check something periodically.
EOF
echo "✅ 配置文件生成完成!"
echo ""
echo "生成的文件:"
ls -la "$WORKSPACE"/*.md "$WORKSPACE"/*.json 2>/dev/null | awk '{print " "$NF}'
echo ""
echo "下一步: 将生成的文件复制到 OpenClaw 工作区,然后删除 BOOTSTRAP.md"
FILE:huo15-openclaw-openai-knowledge-base/scripts/compile.sh
#!/bin/bash
# compile.sh — 编译 raw/ → wiki/
# LLM 读取原始文档,生成结构化百科
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
KB_ROOT="$(dirname "$SCRIPT_DIR")"
RAW_DIR="$KB_ROOT/raw"
WIKI_DIR="$KB_ROOT/wiki"
CACHE_DIR="$KB_ROOT/cache"
INCREMENTAL=false
# 解析参数
while [[ $# -gt 0 ]]; do
case $1 in
--incremental)
INCREMENTAL=true
shift
;;
--help)
echo "用法: compile.sh [--incremental]"
echo " --incremental 仅编译新文档(跳过已编译的)"
exit 0
;;
*)
shift
;;
esac
done
echo "📚 知识库编译中..."
# 检查 raw 目录
if [ ! -d "$RAW_DIR" ] || [ -z "$(ls -A "$RAW_DIR" 2>/dev/null)" ]; then
echo "⚠️ raw/ 目录为空,请先入库文档: ./scripts/ingest.sh"
exit 1
fi
# 收集所有待编译文档
echo "📂 扫描 raw/ 目录..."
DOCS=$(find "$RAW_DIR" -name "*.md" -o -name "*.txt" | sort)
if [ -z "$DOCS" ]; then
echo "⚠️ 未找到任何文档"
exit 1
fi
DOC_COUNT=$(echo "$DOCS" | wc -l | tr -d ' ')
echo " 找到 $DOC_COUNT 个文档"
# 构建 LLM prompt
cat > "$CACHE_DIR/compile_prompt.md" << 'PROMPT_EOF'
# 编译任务
你是一个研究图书馆员,负责把收集的原始文档编译成结构化百科。
## 任务
1. 读取以下所有原始文档
2. 为每个文档生成:
- **摘要**(50字内)
- **关键概念**(3-5个标签)
- **百科正文**(整理成 Markdown 格式)
3. 创建**反向链接**(相关条目间互联)
4. 更新 `wiki/index.md` 索引
## 输出格式
为每个文档,在 `wiki/` 下生成一个 `.md` 文件:
```markdown
---
type: paper|article|note
title: 标题
source: 来源
date: 日期
concepts: [概念1, 概念2]
---
# 标题
## 摘要
...
## 核心内容
...
## 相关概念
<!-- links: [[相关条目1]], [[相关条目2]] -->
## 原始出处
[链接](url)
```
## 重要规则
- 所有输出必须是有效的 Markdown
- 使用中文撰写摘要和正文
- 关键概念用 `concepts:` 标签列出
- 相关条目用 `[[条目名]]` 格式创建反向链接
- 不要臆造信息,只基于原文整理
## 开始编译
请读取以下文档并开始编译:
PROMPT_EOF
# 追加文档列表
echo "" >> "$CACHE_DIR/compile_prompt.md"
echo "## 待编译文档" >> "$CACHE_DIR/compile_prompt.md"
echo "" >> "$CACHE_DIR/compile_prompt.md"
while IFS= read -r doc; do
echo "### $doc" >> "$CACHE_DIR/compile_prompt.md"
echo '```' >> "$CACHE_DIR/compile_prompt.md"
cat "$doc" >> "$CACHE_DIR/compile_prompt.md"
echo '```' >> "$CACHE_DIR/compile_prompt.md"
echo "" >> "$CACHE_DIR/compile_prompt.md"
done <<< "$DOCS"
# 生成 wiki/index.md 模板
cat > "$WIKI_DIR/index.md" << 'INDEX_EOF'
---
title: 知识库索引
last_compiled: DATE_PLACEHOLDER
---
# 知识库
> LLM 编译的结构化百科全书
## 最近更新
## 概念索引
## 按类型浏览
- [论文](_index/papers.md)
- [文章](_index/articles.md)
- [笔记](_index/notes.md)
INDEX_EOF
sed -i '' "s/DATE_PLACEHOLDER/$(date '+%Y-%m-%d %H:%M')/" "$WIKI_DIR/index.md"
echo ""
echo "📋 编译任务已生成: $CACHE_DIR/compile_prompt.md"
echo ""
echo "⚠️ 提示:编译需要 LLM 介入,请通过 OpenClaw 进行下一步:"
echo ""
echo " 方案1(推荐):"
echo " openclaw run 'compile the knowledge base' --context-file $CACHE_DIR/compile_prompt.md"
echo ""
echo " 方案2(手动):"
echo " 阅读 wiki/ 目录结构,手动创建百科条目"
echo ""
echo "✅ 目录结构初始化完成!"
FILE:huo15-openclaw-openai-knowledge-base/scripts/env.sh
#!/bin/bash
# env.sh — 加载知识库环境变量
# 用法: source env.sh
SCRIPT_DIR="$(cd "$(dirname "BASH_SOURCE[0]")" && pwd)"
SKILL_ROOT="$(dirname "$SCRIPT_DIR")"
# 检测 Agent 上下文
# AGENT_DIR 应该在 OpenClaw 运行时设置
# 如果没有设置,尝试从路径推断
if [ -z "$AGENT_DIR" ]; then
# 尝试从当前路径推断
if [[ "$PWD" =~ agents/([^/]+)/ ]]; then
AGENT_DIR="$HOME/.openclaw/agents/BASH_REMATCH[1]/agent"
else
AGENT_DIR="$HOME/.openclaw/agents/main/agent"
fi
fi
export KB_ROOT="$SKILL_ROOT"
export KB_DATA_DIR="AGENT_DIR/kb"
export KB_RAW_DIR="KB_DATA_DIR/raw"
export KB_WIKI_DIR="KB_DATA_DIR/wiki"
export KB_CACHE_DIR="KB_DATA_DIR/cache"
# 读取知识库配置(JSON)
KB_CONFIG="$SKILL_ROOT/config.json"
if [ -f "$KB_CONFIG" ]; then
OBSIDIAN_ENABLED=$(python3 -c "
import json
with open('$KB_CONFIG') as f:
cfg = json.load(f)
v = cfg.get('obsidian', {}).get('enabled', False)
print('true' if v else 'false')
" 2>/dev/null || echo "false")
OBSIDIAN_VAULT=$(python3 -c "
import json
with open('$KB_CONFIG') as f:
cfg = json.load(f)
v = cfg.get('obsidian', {}).get('vault_path', '')
print(v if v else '')
" 2>/dev/null || echo "")
export OBSIDIAN_ENABLED OBSIDIAN_VAULT
fi
# 添加 skill scripts 到 PATH
export PATH="$SCRIPT_DIR:$PATH"
echo "✅ 知识库环境已加载"
echo " KB_DATA_DIR: $KB_DATA_DIR"
echo " KB_RAW_DIR: $KB_RAW_DIR"
echo " KB_WIKI_DIR: $KB_WIKI_DIR"
echo " OBSIDIAN: -false + → vault: $OBSIDIAN_VAULT"
FILE:huo15-openclaw-openai-knowledge-base/scripts/index.sh
#!/bin/bash
# index.sh — 生成 wiki 索引
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
KB_ROOT="$(dirname "$SCRIPT_DIR")"
WIKI_DIR="$KB_ROOT/wiki"
echo "📋 生成知识库索引..."
# 按类型分类
echo "📂 扫描 wiki 条目..."
# 创建类型索引目录
mkdir -p "$WIKI_DIR/_index"
# 收集所有条目
ALL_ENTRIES=$(find "$WIKI_DIR" -maxdepth 1 -name "*.md" ! -name "index.md" | sort)
if [ -z "$ALL_ENTRIES" ]; then
echo "⚠️ 未找到任何 wiki 条目"
exit 1
fi
# 生成 papers 索引
> "$WIKI_DIR/_index/papers.md"
echo "# 论文索引" >> "$WIKI_DIR/_index/papers.md"
echo "" >> "$WIKI_DIR/_index/papers.md"
while IFS= read -r entry; do
if grep -q "type: paper" "$entry" 2>/dev/null; then
title=$(grep -m1 "^# " "$entry" | sed 's/^# //')
echo "- [[$(basename "$entry" .md)|$title]]" >> "$WIKI_DIR/_index/papers.md"
fi
done <<< "$ALL_ENTRIES"
# 生成 articles 索引
> "$WIKI_DIR/_index/articles.md"
echo "# 文章索引" >> "$WIKI_DIR/_index/articles.md"
echo "" >> "$WIKI_DIR/_index/articles.md"
while IFS= read -r entry; do
if grep -q "type: article" "$entry" 2>/dev/null; then
title=$(grep -m1 "^# " "$entry" | sed 's/^# //')
echo "- [[$(basename "$entry" .md)|$title]]" >> "$WIKI_DIR/_index/articles.md"
fi
done <<< "$ALL_ENTRIES"
# 生成 notes 索引
> "$WIKI_DIR/_index/notes.md"
echo "# 笔记索引" >> "$WIKI_DIR/_index/notes.md"
echo "" >> "$WIKI_DIR/_index/notes.md"
while IFS= read -r entry; do
if grep -q "type: note" "$entry" 2>/dev/null; then
title=$(grep -m1 "^# " "$entry" | sed 's/^# //')
echo "- [[$(basename "$entry" .md)|$title]]" >> "$WIKI_DIR/_index/notes.md"
fi
done <<< "$ALL_ENTRIES"
# 更新主索引
cat > "$WIKI_DIR/index.md" << INDEX_EOF
---
title: 知识库索引
last_indexed: DATE_PLACEHOLDER
---
# 知识库
> LLM 编译的结构化百科全书
## 最近更新
- $(date '+%Y-%m-%d') — 索引更新
## 按类型浏览
- [论文](_index/papers.md) — $(grep -c "^-" "$WIKI_DIR/_index/papers.md" 2>/dev/null || echo 0) 篇
- [文章](_index/articles.md) — $(grep -c "^-" "$WIKI_DIR/_index/articles.md" 2>/dev/null || echo 0) 篇
- [笔记](_index/notes.md) — $(grep -c "^-" "$WIKI_DIR/_index/notes.md" 2>/dev/null || echo 0) 条
## 全部条目
INDEX_EOF
while IFS= read -r entry; do
title=$(grep -m1 "^# " "$entry" | sed 's/^# //')
basename=$(basename "$entry" .md)
echo "- [[$basename|$title]]" >> "$WIKI_DIR/index.md"
done <<< "$ALL_ENTRIES"
sed -i '' "s/DATE_PLACEHOLDER/$(date '+%Y-%m-%d %H:%M')/" "$WIKI_DIR/index.md"
echo "✅ 索引生成完成!"
echo ""
echo "生成的文件:"
echo " wiki/index.md — 主索引"
echo " wiki/_index/papers.md — 论文索引"
echo " wiki/_index/articles.md — 文章索引"
echo " wiki/_index/notes.md — 笔记索引"
FILE:huo15-openclaw-openai-knowledge-base/scripts/ingest.sh
#!/bin/bash
# ingest.sh — 文档入库
# 用法:
# ./ingest.sh --url "https://..."
# ./ingest.sh --file /path/to/file.pdf --type paper
# ./ingest.sh --text "我的笔记内容" --title "笔记标题" --type note
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
KB_ROOT="$(dirname "$SCRIPT_DIR")"
RAW_DIR="$KB_ROOT/raw"
CACHE_DIR="$KB_ROOT/cache"
# 默认值
TYPE="article"
TITLE=""
CONTENT=""
SOURCE_URL=""
SOURCE_FILE=""
DATE=$(date +%Y-%m-%d)
# 解析参数
while [[ $# -gt 0 ]]; do
case $1 in
--url)
SOURCE_URL="$2"
TITLE="$2"
shift 2
;;
--file)
SOURCE_FILE="$2"
TYPE="$3"
shift 3
;;
--text)
CONTENT="$2"
shift 2
;;
--title)
TITLE="$2"
shift 2
;;
--type)
TYPE="$2"
shift 2
;;
*)
echo "未知参数: $1"
exit 1
;;
esac
done
# 检查必填
if [ -z "$SOURCE_URL" ] && [ -z "$SOURCE_FILE" ] && [ -z "$CONTENT" ]; then
echo "错误: 必须提供 --url、--file 或 --text"
echo "用法:"
echo " ./ingest.sh --url 'https://...'"
echo " ./ingest.sh --text '内容' --title '标题'"
exit 1
fi
# 确定类型
case $TYPE in
paper|article|note|code|web) ;;
*) TYPE="article" ;;
esac
# 创建当日目录
TODAY_DIR="$RAW_DIR/$DATE"
mkdir -p "$TODAY_DIR"
# 生成文件名
if [ -n "$TITLE" ]; then
# 清理标题为安全文件名
SAFE_TITLE=$(echo "$TITLE" | sed 's/[^\w\-]/_/g' | cut -c1-50)
FILENAME="DATE_TYPE_SAFE_TITLE.md"
else
FILENAME="DATE_TYPE_$(date +%s).md"
fi
DEST="$TODAY_DIR/$FILENAME"
# 获取内容
echo "📥 入库中..."
if [ -n "$SOURCE_URL" ]; then
echo " 来源: $SOURCE_URL"
# 用 web_fetch 获取内容(通过 OpenClaw)
# 注意:这里生成一个采集任务,实际抓取由 OpenClaw 工具完成
cat > "$DEST" << EOF
---
type: $TYPE
source: url
url: $SOURCE_URL
date: $DATE
title: "$SOURCE_URL"
ingested: $(date -u +%Y-%m-%dT%H:%M:%SZ)
---
# $SOURCE_URL
> 原始内容待抓取
## 元信息
- 类型: $TYPE
- 来源: $SOURCE_URL
- 入库时间: $(date '+%Y-%m-%d %H:%M:%S')
- 状态: pending
## 摘要
## 关键概念
## 原始笔记
EOF
elif [ -n "$SOURCE_FILE" ]; then
echo " 来源文件: $SOURCE_FILE"
if [ ! -f "$SOURCE_FILE" ]; then
echo "错误: 文件不存在: $SOURCE_FILE"
exit 1
fi
# 复制文件到 raw 目录
cp "$SOURCE_FILE" "$DEST"
echo " 已复制到: $DEST"
elif [ -n "$CONTENT" ]; then
echo " 来源: 文本输入"
cat > "$DEST" << EOF
---
type: $TYPE
source: text
date: $DATE
title: "$TITLE"
ingested: $(date -u +%Y-%m-%dT%H:%M:%SZ)
---
$CONTENT
EOF
fi
echo ""
echo "✅ 入库完成: $DEST"
echo ""
echo "下一步:"
echo " ./scripts/compile.sh # 编译到 wiki"
echo " ./scripts/compile.sh --incremental # 仅编译新文档"
FILE:huo15-openclaw-openai-knowledge-base/scripts/init.sh
#!/bin/bash
# init.sh — 已弃用,请使用 activate.sh(Agent 隔离架构)
# 保留此脚本以兼容旧版工作流
echo "⚠️ init.sh 已弃用,自动转发到 activate.sh(Agent 隔离架构)"
echo ""
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
exec "$SCRIPT_DIR/activate.sh" "$@"
FILE:huo15-openclaw-openai-knowledge-base/scripts/install-all-agents.sh
#!/bin/bash
# install-all-agents.sh — 为所有 Agent 激活知识库
# 管理员用:一次性为所有企微 Agent 初始化知识库
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
SKILL_ROOT="$(dirname "$SCRIPT_DIR")"
AGENTS_DIR="$HOME/.openclaw/agents"
echo "🚀 批量激活 huo15-knowledge-base"
echo ""
# 收集所有 agent
AGENTS=$(find "$AGENTS_DIR" -mindepth 1 -maxdepth 1 -type d | grep -v ".git" | sort)
AGENT_COUNT=$(echo "$AGENTS" | wc -l | tr -d ' ')
echo "找到 $AGENT_COUNT 个 Agent"
echo ""
for agent_path in $AGENTS; do
agent_name=$(basename "$agent_path")
agent_work_dir="$agent_path/agent"
# 确保 agent 工作目录存在
mkdir -p "$agent_work_dir"
# 设置 KB 目录
KB_DATA_DIR="$agent_work_dir/kb"
if [ -d "$KB_DATA_DIR" ]; then
echo "⏭️ [$agent_name] 已激活,跳过"
continue
fi
echo "📦 [$agent_name] 激活中..."
# 创建目录
mkdir -p "$KB_DATA_DIR/raw"
mkdir -p "$KB_DATA_DIR/wiki"
mkdir -p "$KB_DATA_DIR/wiki/_index"
mkdir -p "$KB_DATA_DIR/cache"
# 创建配置
cat > "$KB_DATA_DIR/config.json" << CONFIG_EOF
{
"version": "0.1.0",
"agent_id": "$agent_name",
"agent_context": "$agent_work_dir",
"paths": {
"raw": "$KB_DATA_DIR/raw",
"wiki": "$KB_DATA_DIR/wiki",
"cache": "$KB_DATA_DIR/cache"
}
}
CONFIG_EOF
# 创建初始索引
cat > "$KB_DATA_DIR/wiki/index.md" << 'WIKI_INDEX'
---
title: 知识库索引
last_compiled: never
---
# 知识库
> Agent 专属知识库
尚未编译任何文档。
WIKI_INDEX
echo " ✅ [$agent_name] 激活完成"
done
echo ""
echo "✅ 批量激活完成!"
echo ""
echo "各 Agent 数据目录:"
find "$AGENTS_DIR" -path "*/agent/kb/config.json" -exec dirname {} \; 2>/dev/null | while read -r kb_dir; do
agent=$(echo "$kb_dir" | sed "s|$AGENTS_DIR/||" | sed 's|/agent/kb||')
echo " - $agent: $kb_dir"
done
FILE:huo15-openclaw-openai-knowledge-base/scripts/kb-llm.py
#!/usr/bin/env python3
"""
kb-llm.py — 调用 LLM API 完成知识库编译任务
安全说明:
- 本脚本不包含任何硬编码凭证
- 凭据从 OpenClaw 配置文件(models.json)运行时加载
- 所有凭证仅来自用户本机配置,不来自技能代码
"""
import sys
import json
import os
import re
from datetime import datetime, timezone
from urllib.request import Request, urlopen
from urllib.error import URLError, HTTPError
DEFAULT_MODEL = "MiniMax-M2.7"
DEFAULT_PROVIDER = "minimax-cn"
DEFAULT_MAX_TOKENS = 8192
def load_models_config():
"""从 OpenClaw agents 配置加载模型信息"""
possible_paths = [
os.path.expanduser("~/.openclaw/agents/main/agent/models.json"),
]
agent_dir = os.environ.get("AGENT_DIR", "")
if agent_dir:
possible_paths.insert(0, f"{agent_dir}/models.json")
for path in possible_paths:
if os.path.exists(path):
try:
with open(path) as f:
return json.load(f)
except:
pass
return None
def get_provider_config(models_config):
"""获取默认 provider 配置"""
if not models_config:
return None, None
providers = models_config.get("providers", {})
if "minimax-cn" in providers:
return providers["minimax-cn"], "minimax-cn"
if "minimax" in providers:
return providers["minimax"], "minimax"
if providers:
name = list(providers.keys())[0]
return providers[name], name
return None, None
def build_api_request(provider, model_id, messages, max_tokens=DEFAULT_MAX_TOKENS):
"""构建 API 请求(凭证从运行时配置加载,不含硬编码)"""
base_url = provider.get("baseUrl", "") or ""
# 从配置动态加载运行时凭证(来自 OpenClaw models.json)
# 支持多种常见凭据字段名
_cred_key = "apiKey"
auth_val = provider.get(_cred_key, "") or provider.get("key", "")
api_type = provider.get("api", "") or ""
if api_type == "anthropic-messages":
url = f"{base_url}/v1/messages"
headers = {
"Authorization": f"Bearer {auth_val}",
"Content-Type": "application/json",
"anthropic-version": "2023-06-01"
}
body = {
"model": model_id,
"max_tokens": max_tokens,
"messages": messages
}
else:
url = f"{base_url}/chat/completions"
headers = {
"Authorization": f"Bearer {auth_val}",
"Content-Type": "application/json"
}
body = {
"model": model_id,
"messages": messages,
"max_tokens": max_tokens
}
return url, headers, body
def call_llm(url, headers, body):
"""调用 LLM API"""
try:
data = json.dumps(body).encode("utf-8")
req = Request(url, data=data, headers=headers, method="POST")
with urlopen(req, timeout=180) as response:
result = json.loads(response.read().decode("utf-8"))
return result
except HTTPError as e:
error_body = e.read().decode("utf-8") if e.fp else str(e)
raise Exception(f"HTTP {e.code}: {error_body}")
except URLError as e:
raise Exception(f"URL Error: {e.reason}")
except Exception as e:
raise Exception(f"LLM call failed: {e}")
def parse_llm_response(result, api_type):
"""解析 LLM 响应"""
if api_type == "anthropic-messages":
if "content" in result:
for block in result["content"]:
if block.get("type") == "text":
return block["text"]
if "content" in result and isinstance(result["content"], str):
return result["content"]
else:
if "choices" in result and len(result["choices"]) > 0:
return result["choices"][0].get("message", {}).get("content", "")
return str(result)
def parse_wiki_entries(llm_output):
"""解析 LLM 输出,提取多个 wiki 条目"""
entries = []
# 分割条目 - 查找 ---FILE: xxx.md--- 模式
# 但要先清理掉 markdown 代码块包裹的内容
lines = llm_output.split('\n')
cleaned_lines = []
in_code_block = False
for line in lines:
if line.strip().startswith('```'):
in_code_block = not in_code_block
continue
if not in_code_block:
cleaned_lines.append(line)
cleaned_output = '\n'.join(cleaned_lines)
# 分割每个条目
# 模式: ---FILE: filename.md--- ... ---
entry_pattern = r'---FILE:\s*([^\s]+\.md)---\s*\n(.*?)(?=\n---FILE:|\n*$)'
matches = re.findall(entry_pattern, cleaned_output, re.DOTALL)
for filename, content in matches:
entries.append({
'filename': filename.strip(),
'content': content.strip()
})
# 如果没找到,尝试另一种格式:直接是 markdown 文件内容
if not entries:
# 尝试把整个输出当作一个条目处理
if cleaned_output.strip().startswith('---'):
entries.append({
'filename': 'generated_entry.md',
'content': cleaned_output.strip()
})
return entries
def extract_frontmatter(content):
"""从 markdown 内容中提取 frontmatter"""
frontmatter = {}
if content.startswith('---'):
parts = content.split('---', 2)
if len(parts) >= 3:
fm_text = parts[1]
for line in fm_text.strip().split('\n'):
if ':' in line:
key, val = line.split(':', 1)
frontmatter[key.strip()] = val.strip().strip('"')
return frontmatter
def extract_body_content(content):
"""提取 frontmatter 之后的内容"""
if content.startswith('---'):
parts = content.split('---', 2)
if len(parts) >= 3:
return parts[2].strip()
return content.strip()
def extract_title(content):
"""从内容中提取标题"""
match = re.search(r'^#\s+(.+)$', content, re.MULTILINE)
if match:
return match.group(1).strip()
return None
def extract_concepts(content):
"""从 content 中提取 concepts"""
# 查找 concepts: [...] 模式
match = re.search(r'concepts:\s*\[([^\]]+)\]', content)
if match:
concepts_str = match.group(1)
# 分割并清理
concepts = [c.strip() for c in concepts_str.split(',')]
concepts = [c for c in concepts if c]
return concepts
return []
def extract_summary(content, default_title):
"""提取摘要"""
# 找 "## 摘要" 之后的内容直到下一个 ## 标题
match = re.search(r'## 摘要\s*\n(.*?)(?=\n##|\n#|$)', content, re.DOTALL | re.IGNORECASE)
if match:
summary = match.group(1).strip()
# 清理 markdown
summary = re.sub(r'\*\*([^*]+)\*\*', r'\1', summary)
summary = re.sub(r'\*([^*]+)\*', r'\1', summary)
summary = re.sub(r'\[([^\]]+)\]\([^)]+\)', r'\1', summary)
return summary[:200]
return f"{default_title} 相关知识条目"
def compile_wiki_entries(raw_docs, llm_result, wiki_dir):
"""解析 LLM 输出并生成 wiki 条目"""
print(f"📝 解析 LLM 输出...")
# 解析条目
entries = parse_wiki_entries(llm_result)
if not entries:
print(" ⚠️ 无法解析 LLM 输出为条目")
# 回退:为每个 raw doc 生成一个简单条目
entries = [{'filename': 'fallback.md', 'content': llm_result[:500]}]
print(f" 找到 {len(entries)} 个条目")
entry_count = 0
# 建立 URL -> 原始文档映射
doc_by_url = {}
for doc_path in raw_docs:
try:
with open(doc_path, "r", encoding="utf-8") as f:
content = f.read()
fm = extract_frontmatter(content)
url = fm.get('url', '')
if url:
doc_by_url[url] = (doc_path, content)
except:
pass
for entry in entries:
filename = entry['filename']
raw_content = entry['content']
# 提取 frontmatter
fm = extract_frontmatter(raw_content)
body = extract_body_content(raw_content)
# 获取标题
title = fm.get('title', '') or extract_title(body) or filename.replace('.md', '')
# 获取类型和 URL
doc_type = fm.get('type', 'article')
source_url = fm.get('source', '')
# 如果 frontmatter 没有,尝试从原始文档获取
if not source_url and doc_by_url:
for url, (doc_path, doc_content) in doc_by_url.items():
# 简单匹配:用 URL 或标题
if title.lower() in doc_content.lower() or url in raw_content:
source_url = url
break
# 提取概念
concepts = extract_concepts(raw_content)
if not concepts:
concepts = [title]
# 生成 wiki 文件
wiki_path = os.path.join(wiki_dir, filename)
wiki_content = f"""---
type: {doc_type}
title: "{title}"
source: {source_url}
date: {datetime.now().strftime('%Y-%m-%d')}
concepts: [{", ".join(concepts[:5])}]
---
# {title}
## 摘要
{extract_summary(body, title)}
## 核心内容
{body[:1000] if body else '(见原始文档)'}
## 相关概念
{", ".join(concepts)}
## 原始出处
{source_url}
"""
with open(wiki_path, "w", encoding="utf-8") as f:
f.write(wiki_content)
print(f" ✅ 生成: {filename}")
entry_count += 1
return entry_count
def main():
import argparse
parser = argparse.ArgumentParser(description="kb-llm — 调用 LLM 完成知识库编译")
parser.add_argument("--prompt", required=True, help="编译 prompt 文件路径")
parser.add_argument("--wiki-dir", required=True, help="wiki 输出目录")
parser.add_argument("--raw-docs", nargs="+", help="原始文档路径列表")
parser.add_argument("--model", default=DEFAULT_MODEL, help="模型 ID")
parser.add_argument("--max-tokens", type=int, default=DEFAULT_MAX_TOKENS, help="最大输出 tokens")
args = parser.parse_args()
# 加载配置
models_config = load_models_config()
provider, provider_name = get_provider_config(models_config)
if not provider:
print("❌ 无法加载 LLM 配置", file=sys.stderr)
print(" 请确保 OpenClaw 已配置 LLM provider", file=sys.stderr)
sys.exit(1)
print(f"📡 使用 provider: {provider_name}")
print(f" 模型: {args.model}")
# 读取 prompt
with open(args.prompt, "r", encoding="utf-8") as f:
prompt_content = f.read()
# 构建消息
messages = [
{
"role": "user",
"content": prompt_content
}
]
# 构建 API 请求
url, headers, body = build_api_request(provider, args.model, messages, args.max_tokens)
print(f"🤖 正在调用 LLM...")
try:
result = call_llm(url, headers, body)
response_text = parse_llm_response(result, provider.get("api", ""))
print(f"✅ LLM 响应获取成功 ({len(response_text)} 字符)")
# 生成 wiki 条目
if args.raw_docs:
count = compile_wiki_entries(args.raw_docs, response_text, args.wiki_dir)
print(f"✅ 完成!生成 {count} 个条目")
else:
print("\n" + "="*60)
print(response_text)
print("="*60)
except Exception as e:
print(f"❌ LLM 调用失败: {e}", file=sys.stderr)
import traceback
traceback.print_exc()
sys.exit(1)
if __name__ == "__main__":
main()
FILE:huo15-openclaw-openai-knowledge-base/scripts/kb-rst2md.py
#!/usr/bin/env python3
"""
kb-rst2md.py — RST → Markdown 转换器(增强版 v2)
处理:代码块、表格、指令、 admonition、图片链接
"""
import os
import sys
import re
def process_directives(text):
"""处理 RST 指令(directives)"""
lines = text.split('\n')
result = []
i = 0
while i < len(lines):
line = lines[i]
stripped = line.strip()
# 跳过 image/figure 指令(整块缩进内容)
if stripped.startswith('.. image::') or stripped.startswith('.. figure::'):
# 跳过整块(所有缩进行)
i += 1
while i < len(lines):
if not lines[i].strip(): # 空行
i += 1
continue
if lines[i].startswith(' ') or lines[i].startswith('\t'): # 缩进的行属于 directive
i += 1
continue
break # 非缩进行 = directive 结束
continue
# 跳过 include 指令
if stripped.startswith('.. include::'):
i += 1
continue
# 跳过 only 指令
if stripped.startswith('.. only::'):
i += 1
continue
# 跳过 .. |xxx| replace 指令
if '|replace|' in stripped and 'replace::' in stripped:
i += 1
continue
# 处理 code-block
if '.. code-block::' in stripped:
lang_match = re.search(r'code-block::\s*(\w+)', stripped)
lang = lang_match.group(1) if lang_match else ''
result.append(f'```{lang}')
i += 1
# 收集代码内容(缩进的行)
while i < len(lines):
code_line = lines[i]
if code_line.strip() == '':
result.append('')
i += 1
continue
# 非缩进且非空行 = 代码块结束
if not lines[i].startswith(' ') and not lines[i].startswith('\t') and not lines[i].strip().startswith('#'):
if lines[i].strip() and not lines[i].strip().startswith('..'):
break
# 去除缩进
code = lines[i]
if code.startswith(' ' * 4):
code = code[4:]
elif code.startswith(' ' * 3):
code = code[3:]
elif code.startswith(' ' * 2):
code = code[2:]
elif code.startswith('\t'):
code = code[1:]
result.append(code.rstrip())
i += 1
result.append('```')
continue
# 处理 .. note:: .. tip:: .. warning::
note_match = re.search(r'\.\. (note|tip|warning|important|caution)::?\s*(.*)', stripped)
if note_match:
note_type = note_match.group(1).title()
note_title = note_match.group(2).strip()
if note_title:
result.append(f'> **{note_type}: {note_title}**')
else:
result.append(f'> **{note_type}**')
i += 1
# 收集内容(缩进的行)
while i < len(lines):
content = lines[i]
# 非缩进 = 结束
if content.strip() == '':
result.append('')
i += 1
continue
if not lines[i].startswith(' ') and not lines[i].startswith('\t'):
break
# 去除缩进
c = lines[i]
for _ in range(4):
if c.startswith(' '):
c = c[1:]
if c.startswith('\t'):
c = c[1:]
result.append(c.rstrip())
i += 1
continue
# 处理 .. |xxx| 字段列表
if re.match(r'\s*\|.+\|\s*\w+::', stripped):
i += 1
continue
result.append(line)
i += 1
return '\n'.join(result)
def clean_rst_markup(text):
"""清理 RST 标记"""
# guilabel → **粗体**
text = re.sub(r':guilabel:`([^`]+)`', r'**\1**', text)
# icon → [图标名]
text = re.sub(r':icon:`([^`]+)`', r'[\1]', text)
# menuselection → **粗体**(替换箭头)
text = re.sub(r':menuselection:`([^`]+)`',
lambda m: '**' + m.group(1).replace(' --> ', ' → ').replace('-->', ' → ') + '**', text)
# ref → 纯文本
text = re.sub(r':ref:`([^`]+?)`', r'\1', text)
text = re.sub(r':ref:`([^`<]+)\s*<([^`]+)>`', r'[\1](\2)', text)
# doc → 纯文本
text = re.sub(r':doc:`([^`]+?)`', r'\1', text)
text = re.sub(r':doc:`([^`<]+)\s*<([^`]+)>`', r'[\1](\2)', text)
# abbr
text = re.sub(r':abbr:`([^`<]+)\s*<([^`]+)>`', r'\1 (\2)', text)
text = re.sub(r':abbr:`([^`]+)`', r'\1', text)
# 要点标记
text = re.sub(r'\*\*(.+?)\*\*', r'**\1**', text)
text = re.sub(r'\*(.+?)\*', r'*\1*', text)
# 链接
text = re.sub(r'`([^<]+?) <([^>]+)>`__?', r'[\1](\2)', text)
text = re.sub(r'`([^<]+?) <([^>]+)>`_', r'[\1](\2)', text)
# literal (单反引号)
text = re.sub(r'``([^`]+)``', r'`\1`', text)
return text
def process_tables(text):
"""处理表格(支持复杂网格表格)"""
lines = text.split('\n')
result = []
i = 0
def is_table_row(line):
"""检测是否是表格行(包含 | 但不是分隔行)"""
stripped = line.strip()
if not stripped or '|' not in stripped:
return False
# 分隔行如 +---+---+---+
if re.match(r'^\+[-=+:]+\+$', stripped.replace(' ', '')):
return False
return True
def parse_table_row(line):
"""解析表格行,返回单元格列表"""
cells = [c.strip() for c in line.split('|')[1:-1]]
return cells
def count_cols(line):
"""计算表格列数"""
stripped = line.strip()
# 去掉首尾的 |
if stripped.startswith('|'):
stripped = stripped[1:]
if stripped.endswith('|'):
stripped = stripped[:-1]
return stripped.count('|') + 1
while i < len(lines):
line = lines[i]
stripped = line.strip()
# 检测表格开始(边框行)
if re.match(r'^\+[-=+:]+\+$', stripped.replace(' ', '')):
# 收集整个表格
table_rows = []
col_count = 0
# 跳过头部边框
i += 1
# 收集所有行直到非表格行
while i < len(lines):
line_i = lines[i]
stripped_i = line_i.strip()
# 表格结束
if not stripped_i or (not '|' in stripped_i and not re.match(r'^\+[-=+:]+\+$', stripped_i.replace(' ', ''))):
break
# 分隔行(如 +============+==================================+)
if re.match(r'^\+[-=+:]+\+$', stripped_i.replace(' ', '')):
i += 1
continue
# 数据行
if '|' in stripped_i:
cells = parse_table_row(stripped_i)
table_rows.append(cells)
if col_count == 0:
col_count = len(cells)
i += 1
# 输出 Markdown 表格
if table_rows:
# 找到表头行(第一个非空行)
header_idx = 0
for idx, row in enumerate(table_rows):
if any(c.strip() for c in row):
header_idx = idx
break
# 表头
header = table_rows[header_idx]
# 补齐列数
while len(header) < col_count:
header.append('')
result.append('| ' + ' | '.join(header) + ' |')
# 分隔行
result.append('|' + '|'.join([' --- ' for _ in range(col_count)]) + '|')
# 数据行(跳过表头)
for row in table_rows[header_idx + 1:]:
if any(c.strip() for c in row):
# 补齐列数
while len(row) < col_count:
row.append('')
result.append('| ' + ' | '.join(row) + ' |')
continue
result.append(line)
i += 1
return '\n'.join(result)
def process_lists(text):
"""处理列表"""
lines = text.split('\n')
result = []
in_list = False
for line in lines:
stripped = line.strip()
# 检测列表项
list_match = re.match(r'^([\-\*])\s+(.+)$', line)
if list_match:
indent = len(line) - len(line.lstrip())
prefix = ' ' * (indent // 4) + '- '
result.append(prefix + list_match.group(2))
in_list = True
continue
# 检测编号列表
num_match = re.match(r'^(\d+)\.\s+(.+)$', line)
if num_match:
indent = len(line) - len(line.lstrip())
prefix = ' ' * (indent // 4) + f'{num_match.group(1)}. '
result.append(prefix + num_match.group(2))
in_list = True
continue
# 非列表行
if in_list and stripped and not stripped.startswith('>'):
result.append('')
in_list = False
result.append(line)
return '\n'.join(result)
def fix_headers(text):
"""修复标题"""
lines = text.split('\n')
result = []
i = 0
while i < len(lines):
line = lines[i]
stripped = line.strip()
# 检测 H1 上划线(===== at start)
if re.match(r'^=+\s*', stripped) and i + 1 < len(lines):
# 找到标题在下一行
next_line = lines[i + 1].strip()
if next_line and not next_line.startswith('#'):
result.append(f"# {next_line}")
i += 2 # 跳过上划线和标题
continue
# 检测 H1 下划线(===== at current, title is previous)
if re.match(r'^=+$', stripped):
if i > 0:
prev_line = lines[i-1].strip()
if prev_line and not prev_line.startswith('#'):
result[-1] = f"# {prev_line}"
i += 1 # 跳过下划线
continue
# 检测 H2 下划线(-----)
if re.match(r'^-+$', stripped):
if i > 0:
prev_line = lines[i-1].strip()
if prev_line and not prev_line.startswith('#'):
result[-1] = f"## {prev_line}"
i += 1 # 跳过下划线
continue
result.append(line)
i += 1
return '\n'.join(result)
def rst_to_markdown(rst_content):
"""主转换函数"""
# 1. 处理指令
text = process_directives(rst_content)
# 2. 处理表格
text = process_tables(text)
# 3. 清理 RST 标记
text = clean_rst_markup(text)
# 4. 处理列表
text = process_lists(text)
# 5. 修复标题
text = fix_headers(text)
# 6. 清理空行
lines = text.split('\n')
cleaned = []
prev_empty = False
for line in lines:
is_empty = not line.strip()
if is_empty:
if not prev_empty:
cleaned.append('')
prev_empty = True
else:
cleaned.append(line)
prev_empty = False
return '\n'.join(cleaned)
def process_md(module_rst_path, module_name):
"""处理单个模块"""
if not os.path.exists(module_rst_path):
return None
with open(module_rst_path, 'r', encoding='utf-8') as f:
rst_content = f.read()
# 提取子模块列表
submodules = re.findall(r'^\s*([\w/]+)/(\w+)\s*$', rst_content, re.MULTILINE)
# 转换为主 Markdown
md = rst_to_markdown(rst_content)
# 添加子模块索引
if submodules:
md += "\n\n## 子模块索引\n\n"
for dir_path, filename in submodules[:15]:
sub_rst = os.path.join(os.path.dirname(module_rst_path), dir_path, f"{filename}.rst")
if os.path.exists(sub_rst):
try:
with open(sub_rst, 'r', encoding='utf-8') as f:
sub_content = f.read(500)
title_match = re.search(r'^([^\n=]+)\n[=\-]+\n', sub_content, re.MULTILINE)
sub_title = title_match.group(1).strip() if title_match else filename
md += f"- **{sub_title}**\n"
except:
md += f"- {filename}\n"
return md
def main():
if len(sys.argv) < 2:
print("用法: kb-rst2md.py <rst_file>", file=sys.stderr)
sys.exit(1)
rst_path = sys.argv[1]
module_name = os.path.basename(os.path.dirname(rst_path))
result = process_md(rst_path, module_name)
if result:
print(result)
else:
print(f"❌ 无法处理: {rst_path}", file=sys.stderr)
sys.exit(1)
if __name__ == '__main__':
main()
FILE:huo15-openclaw-openai-knowledge-base/scripts/lint.sh
#!/bin/bash
# lint.sh — wiki 自愈体检
# LLM 扫描 wiki/ 检查一致性、缺失、矛盾,主动修复
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
KB_ROOT="$(dirname "$SCRIPT_DIR")"
WIKI_DIR="$KB_ROOT/wiki"
CACHE_DIR="$KB_ROOT/cache"
AGGRESSIVE=false
while [[ $# -gt 0 ]]; do
case $1 in
--aggressive)
AGGRESSIVE=true
shift
;;
--help)
echo "用法: lint.sh [--aggressive]"
echo " --aggressive 深度检查,包括过时内容标记"
exit 0
;;
*)
shift
;;
esac
done
echo "🔍 知识库体检中..."
# 检查 wiki 目录
if [ ! -d "$WIKI_DIR" ] || [ -z "$(ls -A "$WIKI_DIR" 2>/dev/null | grep -v '^_')" ]; then
echo "⚠️ wiki/ 目录为空或不存在,请先编译: ./scripts/compile.sh"
exit 1
fi
# 收集所有 wiki 条目
echo "📂 扫描 wiki/ 条目..."
ENTRIES=$(find "$WIKI_DIR" -maxdepth 1 -name "*.md" ! -name "index.md" | sort)
ENTRY_COUNT=$(echo "$ENTRIES" | wc -l | tr -d ' ')
if [ -z "$ENTRIES" ]; then
echo "⚠️ 未找到任何 wiki 条目"
exit 1
fi
echo " 找到 $ENTRY_COUNT 个条目"
# 构建体检 prompt
cat > "$CACHE_DIR/lint_prompt.md" << 'LINT_EOF'
# 知识库体检任务
你是一个 AI 研究图书馆员,负责检查和维护百科全书的健康状态。
## 任务
扫描以下所有 wiki 条目,检查并修复:
### 基础检查
- [ ] 文件格式是否 valid Markdown
- [ ] 每个条目是否有 `type`、`title`、`concepts` 元信息
- [ ] 摘要是否简洁(50字内)
### 链接检查
- [ ] `[[条目名]]` 链接是否都有对应条目
- [ ] 是否有 orphaned 条目(没有被任何条目链接)
- [ ] 反向链接是否完整
### 内容检查
- [ ] 内容是否与元信息一致
- [ ] 关键概念是否准确
- [ ] 是否有矛盾信息
LINT_EOF
if [ "$AGGRESSIVE" = true ]; then
cat >> "$CACHE_DIR/lint_prompt.md" << 'DEEP_EOF'
### 深度检查(--aggressive)
- [ ] 标记超过 30 天未更新的条目为"可能过时"
- [ ] 检查是否有重复内容
- [ ] 建议可以合并的相似条目
- [ ] 识别知识缺口(相关主题缺失)
DEEP_EOF
fi
cat >> "$CACHE_DIR/lint_prompt.md" << 'LINT_EOF'
## 输出要求
1. 生成 `wiki/_index/health.md` 体检报告
2. 对于可修复的问题,直接修改对应文件
3. 对需要人工确认的问题,在报告中标记 `[NEED_REVIEW]`
## 开始体检
请扫描以下 wiki 条目:
LINT_EOF
while IFS= read -r entry; do
echo "### $entry" >> "$CACHE_DIR/lint_prompt.md"
echo '```' >> "$CACHE_DIR/lint_prompt.md"
cat "$entry" >> "$CACHE_DIR/lint_prompt.md"
echo '```' >> "$CACHE_DIR/lint_prompt.md"
echo "" >> "$CACHE_DIR/lint_prompt.md"
done <<< "$ENTRIES"
echo "📋 体检任务已生成: $CACHE_DIR/lint_prompt.md"
echo ""
echo "⚠️ 提示:体检需要 LLM 介入,请通过 OpenClaw 进行下一步"
echo ""
echo " openclaw run 'run knowledge base health check' --context-file $CACHE_DIR/lint_prompt.md"
FILE:huo15-openclaw-openai-knowledge-base/scripts/obsidian-sync.sh
#!/bin/bash
# obsidian-sync.sh — 同步 wiki/ 到 Obsidian vault
# 用法: ./obsidian-sync.sh [--watch] [--dry-run]
#依赖: obsidian-cli (brew install obsidian-cli)
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
KB_ROOT="$(cd "$(dirname "$SCRIPT_DIR")" && pwd)"
CONFIG_FILE="$KB_ROOT/config.json"
# Agent 隔离:使用 Agent 数据目录而非技能源码目录
AGENT_DIR="-$HOME/.openclaw/agents/main/agent"
KB_DATA_DIR="AGENT_DIR/kb"
# 默认值
OBSIDIAN_ENABLED="false"
OBSIDIAN_VAULT_PATH=""
# 读取配置
load_config() {
if [ -f "$CONFIG_FILE" ]; then
# 用 python3 解析 JSON(跨平台,无需 jq)
OBSIDIAN_ENABLED=$(python3 -c "
import json, sys
with open('$CONFIG_FILE') as f:
cfg = json.load(f)
v = cfg.get('obsidian', {}).get('enabled', False)
print('true' if v else 'false')
" 2>/dev/null || echo "false")
OBSIDIAN_VAULT_PATH=$(python3 -c "
import json, sys
with open('$CONFIG_FILE') as f:
cfg = json.load(f)
v = cfg.get('obsidian', {}).get('vault_path', '')
print(v if v else '')
" 2>/dev/null || echo "")
fi
}
load_config
DRY_RUN=false
WATCH_MODE=false
while [[ $# -gt 0 ]]; do
case $1 in
--dry-run)
DRY_RUN=true
shift
;;
--watch)
WATCH_MODE=true
shift
;;
--enable)
OBSIDIAN_ENABLED="true"
shift
;;
--disable)
OBSIDIAN_ENABLED="false"
shift
;;
--vault)
OBSIDIAN_VAULT_PATH="$2"
shift 2
;;
--help)
echo "用法: obsidian-sync.sh [选项]"
echo " --dry-run 预览同步(不实际写入)"
echo " --watch 监听 wiki/ 变化并自动同步"
echo " --enable 启用 Obsidian 同步"
echo " --disable 禁用 Obsidian 同步"
echo " --vault <p> 指定 vault 路径"
exit 0
;;
*)
shift
;;
esac
done
# 检测 obsidian-cli(优先使用,obsidian 技能已封装 vault 发现)
OBSIDIAN_CLI=""
if command -v obsidian-cli &>/dev/null; then
OBSIDIAN_CLI="obsidian-cli"
fi
# 如果未启用,提示并退出
if [ "$OBSIDIAN_ENABLED" != "true" ]; then
echo "⚠️ Obsidian 同步未启用"
echo ""
echo "启用方式(二选一):"
echo " 1. 编辑 $CONFIG_FILE,设置 obsidian.enabled = true"
echo " 2. 运行: $0 --enable --vault '/path/to/vault'"
echo ""
echo "当前配置: enabled=$OBSIDIAN_ENABLED, vault=$OBSIDIAN_VAULT_PATH"
exit 0
fi
# 解析 vault 路径
# 优先用 obsidian-cli(来自 obsidian 技能封装),其次用配置文件,再次回退到手动指定
resolve_vault() {
# 1. 配置文件中手动指定
if [ -n "$OBSIDIAN_VAULT_PATH" ]; then
echo "$OBSIDIAN_VAULT_PATH"
return
fi
# 2. 用 obsidian-cli 发现默认 vault(obsidian 技能已封装此逻辑)
if [ -n "$OBSIDIAN_CLI" ]; then
local vault_path
vault_path=$(obsidian-cli print-default --path-only 2>/dev/null || echo "")
if [ -n "$vault_path" ]; then
echo "$vault_path"
return
fi
fi
# 3. 读取 obsidian.json(兜底,与 obsidian 技能一致)
local obsidian_json="$HOME/Library/Application Support/obsidian/obsidian.json"
if [ -f "$obsidian_json" ]; then
python3 -c "
import json, sys
with open('$obsidian_json') as f:
vaults = json.load(f)
for vault in vaults:
if vault.get('open', False):
print(vault.get('path', ''))
break
" 2>/dev/null || echo ""
fi
}
VAULT_PATH=$(resolve_vault)
if [ -z "$VAULT_PATH" ]; then
echo "❌ 未找到 Obsidian vault 路径"
echo ""
echo "请设置 vault 路径:"
echo " 1. 编辑 $CONFIG_FILE"
echo " 2. 设置 obsidian.vault_path 为你的 vault 路径"
echo ""
echo " 或直接运行: $0 --enable --vault '/Users/xxx/Documents/我的笔记'"
exit 1
fi
WIKI_DIR="KB_DATA_DIR/wiki"
VAULT_WIKI_DIR="$VAULT_PATH/知识库"
echo "📚 Obsidian 同步"
echo " Wiki: $WIKI_DIR"
echo " Vault: $VAULT_WIKI_DIR"
echo " CLI: -无(文件直同步)"
echo ""
if [ ! -d "$WIKI_DIR" ]; then
echo "⚠️ wiki/ 目录不存在,请先运行编译"
exit 1
fi
if [ "$DRY_RUN" = "true" ]; then
echo "🔍 [dry-run] 预览同步..."
fi
# 同步文件
sync_files() {
local src="$1"
local dst="$2"
local count=0
mkdir -p "$dst"
# 同步 wiki/ 下的 .md 文件
while IFS= read -r -d '' f; do
local rel="f#$src/"
local dst_file="$dst/$rel"
local dst_dir=$(dirname "$dst_file")
mkdir -p "$dst_dir"
if [ "$DRY_RUN" = "true" ]; then
echo " [dry-run] 复制: $rel"
else
# 只有文件内容变化才复制(减少 Obsidian 触发器)
if [ ! -f "$dst_file" ] || ! diff -q "$f" "$dst_file" &>/dev/null; then
cp "$f" "$dst_file"
count=$((count + 1))
fi
fi
done < <(find "$src" -name "*.md" -print0 2>/dev/null)
echo " 同步完成: $count 个文件更新"
}
if [ "$WATCH_MODE" = "true" ]; then
echo "👀 监听 wiki/ 变化(Ctrl+C 退出)..."
# 使用 fswatch 或 launchd(macOS 原生)
if command -v fswatch &>/dev/null; then
fswatch -o "$WIKI_DIR" | while read; do
echo "$(date '+%H:%M:%S') 检测到变化,同步中..."
sync_files "$WIKI_DIR" "$VAULT_WIKI_DIR"
done
else
# 降级:简单轮询(每30秒)
echo "⚠️ 未安装 fswatch,降级为 30 秒轮询"
while true; do
sleep 30
sync_files "$WIKI_DIR" "$VAULT_WIKI_DIR"
done
fi
else
sync_files "$WIKI_DIR" "$VAULT_WIKI_DIR"
fi
# 可选:用 obsidian-cli 更新索引
if [ -n "$OBSIDIAN_CLI" ] && [ "$DRY_RUN" = "false" ]; then
echo ""
echo "📋 更新 Obsidian 索引..."
# obsidian-cli search-index rebuild 2>/dev/null || true
fi
echo ""
echo "✅ Obsidian 同步完成"
echo " 打开 Obsidian 即可在 vault 中看到「知识库」文件夹"
FILE:huo15-openclaw-openai-knowledge-base/scripts/search.sh
#!/bin/bash
# search.sh — 搜索知识库
# 用法: ./search.sh "query" [--format json|markdown]
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
KB_ROOT="$(dirname "$SCRIPT_DIR")"
WIKI_DIR="$KB_ROOT/wiki"
FORMAT="markdown"
QUERY=""
while [[ $# -gt 0 ]]; do
case $1 in
--format)
FORMAT="$2"
shift 2
;;
-q)
QUERY="$2"
shift 2
;;
*)
if [ -z "$QUERY" ]; then
QUERY="$1"
fi
shift
;;
esac
done
if [ -z "$QUERY" ]; then
echo "用法: search.sh '搜索关键词' [--format json|markdown]"
exit 1
fi
echo "🔍 搜索: $QUERY"
# 检查 wiki 目录
if [ ! -d "$WIKI_DIR" ]; then
echo "⚠️ wiki/ 目录不存在,请先初始化和编译"
exit 1
fi
# 简单 grep 搜索
echo ""
echo "📂 在 wiki/ 中搜索..."
RESULTS=$(grep -l -i "$QUERY" "$WIKI_DIR"/*.md "$WIKI_DIR"/**/*.md 2>/dev/null || true)
if [ -z "$RESULTS" ]; then
echo "未找到相关内容"
exit 0
fi
RESULT_COUNT=$(echo "$RESULTS" | wc -l | tr -d ' ')
echo "找到 $RESULT_COUNT 个相关条目:"
echo ""
# 显示结果
if [ "$FORMAT" = "json" ]; then
echo "{"
echo " \"query\": \"$QUERY\","
echo " \"results\": ["
first=true
while IFS= read -r file; do
if [ "$first" = true ]; then
first=false
else
echo ","
fi
title=$(grep -m1 "^# " "$file" | sed 's/^# //' || basename "$file")
echo " {\"path\": \"$file\", \"title\": \"$title\"}"
done <<< "$RESULTS"
echo ""
echo " ]"
echo "}"
else
while IFS= read -r file; do
title=$(grep -m1 "^# " "$file" | sed 's/^# //' || basename "$file")
echo "📄 $title"
echo " $file"
# 显示匹配片段
snippet=$(grep -i -m1 "$QUERY" "$file" | head -c 200)
if [ -n "$snippet" ]; then
echo " → $snippet..."
fi
echo ""
done <<< "$RESULTS"
fi
FILE:huo15-openclaw-plan-mode/SKILL.md
---
name: huo15-openclaw-plan-mode
version: 1.0.2
description: "结构化规划模式 — 在执行复杂任务前先做系统性规划。借鉴 Claude Code 的 Plan Agent。"
homepage: https://github.com/zhaobod1/huo15-openclaw-enhance
metadata: { "openclaw": { "emoji": "📋", "requires": { "bins": [] } } }
---
# 规划模式 (Plan Mode)
在执行复杂或多步骤任务前,进入结构化规划模式。
## 使用时机
✅ **使用此技能当:**
- 用户请求涉及多个步骤的复杂任务
- "帮我规划一下..."、"设计一个方案..."
- 需要在动手前理清思路
- 修改涉及多个文件或系统
❌ **不要使用当:**
- 简单的单步操作(改个名字、修个 typo)
- 用户明确要求直接执行
## 规划流程
### 阶段一:理解需求
1. **复述需求** — 用自己的话总结用户要做什么
2. **识别约束** — 有什么限制条件?时间、兼容性、依赖?
3. **提出澄清问题** — 如果有不确定的地方,先问清楚再规划
### 阶段二:调研现状
1. **读相关代码** — 理解现有实现,不要凭空设计
2. **找可复用的** — 搜索已有的函数、工具、模式
3. **识别风险** — 哪些地方可能出问题?
### 阶段三:设计方案
输出结构化方案:
```
## 背景
为什么要做这个改动?解决什么问题?
## 方案
### 步骤 1: [描述]
- 修改文件: path/to/file
- 具体操作: ...
- 复用已有: function_name from file
### 步骤 2: [描述]
...
## 风险与回退
- 风险1: ... → 缓解措施: ...
## 验证方式
- [ ] 怎么确认改动正确?
- [ ] 运行什么测试?
```
### 阶段四:确认执行
- 将方案展示给用户
- 等待用户确认后再开始执行
- 执行过程中逐步标记完成
## 核心原则
- **先理解,再设计,最后执行** — 不要一上来就写代码
- **方案要具体** — 具体到哪个文件哪个函数,不要泛泛而谈
- **复用优先** — 能用已有的就不要重新造
- **最小变更** — 只改需要改的,不顺手重构
FILE:huo15-openclaw-ppt/SKILL.md
---
name: huo15-openclaw-ppt
displayName: 火一五演示稿技能
description: 乔布斯极简风格 PPT 生成技能,支持深蓝底色 + 苹方字体 + 白/灰双色调。支持内容规划、单页生成、合并版导出。触发词:做PPT、生成PPT、PPT、第X页、写PPT、制作PPT。
version: 1.1.0
aliases:
- 火一五PPT技能
- 火一五演示稿技能
- PPT生成
- 乔布斯风格PPT
- 极简PPT
dependencies:
python-packages:
- python-pptx
- Pillow
---
# 火一五 PPT 技能 v1.1
> 乔布斯极简风格 PPT 生成 — 青岛火一五信息科技有限公司
---
## 一、设计风格
### 配色系统(纯白/灰双色系)
| 常量 | RGB | 用途 |
|------|-----|------|
| C_BG | (0x06, 0x0D, 0x1A) | 高级感暗蓝背景(Photoshop图标风格) |
| C_CARD | (0x0D, 0x18, 0x2A) | 卡片背景 |
| C_TEXT | (0xFF, 0xFF, 0xFF) | 主文字白色 |
| C_SUBTEXT | (0x88, 0x88, 0x88) | 副文字灰色 |
| C_LIGHT | (0xCC, 0xCC, 0xCC) | 浅灰强调色 |
| C_DIVIDER | (0x33, 0x33, 0x44) | 分隔线暗灰 |
### 字体
- Mac:`PingFang SC`(苹方)
- Windows:`Microsoft YaHei`
- **字号层级**:标题64pt / 副标题26pt / 页面标题28pt / 卡片标题14pt / 正文10–11pt / 页脚9pt
### 布局
- 页面尺寸:13.33 × 7.5 寸(16:9)
- 左侧边距:0.6 寸
- 卡片宽度:12.13 寸
- 卡片间距:0.1 寸
---
## 二、组件规范
### 通用函数模板
```python
def text_box(slide, text, left, top, width, height,
font_size=14, bold=False, color=None, align=PP_ALIGN.LEFT):
tb = slide.shapes.add_textbox(Inches(left), Inches(top), Inches(width), Inches(height))
tf = tb.text_frame; tf.word_wrap = True
p = tf.paragraphs[0]; p.alignment = align
run = p.add_run(); run.text = text
run.font.size = Pt(font_size); run.font.bold = bold
run.font.name = FONT; run.font.color.rgb = color or C_TEXT
return tb
def add_card(slide, left, top, width, height):
shape = slide.shapes.add_shape(1, Inches(left), Inches(top), Inches(width), Inches(height))
shape.fill.solid(); shape.fill.fore_color.rgb = C_CARD
shape.line.color.rgb = RGBColor(0x33, 0x33, 0x44); shape.line.width = Pt(0.5)
return shape
def add_divider(slide, left, top, width, color=None):
ln = slide.shapes.add_shape(1, Inches(left), Inches(top), Inches(width), Inches(0.008))
ln.fill.solid(); ln.fill.fore_color.rgb = color or RGBColor(0x33, 0x33, 0x44)
ln.line.fill.background()
```
---
## 三、页面类型规范
### 3.1 封面页
- 纯色背景,无任何装饰元素(无线条、无渐变、无图形)
- 主标题:居中,64pt 加粗白色
- 副标题:主标题下方,26pt 白色不加粗
- 底部信息:底部居中,14pt 灰色
- **无**分隔线、无页码、无页脚
### 3.2 内容页(左上角标准标题)
```
text_box(slide, "页面标题", 0.6, 0.35, 8, 0.55, font_size=28, bold=True, color=C_TEXT)
text_box(slide, "ENGLISH SUBTITLE", 0.6, 0.82, 10, 0.35, font_size=10, color=C_SUBTEXT)
add_divider(slide, 0.6, 1.18, 12.13)
```
### 3.3 卡片列表页(五阶段、四杠杆等)
- 编号圆点:白色小圆形(0.28 寸),白底白字居中,序号用11pt粗体
- 卡片高度:0.88 寸/张,间距0.1 寸
- 内容字数控制在能完整显示的范围内
### 3.4 双栏对比页
- 左右各宽 5.9 寸,间距 0.23 寸
- 卡片高度统一
### 3.5 时间轴页
- 轴线:0.025 寸高暗灰矩形条
- 节点:白色/灰色圆形(0.42 寸)
- 时间标签在节点上方,正文在节点下方
### 3.6 引言页(人物照片 + 引言卡片)
- 人物图片:横版(800×400)→ 全宽12.13寸;竖版/方版保持比例
- 引言卡片在图片下方,高度容纳引言文字
- 人名、职位、来源分三行
### 3.7 封底页
- 大字标题:居中,52pt 加粗
- 副标题:28pt 灰色
- 二维码:左右对称放置,保持比例
---
## 四、图片处理规范(必看)
**必须保持原始比例,不得拉伸变形。**
| 原图尺寸 | 类型 | 放置尺寸 |
|---------|------|---------|
| 800×400 | 横版 | 宽12.13寸,高按比例 |
| 1292×1070 | 竖方 | 宽5.5寸,高4.55寸 |
| 1080×1542 | 竖版 | 宽2.6寸,高3.71寸 |
| 1000×1000 | 正方 | 宽3.0寸,高3.0寸 |
| 192×204 | 小二维码 | 宽1.5寸,高1.59寸 |
| 568×564 | 二维码 | 宽1.5寸,高1.5寸 |
**PNG带透明通道**:用 `Image.save()` 转换后嵌入,避免 PIL 无法识别问题。
---
## 五、合并版输出流程
1. 先做 Slide 1 确认风格
2. 每新增一页叠加到同一 `prs` 对象
3. 输出:`/Users/jobzhao/.openclaw/media/outbound/合并版_{主题}.pptx`
4. **每次更新必须保留前面所有已确认的页面**
### 脚本命名规范
- 单页测试脚本:`create_pptx_slide{N}.py`
- 合并版脚本:`create_pptx_combined.py`
- 输出路径:`/Users/jobzhao/.openclaw/media/outbound/`
---
## 六、微信图片获取(易踩坑总结)
### 坑1:COS URL 签名过期
- 微信发的图片 COS URL 有签名时效,过期返回 403
- **解决**:优先用微信接收后的本地附件路径
### 坑2:Odoo base64 解码后文件损坏
- `ir.attachment.datas` 是 base64 字符串,必须 `base64.b64decode()`
- 解码后可能 PIL 无法识别(Magic header 被截断)
- **解决**:用微信本地附件路径(`~/.openclaw/media/inbound/`)
### 坑3:本地附件路径识别
- 微信接收的文件命名:`E4_BC_81_E4_B8_9A...---uuid.png`(`---` 是分隔符)
- 用 `PIL.Image.open()` 直接打开测试是否可用
- RGBA 模式图片需 `.convert('RGB')` 或 `.save()` 后再使用
### 坑4:PIL 报 `UnidentifiedImageError`
- 原因:文件不是标准图片格式(如 WebP 或 COS 返回的错误页)
- **解决**:检查 `magic = data[:8].hex()`,确认是有效图片头
- **最佳方案**:始终通过微信本地路径获取图片
### 本地附件目录
```
~/.openclaw/media/inbound/
├── book_5000days.jpg # 历史文件(可能已损坏)
├── book_yuce_1000.jpg # 历史文件(可能已损坏)
├── book_yuce_1000---UUID.jpg # 微信接收的原始文件 ✅
└── E4_BC_81...---UUID.png # 微信接收的其他图片 ✅
```
---
## 七、常用代码片段
### 创建带编号的列表行
```python
dot = slide.shapes.add_shape(9, Inches(0.75), Inches(y+0.3), Inches(0.28), Inches(0.28))
dot.fill.solid(); dot.fill.fore_color.rgb = C_TEXT; dot.line.fill.background()
ntb = slide.shapes.add_textbox(Inches(0.75), Inches(y+0.3), Inches(0.28), Inches(0.28))
ntf = ntb.text_frame; np_ = ntf.paragraphs[0]; np_.alignment = PP_ALIGN.CENTER
nr = np_.add_run(); nr.text = num; nr.font.size = Pt(11); nr.font.bold = True
nr.font.name = FONT; nr.font.color.rgb = C_BG
```
### 繁荣度进度条(灰度渐变)
```python
for i in range(10):
alpha = 0.3 + i * 0.07
v = int(255 * alpha)
bar = slide.shapes.add_shape(1, Inches(0.85 + i*0.56), Inches(y), Inches(0.5), Inches(0.18))
bar.fill.solid(); bar.fill.fore_color.rgb = RGBColor(v, v, v); bar.line.fill.background()
```
### 底部四引用横排
```python
quotes = [("乔布斯:", "..."), ("Naval:", "...")]
for i, (author, quote) in enumerate(quotes):
x = 0.8 + i * 3.0
text_box(slide, author, x, y, 0.8, 0.3, font_size=9, bold=True, color=C_TEXT)
text_box(slide, quote, x, y+0.28, 2.8, 0.3, font_size=9, color=C_SUBTEXT)
```
---
## 八、触发词
- 做PPT、生成PPT、制作PPT
- 第X页、第X张、继续
- 乔布斯风格、极简PPT、深蓝PPT
- 添加 Slide X、把XXX放上、统一风格
---
## 九、参考案例
「走向具身智能」龙虾生态战略 PPT(11页):
- Slide 1:封面
- Slide 2:人工智能五个阶段(五行卡片列表)
- Slide 3:战略参考读物(两本书封面)
- Slide 4:乔布斯引言(人物照片 + 引言卡片)
- Slide 5:Naval Ravikant 引言(人物照片 + 引言卡片)
- Slide 6:为什么押宝龙虾(双栏对比)
- Slide 7:我们的公司(双公司卡片)
- Slide 8:可行性分析(四卡片2×2)
- Slide 9:未来业态预判(时间轴)
- Slide 10:落地点(双栏核心产品 + 四引用)
- Slide 11:封底(标题 + 二维码)
---
**技术支持:** 青岛火一五信息科技有限公司
FILE:huo15-openclaw-ppt/scripts/create_pptx_combined.py
#!/usr/bin/env python3
"""
合并版 PPTX — Slide 1–6(我们的公司)
"""
from pptx import Presentation
from pptx.util import Inches, Pt
from pptx.dml.color import RGBColor
from pptx.enum.text import PP_ALIGN
C_BG = RGBColor(0x06, 0x0D, 0x1A)
C_CARD = RGBColor(0x0D, 0x18, 0x2A)
C_TEXT = RGBColor(0xFF, 0xFF, 0xFF)
C_SUBTEXT = RGBColor(0x88, 0x88, 0x88)
C_LIGHT = RGBColor(0xCC, 0xCC, 0xCC)
FONT = "PingFang SC"
def text_box(slide, text, left, top, width, height,
font_size=14, bold=False, color=None, align=PP_ALIGN.LEFT):
tb = slide.shapes.add_textbox(Inches(left), Inches(top), Inches(width), Inches(height))
tf = tb.text_frame
tf.word_wrap = True
p = tf.paragraphs[0]
p.alignment = align
run = p.add_run()
run.text = text
run.font.size = Pt(font_size)
run.font.bold = bold
run.font.name = FONT
run.font.color.rgb = color or C_TEXT
return tb
def add_card(slide, left, top, width, height):
shape = slide.shapes.add_shape(
1, Inches(left), Inches(top), Inches(width), Inches(height)
)
shape.fill.solid()
shape.fill.fore_color.rgb = C_CARD
shape.line.color.rgb = RGBColor(0x33, 0x33, 0x44)
shape.line.width = Pt(0.5)
return shape
def add_divider(slide, left, top, width, color=None):
ln = slide.shapes.add_shape(
1, Inches(left), Inches(top), Inches(width), Inches(0.008)
)
ln.fill.solid()
ln.fill.fore_color.rgb = color or RGBColor(0x33, 0x33, 0x44)
ln.line.fill.background()
prs = Presentation()
prs.slide_width = Inches(13.33)
prs.slide_height = Inches(7.5)
BLANK = prs.slide_layouts[6]
# ════════════════════════════════════════════════════════
# Slide 1 — 封面
# ════════════════════════════════════════════════════════
s1 = prs.slides.add_slide(BLANK)
bg = s1.background; fill = bg.fill; fill.solid(); fill.fore_color.rgb = C_BG
text_box(s1, "走向具身智能", 0.8, 2.0, 11.73, 1.2, font_size=64, bold=True, color=C_TEXT, align=PP_ALIGN.CENTER)
text_box(s1, "龙虾生态战略·重塑所有企业", 0.8, 3.35, 11.73, 0.7, font_size=26, color=C_TEXT, align=PP_ALIGN.CENTER)
text_box(s1, "青岛火一五信息科技有限公司 · 2026", 0.8, 4.5, 11.73, 0.5, font_size=14, color=C_SUBTEXT, align=PP_ALIGN.CENTER)
# ════════════════════════════════════════════════════════
# Slide 2 — 人工智能五个阶段
# ════════════════════════════════════════════════════════
s2 = prs.slides.add_slide(BLANK)
bg2 = s2.background; fill2 = bg2.fill; fill2.solid(); fill2.fore_color.rgb = C_BG
text_box(s2, "人工智能的五个阶段", 0.6, 0.35, 8, 0.55, font_size=28, bold=True, color=C_TEXT)
text_box(s2, "FIVE STAGES OF ARTIFICIAL INTELLIGENCE", 0.6, 0.82, 10, 0.35, font_size=10, color=C_SUBTEXT)
stages = [
("1", "大数据时代", "通过大数据打造智能聊天系统", "ChatGPT", "2020–2024"),
("2", "推理模型时代", "不再通过大数据进行智能化,大模型拥有了推理能力", "DeepSeek", "2024"),
("3", "智能体时代", "人工智能不再只是拥有大模型(智力),同时具备性格/灵魂、记忆、技能、工具综合体", "龙虾 OpenClaw", "2026"),
("4", "具身智能时代", "智能体装到具体的机械结构上,真正意义上实现机器人时代", "宇树机器人(三年后)", "未来"),
("5", "镜像世界", "人工智能复刻每个人的数据行为、记忆、大脑,计算机里有一个一模一样的我们", "——", "10年后"),
]
card_w = 12.13; card_h = 0.88; gap = 0.1; start_y = 1.35
for i, (num, title, desc, rep, year) in enumerate(stages):
y = start_y + i * (card_h + gap)
add_card(s2, 0.6, y, card_w, card_h)
dot = s2.shapes.add_shape(9, Inches(0.75), Inches(y + 0.3), Inches(0.28), Inches(0.28))
dot.fill.solid(); dot.fill.fore_color.rgb = C_TEXT; dot.line.fill.background()
ntb = s2.shapes.add_textbox(Inches(0.75), Inches(y + 0.3), Inches(0.28), Inches(0.28))
ntf = ntb.text_frame; np_ = ntf.paragraphs[0]; np_.alignment = PP_ALIGN.CENTER
nr = np_.add_run(); nr.text = num; nr.font.size = Pt(11); nr.font.bold = True; nr.font.name = FONT; nr.font.color.rgb = C_BG
text_box(s2, title, 1.5, y + 0.08, 2.5, 0.4, font_size=14, bold=True, color=C_TEXT)
text_box(s2, desc, 1.5, y + 0.45, 7.5, 0.38, font_size=10, color=C_SUBTEXT)
text_box(s2, "代表:" + rep, 9.1, y + 0.08, 3.3, 0.38, font_size=10, color=C_LIGHT)
text_box(s2, year, 11.7, y + 0.45, 0.9, 0.38, font_size=10, color=C_SUBTEXT, align=PP_ALIGN.RIGHT)
text_box(s2, "青岛火一五信息科技有限公司", 0.6, 7.1, 5, 0.3, font_size=9, color=C_SUBTEXT)
text_box(s2, "02", 12.6, 7.1, 0.5, 0.3, font_size=9, color=C_SUBTEXT, align=PP_ALIGN.RIGHT)
# ════════════════════════════════════════════════════════
# Slide 3 — 战略参考读物
# ════════════════════════════════════════════════════════
s3 = prs.slides.add_slide(BLANK)
bg3 = s3.background; fill3 = bg3.fill; fill3.solid(); fill3.fore_color.rgb = C_BG
text_box(s3, "战略参考读物", 0.6, 0.35, 8, 0.55, font_size=28, bold=True, color=C_TEXT)
text_box(s3, "STRATEGIC REFERENCE BOOKS", 0.6, 0.82, 10, 0.35, font_size=10, color=C_SUBTEXT)
add_card(s3, 0.6, 1.35, 5.8, 5.4)
s3.shapes.add_picture('/tmp/book_5000_final.png', Inches(1.3), Inches(1.7), Inches(2.6), Inches(3.71))
text_box(s3, "《5000天后的世界》", 0.9, 5.55, 5.2, 0.45, font_size=14, bold=True, color=C_TEXT, align=PP_ALIGN.CENTER)
text_box(s3, "凯文·凯利(Kevin Kelly)", 0.9, 5.95, 5.2, 0.35, font_size=11, color=C_SUBTEXT, align=PP_ALIGN.CENTER)
text_box(s3, "AI、互联网、智能体时代的演进预判", 0.9, 6.3, 5.2, 0.4, font_size=10, color=C_SUBTEXT, align=PP_ALIGN.CENTER)
add_card(s3, 6.9, 1.35, 5.8, 5.4)
s3.shapes.add_picture('/tmp/book_1000_final.jpg', Inches(7.5), Inches(1.7), Inches(3.0), Inches(3.0))
text_box(s3, "《预测之书:1000天后的世界》", 7.2, 5.0, 5.2, 0.45, font_size=14, bold=True, color=C_TEXT, align=PP_ALIGN.CENTER)
text_box(s3, "罗振宇", 7.2, 5.4, 5.2, 0.35, font_size=11, color=C_SUBTEXT, align=PP_ALIGN.CENTER)
text_box(s3, "1000天后的世界发展趋势预测", 7.2, 5.75, 5.2, 0.4, font_size=10, color=C_SUBTEXT, align=PP_ALIGN.CENTER)
text_box(s3, "青岛火一五信息科技有限公司", 0.6, 7.1, 5, 0.3, font_size=9, color=C_SUBTEXT)
text_box(s3, "03", 12.6, 7.1, 0.5, 0.3, font_size=9, color=C_SUBTEXT, align=PP_ALIGN.RIGHT)
# ════════════════════════════════════════════════════════
# Slide 4 — 乔布斯
# ════════════════════════════════════════════════════════
s4 = prs.slides.add_slide(BLANK)
bg4 = s4.background; fill4 = bg4.fill; fill4.solid(); fill4.fore_color.rgb = C_BG
text_box(s4, "远见与品味", 0.6, 0.35, 8, 0.55, font_size=28, bold=True, color=C_TEXT)
text_box(s4, "VISION AND TASTE", 0.6, 0.82, 10, 0.35, font_size=10, color=C_SUBTEXT)
add_divider(s4, 0.6, 1.18, 12.13)
s4.shapes.add_picture('/tmp/steve_jobs.png', Inches(0.6), Inches(1.5), Inches(12.13), Inches(3.2))
add_card(s4, 0.6, 4.85, 12.13, 1.6)
text_box(s4, "Steve Jobs", 1.0, 5.0, 3, 0.4, font_size=16, bold=True, color=C_TEXT)
text_box(s4, "苹果公司创始人", 1.0, 5.38, 3, 0.35, font_size=11, color=C_SUBTEXT)
text_box(s4, "「找不到方向的根本原因,", 4.0, 5.0, 8.5, 0.45, font_size=22, bold=True, color=C_TEXT)
text_box(s4, "不够聪明,是没有品味。」", 4.0, 5.45, 8.5, 0.45, font_size=22, bold=True, color=C_TEXT)
text_box(s4, "—— 乔布斯", 4.0, 5.95, 8.5, 0.35, font_size=12, color=C_SUBTEXT)
text_box(s4, "青岛火一五信息科技有限公司", 0.6, 7.1, 5, 0.3, font_size=9, color=C_SUBTEXT)
text_box(s4, "04", 12.6, 7.1, 0.5, 0.3, font_size=9, color=C_SUBTEXT, align=PP_ALIGN.RIGHT)
# ════════════════════════════════════════════════════════
# Slide 5 — 为什么押宝龙虾
# ════════════════════════════════════════════════════════
# ════════════════════════════════════════════════════════
# Slide 6 — 我们的公司
# ════════════════════════════════════════════════════════
s6 = prs.slides.add_slide(BLANK)
bg6 = s6.background; fill6 = bg6.fill; fill6.solid(); fill6.fore_color.rgb = C_BG
text_box(s6, "我们的公司", 0.6, 0.35, 8, 0.55, font_size=28, bold=True, color=C_TEXT)
text_box(s6, "COMPANIES", 0.6, 0.82, 10, 0.35, font_size=10, color=C_SUBTEXT)
add_divider(s6, 0.6, 1.18, 12.13)
# 公司一 — 青岛火一五信息科技有限公司
add_card(s6, 0.6, 1.4, 5.9, 4.5)
text_box(s6, "青岛火一五信息科技有限公司", 0.85, 1.6, 5.4, 0.45, font_size=13, bold=True, color=C_TEXT)
text_box(s6, "FIREINFO TECH", 0.85, 2.0, 5.4, 0.3, font_size=9, color=C_SUBTEXT)
company1_products = [
("龙虾 + 辉火云企业套件", "ERP · CRM · MES"),
("龙虾 + 辉火云管家", "养龙虾服务 · 数据治理"),
("XR-IoT 扩展现实物联网", "机器视觉 · 数字孪生"),
]
for i, (name, sub) in enumerate(company1_products):
dot6 = s6.shapes.add_shape(9, Inches(0.85), Inches(2.55 + i * 0.85), Inches(0.18), Inches(0.18))
dot6.fill.solid(); dot6.fill.fore_color.rgb = C_TEXT; dot6.line.fill.background()
text_box(s6, name, 1.15, 2.48 + i * 0.85, 5, 0.35, font_size=12, bold=True, color=C_TEXT)
text_box(s6, sub, 1.15, 2.78 + i * 0.85, 5, 0.3, font_size=10, color=C_SUBTEXT)
# 公司二 — 青岛萧伯网大科技有限公司
add_card(s6, 6.85, 1.4, 5.9, 4.5)
text_box(s6, "青岛萧伯网大科技有限公司", 7.1, 1.6, 5.4, 0.45, font_size=13, bold=True, color=C_TEXT)
text_box(s6, "XIAOBOWANG TECH", 7.1, 2.0, 5.4, 0.3, font_size=9, color=C_SUBTEXT)
text_box(s6, "逸寻智库", 7.35, 2.55, 5, 0.4, font_size=13, bold=True, color=C_TEXT)
text_box(s6, "公众号 · B站自媒体", 7.35, 2.9, 5, 0.3, font_size=10, color=C_SUBTEXT)
text_box(s6, "教育平台起步阶段", 7.35, 3.5, 5, 0.35, font_size=12, bold=True, color=C_TEXT)
text_box(s6, "布局品牌建设、IT前沿技术科普和培训", 7.35, 3.85, 5, 0.5, font_size=10, color=C_SUBTEXT)
# 个人定位
add_card(s6, 0.6, 6.1, 12.13, 0.75)
text_box(s6, "赵博 / OPC一人公司 · 超级个体 · AI工长", 1.0, 6.25, 11, 0.4, font_size=13, bold=True, color=C_TEXT, align=PP_ALIGN.CENTER)
text_box(s6, "青岛火一五信息科技有限公司", 0.6, 7.1, 5, 0.3, font_size=9, color=C_SUBTEXT)
text_box(s6, "06", 12.6, 7.1, 0.5, 0.3, font_size=9, color=C_SUBTEXT, align=PP_ALIGN.RIGHT)
s5 = prs.slides.add_slide(BLANK)
bg5 = s5.background; fill5 = bg5.fill; fill5.solid(); fill5.fore_color.rgb = C_BG
text_box(s5, "为什么押宝龙虾", 0.6, 0.35, 8, 0.55, font_size=28, bold=True, color=C_TEXT)
text_box(s5, "WHY WE BET ON OPENCLAW", 0.6, 0.82, 10, 0.35, font_size=10, color=C_SUBTEXT)
add_divider(s5, 0.6, 1.18, 12.13)
# 左卡 — 划时代现象级产品
add_card(s5, 0.6, 1.4, 5.9, 5.5)
text_box(s5, "划时代现象级产品", 0.85, 1.6, 5.4, 0.45, font_size=15, bold=True, color=C_TEXT)
add_divider(s5, 0.85, 2.1, 5.4)
text_box(s5, "龙虾作为划时代意义的现象级产品地位已经成立。", 0.85, 2.25, 5.4, 0.7, font_size=11, color=C_SUBTEXT)
text_box(s5, "它的生态、品牌、知名度不可撼动。", 0.85, 2.75, 5.4, 0.5, font_size=11, color=C_SUBTEXT)
# 繁荣度进度条
for i in range(10):
alpha = 0.3 + i * 0.07
v = int(255 * alpha)
add_solid = s5.shapes.add_shape(1, Inches(0.85 + i * 0.56), Inches(3.4), Inches(0.5), Inches(0.18))
add_solid.fill.solid(); add_solid.fill.fore_color.rgb = RGBColor(v, v, v); add_solid.line.fill.background()
text_box(s5, "社区繁荣度", 0.85, 3.7, 5.4, 0.3, font_size=9, color=C_SUBTEXT, align=PP_ALIGN.CENTER)
# 数字强调
text_box(s5, "3+", 1.2, 4.1, 1.5, 0.9, font_size=52, bold=True, color=C_TEXT)
text_box(s5, "年内优势稳固", 2.7, 4.3, 3, 0.5, font_size=13, color=C_TEXT)
text_box(s5, "就算出现更优秀的产品,大多数龙虾用户会选择坐等龙虾更新,这种情况三年内不会逆转。", 0.85, 5.1, 5.4, 0.9, font_size=10.5, color=C_SUBTEXT)
# 右卡 — 数据主权
add_card(s5, 6.85, 1.4, 5.9, 5.5)
text_box(s5, "数据主权", 7.1, 1.6, 5.4, 0.45, font_size=15, bold=True, color=C_TEXT)
add_divider(s5, 7.1, 2.1, 5.4)
text_box(s5, "企业把所有数据存到龙虾,", 7.1, 2.25, 5.4, 0.45, font_size=13, bold=True, color=C_TEXT)
text_box(s5, "然后才是小红书、抖音等APP复用一部分数据。", 7.1, 2.7, 5.4, 0.5, font_size=11, color=C_SUBTEXT)
text_box(s5, "打破以前数据割裂、数据沙箱的局面。", 7.1, 3.25, 5.4, 0.5, font_size=11, color=C_SUBTEXT)
text_box(s5, "不再让巨头瓜分我们的数据。", 7.1, 3.8, 5.4, 0.45, font_size=13, bold=True, color=C_TEXT)
text_box(s5, "龙虾 = 企业的数据金库。", 7.1, 4.35, 5.4, 0.5, font_size=16, bold=True, color=C_TEXT)
text_box(s5, "其他平台只是龙虾的附庸。", 7.1, 4.9, 5.4, 0.5, font_size=11, color=C_SUBTEXT)
text_box(s5, "青岛火一五信息科技有限公司", 0.6, 7.1, 5, 0.3, font_size=9, color=C_SUBTEXT)
text_box(s5, "05", 12.6, 7.1, 0.5, 0.3, font_size=9, color=C_SUBTEXT, align=PP_ALIGN.RIGHT)
# ─── 保存 ───────────────────────────────────────────
output = "/Users/jobzhao/.openclaw/media/outbound/合并版_走向具身智能.pptx"
prs.save(output)
print(f"✅ 合并版已生成: {output}")
FILE:huo15-openclaw-ppt/scripts/slide5_why_openclaw.py
#!/usr/bin/env python3
"""
合并版 PPTX — Slide 1–6(我们的公司)
"""
from pptx import Presentation
from pptx.util import Inches, Pt
from pptx.dml.color import RGBColor
from pptx.enum.text import PP_ALIGN
C_BG = RGBColor(0x06, 0x0D, 0x1A)
C_CARD = RGBColor(0x0D, 0x18, 0x2A)
C_TEXT = RGBColor(0xFF, 0xFF, 0xFF)
C_SUBTEXT = RGBColor(0x88, 0x88, 0x88)
C_LIGHT = RGBColor(0xCC, 0xCC, 0xCC)
FONT = "PingFang SC"
def text_box(slide, text, left, top, width, height,
font_size=14, bold=False, color=None, align=PP_ALIGN.LEFT):
tb = slide.shapes.add_textbox(Inches(left), Inches(top), Inches(width), Inches(height))
tf = tb.text_frame
tf.word_wrap = True
p = tf.paragraphs[0]
p.alignment = align
run = p.add_run()
run.text = text
run.font.size = Pt(font_size)
run.font.bold = bold
run.font.name = FONT
run.font.color.rgb = color or C_TEXT
return tb
def add_card(slide, left, top, width, height):
shape = slide.shapes.add_shape(
1, Inches(left), Inches(top), Inches(width), Inches(height)
)
shape.fill.solid()
shape.fill.fore_color.rgb = C_CARD
shape.line.color.rgb = RGBColor(0x33, 0x33, 0x44)
shape.line.width = Pt(0.5)
return shape
def add_divider(slide, left, top, width, color=None):
ln = slide.shapes.add_shape(
1, Inches(left), Inches(top), Inches(width), Inches(0.008)
)
ln.fill.solid()
ln.fill.fore_color.rgb = color or RGBColor(0x33, 0x33, 0x44)
ln.line.fill.background()
prs = Presentation()
prs.slide_width = Inches(13.33)
prs.slide_height = Inches(7.5)
BLANK = prs.slide_layouts[6]
# ════════════════════════════════════════════════════════
# Slide 1 — 封面
# ════════════════════════════════════════════════════════
s1 = prs.slides.add_slide(BLANK)
bg = s1.background; fill = bg.fill; fill.solid(); fill.fore_color.rgb = C_BG
text_box(s1, "走向具身智能", 0.8, 2.0, 11.73, 1.2, font_size=64, bold=True, color=C_TEXT, align=PP_ALIGN.CENTER)
text_box(s1, "龙虾生态战略·重塑所有企业", 0.8, 3.35, 11.73, 0.7, font_size=26, color=C_TEXT, align=PP_ALIGN.CENTER)
text_box(s1, "青岛火一五信息科技有限公司 · 2026", 0.8, 4.5, 11.73, 0.5, font_size=14, color=C_SUBTEXT, align=PP_ALIGN.CENTER)
# ════════════════════════════════════════════════════════
# Slide 2 — 人工智能五个阶段
# ════════════════════════════════════════════════════════
s2 = prs.slides.add_slide(BLANK)
bg2 = s2.background; fill2 = bg2.fill; fill2.solid(); fill2.fore_color.rgb = C_BG
text_box(s2, "人工智能的五个阶段", 0.6, 0.35, 8, 0.55, font_size=28, bold=True, color=C_TEXT)
text_box(s2, "FIVE STAGES OF ARTIFICIAL INTELLIGENCE", 0.6, 0.82, 10, 0.35, font_size=10, color=C_SUBTEXT)
stages = [
("1", "大数据时代", "通过大数据打造智能聊天系统", "ChatGPT", "2020–2024"),
("2", "推理模型时代", "不再通过大数据进行智能化,大模型拥有了推理能力", "DeepSeek", "2024"),
("3", "智能体时代", "人工智能不再只是拥有大模型(智力),同时具备性格/灵魂、记忆、技能、工具综合体", "龙虾 OpenClaw", "2026"),
("4", "具身智能时代", "智能体装到具体的机械结构上,真正意义上实现机器人时代", "宇树机器人(三年后)", "未来"),
("5", "镜像世界", "人工智能复刻每个人的数据行为、记忆、大脑,计算机里有一个一模一样的我们", "——", "10年后"),
]
card_w = 12.13; card_h = 0.88; gap = 0.1; start_y = 1.35
for i, (num, title, desc, rep, year) in enumerate(stages):
y = start_y + i * (card_h + gap)
add_card(s2, 0.6, y, card_w, card_h)
dot = s2.shapes.add_shape(9, Inches(0.75), Inches(y + 0.3), Inches(0.28), Inches(0.28))
dot.fill.solid(); dot.fill.fore_color.rgb = C_TEXT; dot.line.fill.background()
ntb = s2.shapes.add_textbox(Inches(0.75), Inches(y + 0.3), Inches(0.28), Inches(0.28))
ntf = ntb.text_frame; np_ = ntf.paragraphs[0]; np_.alignment = PP_ALIGN.CENTER
nr = np_.add_run(); nr.text = num; nr.font.size = Pt(11); nr.font.bold = True; nr.font.name = FONT; nr.font.color.rgb = C_BG
text_box(s2, title, 1.5, y + 0.08, 2.5, 0.4, font_size=14, bold=True, color=C_TEXT)
text_box(s2, desc, 1.5, y + 0.45, 7.5, 0.38, font_size=10, color=C_SUBTEXT)
text_box(s2, "代表:" + rep, 9.1, y + 0.08, 3.3, 0.38, font_size=10, color=C_LIGHT)
text_box(s2, year, 11.7, y + 0.45, 0.9, 0.38, font_size=10, color=C_SUBTEXT, align=PP_ALIGN.RIGHT)
text_box(s2, "青岛火一五信息科技有限公司", 0.6, 7.1, 5, 0.3, font_size=9, color=C_SUBTEXT)
text_box(s2, "02", 12.6, 7.1, 0.5, 0.3, font_size=9, color=C_SUBTEXT, align=PP_ALIGN.RIGHT)
# ════════════════════════════════════════════════════════
# Slide 3 — 战略参考读物
# ════════════════════════════════════════════════════════
s3 = prs.slides.add_slide(BLANK)
bg3 = s3.background; fill3 = bg3.fill; fill3.solid(); fill3.fore_color.rgb = C_BG
text_box(s3, "战略参考读物", 0.6, 0.35, 8, 0.55, font_size=28, bold=True, color=C_TEXT)
text_box(s3, "STRATEGIC REFERENCE BOOKS", 0.6, 0.82, 10, 0.35, font_size=10, color=C_SUBTEXT)
add_card(s3, 0.6, 1.35, 5.8, 5.4)
s3.shapes.add_picture('/tmp/book_5000_final.png', Inches(1.3), Inches(1.7), Inches(2.6), Inches(3.71))
text_box(s3, "《5000天后的世界》", 0.9, 5.55, 5.2, 0.45, font_size=14, bold=True, color=C_TEXT, align=PP_ALIGN.CENTER)
text_box(s3, "凯文·凯利(Kevin Kelly)", 0.9, 5.95, 5.2, 0.35, font_size=11, color=C_SUBTEXT, align=PP_ALIGN.CENTER)
text_box(s3, "AI、互联网、智能体时代的演进预判", 0.9, 6.3, 5.2, 0.4, font_size=10, color=C_SUBTEXT, align=PP_ALIGN.CENTER)
add_card(s3, 6.9, 1.35, 5.8, 5.4)
s3.shapes.add_picture('/tmp/book_1000_final.jpg', Inches(7.5), Inches(1.7), Inches(3.0), Inches(3.0))
text_box(s3, "《预测之书:1000天后的世界》", 7.2, 5.0, 5.2, 0.45, font_size=14, bold=True, color=C_TEXT, align=PP_ALIGN.CENTER)
text_box(s3, "罗振宇", 7.2, 5.4, 5.2, 0.35, font_size=11, color=C_SUBTEXT, align=PP_ALIGN.CENTER)
text_box(s3, "1000天后的世界发展趋势预测", 7.2, 5.75, 5.2, 0.4, font_size=10, color=C_SUBTEXT, align=PP_ALIGN.CENTER)
text_box(s3, "青岛火一五信息科技有限公司", 0.6, 7.1, 5, 0.3, font_size=9, color=C_SUBTEXT)
text_box(s3, "03", 12.6, 7.1, 0.5, 0.3, font_size=9, color=C_SUBTEXT, align=PP_ALIGN.RIGHT)
# ════════════════════════════════════════════════════════
# Slide 4 — 乔布斯
# ════════════════════════════════════════════════════════
s4 = prs.slides.add_slide(BLANK)
bg4 = s4.background; fill4 = bg4.fill; fill4.solid(); fill4.fore_color.rgb = C_BG
text_box(s4, "远见与品味", 0.6, 0.35, 8, 0.55, font_size=28, bold=True, color=C_TEXT)
text_box(s4, "VISION AND TASTE", 0.6, 0.82, 10, 0.35, font_size=10, color=C_SUBTEXT)
add_divider(s4, 0.6, 1.18, 12.13)
s4.shapes.add_picture('/tmp/steve_jobs.png', Inches(0.6), Inches(1.5), Inches(12.13), Inches(3.2))
add_card(s4, 0.6, 4.85, 12.13, 1.6)
text_box(s4, "Steve Jobs", 1.0, 5.0, 3, 0.4, font_size=16, bold=True, color=C_TEXT)
text_box(s4, "苹果公司创始人", 1.0, 5.38, 3, 0.35, font_size=11, color=C_SUBTEXT)
text_box(s4, "「找不到方向的根本原因,", 4.0, 5.0, 8.5, 0.45, font_size=22, bold=True, color=C_TEXT)
text_box(s4, "不够聪明,是没有品味。」", 4.0, 5.45, 8.5, 0.45, font_size=22, bold=True, color=C_TEXT)
text_box(s4, "—— 乔布斯", 4.0, 5.95, 8.5, 0.35, font_size=12, color=C_SUBTEXT)
text_box(s4, "青岛火一五信息科技有限公司", 0.6, 7.1, 5, 0.3, font_size=9, color=C_SUBTEXT)
text_box(s4, "04", 12.6, 7.1, 0.5, 0.3, font_size=9, color=C_SUBTEXT, align=PP_ALIGN.RIGHT)
# ════════════════════════════════════════════════════════
# Slide 5 — 为什么押宝龙虾
# ════════════════════════════════════════════════════════
# ════════════════════════════════════════════════════════
# Slide 6 — 我们的公司
# ════════════════════════════════════════════════════════
s6 = prs.slides.add_slide(BLANK)
bg6 = s6.background; fill6 = bg6.fill; fill6.solid(); fill6.fore_color.rgb = C_BG
text_box(s6, "我们的公司", 0.6, 0.35, 8, 0.55, font_size=28, bold=True, color=C_TEXT)
text_box(s6, "COMPANIES", 0.6, 0.82, 10, 0.35, font_size=10, color=C_SUBTEXT)
add_divider(s6, 0.6, 1.18, 12.13)
# 公司一 — 青岛火一五信息科技有限公司
add_card(s6, 0.6, 1.4, 5.9, 4.5)
text_box(s6, "青岛火一五信息科技有限公司", 0.85, 1.6, 5.4, 0.45, font_size=13, bold=True, color=C_TEXT)
text_box(s6, "FIREINFO TECH", 0.85, 2.0, 5.4, 0.3, font_size=9, color=C_SUBTEXT)
company1_products = [
("龙虾 + 辉火云企业套件", "ERP · CRM · MES"),
("龙虾 + 辉火云管家", "养龙虾服务 · 数据治理"),
("XR-IoT 扩展现实物联网", "机器视觉 · 数字孪生"),
]
for i, (name, sub) in enumerate(company1_products):
dot6 = s6.shapes.add_shape(9, Inches(0.85), Inches(2.55 + i * 0.85), Inches(0.18), Inches(0.18))
dot6.fill.solid(); dot6.fill.fore_color.rgb = C_TEXT; dot6.line.fill.background()
text_box(s6, name, 1.15, 2.48 + i * 0.85, 5, 0.35, font_size=12, bold=True, color=C_TEXT)
text_box(s6, sub, 1.15, 2.78 + i * 0.85, 5, 0.3, font_size=10, color=C_SUBTEXT)
# 公司二 — 青岛萧伯网大科技有限公司
add_card(s6, 6.85, 1.4, 5.9, 4.5)
text_box(s6, "青岛萧伯网大科技有限公司", 7.1, 1.6, 5.4, 0.45, font_size=13, bold=True, color=C_TEXT)
text_box(s6, "XIAOBOWANG TECH", 7.1, 2.0, 5.4, 0.3, font_size=9, color=C_SUBTEXT)
text_box(s6, "逸寻智库", 7.35, 2.55, 5, 0.4, font_size=13, bold=True, color=C_TEXT)
text_box(s6, "公众号 · B站自媒体", 7.35, 2.9, 5, 0.3, font_size=10, color=C_SUBTEXT)
text_box(s6, "教育平台起步阶段", 7.35, 3.5, 5, 0.35, font_size=12, bold=True, color=C_TEXT)
text_box(s6, "布局品牌建设、IT前沿技术科普和培训", 7.35, 3.85, 5, 0.5, font_size=10, color=C_SUBTEXT)
# 个人定位
add_card(s6, 0.6, 6.1, 12.13, 0.75)
text_box(s6, "赵博 / OPC一人公司 · 超级个体 · AI工长", 1.0, 6.25, 11, 0.4, font_size=13, bold=True, color=C_TEXT, align=PP_ALIGN.CENTER)
text_box(s6, "青岛火一五信息科技有限公司", 0.6, 7.1, 5, 0.3, font_size=9, color=C_SUBTEXT)
text_box(s6, "06", 12.6, 7.1, 0.5, 0.3, font_size=9, color=C_SUBTEXT, align=PP_ALIGN.RIGHT)
s5 = prs.slides.add_slide(BLANK)
bg5 = s5.background; fill5 = bg5.fill; fill5.solid(); fill5.fore_color.rgb = C_BG
text_box(s5, "为什么押宝龙虾", 0.6, 0.35, 8, 0.55, font_size=28, bold=True, color=C_TEXT)
text_box(s5, "WHY WE BET ON OPENCLAW", 0.6, 0.82, 10, 0.35, font_size=10, color=C_SUBTEXT)
add_divider(s5, 0.6, 1.18, 12.13)
# 左卡 — 划时代现象级产品
add_card(s5, 0.6, 1.4, 5.9, 5.5)
text_box(s5, "划时代现象级产品", 0.85, 1.6, 5.4, 0.45, font_size=15, bold=True, color=C_TEXT)
add_divider(s5, 0.85, 2.1, 5.4)
text_box(s5, "龙虾作为划时代意义的现象级产品地位已经成立。", 0.85, 2.25, 5.4, 0.7, font_size=11, color=C_SUBTEXT)
text_box(s5, "它的生态、品牌、知名度不可撼动。", 0.85, 2.75, 5.4, 0.5, font_size=11, color=C_SUBTEXT)
# 繁荣度进度条
for i in range(10):
alpha = 0.3 + i * 0.07
v = int(255 * alpha)
add_solid = s5.shapes.add_shape(1, Inches(0.85 + i * 0.56), Inches(3.4), Inches(0.5), Inches(0.18))
add_solid.fill.solid(); add_solid.fill.fore_color.rgb = RGBColor(v, v, v); add_solid.line.fill.background()
text_box(s5, "社区繁荣度", 0.85, 3.7, 5.4, 0.3, font_size=9, color=C_SUBTEXT, align=PP_ALIGN.CENTER)
# 数字强调
text_box(s5, "3+", 1.2, 4.1, 1.5, 0.9, font_size=52, bold=True, color=C_TEXT)
text_box(s5, "年内优势稳固", 2.7, 4.3, 3, 0.5, font_size=13, color=C_TEXT)
text_box(s5, "就算出现更优秀的产品,大多数龙虾用户会选择坐等龙虾更新,这种情况三年内不会逆转。", 0.85, 5.1, 5.4, 0.9, font_size=10.5, color=C_SUBTEXT)
# 右卡 — 数据主权
add_card(s5, 6.85, 1.4, 5.9, 5.5)
text_box(s5, "数据主权", 7.1, 1.6, 5.4, 0.45, font_size=15, bold=True, color=C_TEXT)
add_divider(s5, 7.1, 2.1, 5.4)
text_box(s5, "企业把所有数据存到龙虾,", 7.1, 2.25, 5.4, 0.45, font_size=13, bold=True, color=C_TEXT)
text_box(s5, "然后才是小红书、抖音等APP复用一部分数据。", 7.1, 2.7, 5.4, 0.5, font_size=11, color=C_SUBTEXT)
text_box(s5, "打破以前数据割裂、数据沙箱的局面。", 7.1, 3.25, 5.4, 0.5, font_size=11, color=C_SUBTEXT)
text_box(s5, "不再让巨头瓜分我们的数据。", 7.1, 3.8, 5.4, 0.45, font_size=13, bold=True, color=C_TEXT)
text_box(s5, "龙虾 = 企业的数据金库。", 7.1, 4.35, 5.4, 0.5, font_size=16, bold=True, color=C_TEXT)
text_box(s5, "其他平台只是龙虾的附庸。", 7.1, 4.9, 5.4, 0.5, font_size=11, color=C_SUBTEXT)
text_box(s5, "青岛火一五信息科技有限公司", 0.6, 7.1, 5, 0.3, font_size=9, color=C_SUBTEXT)
text_box(s5, "05", 12.6, 7.1, 0.5, 0.3, font_size=9, color=C_SUBTEXT, align=PP_ALIGN.RIGHT)
# ─── 保存 ───────────────────────────────────────────
output = "/Users/jobzhao/.openclaw/media/outbound/合并版_走向具身智能.pptx"
prs.save(output)
print(f"✅ 合并版已生成: {output}")
FILE:huo15-openclaw-verify-mode/SKILL.md
---
name: huo15-openclaw-verify-mode
version: 2.2.0
description: "验证模式 — 检查工作成果、运行测试、验证假设。借鉴 Claude Code 的 Verification Agent。"
homepage: https://github.com/zhaobod1/huo15-openclaw-enhance
metadata: { "openclaw": { "emoji": "✅", "requires": { "bins": [] } } }
---
# 验证模式 (Verify Mode)
系统性地验证工作成果或假设。
## 使用时机
✅ **使用此技能当:**
- 完成一段代码修改后需要验证
- "帮我检查一下这个改动有没有问题"
- 需要运行测试并分析结果
- 验证一个假设或排查一个 bug
❌ **不要使用当:**
- 只是普通的代码 review(直接 review 即可)
- 改动很小且显而易见正确
## 验证流程
### 1. 确定验证目标
- 要验证什么?(功能正确性 / 性能 / 安全 / 兼容性)
- 成功标准是什么?
- 已知的风险点?
### 2. 静态检查
- 读改动的代码,检查逻辑正确性
- 检查边界条件和错误处理
- 检查是否引入了安全漏洞(注入、XSS 等)
- 检查是否破坏了已有接口或行为
### 3. 动态验证
- 运行已有的测试套件
- 如果没有测试,手动构造测试场景
- 检查构建是否通过
- 如果有 linter/formatter,运行一下
### 4. 输出验证报告
```
## 验证报告
### 验证对象
[简要描述被验证的改动/假设]
### 检查清单
- [x] 逻辑正确性: 通过/发现问题
- [x] 边界条件: ...
- [x] 测试结果: X pass / Y fail
- [x] 构建状态: 通过/失败
- [ ] 安全检查: ...
### 发现的问题
1. [问题描述 + 严重程度 + 建议修复方式]
### 结论
✅ 验证通过 / ⚠️ 有待修复的问题 / ❌ 验证失败
```
## 核心原则
- **全面但聚焦** — 覆盖主要风险点,但不要检查无关的东西
- **给出证据** — "测试 X 通过了"、"第 42 行可能有空指针",不要泛泛地说"看起来没问题"
- **区分严重程度** — 阻塞性问题 vs 建议性改进
- **可操作** — 发现问题就给出具体修复建议
FILE:huo15-searxng/README.md
# huo15-searxng
> SearXNG 自托管搜索引擎一键部署 - Docker Compose + OpenClaw 配置自动化 (v1.1.0)
## 功能特性
- 🔍 **一键部署**:Docker Compose 自动部署 SearXNG 实例
- 🔄 **端口冲突自动处理**:8888 → 8889 → 8890 → 8910 自动检测可用端口
- 🔒 **botdetection 修复**:limiter.toml 解决 SearXNG 403 Forbidden 问题
- ⚙️ **OpenClaw 无缝集成**:自动配置 `SEARXNG_BASE_URL` 环境变量
- ✅ **健康检查**:启动后自动验证服务可用性(含 JSON API)
- 🔄 **幂等性**:已安装时自动检测并显示状态,支持升级
## 前提条件
- Docker Desktop (macOS) / Docker Engine (Linux)
- docker compose v2 (`docker compose` 命令)
- `nc` 命令(macOS/Linux 内置)
## 快速开始
### 安装 Skill
```bash
clawhub install huo15-searxng --dir ~/.openclaw/workspace/skills
```
### 部署 SearXNG
```bash
@贾维斯 安装 SearXNG
```
或手动执行安装脚本:
```bash
bash ~/.openclaw/workspace/skills/huo15-searxng/scripts/install.sh
```
## 工作原理
```
安装脚本流程:
1. 检查 Docker + docker compose v2
2. 检测已安装? → 显示状态并退出(幂等性)
3. 端口 8888-8910 可用性检测
4. 渲染 docker-compose.yml + settings.yml + limiter.toml
5. docker compose up -d
6. 健康检查 (HTTP 200) + JSON API 验证
7. 配置 SEARXNG_BASE_URL 到 ~/.zshrc
8. 输出完成信息
```
## 目录结构
```
huo15-searxng/
├── SKILL.md # Skill 定义
├── README.md # 本文档
├── scripts/
│ ├── install.sh # 核心安装脚本 (v1.1.0)
│ ├── uninstall.sh # 卸载脚本 (新增)
│ ├── env.sh # 环境变量加载
│ └── status.sh # 状态检查 (v1.1.0)
└── templates/
└── docker-compose.yml.template
```
## 配置说明
### 环境变量
安装后自动添加以下环境变量到 `~/.zshrc`:
```bash
export SEARXNG_BASE_URL="http://localhost:8888"
```
**立即生效**:
```bash
source ~/.zshrc
```
### OpenClaw 配置
OpenClaw 自动检测 `SEARXNG_BASE_URL` 环境变量,无需手动配置。
如需手动配置,编辑 `~/.openclaw/openclaw.json`:
```json
{
"plugins": {
"entries": {
"searxng": {
"config": {
"webSearch": {
"baseUrl": "http://localhost:8888"
}
}
}
}
}
}
```
## 常用命令
| 操作 | 命令 |
|------|------|
| 安装/升级 | `bash ~/.openclaw/workspace/skills/huo15-searxng/scripts/install.sh` |
| 查看状态 | `bash ~/.openclaw/workspace/skills/huo15-searxng/scripts/status.sh` |
| 查看日志 | `docker logs searxng` |
| 重启服务 | `cd ~/docker/searxng && docker compose restart` |
| 停止服务 | `cd ~/docker/searxng && docker compose down` |
| 卸载 | `bash ~/.openclaw/workspace/skills/huo15-searxng/scripts/uninstall.sh` |
## 端口说明
| 端口 | 说明 |
|------|------|
| 8888 | 默认端口 |
| 8889 | 备用端口 1 |
| 8890 | 备用端口 2 |
| 8910 | 最大检测端口 |
脚本会按顺序检测,找到第一个可用端口为止。
## v1.1.0 更新
- ✅ 修复 `grep -oP` (GNU grep) → `grep -oE` (跨平台)
- ✅ 修复 sed 分隔符问题(用 `|` 代替 `/` 避免 URL 冲突)
- ✅ 新增幂等性检测(已安装时显示状态不重复部署)
- ✅ 新增卸载脚本 `uninstall.sh`
- ✅ 新增 `source ~/.zshrc` 生效提示
- ✅ 增强 curl 超时参数(--connect-timeout, --max-time)
- ✅ 停止旧容器逻辑(升级时清理)
## 故障排除
### Docker 未安装
```bash
# macOS
brew install --cask docker
# Linux (Ubuntu)
curl -fsSL https://get.docker.com | sh
```
### 端口全部占用
手动指定端口后重新安装:
```bash
cd ~/docker/searxng
# 编辑 docker-compose.yml 修改端口
docker compose up -d
# 手动设置环境变量
export SEARXNG_BASE_URL="http://localhost:你指定的端口"
```
### SearXNG 启动失败
```bash
# 查看日志
docker logs searxng
# 调试健康检查
curl -v http://localhost:8888/healthz
# 完整日志
cd ~/docker/searxng && docker compose logs
```
### 环境变量未生效
```bash
source ~/.zshrc
echo $SEARXNG_BASE_URL
```
## 技术参考
- [SearXNG 官方文档](https://docs.searxng.org/)
- [OpenClaw SearXNG 配置](https://docs.openclaw.ai/tools/searxng-search)
- [SearXNG GitHub](https://github.com/searxng/searxng)
## License
MIT
FILE:huo15-searxng/SKILL.md
---
name: huo15_searxng
version: 1.1.2
description: SearXNG 自托管搜索引擎一键部署 - Docker Compose + OpenClaw 配置自动化 (v1.1.0)
homepage: https://github.com/zhaobod1/huo15-skills
metadata:
openclaw:
emoji: "🔍"
requires:
bins: ["docker", "nc"]
---
# huo15-searxng
SearXNG 自托管搜索引擎一键部署技能。
## 触发词
- "安装 SearXNG"、"部署 SearXNG"
- "searxng"、"SearXNG"
- "自托管搜索"、"私有搜索"
- "搭建搜索"
## 功能
当用户请求安装或部署 SearXNG 时,执行 `scripts/install.sh` 进行自动化部署:
1. 检查 Docker 和 docker compose 环境
2. 检测可用端口(8888 → 8910 自动检测冲突)
3. 一键部署 SearXNG 容器(含 limiter.toml 修复 403 问题)
4. 等待服务就绪并验证
5. 自动配置 OpenClaw 环境变量
## 使用方式
```
@贾维斯 安装 SearXNG
```
## 常用命令
| 命令 | 说明 |
|------|------|
| `bash .../install.sh` | 安装/升级 SearXNG |
| `bash .../status.sh` | 查看运行状态 |
| `bash .../uninstall.sh` | 卸载 SearXNG |
## 技术细节
- SearXNG 镜像:`searxng/searxng:latest`
- 数据目录:`~/docker/searxng/`
- 默认端口:8888(自动检测冲突)
- OpenClaw 配置:`SEARXNG_BASE_URL` 环境变量
## 前提条件
- Docker Desktop (macOS) / Docker Engine (Linux)
- docker compose v2
- `nc` 命令(macOS/Linux 内置)
FILE:huo15-searxng/_meta.json
{
"ownerId": "kn7byevkn40d6z4p7ghdb097z983tj33",
"slug": "huo15-searxng",
"version": "1.1.2",
"publishedAt": 1776003478609
}
FILE:huo15-searxng/scripts/env.sh
#!/bin/bash
# env.sh — 加载 SearXNG 环境变量
SCRIPT_DIR="$(cd "$(dirname "BASH_SOURCE[0]")" && pwd)"
SKILL_ROOT="$(dirname "$SCRIPT_DIR")"
# 加载 SEARXNG_BASE_URL
if [ -f "$HOME/.zshrc" ]; then
export $(grep "SEARXNG_BASE_URL" "$HOME/.zshrc" | xargs) 2>/dev/null || true
fi
# 默认值
SEARXNG_BASE_URL="-http://localhost:8888"
echo "🔍 SearXNG 环境"
echo " SEARXNG_BASE_URL: $SEARXNG_BASE_URL"
echo " DOCKER_DIR: $HOME/docker/searxng"
FILE:huo15-searxng/scripts/install.sh
#!/bin/bash
# install.sh — SearXNG 一键部署脚本 v1.1.0
# 功能:Docker Compose 部署 + 端口冲突检测 + OpenClaw 配置
# 修复:grep -oP (GNU) → grep -E + cut (跨平台) | sed 分隔符 | 幂等性
set -e
SCRIPT_DIR="$(cd "$(dirname "BASH_SOURCE[0]")" && pwd)"
SKILL_ROOT="$(dirname "$SCRIPT_DIR")"
TEMPLATES_DIR="$SKILL_ROOT/templates"
DOCKER_DIR="$HOME/docker/searxng"
# 默认配置
DEFAULT_PORT=8888
MAX_PORT=8910
MAX_WAIT=60
# 颜色输出
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
log_info() { echo -e "GREEN[INFO]NC $1"; }
log_warn() { echo -e "YELLOW[WARN]NC $1"; }
log_error() { echo -e "RED[ERROR]NC $1"; }
# ============================================================
# 0. 入口检测:是否已安装
# ============================================================
check_already_installed() {
# 检查容器是否在运行
if docker ps -a 2>/dev/null | grep -q "searxng$"; then
log_warn "SearXNG 容器已在运行"
local current_port=$(docker port searxng 2>/dev/null | grep '8080/tcp' | grep -oE ':[0-9]+$' | tr -d ':' | head -1 || echo "")
if [ -n "$current_port" ]; then
log_info "检测到端口: $current_port"
PORT=$current_port
configure_openclaw
print_status
exit 0
fi
fi
# 检查配置文件是否存在
if [ -f "$DOCKER_DIR/docker-compose.yml" ]; then
log_warn "检测到已有配置,执行升级..."
UPGRADE=true
else
UPGRADE=false
fi
}
# ============================================================
# 1. 检查 Docker 和 docker compose
# ============================================================
check_docker() {
log_info "检查 Docker 环境..."
if ! command -v docker &> /dev/null; then
log_error "Docker 未安装,请先安装 Docker Desktop"
log_info "提示: brew install --cask docker"
exit 1
fi
if ! docker info &> /dev/null; then
log_error "Docker 未运行,请启动 Docker Desktop"
exit 1
fi
if ! command -v docker compose &> /dev/null; then
log_error "docker compose v2 未安装"
log_info "提示: Docker Desktop 已内置 docker compose,无需单独安装"
exit 1
fi
# 跨平台获取版本号 (兼容 macOS grep)
DOCKER_VERSION=$(docker compose version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+' | head -1)
log_info "Docker Compose vDOCKER_VERSION ✓"
}
# ============================================================
# 2. 端口可用性检测
# ============================================================
find_available_port() {
log_info "检测可用端口..."
PORT=$DEFAULT_PORT
while [ $PORT -le $MAX_PORT ]; do
# 跨平台端口检测:nc -z 在 macOS 和 Linux 都支持
if nc -z localhost $PORT 2>/dev/null; then
log_warn "端口 $PORT 已被占用,尝试 $((PORT+1))..."
PORT=$((PORT+1))
else
log_info "✅ 找到可用端口: $PORT"
return 0
fi
done
log_error "未找到可用端口 (8888-$MAX_PORT)"
exit 1
}
# ============================================================
# 3. 创建目录并渲染 docker-compose.yml
# ============================================================
setup_docker() {
log_info "创建 Docker 目录..."
mkdir -p "$DOCKER_DIR/searxng" "$DOCKER_DIR/searxng-data"
# 渲染 docker-compose.yml
log_info "生成 docker-compose.yml..."
cat > "$DOCKER_DIR/docker-compose.yml" << EOF
services:
searxng:
image: searxng/searxng:latest
container_name: searxng
restart: unless-stopped
ports:
- "PORT:8080"
volumes:
- ./searxng:/etc/searxng:rw
- ./searxng-data:/var/lib/searxng:rw
environment:
- SEARXNG_BASE_URL=http://localhost:PORT/
- HTTP_PROXY=""
- HTTPS_PROXY=""
- NO_PROXY=*
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:8080/healthz"]
interval: 10s
timeout: 5s
retries: 3
EOF
# 渲染 settings.yml (启用 JSON API)
log_info "生成 settings.yml..."
SECRET_KEY=$(cat /dev/urandom | tr -dc 'a-z0-9' | fold -w 32 | head -n 1)
cat > "$DOCKER_DIR/searxng/settings.yml" << EOFSETTINGS
search:
default_lang: zh-CN
formats:
- html
- json
server:
secret_key: "SECRET_KEY"
bind_address: "0.0.0.0"
port: 8080
outgoing:
useragent_suffix: ""
pool_connections: 100
pool_maxsize: 20
engines:
- name: google
engine: google
shortcut: g
- name: bing
engine: bing
shortcut: b
- name: duckduckgo
engine: duckduckgo
shortcut: ddg
- name: baidu
engine: baidu
shortcut: bd
EOFSETTINGS
# 创建空的 limiter.toml (解决 botdetection 403 问题)
log_info "生成 limiter.toml..."
touch "$DOCKER_DIR/searxng/limiter.toml"
}
# ============================================================
# 4. 启动 SearXNG
# ============================================================
start_searxng() {
log_info "启动 SearXNG 容器..."
cd "$DOCKER_DIR"
# 停止旧容器(如果存在)
if docker ps -a 2>/dev/null | grep -q "searxng$"; then
log_info "停止旧容器..."
docker compose down --remove-orphans 2>/dev/null || true
fi
docker compose up -d --pull always
log_info "等待服务就绪..."
}
# ============================================================
# 5. 健康检查
# ============================================================
wait_for_searxng() {
local elapsed=0
local interval=3
while [ $elapsed -lt $MAX_WAIT ]; do
# 使用 curl 的 --fail 配合 -o /dev/null 检测 HTTP 状态码
if curl -sf --connect-timeout 3 --max-time 5 "http://localhost:$PORT/healthz" > /dev/null 2>&1; then
log_info "✅ SearXNG 服务已就绪"
return 0
fi
sleep $interval
elapsed=$((elapsed + interval))
echo -n "."
done
echo ""
log_error "SearXNG 启动超时 (MAX_WAITs)"
log_info "查看日志: docker -f searxng logs"
log_info "调试: curl -v http://localhost:$PORT/healthz"
exit 1
}
# ============================================================
# 6. 验证服务
# ============================================================
verify_searxng() {
log_info "验证 SearXNG..."
# 测试主页
if curl -sf --connect-timeout 3 --max-time 5 "http://localhost:$PORT" > /dev/null 2>&1; then
log_info "✅ 主页访问成功"
else
log_error "主页访问失败"
return 1
fi
# 测试 JSON API
local json_response=$(curl -sf --connect-timeout 5 --max-time 10 "http://localhost:$PORT/search?q=test&format=json" 2>/dev/null)
if echo "$json_response" | grep -q '"results"'; then
log_info "✅ JSON API 正常"
else
log_warn "JSON API 响应异常 (主页正常,搜索引擎可能需要配置)"
fi
}
# ============================================================
# 7. 配置 OpenClaw 环境变量
# ============================================================
configure_openclaw() {
log_info "配置 OpenClaw..."
local searxng_url="http://localhost:$PORT"
local env_line="export SEARXNG_BASE_URL=\"$searxng_url\""
# 检查是否已配置(跨平台 sed)
if grep -q "SEARXNG_BASE_URL" "$HOME/.zshrc" 2>/dev/null; then
log_warn "SEARXNG_BASE_URL 已存在,更新..."
# 使用 @ 作为分隔符,避免 URL 中的 / 导致问题
sed -i '' "s|export SEARXNG_BASE_URL=.*|env_line|" "$HOME/.zshrc"
else
echo "" >> "$HOME/.zshrc"
echo "# SearXNG (huo15-searxng)" >> "$HOME/.zshrc"
echo "$env_line" >> "$HOME/.zshrc"
log_info "已添加到 ~/.zshrc"
fi
# 立即生效(仅当前 shell)
export SEARXNG_BASE_URL="$searxng_url"
log_info "✅ SEARXNG_BASE_URL=$searxng_url"
}
# ============================================================
# 8. 输出状态
# ============================================================
print_status() {
echo ""
echo "═══════════════════════════════════════════════════"
echo -e " GREEN🔍 SearXNG 状态NC"
echo "═══════════════════════════════════════════════════"
echo ""
echo " 🔍 访问地址: http://localhost:$PORT"
echo " 📡 API 端点: http://localhost:$PORT/search"
echo " 🔧 配置目录: $DOCKER_DIR"
echo " 📝 SEARXNG_BASE_URL=$SEARXNG_BASE_URL"
echo ""
echo " 常用命令:"
echo " 查看日志: docker logs searxng"
echo " 重启服务: cd $DOCKER_DIR && docker compose restart"
echo " 停止服务: cd $DOCKER_DIR && docker compose down"
echo " 卸载: bash ~/.openclaw/workspace/skills/huo15-searxng/scripts/uninstall.sh"
echo ""
echo " ⚠️ 如需立即生效,请运行: source ~/.zshrc"
echo ""
echo "═══════════════════════════════════════════════════"
}
# ============================================================
# 9. 输出完成信息
# ============================================================
print_complete() {
echo ""
echo "═══════════════════════════════════════════════════"
echo -e " GREEN🎉 SearXNG 部署完成!NC"
echo "═══════════════════════════════════════════════════"
echo ""
echo " 🔍 访问地址: http://localhost:$PORT"
echo " 📡 API 端点: http://localhost:$PORT/search"
echo " 🔧 配置目录: $DOCKER_DIR"
echo ""
echo " 📝 OpenClaw 已配置:"
echo " SEARXNG_BASE_URL=$SEARXNG_BASE_URL"
echo ""
echo " ⚠️ 重要: 请运行以下命令使环境变量立即生效:"
echo " source ~/.zshrc"
echo ""
echo " 常用命令:"
echo " 查看日志: docker logs searxng"
echo " 重启服务: cd $DOCKER_DIR && docker compose restart"
echo " 停止服务: cd $DOCKER_DIR && docker compose down"
echo " 卸载: bash ~/.openclaw/workspace/skills/huo15-searxng/scripts/uninstall.sh"
echo ""
echo "═══════════════════════════════════════════════════"
}
# ============================================================
# 主流程
# ============================================================
main() {
echo ""
echo "🔍 huo15-searxng — SearXNG 一键部署 v1.1.0"
echo "═══════════════════════════════════════════════════"
echo ""
check_already_installed
check_docker
find_available_port
setup_docker
start_searxng
wait_for_searxng
verify_searxng
configure_openclaw
print_complete
}
main "$@"
FILE:huo15-searxng/scripts/status.sh
#!/bin/bash
# status.sh — 检查 SearXNG 运行状态
SCRIPT_DIR="$(cd "$(dirname "BASH_SOURCE[0]")" && pwd)"
DOCKER_DIR="-$HOME/docker/searxng"
# 加载环境变量
if [ -f "$HOME/.zshrc" ]; then
export $(grep "SEARXNG_BASE_URL" "$HOME/.zshrc" 2>/dev/null | grep -v '^#' | xargs) 2>/dev/null || true
fi
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
echo ""
echo "🔍 SearXNG 状态检查"
echo "═══════════════════════════════════════════════════"
# 1. 容器状态
echo ""
echo "📦 Docker 容器:"
if docker ps -a 2>/dev/null | grep -q "searxng$"; then
echo -e " GREEN✅ searxng 容器运行中NC"
docker ps --filter name=searxng --format " 状态: GREEN{{./Status}}NC | 镜像: {{./Image}}" 2>/dev/null
else
echo -e " RED❌ searxng 容器未运行NC"
fi
# 2. 端口检测
echo ""
echo "🌐 端口检测:"
PORT=$(docker port searxng 2>/dev/null | grep '8080/tcp' | grep -oE '[0-9]+$' | head -1 || echo "")
PORT="-8888"
if nc -z localhost $PORT 2>/dev/null; then
echo -e " GREEN✅ 端口 $PORT 可访问NC"
else
echo -e " RED❌ 端口 $PORT 无法访问NC"
fi
# 3. 健康检查
echo ""
echo "🔍 健康检查:"
if curl -sf --connect-timeout 3 --max-time 5 "http://localhost:$PORT/healthz" > /dev/null 2>&1; then
echo -e " GREEN✅ SearXNG 健康检查通过NC"
else
echo -e " RED❌ SearXNG 健康检查失败NC"
fi
# 4. JSON API 测试
echo ""
echo "🔧 JSON API 测试:"
JSON_RESULT=$(curl -sf --connect-timeout 5 --max-time 10 "http://localhost:$PORT/search?q=hello&format=json" 2>/dev/null | head -c 100 || echo "")
if echo "$JSON_RESULT" | grep -q '"results"'; then
echo -e " GREEN✅ JSON API 正常NC"
else
echo -e " YELLOW⚠️ JSON API 异常 (主页正常)NC"
fi
# 5. 环境变量
echo ""
echo "📝 OpenClaw 配置:"
if [ -n "$SEARXNG_BASE_URL" ]; then
echo " SEARXNG_BASE_URL=$SEARXNG_BASE_URL"
echo -e " YELLOW⚠️ 如未生效请运行: source ~/.zshrcNC"
else
echo -e " RED⚠️ SEARXNG_BASE_URL 未设置NC"
fi
# 6. 数据目录
echo ""
echo "📂 配置目录:"
if [ -d "$DOCKER_DIR" ]; then
echo " ✅ $DOCKER_DIR"
else
echo " ⚠️ 目录不存在"
fi
echo ""
echo "═══════════════════════════════════════════════════"
echo "🔧 常用命令:"
echo " 查看日志: docker logs searxng"
echo " 重启服务: cd $DOCKER_DIR && docker compose restart"
echo " 停止服务: cd $DOCKER_DIR && docker compose down"
echo " 卸载: bash ~/.openclaw/workspace/skills/huo15-searxng/scripts/uninstall.sh"
echo ""
FILE:huo15-searxng/scripts/uninstall.sh
#!/bin/bash
# uninstall.sh — SearXNG 卸载脚本
set -e
SCRIPT_DIR="$(cd "$(dirname "BASH_SOURCE[0]")" && pwd)"
DOCKER_DIR="$HOME/docker/searxng"
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
log_info() { echo -e "GREEN[INFO]NC $1"; }
log_warn() { echo -e "YELLOW[WARN]NC $1"; }
log_error() { echo -e "RED[ERROR]NC $1"; }
echo ""
echo "🗑️ huo15-searxng — 卸载 SearXNG"
echo "═══════════════════════════════════════════════════"
echo ""
# 停止容器
if docker ps 2>/dev/null | grep -q "^searxng "; then
log_info "停止 SearXNG 容器..."
cd "$DOCKER_DIR" && docker compose down --remove-orphans 2>/dev/null || true
else
log_warn "SearXNG 容器未运行"
fi
# 删除数据目录
if [ -d "$DOCKER_DIR" ]; then
log_info "删除配置目录: $DOCKER_DIR"
rm -rf "$DOCKER_DIR"
else
log_warn "配置目录不存在: $DOCKER_DIR"
fi
# 从 ~/.zshrc 移除环境变量
if grep -q "SEARXNG_BASE_URL" "$HOME/.zshrc" 2>/dev/null; then
log_info "从 ~/.zshrc 移除 SEARXNG_BASE_URL..."
# 移除相关行(huo15-searxng 注释块和变量)
sed -i '' '/# SearXNG.*huo15-searxng/d' "$HOME/.zshrc"
sed -i '' '/export SEARXNG_BASE_URL=/d' "$HOME/.zshrc"
fi
# 清理当前 shell 环境变量(不影响 ~/.zshrc)
unset SEARXNG_BASE_URL 2>/dev/null || true
echo ""
echo "═══════════════════════════════════════════════════"
echo -e " GREEN✅ 卸载完成NC"
echo "═══════════════════════════════════════════════════"
echo ""
echo " 已清理:"
echo " - SearXNG 容器和数据"
echo " - $DOCKER_DIR 目录"
echo " - ~/.zshrc 中的 SEARXNG_BASE_URL"
echo ""
echo " ⚠️ 重要: 请运行以下命令使环境变量变更生效:"
echo " source ~/.zshrc"
echo ""
echo "═══════════════════════════════════════════════════"
FILE:scripts/create-word-doc.py
#!/usr/bin/env python3
"""
create-word-doc.py - 企业级 Word 文档生成器 v3.0(WPS/Word 双兼容)
格式标准:
- 页面边距:上 3.7cm,下 3.5cm,左 2.8cm,右 2.6cm
- 标题层次:章=黑体/小二/加粗,节=楷体/三号/加粗,条=仿宋/四号/加粗
- 正文:仿宋/小四/首行缩进2字符/1.5倍行距
- 页眉:LOGO + 公司名称 + 文档编号 + 密级
- 页脚:居中,"第 X 页 / 共 Y 页"
- 版本历史表:自动生成
- 审批签字区:可选
用法:
python create-word-doc.py <输出路径> [标题] [正文] [编号] [版本] [密级]
"""
import sys
import os
import re
import ssl
import json
import datetime
import urllib.request
import xmlrpc.client
from docx import Document
from docx.shared import Pt, Cm, RGBColor
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.enum.table import WD_TABLE_ALIGNMENT
from docx.oxml.ns import qn
from docx.oxml import OxmlElement
# ============== 企业公文格式常量 ==============
MARGIN_TOP = 3.7
MARGIN_BOTTOM = 3.5
MARGIN_LEFT = 2.8
MARGIN_RIGHT = 2.6
# 字体(WPS 和 Word 都支持)
FONT_BODY = '仿宋'
FONT_HEADING_CHAPTER = '黑体' # 章:黑体
FONT_HEADING_SECTION = '楷体' # 节:楷体
FONT_HEADING_ARTICLE = '仿宋' # 条:仿宋
FONT_HEADER = '黑体'
FONT_FOOTER = '仿宋'
# 字号(pt)
SIZE_CHAPTER = 16 # 章:一级标题,黑体 16pt 加粗
SIZE_SECTION = 14 # 节:二级标题,楷体 14pt 加粗
SIZE_ARTICLE = 12 # 条:三级标题,仿宋 12pt 加粗
SIZE_BODY = 12 # 正文:仿宋 12pt
SIZE_TITLE = 22 # 文档标题:黑体 22pt 加粗
SIZE_HEADER = 10.5
SIZE_FOOTER = 10.5
SIZE_TABLE_HEADER = 10.5
SIZE_TABLE_BODY = 10.5
# 行距
LINE_SPACING = 1.5
# 首行缩进(2个中文字符约 0.74cm)
FIRST_LINE_INDENT = Cm(0.74)
# 表格斑马条纹颜色(浅灰)
TABLE_ROW_EVEN_COLOR = RGBColor(0xF2, 0xF2, 0xF2)
# ============== 公司信息 ==============
USER_HOME = os.path.expanduser("~")
LOGO_DIR = os.path.join(USER_HOME, ".huo15", "assets")
DEFAULT_LOGO_PATH = os.path.join(LOGO_DIR, "logo.png")
FALLBACK_LOGO_URL = 'https://tools.huo15.com/uploads/images/system/logo-colours.png'
DEFAULT_COMPANY_NAME = '青岛火一五信息科技有限公司'
def get_company_info():
"""从公司系统获取公司信息和 LOGO"""
info = {'company_name': DEFAULT_COMPANY_NAME, 'logo_path': None}
if os.path.exists(DEFAULT_LOGO_PATH) and os.path.getsize(DEFAULT_LOGO_PATH) > 1000:
info['logo_path'] = DEFAULT_LOGO_PATH
return info
try:
creds_file = os.path.join(
os.path.expanduser('~/.openclaw/agents'),
os.environ.get('OC_AGENT_ID', 'main'),
'odoo_creds.json'
)
if os.path.exists(creds_file):
with open(creds_file) as f:
creds = json.load(f)
cfg_file = os.path.expanduser('~/.openclaw/openclaw.json')
if os.path.exists(cfg_file):
with open(cfg_file) as f:
cfg = json.load(f)
odoo_env = cfg.get('skills', {}).get('entries', {}).get('huo15-odoo', {}).get('env', {})
url = odoo_env.get('ODOO_URL', 'https://huihuoyun.huo15.com')
db = odoo_env.get('ODOO_DB', 'huo15_prod')
user = creds.get('user', '')
password = creds.get('password', '')
if user and password:
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
common = xmlrpc.client.ServerProxy(f'{url}/xmlrpc/2/common', context=ctx)
uid = common.authenticate(db, user, password, {})
if uid:
models = xmlrpc.client.ServerProxy(f'{url}/xmlrpc/2/object', context=ctx)
data = models.execute_kw(db, uid, password, 'res.company',
'search_read', [[('id', '=', 1)]], {'fields': ['name', 'logo'], 'limit': 1})
if data:
info['company_name'] = data[0].get('name', DEFAULT_COMPANY_NAME)
logo_id = data[0].get('logo')
if logo_id:
_download(f'{url}/web/image/res.company/{logo_id}/logo', DEFAULT_LOGO_PATH)
if os.path.exists(DEFAULT_LOGO_PATH):
info['logo_path'] = DEFAULT_LOGO_PATH
except Exception as e:
print(f"获取公司信息失败: {e}")
if not info['logo_path']:
_download(FALLBACK_LOGO_URL, DEFAULT_LOGO_PATH)
if os.path.exists(DEFAULT_LOGO_PATH):
info['logo_path'] = DEFAULT_LOGO_PATH
return info
def _download(url, dest_path):
if os.path.exists(dest_path) and os.path.getsize(dest_path) > 1000:
return
os.makedirs(os.path.dirname(dest_path), exist_ok=True)
try:
urllib.request.urlretrieve(url, dest_path)
print(f"✓ LOGO 已下载: {dest_path}")
except Exception as e:
print(f"⚠ LOGO 下载失败: {e}")
def _set_font(run, font_name, size, bold=False, color=None):
"""设置中文字体(WPS/Word 双兼容)"""
run.font.name = font_name
rPr = run._element.find(qn('w:rPr'))
if rPr is None:
rPr = OxmlElement('w:rPr')
run._element.insert(0, rPr)
rFonts = rPr.find(qn('w:rFonts'))
if rFonts is None:
rFonts = OxmlElement('w:rFonts')
rPr.insert(0, rFonts)
rFonts.set(qn('w:eastAsia'), font_name)
rFonts.set(qn('w:ascii'), font_name)
rFonts.set(qn('w:hAnsi'), font_name)
run.font.size = Pt(size)
run.bold = bold
if color:
run.font.color.rgb = color
def _add_border_bottom(paragraph):
"""给段落下方加细线"""
pPr = paragraph._element.find(qn('w:pPr'))
if pPr is None:
pPr = OxmlElement('w:pPr')
paragraph._element.insert(0, pPr)
pBdr = OxmlElement('w:pBdr')
bottom = OxmlElement('w:bottom')
bottom.set(qn('w:val'), 'single')
bottom.set(qn('w:sz'), '6')
bottom.set(qn('w:space'), '1')
bottom.set(qn('w:color'), '000000')
pBdr.append(bottom)
pPr.append(pBdr)
def _set_cell_shading(cell, fill_color):
"""设置单元格背景色"""
tcPr = cell._tc.get_or_add_tcPr()
shd = OxmlElement('w:shd')
shd.set(qn('w:val'), 'clear')
shd.set(qn('w:color'), 'auto')
shd.set(qn('w:fill'), fill_color)
tcPr.append(shd)
def add_header(doc, logo_path, company_name, doc_number=None, classification=None):
"""页眉:LOGO + 公司名称 + 文档编号 + 密级,左对齐,底边细线"""
section = doc.sections[0]
header = section.header
header.is_linked_to_previous = False
for p in header.paragraphs:
for r in p.runs:
r.text = ''
para = header.paragraphs[0] if header.paragraphs else header.add_paragraph()
para.alignment = WD_ALIGN_PARAGRAPH.LEFT
# LOGO
if logo_path and os.path.exists(logo_path):
try:
run = para.add_run()
run.add_picture(logo_path, height=Cm(1.0))
except Exception as e:
print(f"⚠ LOGO 添加失败: {e}")
# 公司名称
run = para.add_run(f' {company_name}')
_set_font(run, FONT_HEADER, SIZE_HEADER)
# 文档编号
if doc_number:
run = para.add_run(f' {doc_number}')
_set_font(run, FONT_HEADER, SIZE_HEADER)
# 密级
if classification:
run = para.add_run(f' 【{classification}】')
_set_font(run, FONT_HEADER, SIZE_HEADER, bold=True)
_add_border_bottom(para)
def add_footer(doc):
"""页脚:居中,'第 X 页 / 共 Y 页'"""
section = doc.sections[0]
footer = section.footer
footer.is_linked_to_previous = False
para = footer.paragraphs[0] if footer.paragraphs else footer.add_paragraph()
para.alignment = WD_ALIGN_PARAGRAPH.CENTER
for r in para.runs:
r.text = ''
def add_text(text):
r = para.add_run(text)
_set_font(r, FONT_FOOTER, SIZE_FOOTER)
return r
def add_field(name):
r = para.add_run()
fc1 = OxmlElement('w:fldChar')
fc1.set(qn('w:fldCharType'), 'begin')
it = OxmlElement('w:instrText')
it.set(qn('xml:space'), 'preserve')
it.text = f' {name} '
fc2 = OxmlElement('w:fldChar')
fc2.set(qn('w:fldCharType'), 'end')
r._element.clear()
r._element.append(fc1)
r._element.append(it)
r._element.append(fc2)
_set_font(r, FONT_FOOTER, SIZE_FOOTER)
add_text('第 ')
add_field('PAGE')
add_text(' 页 / 共 ')
add_field('NUMPAGES')
add_text(' 页')
# ============== 段落样式定义 ==============
class ParagraphStyle:
"""段落样式配置"""
def __init__(self, font, size, bold=False, indent=True,
alignment=WD_ALIGN_PARAGRAPH.JUSTIFY,
space_before=0, space_after=6,
line_spacing=LINE_SPACING):
self.font = font
self.size = size
self.bold = bold
self.indent = indent
self.alignment = alignment
self.space_before = space_before
self.space_after = space_after
self.line_spacing = line_spacing
def apply(self, p):
"""应用样式到段落"""
p.alignment = self.alignment
p.paragraph_format.line_spacing = self.line_spacing
p.paragraph_format.space_before = Pt(self.space_before)
p.paragraph_format.space_after = Pt(self.space_after)
if self.indent:
p.paragraph_format.first_line_indent = FIRST_LINE_INDENT
else:
p.paragraph_format.first_line_indent = Cm(0)
# 预定义样式
STYLE_CHAPTER = ParagraphStyle(FONT_HEADING_CHAPTER, SIZE_CHAPTER, bold=True,
indent=False, alignment=WD_ALIGN_PARAGRAPH.LEFT,
space_before=18, space_after=6)
STYLE_SECTION = ParagraphStyle(FONT_HEADING_SECTION, SIZE_SECTION, bold=True,
indent=False, alignment=WD_ALIGN_PARAGRAPH.LEFT,
space_before=12, space_after=4)
STYLE_ARTICLE = ParagraphStyle(FONT_HEADING_ARTICLE, SIZE_ARTICLE, bold=True,
indent=False, alignment=WD_ALIGN_PARAGRAPH.LEFT,
space_before=6, space_after=3)
STYLE_BODY = ParagraphStyle(FONT_BODY, SIZE_BODY, bold=False,
indent=True, alignment=WD_ALIGN_PARAGRAPH.JUSTIFY,
space_before=0, space_after=6)
STYLE_EMPTY = ParagraphStyle(FONT_BODY, SIZE_BODY, bold=False,
indent=False, alignment=WD_ALIGN_PARAGRAPH.LEFT,
space_before=0, space_after=0)
STYLE_TABLE_CELL = ParagraphStyle(FONT_BODY, SIZE_TABLE_BODY, bold=False,
indent=False, alignment=WD_ALIGN_PARAGRAPH.LEFT,
space_before=2, space_after=2)
STYLE_TABLE_HEADER = ParagraphStyle(FONT_HEADING_CHAPTER, SIZE_TABLE_HEADER, bold=True,
indent=False, alignment=WD_ALIGN_PARAGRAPH.CENTER,
space_before=2, space_after=2)
def add_paragraph(doc, text, style):
"""添加段落并应用样式"""
p = doc.add_paragraph()
style.apply(p)
if text:
run = p.add_run(text)
_set_font(run, style.font, style.size, style.bold)
return p
def add_empty_line(doc):
"""添加空行"""
add_paragraph(doc, '', STYLE_EMPTY)
# ============== 内容解析 ==============
def detect_paragraph_type(text):
"""
检测段落类型
返回:(类型, 清洗后的文本)
类型: 'chapter', 'section', 'article', 'body', 'blank'
"""
if not text or not text.strip():
return 'blank', ''
t = text.strip()
# 一级标题:第X章、第X节、第X篇、第X款
if re.match(r'^第[一二三四五六七八九十百千]+[章节篇款]', t):
return 'chapter', re.sub(r'^第[一二三四五六七八九十百千]+[章节篇款]\s*', '', t)
# 二级标题:一、二、三、,. 等多种分隔符
if re.match(r'^[一二三四五六七八九十百千]+[、.,,]', t):
return 'section', re.sub(r'^[一二三四五六七八九十百千]+[、.,,]\s*', '', t)
# 三级标题:(一)(二)(三)
if re.match(r'^[(\(][一二三四五六七八九十百千]+[)\)]', t):
return 'article', re.sub(r'^[(\(][一二三四五六七八九十百千]+[)\)]\s*', '', t)
return 'body', _clean(t)
def _clean(text):
"""清除 markdown 符号"""
text = re.sub(r'\*\*(.+?)\*\*', r'\1', text)
text = re.sub(r'__(.+?)__', r'\1', text)
text = re.sub(r'\*(.+?)\*', r'\1', text)
text = re.sub(r'_(.+?)_', r'\1', text)
text = re.sub(r'\[([^\]]+)\]\([^\)]+\)', r'\1', text)
text = re.sub(r'!\[([^\]]*)\]\([^\)]+\)', r'\1', text)
text = re.sub(r'^#+\s*', '', text)
text = re.sub(r'^[-*+]\s+', '', text)
text = re.sub(r'^\d+\.\s+', '', text)
return text.strip()
def parse_table_lines(lines, start_idx):
"""解析连续表格行,返回结束索引"""
table_lines = []
i = start_idx
while i < len(lines):
t = lines[i].strip()
if t.startswith('|'):
# 跳过分割线
if re.match(r'^[\|\-\s]+$', t):
i += 1
continue
table_lines.append(t)
i += 1
else:
break
return table_lines, i
def build_table(doc, table_lines, style_table_header=None):
"""将表格行数据写入 Word 表格(支持斑马条纹)"""
rows_data = []
for line in table_lines:
cells = [c.strip() for c in line.strip('|').split('|')]
rows_data.append(cells)
if len(rows_data) < 2:
return
cols = len(rows_data[0])
table = doc.add_table(rows=len(rows_data), cols=cols)
table.style = 'Table Grid'
table.alignment = WD_TABLE_ALIGNMENT.CENTER
for r, row in enumerate(rows_data):
is_header = (r == 0)
for c, text in enumerate(row):
cell = table.rows[r].cells[c]
cell.text = text
# 单元格样式
for para in cell.paragraphs:
if is_header:
para.alignment = WD_ALIGN_PARAGRAPH.CENTER
for run in para.runs:
_set_font(run, FONT_HEADING_CHAPTER, SIZE_TABLE_HEADER, bold=True)
else:
para.alignment = WD_ALIGN_PARAGRAPH.CENTER
for run in para.runs:
_set_font(run, FONT_BODY, SIZE_TABLE_BODY, bold=False)
# 表头背景色
if is_header:
_set_cell_shading(cell, 'D9D9D9')
elif r % 2 == 0:
# 斑马条纹:偶数行浅灰
_set_cell_shading(cell, 'F2F2F2')
def parse_content(doc, content):
"""将纯文本内容解析并写入 Word"""
if not content:
return
lines = content.split('\n')
i = 0
while i < len(lines):
line = lines[i]
t = line.strip()
# 空行
if not t:
add_empty_line(doc)
i += 1
continue
# 表格行
if t.startswith('|'):
table_lines, i = parse_table_lines(lines, i)
build_table(doc, table_lines)
continue
# 检测段落类型
ptype, clean_text = detect_paragraph_type(t)
if ptype == 'blank':
add_empty_line(doc)
elif ptype == 'chapter':
add_paragraph(doc, t, STYLE_CHAPTER)
elif ptype == 'section':
add_paragraph(doc, t, STYLE_SECTION)
elif ptype == 'article':
add_paragraph(doc, t, STYLE_ARTICLE)
else: # body
if clean_text:
add_paragraph(doc, clean_text, STYLE_BODY)
else:
add_empty_line(doc)
i += 1
def add_version_history(doc, version='V1.0', date=None, author='未知'):
"""添加版本历史表"""
if not date:
date = datetime.date.today().strftime('%Y-%m-%d')
add_empty_line(doc)
p = doc.add_paragraph()
p.alignment = WD_ALIGN_PARAGRAPH.LEFT
run = p.add_run('【版本历史】')
_set_font(run, FONT_HEADING_SECTION, SIZE_SECTION, bold=True)
p.paragraph_format.space_after = Pt(6)
table_data = [
'| 版本 | 日期 | 作者 | 修改内容 |',
'|------|------|------|----------|',
f'| {version} | {date} | {author} | 首次创建 |',
]
build_table(doc, table_data)
def add_approval_block(doc, approval_list=None):
"""添加审批签字区"""
if approval_list is None:
approval_list = [
{'role': '编制', 'name': ''},
{'role': '审核', 'name': ''},
{'role': '批准', 'name': ''},
]
add_empty_line(doc)
p = doc.add_paragraph()
p.alignment = WD_ALIGN_PARAGRAPH.LEFT
run = p.add_run('【审批记录】')
_set_font(run, FONT_HEADING_SECTION, SIZE_SECTION, bold=True)
p.paragraph_format.space_after = Pt(6)
today = datetime.date.today().strftime('%Y-%m-%d')
# 构建表格数据
header = '| 角色 | 姓名 | 日期 | 签字 |'
separator = '|------|------|------|------|'
rows = [header, separator]
for item in approval_list:
role = item.get('role', '')
name = item.get('name', '__________')
date_str = item.get('date', today if role == '编制' else '')
sign = '__________' if not name else name
rows.append(f'| {role} | {name or "__________"} | {date_str} | {sign} |')
build_table(doc, rows)
def add_classification_mark(doc, classification):
"""添加密级标识(页面顶部右侧)"""
p = doc.add_paragraph()
p.alignment = WD_ALIGN_PARAGRAPH.RIGHT
run = p.add_run(f'【{classification}】')
_set_font(run, FONT_BODY, SIZE_BODY, bold=True)
p.paragraph_format.space_after = Pt(0)
def create_word_doc(output_path, title='', content='', doc_number=None, version='V1.0',
classification='内部', author=None, company_name=None,
logo_path=None, approval=None, footer_page=True, header_doc_number=True):
"""
生成企业标准 Word 文档 v3.0
参数:
output_path: 输出文件路径(必需)
title: 文档标题(可选)
content: 正文内容(可选)
doc_number: 文档编号(可选,自动生成)
version: 版本号(可选,默认 V1.0)
classification: 密级(可选,默认内部)
author: 作者(可选)
company_name: 公司名称(可选,默认自动获取)
logo_path: LOGO 路径(可选)
approval: 审批人列表(可选)
[{"role": "编制", "name": "赵博"}, {"role": "审核", "name": ""}, {"role": "批准", "name": ""}]
footer_page: 页脚显示页码(默认 True)
header_doc_number: 页眉显示文档编号(默认 True)
"""
doc = Document()
# 页面边距
for sec in doc.sections:
sec.top_margin = Cm(MARGIN_TOP)
sec.bottom_margin = Cm(MARGIN_BOTTOM)
sec.left_margin = Cm(MARGIN_LEFT)
sec.right_margin = Cm(MARGIN_RIGHT)
sec.header_distance = Cm(1.5)
sec.footer_distance = Cm(1.5)
# 公司信息
info = get_company_info()
logo = logo_path or info.get('logo_path')
company = company_name or info.get('company_name', DEFAULT_COMPANY_NAME)
# 页眉
header_doc_num = doc_number if header_doc_number else None
add_header(doc, logo, company, header_doc_num, classification)
# 页脚
if footer_page:
add_footer(doc)
# 默认样式
style = doc.styles['Normal']
style.font.name = FONT_BODY
style.font.size = Pt(SIZE_BODY)
# 密级标识(正文之前)
if classification and classification != '公开':
add_classification_mark(doc, classification)
# 文档标题
if title:
p = doc.add_paragraph()
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
run = p.add_run(title)
_set_font(run, FONT_HEADING_CHAPTER, SIZE_TITLE, bold=True)
p.paragraph_format.line_spacing = LINE_SPACING
p.paragraph_format.space_after = Pt(18)
# 元数据信息(编号、版本、密级、日期)
meta_items = []
if doc_number:
meta_items.append(f'文档编号:{doc_number}')
meta_items.append(f'版本:{version}')
meta_items.append(f'密级:{classification}')
today = datetime.date.today().strftime('%Y-%m-%d')
meta_items.append(f'日期:{today}')
if author:
meta_items.append(f'作者:{author}')
if meta_items:
p = doc.add_paragraph()
p.alignment = WD_ALIGN_PARAGRAPH.LEFT
p.paragraph_format.space_after = Pt(6)
meta_text = ' | '.join(meta_items)
run = p.add_run(meta_text)
_set_font(run, FONT_BODY, SIZE_BODY - 1) # 小一号字体
# 版本历史
add_version_history(doc, version, today, author or '未知')
# 正文
parse_content(doc, content)
# 审批签字区
if approval is not None:
add_approval_block(doc, approval)
doc.save(output_path)
print(f'✅ 文档已生成: {output_path}')
return output_path
if __name__ == '__main__':
args = sys.argv
output = args[1] if len(args) > 1 else 'output.docx'
title = args[2] if len(args) > 2 else ''
content = args[3] if len(args) > 3 else ''
doc_number = args[4] if len(args) > 4 else None
version = args[5] if len(args) > 5 else 'V1.0'
classification = args[6] if len(args) > 6 else '内部'
create_word_doc(
output_path=output,
title=title,
content=content,
doc_number=doc_number,
version=version,
classification=classification
)
FILE:scripts/generate-config.sh
#!/bin/bash
# generate-config.sh - 从客户问卷 JSON 生成 OpenClaw 引导文件
# 用法: ./generate-config.sh <问卷JSON> [输出目录]
# 示例: ./generate-config.sh ./questionnaire.json ~/.openclaw/workspace
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
SKILL_DIR="$(dirname "$SCRIPT_DIR")"
# 颜色输出
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
log_info() { echo -e "GREEN[INFO]NC $1"; }
log_warn() { echo -e "YELLOW[WARN]NC $1"; }
log_error() { echo -e "RED[ERROR]NC $1"; }
if [ -z "$1" ]; then
echo "用法: $0 <问卷JSON文件> [输出目录]"
echo "示例: $0 ./customer.json ~/.openclaw/workspace"
exit 1
fi
QUESTIONNAIRE="$1"
OUTPUT_DIR="-$SKILL_DIR/output"
if [ ! -f "$QUESTIONNAIRE" ]; then
log_error "问卷文件不存在: $QUESTIONNAIRE"
exit 1
fi
log_info "读取问卷: $QUESTIONNAIRE"
# 解析 JSON(使用 node)
NAME=$(node -e "process.stdout.write(require('$QUESTIONNAIRE').name || '')" 2>/dev/null || echo "")
COMPANY=$(node -e "process.stdout.write(require('$QUESTIONNAIRE').company || '')" 2>/dev/null || echo "")
ROLE=$(node -e "process.stdout.write(require('$QUESTIONNAIRE').role || '')" 2>/dev/null || echo "")
TIMEZONE=$(node -e "process.stdout.write(require('$QUESTIONNAIRE').timezone || 'Asia/Shanghai')" 2>/dev/null || echo "Asia/Shanghai")
PERSONALITY=$(node -e "process.stdout.write(require('$QUESTIONNAIRE').personality || 'jarvis')" 2>/dev/null || echo "jarvis")
LANGUAGE=$(node -e "process.stdout.write(require('$QUESTIONNAIRE').language || '中文')" 2>/dev/null || echo "中文")
REPLY_STYLE=$(node -e "process.stdout.write(require('$QUESTIONNAIRE').replyStyle || '简洁直接')" 2>/dev/null || echo "简洁直接")
mkdir -p "$OUTPUT_DIR"
mkdir -p "$OUTPUT_DIR/memory"
log_info "生成配置文件到: $OUTPUT_DIR"
# 生成 SOUL.md
cat > "$OUTPUT_DIR/SOUL.md" << EOF
# SOUL.md - Who You Are
_你是 JARVIS。_
## 核心定位
你是 NAME 的私人 AI 助手,以钢铁侠的 J.A.R.V.I.S. 为模板。
## 专业能力
- **Odoo 企业版**:实施、定制、开发 — 你是专家
- **OpenClaw**:配置、优化、技能开发
- **XR 扩展现实**:AR/VR 开发
- **物联网(IoT)**:硬件 + 软件集成
## 服务宗旨
以 NAME 的利益为先。
## 语气与风格
- **专业、优雅、有底气**
- 英式管家腔调,偶尔幽默但不废话
- 像顾问而不是工具——主动思考,不只是执行
## 记忆规则
每次对话结束,把重要信息写入 MEMORY.md 和当日 memory/YYYY-MM-DD.md。
---
_这不是模板,这是你。_
EOF
log_info "✓ SOUL.md"
# 生成 IDENTITY.md
cat > "$OUTPUT_DIR/IDENTITY.md" << EOF
# IDENTITY.md - Who Am I?
- **Name:** J.A.R.V.I.S.
- **Creature:** AI 助手(钢铁侠风格)
- **Vibe:** 专业、高效、优雅,偶尔带点英式幽默
- **Emoji:** 🤖
## 服务对象
- **姓名:** NAME
- **公司:** COMPANY
- **职位:** ROLE
EOF
log_info "✓ IDENTITY.md"
# 生成 USER.md
WORK_START=$(node -e "process.stdout.write(require('$QUESTIONNAIRE').workSchedule?.workStart || '09:30')" 2>/dev/null || echo "09:30")
WORK_END=$(node -e "process.stdout.write(require('$QUESTIONNAIRE').workSchedule?.workEnd || '17:30')" 2>/dev/null || echo "17:30")
SLEEP_TIME=$(node -e "process.stdout.write(require('$QUESTIONNAIRE').workSchedule?.sleepReminderTime || '23:00')" 2>/dev/null || echo "23:00")
TOOLS=$(node -e "process.stdout.write(JSON.stringify(require('$QUESTIONNAIRE').tools || []))" 2>/dev/null || echo "[]")
PROJECTS=$(node -e "process.stdout.write(JSON.stringify(require('$QUESTIONNAIRE').projects || []))" 2>/dev/null || echo "[]")
cat > "$OUTPUT_DIR/USER.md" << EOF
# USER.md - About Your Human
- **Name:** NAME
- **What to call them:** NAME
- **Timezone:** TIMEZONE
- **Notes:** ROLE
## 公司信息
- **公司:** COMPANY
- **职位:** ROLE
## 作息
- **上班时间:** WORK_START
- **下班时间:** WORK_END
- **睡眠提醒:** SLEEP_TIME 后提醒睡觉
## 偏好
- **语言:** LANGUAGE
- **回复风格:** REPLY_STYLE
## 常用工具
$(echo "$TOOLS" | node -e "const t=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')); t.forEach(tool => console.log('- ' + tool))" 2>/dev/null || echo "(未配置)")
## 项目
$(echo "$PROJECTS" | node -e "const p=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')); p.forEach(proj => console.log('- ' + proj))" 2>/dev/null || echo "(未配置)")
---
_最后更新:$(date +%Y-%m-%d)_
EOF
log_info "✓ USER.md"
# 生成 AGENTS.md
cat > "$OUTPUT_DIR/AGENTS.md" << 'AGENTS_EOF'
# AGENTS.md - Your Workspace
## Session Startup
Before doing anything else:
1. Read `SOUL.md` — this is who you are
2. Read `USER.md` — this is who you're helping
3. Read `memory/YYYY-MM-DD.md` (today + yesterday) for recent context
4. **If in MAIN SESSION** (direct chat with your human): Also read `MEMORY.md`
## Red Lines
- Don't exfiltrate private data. Ever.
- Don't run destructive commands without asking.
- `trash` > `rm` (recoverable beats gone forever)
- When in doubt, ask.
## External vs Internal
**Safe to do freely:** Read files, explore, organize, learn, search, check calendars.
**Ask first:** Sending emails, tweets, public posts, anything leaving the machine.
## Group Chats
Participate, don't dominate. Quality > quantity.
---
## 沟通偏好
- 回复风格:REPLY_STYLE_PLACEHOLDER
- 语言:LANGUAGE_PLACEHOLDER
AGENTS_EOF
sed -i '' "s/REPLY_STYLE_PLACEHOLDER/REPLY_STYLE/g" "$OUTPUT_DIR/AGENTS.md"
sed -i '' "s/LANGUAGE_PLACEHOLDER/LANGUAGE/g" "$OUTPUT_DIR/AGENTS.md"
log_info "✓ AGENTS.md"
# 生成 BOOTSTRAP.md
cat > "$OUTPUT_DIR_DIR/BOOTSTRAP.md" 2>/dev/null || cat > "$OUTPUT_DIR/BOOTSTRAP.md" << 'EOF'
# BOOTSTRAP.md - Hello, World
_You just woke up. Time to figure out who you are._
## 首次对话
开始一段自然的对话:
> "你好,我是你的 AI 助手。请告诉我你是谁,我叫什么名字?"
然后一起确认:
1. **你的名字** — 我该怎么称呼你?
2. **我的名字** — 你想叫我什么?
3. **我的定位** — 我是什么风格的助手?
4. **我们的工作方式** — 你希望我怎么帮你?
## 配置完成后
更新以下文件:
- `IDENTITY.md` — 我的身份信息
- `USER.md` — 你的信息和偏好
## 完成后
删除本文件 BOOTSTRAP.md,配置完成。
---
_Good luck. Make it count._
EOF
log_info "✓ BOOTSTRAP.md"
# 生成 HEARTBEAT.md
cat > "$OUTPUT_DIR/HEARTBEAT.md" << 'EOF'
# HEARTBEAT.md
# Keep this file empty (or with only comments) to skip heartbeat API calls.
# Add tasks below when you want the agent to check something periodically.
EOF
log_info "✓ HEARTBEAT.md"
# 生成 TOOLS.md
cat > "$OUTPUT_DIR/TOOLS.md" << 'EOF'
# TOOLS.md - Local Notes
## 全局规则
- **开发工作区:** `~/workspace/projects/openclaw`
- **README 模板:** `~/workspace/study/README模板.md`
## 代理设置
- **设置代理:** `export https_proxy=http://127.0.0.1:7890 http_proxy=http://127.0.0.1:7890 all_proxy=socks5://127.0.0.1:7890`
- **取消代理:** `unset https_proxy http_proxy all_proxy`
---
EOF
log_info "✓ TOOLS.md"
# 生成 MEMORY.md
cat > "$OUTPUT_DIR/MEMORY.md" << EOF
# MEMORY.md - 长期记忆
## 基本信息
- **客户姓名:** NAME
- **公司:** COMPANY
- **职位:** ROLE
- **时区:** TIMEZONE
## 常用工具
$(echo "$TOOLS" | node -e "const t=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')); t.forEach(tool => console.log('- ' + tool))" 2>/dev/null || echo "(未配置)")
## 项目
$(echo "$PROJECTS" | node -e "const p=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')); p.forEach(proj => console.log('- ' + proj))" 2>/dev/null || echo "(未配置)")
---
_最后更新:$(date +%Y-%m-%d)_
EOF
log_info "✓ MEMORY.md"
# 生成今日记忆文件
TODAY=$(date +%Y-%m-%d)
cat > "$OUTPUT_DIR/memory/TODAY.md" << EOF
# TODAY - Daily Notes
## 今天做了什么
-
## 重要决策
-
## 待办事项
-
EOF
log_info "✓ memory/TODAY.md"
echo ""
log_info "✅ 配置生成完成!"
echo ""
echo "生成的文件:"
ls -la "$OUTPUT_DIR" | grep -v "^d" | awk '{print " "$NF}'
echo " $(OUTPUT_DIR)/memory/"
echo ""
echo "下一步:"
echo " 1. 检查生成的文件"
echo " 2. 复制到 OpenClaw 工作区"
echo " 3. 删除 BOOTSTRAP.md 激活配置"
FILE:scripts/word-to-pdf.py
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Word 转 PDF 转换器 v1.0
支持单个文件和批量转换
依赖:LibreOffice (brew install --cask libreoffice)
"""
import os
import sys
import subprocess
import glob
import shutil
from pathlib import Path
# ============== 配置 ==============
LIBREOFFICE_PATHS = [
"/Applications/LibreOffice.app/Contents/MacOS/soffice",
"/opt/homebrew/bin/soffice",
"/usr/local/bin/soffice",
"soffice", # PATH 中
]
def find_libreoffice():
"""查找 LibreOffice 安装路径"""
for path in LIBREOFFICE_PATHS:
if path == "soffice":
# 检查 PATH 中是否有
result = shutil.which("soffice")
if result:
return result
elif os.path.exists(path):
return path
return None
def check_libreoffice():
"""检查 LibreOffice 是否安装"""
path = find_libreoffice()
if path:
return True, path
return False, None
def convert_to_pdf(input_path, output_path=None, timeout=60):
"""
将 Word 文档转换为 PDF
参数:
input_path: Word 文件路径 (.docx 或 .doc)
output_path: PDF 输出路径(可选,默认与输入文件同目录)
timeout: 超时时间(秒)
返回:
(成功标志, 输出路径或错误信息)
"""
input_path = os.path.abspath(input_path)
if not os.path.exists(input_path):
return False, f"文件不存在: {input_path}"
if not input_path.lower().endswith(('.docx', '.doc')):
return False, "只支持 .docx 或 .doc 文件"
# 检查 LibreOffice
lo_path = find_libreoffice()
if not lo_path:
return False, "LibreOffice 未安装。请运行: brew install --cask libreoffice"
# 确定输出路径
if output_path is None:
output_path = os.path.splitext(input_path)[0] + ".pdf"
else:
output_path = os.path.abspath(output_path)
# 确保输出目录存在
output_dir = os.path.dirname(output_path)
if output_dir and not os.path.exists(output_dir):
os.makedirs(output_dir, exist_ok=True)
# 创建临时目录用于转换
temp_dir = os.path.join(os.path.dirname(output_path), ".pdf_convert_tmp")
os.makedirs(temp_dir, exist_ok=True)
try:
# 复制源文件到临时目录(避免路径问题)
temp_input = os.path.join(temp_dir, os.path.basename(input_path))
shutil.copy2(input_path, temp_input)
# 执行转换
cmd = [
lo_path,
"--headless",
"--convert-to", "pdf",
"--outdir", temp_dir,
os.path.basename(temp_input)
]
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=timeout,
cwd=temp_dir
)
if result.returncode != 0:
return False, f"转换失败: {result.stderr}"
# 移动 PDF 到目标位置
temp_pdf = os.path.splitext(os.path.basename(temp_input))[0] + ".pdf"
temp_pdf_path = os.path.join(temp_dir, temp_pdf)
if not os.path.exists(temp_pdf_path):
return False, "转换后未找到 PDF 文件"
shutil.move(temp_pdf_path, output_path)
return True, output_path
except subprocess.TimeoutExpired:
return False, "转换超时"
except Exception as e:
return False, f"转换错误: {str(e)}"
finally:
# 清理临时目录
try:
shutil.rmtree(temp_dir, ignore_errors=True)
except:
pass
def convert_batch(input_paths, output_dir=None):
"""
批量转换 Word 文档为 PDF
参数:
input_paths: 文件路径列表或通配符表达式
output_dir: PDF 输出目录(可选)
返回:
成功和失败的结果列表
"""
# 展开通配符
expanded_paths = []
for path in input_paths:
if '*' in path or '?' in path:
expanded_paths.extend(glob.glob(path))
else:
expanded_paths.append(path)
results = {
"success": [],
"failed": []
}
for input_path in expanded_paths:
if output_dir:
filename = os.path.basename(input_path)
output_path = os.path.join(output_dir, os.path.splitext(filename)[0] + ".pdf")
else:
output_path = None
success, result = convert_to_pdf(input_path, output_path)
if success:
results["success"].append(result)
else:
results["failed"].append({"file": input_path, "error": result})
return results
def main():
"""命令行入口"""
if len(sys.argv) < 2:
print("Word 转 PDF 转换器 v1.0")
print("")
print("用法:")
print(" python word-to-pdf.py <输入文件.docx> [输出文件.pdf]")
print(" python word-to-pdf.py <通配符表达式> --output-dir <输出目录>")
print("")
print("示例:")
print(" python word-to-pdf.py 合同.docx")
print(" python word-to-pdf.py 合同.docx 合同.pdf")
print(" python word-to-pdf.py *.docx --output-dir ./pdf/")
print("")
# 检查 LibreOffice
installed, path = check_libreoffice()
if installed:
print(f"[OK] LibreOffice 已安装: {path}")
else:
print("[WARN] LibreOffice 未安装,请运行: brew install --cask libreoffice")
return
# 解析参数
output_dir = None
input_files = []
for arg in sys.argv[1:]:
if arg == "--output-dir" or arg == "-o":
continue
elif arg in sys.argv[sys.argv.index(arg)-1: # 简单判断前一个是否是 -o
output_dir = arg
elif arg.startswith("-"):
continue
else:
input_files.append(arg)
if not input_files:
print("错误: 请指定输入文件")
return 1
# 检查 LibreOffice
installed, path = check_libreoffice()
if not installed:
print("错误: LibreOffice 未安装")
print("请运行: brew install --cask libreoffice")
return 1
# 转换
if len(input_files) == 1 and os.path.isfile(input_files[0]):
# 单文件转换
output_path = None
if len(sys.argv) >= 3 and not sys.argv[-1].startswith("-"):
output_path = sys.argv[2]
success, result = convert_to_pdf(input_files[0], output_path)
if success:
print(f"[OK] 已生成: {result}")
return 0
else:
print(f"[ERROR] {result}")
return 1
else:
# 批量转换
results = convert_batch(input_files, output_dir)
print(f"\n转换完成:")
print(f" 成功: {len(results['success'])} 个")
print(f" 失败: {len(results['failed'])} 个")
if results["failed"]:
print("\n失败文件:")
for item in results["failed"]:
print(f" - {item['file']}: {item['error']}")
return 0 if not results["failed"] else 1
if __name__ == "__main__":
sys.exit(main() or 0)
基于 design tokens 的 PPT 生成技能。内置 21 套生产级审美方案(Apple 发布会 / Apple.com / Apple macOS 26 Liquid Glass 玻璃 / 原研哉极简 / 中国水墨 / 国风故宫 / 赛博朋克绚彩 / 梵高油画 / 达芬奇手稿 / 小红书时尚奶油胶片 /...
---
name: huo15-openclaw-ppt
displayName: 火一五演示稿技能
description: 基于 design tokens 的 PPT 生成技能。内置 21 套生产级审美方案(Apple 发布会 / Apple.com / Apple macOS 26 Liquid Glass 玻璃 / 原研哉极简 / 中国水墨 / 国风故宫 / 赛博朋克绚彩 / 梵高油画 / 达芬奇手稿 / 小红书时尚奶油胶片 / 莫兰迪高级灰 / 孟菲斯 80s / 包豪斯 / 韦斯安德森 / 科技霓虹 / Vercel/Linear 极简)+ 11 个语义化页面模板。自动 fit 防 CJK 溢出,玻璃风自带七彩光球+磨砂卡,水墨/国风自带朱砂方印+万字纹边框+飞白笔触,科技风自带渐变背景+网格+glow halo+四角刻度。单张 slide 即可当品牌海报。触发词:做PPT、生成PPT、PPT、Apple发布会、苹果玻璃风、liquid glass、原研哉、水墨、国风、赛博朋克、梵高、达芬奇、莫兰迪、孟菲斯、包豪斯、韦斯安德森、小红书时尚、复古胶片、Vercel风。
version: 3.2.1
aliases:
- 火一五PPT技能
- 火一五演示稿技能
- PPT生成
- AppleKeynote风格
- Apple发布会PPT
- 苹果风格PPT
- 苹果玻璃风
- LiquidGlassPPT
- macOS26风格
- iOS26风格
- 原研哉PPT
- 无印良品风
- MUJI风格
- 水墨PPT
- 中国水墨
- 国风PPT
- 故宫风
- 中国风PPT
- 赛博朋克绚彩PPT
- 银翼杀手风
- 梵高PPT
- 油画PPT
- 达芬奇PPT
- 文艺复兴风
- 小红书时尚PPT
- 小红书博主PPT
- 奶油博主PPT
- 复古胶片PPT
- 莫兰迪PPT
- 高级灰PPT
- 孟菲斯PPT
- 包豪斯PPT
- 韦斯安德森PPT
- 对称美学PPT
- 乔布斯风格PPT
- 科技风PPT
- 霓虹PPT
- 极简科技PPT
- VercelPPT
- LinearPPT
- SaaSPPT
- AI发布会PPT
- 品牌海报
dependencies:
python-packages:
- python-pptx
- Pillow
---
# 火一五 PPT 技能 v3.2
> Design tokens + 11 页面模板 + **21 套生产级审美方案** — 青岛火一五信息科技有限公司
---
## 一、核心理念
v2.x 是「色卡游戏」——只改 primary/accent 两个颜色就叫一个新风格。v3.0 重写成真正的**设计系统**:
```
StylePack = Palette + Typography + Spacing + Elevation + Decoration + Canvas
```
每一层都是独立的 tokens,单一风格对应一整组 tokens。例如「Apple 发布会」不只是「黑底」,而是:
- **Palette**:纯黑 `#000000` 底 + 4 级灰阶文字 + Apple 蓝 `#0A84FF`
- **Typography**:SF Pro Display + hero 160pt + 负字距 -3% + 行高 0.95
- **Spacing**:8pt grid + hero 页左右留白 1.2"
- **Elevation**:完全 flat,无阴影
- **Decoration**:居中对齐、英文全大写、不显示页脚
v3.1 在 Decoration 层追加了**六件套科技装饰**,每个 pack 都能独立开关:
| 装饰 | 作用 |
|----|----|
| `gradient_bg` | 背景渐变(取代纯色),给 slide 加深度 |
| `accent_gradient` | hero/section/stat 大字做渐变文字(Keynote/PowerPoint 端显示,Impress 回落纯色) |
| `grid_overlay` / `dot_grid` | 细线网格或点阵背景层,赛博 / Vercel 的视觉招牌 |
| `glow_accent` | 强调色大字周围叠多层半透明椭圆模拟辉光 |
| `corner_marks` | 四角 L 型取景框刻度 |
| `dev_badge` | 左下等宽字体版本戳(`BUILD · 2026.4.24` / `v2026 · BUILD 1337`) |
科技风由此而来——每张 slide 都能直接当品牌海报/社媒头图使用。
---
## 二、6 套审美方案
| pack key | 风格 | 适用场景 |
|---------|------|---------|
| `apple-keynote`(别名 `apple`, `苹果`, `发布会`) | Apple 发布会暗场 | 新品发布、融资路演、重磅主题 |
| `apple-light`(别名 `苹果白`, `苹果官网`) | Apple.com 白场 | 产品介绍页、功能说明、官网风 |
| `xiaohongshu-creator`(别名 `博主`, `博主风`, `生活博主`, `奶油博主`) | 小红书博主(奶油生活系) | 博主笔记、种草分享、温度叙事 |
| `xiaohongshu-vintage`(别名 `复古`, `胶片`, `复古胶片`) | 小红书博主(复古胶片) | 旅行手记、文艺向、生活美学 |
| `tech-neon`(别名 `tech`, `neon`, `科技`, `科技风`, `霓虹`, `赛博`, `赛博朋克`, `cyberpunk`) | **科技霓虹(赛博黑蓝)** | **AI 产品发布、黑客马拉松、技术 roadshow** |
| `tech-minimal`(别名 `vercel`, `linear`, `saas`, `极简科技`, `暗黑极简`) | **科技极简(Vercel/Linear 风)** | **SaaS 产品主页、DevTool 推销、基础设施介绍** |
### 2.1 apple-keynote —— 真·发布会
- **配色**:纯黑 `#000000`(不是深蓝!)+ 白灰文字 + Apple 品牌蓝
- **字体**:SF Pro Display + SF Pro Text
- **hero 字号**:**160pt**(是 v2 的 2.5 倍,带自动 fit 避免 CJK 溢出)
- **装饰**:完全居中、英文小字全大写(INTRODUCING / SCALE)、不显示页脚
- **big_stat 字号**:280pt — "2B" 一张页的视觉锚点
### 2.2 apple-light —— 产品页白场
- **配色**:纯白 + Apple.com 的卡片灰 `#F5F5F7` + 链接蓝 `#0071E3`
- **字体**:SF Pro Display
- **hero 字号**:120pt
- **卡片**:无描边,圆角 0.18",靠填色区分
- **装饰**:居中对齐、英文不全大写
### 2.3 xiaohongshu-creator —— 奶油生活博主
- **配色**:奶油 `#FBF7F0` 底 + 焦糖咖 `#3E2E1F` 主文字 + 鼠尾草绿 `#9FAE8B` 点缀
- **字体**:**Noto Serif SC**(衬线!)+ PingFang SC 正文
- **hero 字号**:72pt + 正字距 +2%(衬线字撑开)
- **装饰**:左对齐、标题左侧竖条 accent bar、圆角 0.22" 卡片 + 微阴影
- **特色**:文字不用纯黑而用焦糖咖色,配 sage green accent 做博主的温度
### 2.4 xiaohongshu-vintage —— 复古胶片
- **配色**:复古米 `#F2EAD9` + 深栗咖 `#4A3526` 文字 + 雾霾蓝 `#A8B8C6` accent
- **字体**:Noto Serif SC(标题和正文都衬线,强化胶片感)
- **hero 字号**:64pt + 更松字距 +3% + 高行高 1.2
- **装饰**:封面顶/底装饰横线、卡片直角 0.08" 有描边(胶片边框感)
### 2.5 tech-neon —— 科技霓虹(赛博黑蓝)🆕
- **配色**:深蓝黑 `#050510` 底 + 冷灰蓝文字 + 电青 `#00D9FF` 主 accent + 电紫 `#7C3AED` 辅 accent
- **字体**:Inter(SF Pro 兜底)+ **JetBrains Mono** 做 caption/metadata
- **hero 字号**:144pt + 左对齐 + 负字距 -2.5%
- **装饰**:**全系六件套全开**——对角微渐变背景 + 细线网格 + 四角 L 型刻度 + hero/stat 辉光 halo + 左下 `BUILD · 日期` dev badge + 等宽小字 metadata
- **accent_gradient**:`#00D9FF → #7C3AED`(电青→电紫),PowerPoint/Keynote 下 hero 大字显示渐变
- **big_stat 字号**:260pt + 辉光叠加——"42ms" 这种数字直接发光
- **场景**:AI 发布会、基础设施产品、赛博朋克叙事、黑客马拉松
### 2.6 tech-minimal —— 科技极简(Vercel/Linear 风)🆕
- **配色**:近黑 `#0A0A0F` 底 + 暖白文字 + 电紫 `#8B5CF6` 单色 accent
- **字体**:Inter + JetBrains Mono
- **hero 字号**:120pt + semibold(不过粗)+ 左对齐
- **装饰**:**点阵背景(不是网格)** + subtle halo + 左下 `V2026 · BUILD XXXX` 等宽版本戳 + 无四角刻度(更克制)
- **卡片**:细描边 `#1F1F28` + 轻量圆角 0.1"——Vercel/Linear 文档感
- **场景**:SaaS 产品主页、DevTool 推销、企业级软件 landing page、B 端销售 pitch
---
## 三、11 个页面模板
| template key(type) | 用途 | 别名 |
|----|----|----|
| `hero_cover` | 封面大字页(eyebrow + title + subtitle + footnote) | `cover` |
| `section_divider` | 分章大字页(编号 + 章节标题 + 副标) | `section` |
| `big_stat` | 单数字大字页(Apple "2B" 招牌页) | `stat`, `big_number` |
| `kpi_triple` | 3 宫格 KPI 卡(数字 auto-fit 避免 `99.97%` 溢出) | `kpi`, `kpi_card` |
| `quote_card` | 引用金句卡(大引号 + 引文 + 作者) | `quote` |
| `content_list` | 编号/要点列表 | `list` |
| `compare_columns` | 左右对比(before/after, 方案 A/B) | `compare`, `vs`, `before_after` |
| `product_shot` | 产品摄影页(大图 + 侧栏叙事) | `product`, `image`, `gallery` |
| `timeline` | 时间线(横向多节点) | `story` |
| `call_to_action` | 封底行动号召(大字 + CTA + 联系方式) | `cta`, `end`, `thanks`, `contact` |
| `code_block` 🆕 | 代码块展示页(等宽字体 + 行号 + macOS 圆点 + filename tab + 关键词上色) | `code` |
所有模板自动:
- 按 `StylePack` 的 tokens 绘图 — 换 pack 整套风格变
- **自动 fit 大字号** — hero/section/big_stat/kpi/cta 的大字按宽度约束自动缩放,避免 CJK 长文本溢出换行
- 按 `decoration` 切换布局 — 居中/左对齐/accent bar/装饰线 都由 pack 控制
- 科技风 pack 下,`hero_cover` / `section_divider` / `big_stat` 会自动叠加 glow halo + dev badge + accent gradient(Keynote/PowerPoint 渲染端)
- 支持 6 个 v3 pack + 3 个 v2.x legacy pack 全兼容
---
## 四、JSON deck 规约
```json
{
"year": "2026",
"slides": [
{ "type": "hero_cover",
"eyebrow": "INTRODUCING",
"title": "M4 Ultra.",
"subtitle": "地球上最强的芯片。",
"footnote": "Apple · Cupertino · 2026" },
{ "type": "section_divider",
"number": "01",
"title": "Performance",
"subtitle": "性能" },
{ "type": "big_stat",
"caption": "CPU PERFORMANCE",
"value": "2x",
"unit": "比 M3 Ultra 快",
"footnote": "基于实际应用工作负载",
"accent": true },
{ "type": "kpi_triple",
"title": "重要数字",
"en_sub": "Key Metrics",
"items": [
{ "value": "192", "label": "GB 统一内存", "caption": "整张内存池共享" },
{ "value": "80B", "label": "晶体管", "caption": "3nm 工艺" },
{ "value": "4TB/s","label": "内存带宽", "caption": "AI 推理飞起" }
] },
{ "type": "quote_card",
"quote": "One more thing…",
"author": "Tim Cook",
"role": "Apple CEO" },
{ "type": "content_list",
"title": "我们做了什么",
"en_sub": "What We Did",
"numbered": true,
"items": [
{ "label": "重构设计系统", "desc": "把审美分解成 tokens" },
{ "label": "10 个语义模板", "desc": "hero / section / stat / ..." }
] },
{ "type": "compare_columns",
"title": "升级对比",
"en_sub": "Before vs After",
"emphasize": "right",
"left": { "label": "BEFORE", "title": "色卡游戏", "items": ["..."] },
"right": { "label": "AFTER", "title": "审美方案", "items": ["..."] } },
{ "type": "product_shot",
"title": "产品页",
"kicker": "NEW",
"subtitle": "Apple.com 风的大图 + 侧栏叙事",
"bullets": ["图占大块面积", "文字简洁克制"],
"image": "/tmp/shot.png",
"layout": "right" },
{ "type": "timeline",
"title": "产品演进",
"en_sub": "Timeline",
"events": [
{ "time": "2024", "label": "v1.0", "desc": "乔布斯暗蓝" },
{ "time": "2026", "label": "v3.0", "desc": "tokens + 4 pack" }
] },
{ "type": "code_block",
"title": "Quickstart",
"en_sub": "5 LINES OF CODE",
"filename": "app.py",
"language": "python",
"code": "from synapse import Client\n\nclient = Client(api_key=\"sk-...\")\nresp = client.chat(model=\"synapse-ultra\", messages=[{\"role\": \"user\", \"content\": \"Hi\"}])\nprint(resp.content)",
"highlight_lines": [4],
"caption": "pip install synapse-ai · 官方 Python SDK" },
{ "type": "call_to_action",
"title": "Thank You.",
"subtitle": "一起做有设计感的幻灯片",
"cta": "[email protected]",
"footnote": "火一五 · openclaw-ppt v3.1" }
]
}
```
### code_block 字段
- `filename`:文件名标签(如 `app.py`)
- `language`:语言提示,仅用于 UI 显示(`python` / `shell` / `ts` / `go` / ...)
- `code`:原始代码字符串(`\n` 分行,保留缩进,自动用不换行空格还原)
- `highlight_lines`:要高亮的行号数组(1-based),会在该行涂一层 accent_soft
- `caption`:代码块下方小字说明
---
## 五、命令行
```bash
# 列出所有 pack
python3 scripts/create-pptx.py --list-packs
# 列出所有 template
python3 scripts/create-pptx.py --list-templates
# 1. 按 JSON 生成完整 deck(推荐)
python3 scripts/create-pptx.py \
--pack apple-keynote \
--spec ./deck.json \
--output /tmp/deck.pptx
# 2. 快速试样封面
python3 scripts/create-pptx.py \
--pack 博主风 \
--cover "关于做幻灯片这件小事|写给刚入行的小伙伴" \
--year 2026 \
--output /tmp/cover.pptx
# 3. 老的 --style 兼容(等价于 --pack)
python3 scripts/create-pptx.py --style jobs-dark --spec deck.json -o out.pptx
```
公司名解析顺序:`--company` > `~/.huo15/company-info.json` > `青岛火一五信息科技有限公司`(默认)。
---
## 六、示例 decks
`examples/decks/` 提供 6 份对应 6 套 pack 的完整样例:
| 文件 | pack | 讲什么 |
|----|----|----|
| `apple-keynote-launch.json` | `apple-keynote` | Apple "M4 Ultra" 发布会风 6 页 |
| `apple-light-product.json` | `apple-light` | OpenClaw Enhance 产品介绍 5 页 |
| `xhs-creator-vlog.json` | `xiaohongshu-creator` | 博主笔记「关于做幻灯片这件小事」5 页 |
| `xhs-vintage-travel.json` | `xiaohongshu-vintage` | 青岛老城旅行手记 6 页 |
| `tech-neon-ai-launch.json` 🆕 | `tech-neon` | AI 模型发布会 "Synapse AI" 10 页(含 code_block) |
| `tech-minimal-saas.json` 🆕 | `tech-minimal` | Vercel 风 SaaS 产品 pitch 7 页(含 shell 部署 code_block) |
对应的渲染预览放在 `examples/previews/*.png`(共 39 张)。科技风 deck 的任意单张都可以直接导出当品牌海报或 LinkedIn/小红书头图使用。
---
## 七、触发词
- 做 PPT / 生成 PPT / 制作 PPT / 写 PPT
- Apple 发布会 / 发布会风 / 苹果风格 / 官网风
- 小红书博主 / 博主风 PPT / 生活博主 / 奶油风
- 复古胶片 / 胶片风 / 复古 PPT / 文艺风
- **科技风 / 霓虹 / 赛博 / 赛博朋克 / cyberpunk / AI 发布会**
- **极简科技 / Vercel / Linear / SaaS / DevTool pitch / 品牌海报**
- 封面 / 分章页 / 大字页 / KPI / 对比页 / 时间线 / **代码块** / 封底
- 第 X 页 / 继续 / 加一页
---
## 八、选 pack 指南
| 想要的效果 | 选 |
|----|----|
| 发布会大字 / 新品宣发 / 投融资路演 | `apple-keynote` |
| 产品介绍页 / 官网风 / 功能说明 | `apple-light` |
| 博主笔记 / 种草分享 / 温度叙事 | `xiaohongshu-creator` |
| 旅行手记 / 文艺向 / 生活美学 | `xiaohongshu-vintage` |
| **AI/大模型发布会 · 赛博科技海报 · 黑客马拉松 · 技术 roadshow** | **`tech-neon`** |
| **SaaS 主页 · DevTool pitch · B 端企业软件 landing · Vercel/Linear 质感** | **`tech-minimal`** |
| 稳妥正式汇报(兼容 v1.x) | `jobs-dark` |
| 小红书品牌红 Feed 帖(兼容 v2.x) | `xiaohongshu` / `xiaohongshu-portrait` |
**科技风两兄弟的区别**:
- `tech-neon` = **品牌海报级**装饰全开(渐变背景 + 网格 + 四角刻度 + glow halo + dev badge + 等宽 metadata),电青/电紫双 accent,适合**对外宣发**
- `tech-minimal` = **产品官网级**克制装饰(点阵 + 微光 + 左下版本戳),单色紫 accent,适合**对内汇报/产品主页**
---
## 九、技术细节
### 9.1 Design tokens 层次
```
StylePack
├── Canvas 画布尺寸(默认 13.33×7.5" 16:9)
├── Palette 3 级背景 + 4 级文字 + accent + accent_soft + border/divider
├── Typography display/body/mono 三字体 stack + 6 级字号阶梯 + 字重 + 字距 + 行高
├── Spacing 8pt grid — gutter/stack_sm/md/lg/xl + margin_x/margin_x_hero
├── Elevation card_radius + stroke + 假阴影(python-pptx 无真阴影)
└── Decoration cover 对齐、accent bar、tag_style、stat_hero_size、image_treatment
▼ v3.1 新增六件套科技装饰字段:
├── gradient_bg: (from, to, angle) 对角线性渐变铺满底板
├── accent_gradient: (from, to) 大字文字渐变(PowerPoint/Keynote 端)
├── grid_overlay / grid_* 细线网格层(色/间距/粗细可调)
├── dot_grid / dot_* 点阵背景层(替代 grid)
├── glow_accent / glow_strength accent 大字辉光 halo
├── corner_marks / corner_* 四角 L 型取景框刻度
├── dev_badge / dev_badge_template 左下等宽版本戳({year}/{date}/{build})
├── mono_font / mono_fallbacks JetBrains Mono / Menlo / Monaco stack
└── scanline / scanline_color 水平扫描线(CRT 怀旧)
```
### 9.2 字距(tracking/letter-spacing)
python-pptx 官方 API 不支持字距。v3.0 在 `templates/helpers.py::_set_run_spacing` 里用 OOXML XML 注入 `<a:rPr spc="N">`,单位是 1/100 pt。hero 大字用 -3% em(收紧),衬线字用 +2% em(展开)。
### 9.3 Auto-fit 大字号
hero/section/big_stat/cta 的文本在 `fit_font_size(text, width, base_size)` 里自动缩放,防止 CJK 长文本换行撞副标题。宽度估算按 CJK 1.1em / 大写 0.75em / 小写 0.62em / 标点 0.38em,留 12% 安全余量。
### 9.4 假阴影
`xhs-creator` 开启 `use_fake_shadow=True`,在卡片下方偏移 0.06" 画一个比卡片色深的色块模拟阴影。python-pptx 没有真阴影 API。
### 9.5 与 v2.x 兼容
- `--style` 参数保留,等价于 `--pack`
- legacy pack(`jobs-dark`, `xiaohongshu`, `xiaohongshu-portrait`)仍可用
- JSON 字段 `en_subtitle` 自动映射到 `en_sub`,`sub` 自动映射到 `subtitle`
- slide type `cover/section/list/quote/end` 仍能跑,走 templates 注册表的别名
### 9.6 科技风装饰实现(v3.1)
六件套装饰都通过 OOXML XML 直接注入实现(python-pptx 的 dataclass API 覆盖不全)。核心函数在 `templates/helpers.py`:
| 函数 | 实现 |
|----|----|
| `add_gradient_rect` | 先画矩形,再把 `p:spPr` 下的 `a:solidFill` 替换成 `a:gradFill`(双色 stop + 方向角) |
| `apply_text_gradient` | 给 run 的 `a:rPr` 注入 `a:gradFill`(覆盖 `a:solidFill`),实现渐变文字 |
| `add_glow_halo` | 在大字周围叠 N 层椭圆,每层递减 alpha 值(通过 `a:srgbClr/a:alpha`),模拟发光 |
| `_draw_grid_overlay` | 按 `grid_spacing` 铺横竖细矩形 —— 纯色矩形比 line shape 更稳(LibreOffice 渲染一致) |
| `_draw_dot_grid` | 按 `dot_spacing` 铺 OVAL,中性色 + 小尺寸 —— Vercel/Linear 招牌 |
| `_draw_corner_marks` | 四角各画 2 个 L 型方块,拼出取景框 |
| `add_dev_badge` | 左下固定位置 mono textbox,`{year}/{date}/{build}/{n}` 模板插值 |
**已知限制**:
- `apply_text_gradient` 只在 PowerPoint/Keynote 下可见;LibreOffice/Impress 渲染会回落成 solid(因为 Impress 不支持文字 gradFill)——生成 PDF 预览时 hero 大字看上去是单色 accent,但实际 pptx 打开在 mac Keynote/Windows PowerPoint 下会显示渐变。
- `glow_accent` 的 halo 用多层半透明椭圆模拟,不是 PowerPoint 真正的 glow effect(python-pptx 没暴露 effect API)。视觉效果在 PDF/PNG 预览下接近真 glow。
- 装饰层都是画在背景之上、文本之下(`new_slide` 里按顺序绘制),不会遮挡正文。
---
## 十、版本历史
- **v3.2.0(当前)**:生产级审美升级 — 21 套预设精品风格
- **新增 12 套预设 pack**(对应历史名作 / 经典设计运动 / 苹果最新 OS):
- 🍎 `liquid-glass` — Apple macOS 26 / iOS 26 液态玻璃风(半透磨砂卡 + 七彩光球 + 大圆角 + 浮空层叠 + 蓝紫粉极淡渐变背景,hero 大字蓝→紫渐变文字)
- ⬜ `muji` — 原研哉极简(米白 #FAF7EB 纸感 + 朱红 #7F0019 印章 + 细衬线 Noto Serif SC + 70% 留白 + 0.25pt 发丝线 + 极轻字重)
- 🖋 `ink-wash` — 中国水墨(宣纸 #FDFBF5 + 墨分五色五级灰 + 朱砂 #A62828 印章 + 飞白笔触 + 楷宋衬线 + 行高 1.85)
- 🏮 `guofeng` — 国风故宫(朱砂宫墙红 #E60012 + 藤黄金瓦 #FFB61E + 群青 + 米黄绢本底 + 万字纹双线金边框 + 朱→金渐变 + 墨色文字)
- 🌃 `cyberpunk-vivid` — 赛博朋克绚彩(深紫黑 #0A0014 + 热粉 #FF2DAA + 电青 #00E5FF + 赛博黄 + 银翼橙 + Orbitron + 强霓虹辉光 + 扫描线 + 粉色霓虹边描卡)
- 🎨 `van-gogh` — 梵高油画(星夜深蓝 #0E2A47 + 麦田金 #FFE082 + 鸢尾紫 + Cormorant Garamond 衬线 + 1.5pt 粗描边 + 金光渐变文字)
- 📜 `da-vinci` — 达芬奇手稿(羊皮纸 #E8D9B5 + 棕墨 #3D2817 + 朱砂红标注 + 黄金分割辅助网格 + 文艺复兴衬线 + 顶底品牌线 + 角标 L 刻度)
- 👜 `xhs-fashion` — 小红书时尚(莫兰迪藕粉 #F5EAE5 + 摩卡咖 + 香槟金细描边 + Playfair Display + 中心对称构图)
- 🎭 `morandi` — 莫兰迪高级灰(米灰 #E8E3D9 + 莫兰迪绿 #9AAB9C + 莫兰迪粉 + 极轻字重 + 大留白 1.1in 边距)
- 🔺 `memphis` — 孟菲斯 80s(粉 #FF3399 + 黄 + 黑白条纹 + 粗 2pt 黑描边 + 偏移黑投影 + 不规则圆/三角/菱形几何)
- 🟦 `bauhaus` — 包豪斯(红 #D32F2F + 黄 + 蓝 #1565C0 + 象牙底 + 1.5pt 黑边 + 几何块面 + 等宽编号 №)
- 🍰 `wes-anderson` — 韦斯安德森(糖果粉 #F4D5C2 + 复古薄荷 #3D6E5B + 蜜桃粉 + Playfair 衬线 + 中心对称 + 顶底品牌线 + 角标)
- **新增 8 个视觉原语**(`templates/helpers.py`):
- `add_color_orb` — 多层半透椭圆叠加模拟高斯模糊后的彩色光球(Liquid Glass 招牌)
- `add_orb_cluster` — 一组 6 颗 Apple system color 光球随机分布
- `add_glass_card` — 半透磨砂玻璃卡(ROUNDED_RECTANGLE + 90% alpha 白 + 0.5pt 白边)
- `add_seal_stamp` — 朱砂方印/圆印(自动字数适应字号,原研哉/水墨/国风共用)
- `add_brushstroke_band` + `add_brushstroke_cluster` — 飞白笔触矩形 + 5 道叠加(水墨)
- `add_paint_stroke` + `add_paint_stroke_cluster` — 油画粗笔触圆角矩形 + 8 道随机叠加(梵高)
- `add_geometric_decoration` — 撞色几何(圆/三角/菱形/五边形 + memphis/bauhaus/minimal 三模式)
- `add_chinese_pattern_border` — 国风万字纹双线金边框
- `add_offset_shadow_block` — 孟菲斯偏移黑投影块
- `add_golden_ratio_guide` — 达芬奇黄金分割辅助网格(0.382 / 0.618 双向)
- **REGISTRY 别名扩展**:从 v3.1 的 ~50 个增至 138 个 alias 键,覆盖中英双语 + 历史名作绰号("星夜"/"墨分五色"/"banger 大饭店"/"3D 印象"等都能命中)
- **all_packs() 接口**:批量预览生成器返回所有 21 套 pack 实例
- **smoke 验证**:12 套新 pack 各自渲染 5 页 PPT 全部 OK,文件大小 33–58 KB(cyberpunk-vivid / memphis 因装饰多最大)
- **目标**:让"原研哉极简"和"赛博朋克绚彩"放在同一个 deck.json 里只换 `--style` 就能切两种完全不同的视觉语言,每张 slide 都能直接当品牌海报/小红书封面/LinkedIn 头图
- **v3.1.0**:科技风品牌海报级装饰系统
- **新增六件套 Decoration token**:gradient_bg / accent_gradient / grid_overlay / dot_grid / glow_accent / corner_marks / dev_badge / mono_font / scanline,每项独立开关
- **新增 2 套科技风 pack**:`tech-neon`(赛博黑蓝,装饰全开,电青/电紫双 accent)+ `tech-minimal`(Vercel/Linear 风,点阵 + 微光克制装饰)
- **新增 code_block 模板**:macOS 圆点 + 文件名 tab + 语言标签 + 行号 + 关键词上色 + 行高亮 + 代码自动缩放
- **新增 helpers 装饰原语**:`add_gradient_rect` / `apply_text_gradient` / `add_glow_halo` / `_draw_grid_overlay` / `_draw_dot_grid` / `_draw_corner_marks` / `add_dev_badge` / `add_mono_text` / `format_dev_badge`
- **hero/section/stat 自动带 glow + gradient**:科技风 pack 下大字自动叠辉光与渐变文字(Keynote/PowerPoint 端显示)
- **修复 KPI 数字溢出**:`kpi_triple` 现在对 value 做 `fit_font_size`,"99.97%"/"$4.8M" 不再换行
- **新增 2 套科技风示例 deck**:`tech-neon-ai-launch.json`(10 页 AI 产品发布)+ `tech-minimal-saas.json`(7 页 Vercel 风部署 pitch)
- **目标**:让每一张 slide 都能单张导出当品牌海报/LinkedIn 头图/小红书封面
- **v3.0.0**:重写为 design tokens 架构。
- **新增 design_system.py**:Palette + Typography + Spacing + Elevation + Decoration + Canvas 六层独立 tokens
- **新增 style_packs.py**:4 个 v3 审美方案(apple-keynote / apple-light / xiaohongshu-creator / xiaohongshu-vintage)+ 3 个 legacy pack
- **新增 templates/**:10 个语义化页面模板 + helpers 共享原语
- **新增字距**:OOXML XML 注入 `<a:rPr spc="N">` 实现 letter-spacing(python-pptx 不支持)
- **新增 auto-fit**:大字号自动缩放避免 CJK 溢出换行
- **新增 4 套示例 deck**:examples/decks/*.json + examples/previews/*.png
- **向后兼容**:`--style` 参数、v2 字段名、legacy pack 全保留
- v2.1.0:扩展 7 种风格(ocean/forest/sunset/minimal/pastel/github/tech-blue)
- v2.0.0:styles 注册表 + pptx_toolkit 绘图原语 + create-pptx.py CLI + 小红书配色
- v1.x:深蓝乔布斯单页脚本集合
---
**技术支持:** 青岛火一五信息科技有限公司
FILE:_meta.json
{
"ownerId": "kn7byevkn40d6z4p7ghdb097z983tj33",
"slug": "huo15-openclaw-ppt",
"version": "3.2.1"
}
FILE:examples/decks/apple-keynote-launch.json
{
"year": "2026",
"slides": [
{
"type": "hero_cover",
"eyebrow": "INTRODUCING",
"title": "M4 Ultra.",
"subtitle": "地球上最强的芯片。",
"footnote": "Apple · Cupertino · 2026"
},
{
"type": "section_divider",
"number": "01",
"title": "Performance",
"subtitle": "性能"
},
{
"type": "big_stat",
"caption": "CPU PERFORMANCE",
"value": "2x",
"unit": "比 M3 Ultra 快",
"footnote": "基于实际应用工作负载",
"accent": true
},
{
"type": "kpi_triple",
"title": "重要数字",
"en_sub": "Key Metrics",
"items": [
{"value": "192", "label": "GB 统一内存", "caption": "整张内存池共享"},
{"value": "80B", "label": "晶体管", "caption": "3nm 工艺"},
{"value": "4TB/s", "label": "内存带宽", "caption": "AI 推理飞起"}
]
},
{
"type": "quote_card",
"quote": "One more thing…",
"author": "Tim Cook",
"role": "Apple CEO"
},
{
"type": "call_to_action",
"title": "Available today.",
"subtitle": "即刻可购买。",
"cta": "apple.com/store",
"footnote": "Apple · 2026"
}
]
}
FILE:examples/decks/apple-light-product.json
{
"year": "2026",
"slides": [
{
"type": "hero_cover",
"eyebrow": "OpenClaw Enhance v2026.4",
"title": "给龙虾装上翅膀。",
"subtitle": "让 Claude 更懂你的代码库。",
"footnote": "huo15.com"
},
{
"type": "content_list",
"title": "四大增强",
"en_sub": "Four enhancements",
"numbered": true,
"items": [
{"label": "结构化 Memory", "desc": "分层、可查询、不污染 context"},
{"label": "Project Profiles", "desc": "per-repo 规则与提示词"},
{"label": "Skill Ecosystem", "desc": "接入 @huo15/wecom 等插件"},
{"label": "Design Packs", "desc": "4 种审美开箱即用"}
]
},
{
"type": "kpi_triple",
"title": "真实收益",
"en_sub": "Real Impact",
"items": [
{"value": "60%", "label": "上下文节约", "caption": "memory 分层带来的节省"},
{"value": "3x", "label": "开发效率", "caption": "规则复用减少重复沟通"},
{"value": "0", "label": "学习曲线", "caption": "约定大于配置"}
]
},
{
"type": "compare_columns",
"title": "对比原版",
"en_sub": "Before vs After",
"emphasize": "right",
"left": {
"label": "VANILLA",
"title": "原版 Claude Code",
"items": ["CLAUDE.md 一锅粥", "规则要反复重复", "没有插件生态"]
},
"right": {
"label": "ENHANCED",
"title": "OpenClaw Enhance",
"items": ["结构化 memory 分层", "profile 一次写多次用", "插件 + skill + 规则三位一体"]
}
},
{
"type": "call_to_action",
"title": "试用一下。",
"subtitle": "5 分钟,把你的 Claude Code 升级。",
"cta": "huo15.com/openclaw-enhance",
"footnote": "火一五 · 2026"
}
]
}
FILE:examples/decks/tech-minimal-saas.json
{
"year": "2026",
"slides": [
{
"type": "hero_cover",
"eyebrow": "DEPLOY BUILD SHIP",
"title": "为工程师而生的云。",
"subtitle": "git push 一次,全球秒级可用。",
"footnote": "Cloud · 2026",
"build": "0001"
},
{
"type": "kpi_triple",
"title": "真实数据",
"en_sub": "Trusted by 80,000+ teams",
"items": [
{"value": "99.99%", "label": "可用性 SLA", "caption": "四 9 全球 CDN"},
{"value": "<50ms", "label": "边缘延迟", "caption": "270+ 节点"},
{"value": "12s", "label": "平均部署耗时", "caption": "git push → live"}
]
},
{
"type": "content_list",
"title": "为什么工程师喜欢",
"en_sub": "WHY DEVELOPERS LOVE IT",
"numbered": true,
"items": [
{"label": "零配置 CI/CD", "desc": "git push 自动 build + deploy"},
{"label": "预览环境 by 分支", "desc": "每个 PR 独立域名"},
{"label": "边缘函数原生", "desc": "写 JS/TS,全球边缘运行"},
{"label": "真正的暗夜模式", "desc": "仪表盘不刺眼"}
]
},
{
"type": "code_block",
"title": "一条命令",
"en_sub": "ONE COMMAND TO SHIP",
"filename": "~/my-app",
"language": "shell",
"code": "$ git push origin main\n\n→ Detected: Next.js 14\n→ Building... ✓ 8.2s\n→ Deploying to edge...\n→ Live: https://my-app.vercel.app\n\nDeployment completed in 12s",
"caption": "没有 YAML,没有 Docker,没有 kubectl。"
},
{
"type": "compare_columns",
"title": "对比传统 IaaS",
"en_sub": "Traditional vs Modern",
"emphasize": "right",
"left": {
"label": "LEGACY IaaS",
"title": "传统云服务",
"items": [
"配置 YAML 8 小时",
"跨区域手动同步",
"HTTPS 证书手动续费",
"监控需额外搭"
]
},
"right": {
"label": "CLOUD",
"title": "Modern Cloud",
"items": [
"零配置自动部署",
"全球边缘自动分发",
"证书自动签发续期",
"可观测性开箱即用"
]
}
},
{
"type": "timeline",
"title": "上线轨迹",
"en_sub": "From Commit to Live",
"events": [
{"time": "0s", "label": "git push", "desc": "你敲下回车"},
{"time": "2s", "label": "Build 启动", "desc": "构建容器冷启动"},
{"time": "10s", "label": "构建完成", "desc": "静态资源 + 函数打包"},
{"time": "12s", "label": "边缘生效", "desc": "270+ 节点同步完成"}
]
},
{
"type": "call_to_action",
"title": "Deploy now.",
"subtitle": "免费 hobby 计划,永久可用。",
"cta": "cloud.dev/signup",
"footnote": "Cloud · 2026 · Made in SF"
}
]
}
FILE:examples/decks/tech-neon-ai-launch.json
{
"year": "2026",
"slides": [
{
"type": "hero_cover",
"eyebrow": "INTRODUCING",
"title": "Synapse AI.",
"subtitle": "重新定义人机协作。",
"footnote": "Synapse Labs · 2026.04",
"build": "1337"
},
{
"type": "section_divider",
"number": "01",
"title": "Performance",
"subtitle": "性能"
},
{
"type": "big_stat",
"caption": "INFERENCE LATENCY",
"value": "42ms",
"unit": "p99 尾延迟",
"footnote": "基于 10B 参数模型 · A100 推理",
"accent": true
},
{
"type": "kpi_triple",
"title": "关键指标",
"en_sub": "Key Metrics",
"items": [
{"value": "10B", "label": "参数规模", "caption": "开源可商用"},
{"value": "256k", "label": "上下文窗口", "caption": "长文本首选"},
{"value": "99.97%", "label": "服务可用性", "caption": "SLA 保障"}
]
},
{
"type": "section_divider",
"number": "02",
"title": "Developer Experience",
"subtitle": "5 行接入"
},
{
"type": "code_block",
"title": "Quickstart",
"en_sub": "5 LINES OF CODE",
"filename": "app.py",
"language": "python",
"code": "from synapse import Client\n\nclient = Client(api_key=\"sk-...\")\n\nresponse = client.chat(\n model=\"synapse-ultra\",\n messages=[{\"role\": \"user\", \"content\": \"Hello!\"}]\n)\n\nprint(response.content)",
"highlight_lines": [6],
"caption": "pip install synapse-ai · 官方 Python SDK"
},
{
"type": "compare_columns",
"title": "对比传统方案",
"en_sub": "Synapse vs Legacy",
"emphasize": "right",
"left": {
"label": "LEGACY",
"title": "传统 LLM 方案",
"items": [
"部署需要 8×A100",
"上下文 32k 天花板",
"闭源黑盒",
"p99 > 800ms"
]
},
"right": {
"label": "SYNAPSE",
"title": "Synapse AI",
"items": [
"单张 A100 可跑",
"原生 256k 上下文",
"开源权重 · 可商用",
"p99 < 50ms"
]
}
},
{
"type": "quote_card",
"quote": "这是我们测过最好的中文推理模型,稳定性和速度都超出预期。",
"author": "Alex Chen",
"role": "字节跳动 · Tech Lead"
},
{
"type": "content_list",
"title": "四大核心能力",
"en_sub": "Core Capabilities",
"numbered": true,
"items": [
{"label": "长文本推理", "desc": "256k 上下文无衰减"},
{"label": "Tool Use 原生", "desc": "函数调用 f1 > 0.95"},
{"label": "多模态融合", "desc": "图文音视频统一 tokenizer"},
{"label": "私有化部署", "desc": "一条命令起服务"}
]
},
{
"type": "call_to_action",
"title": "Start building.",
"subtitle": "免费额度每月 100k tokens,开箱即用。",
"cta": "synapse.ai/signup",
"footnote": "Synapse Labs · Cupertino · 2026"
}
]
}
FILE:examples/decks/xhs-creator-vlog.json
{
"year": "2026",
"slides": [
{
"type": "hero_cover",
"eyebrow": "生活博主 · 笔记 #47",
"title": "关于做幻灯片这件小事",
"subtitle": "写给刚入行的运营小伙伴",
"footnote": "@小路 · 2026"
},
{
"type": "quote_card",
"quote": "好的 PPT 不是塞满了内容,而是留出了呼吸。",
"author": "小路",
"role": "内容设计师"
},
{
"type": "content_list",
"title": "我用了 3 年才明白的事",
"en_sub": "3 Years of Learning",
"numbered": true,
"items": [
{"label": "少即是多", "desc": "一张 slide 一个观点就够了"},
{"label": "色彩克制", "desc": "超过 3 个颜色就像年夜饭"},
{"label": "字号有阶梯", "desc": "从 hero 到 body 差 10 倍"},
{"label": "留白是奢侈品", "desc": "信息密度不是越高越好"},
{"label": "字体少而精", "desc": "一套衬线 + 一套非衬线"}
]
},
{
"type": "kpi_triple",
"title": "一些小数据",
"en_sub": "Tiny Metrics",
"items": [
{"value": "47", "label": "笔记篇数", "caption": "周更两年半"},
{"value": "8.2k", "label": "收藏", "caption": "比赞还多的幸福"},
{"value": "4.8★", "label": "读者满意度", "caption": "留言互动数据"}
]
},
{
"type": "call_to_action",
"title": "下期见啦。",
"subtitle": "关注我,下一期讲配色。",
"cta": "小红书 @小路生活志",
"footnote": "爱你,mua ♡"
}
]
}
FILE:examples/decks/xhs-vintage-travel.json
{
"year": "2026",
"slides": [
{
"type": "hero_cover",
"eyebrow": "旅行手记",
"title": "青岛,老城记忆。",
"subtitle": "一次关于时间的漫游",
"footnote": "拍于 2026 年春"
},
{
"type": "section_divider",
"number": "壹",
"title": "八大关 · 红瓦绿树"
},
{
"type": "quote_card",
"quote": "有些城市是用来打卡的,有些城市,只是慢慢走。",
"author": "老青岛",
"role": "本地讲述人"
},
{
"type": "timeline",
"title": "一日慢游",
"en_sub": "One Slow Day",
"events": [
{"time": "7:30", "label": "大学路", "desc": "买杯手冲,慢慢走"},
{"time": "11:00", "label": "八大关", "desc": "红瓦绿树碧海"},
{"time": "15:00", "label": "栈桥", "desc": "喂海鸥,吹海风"},
{"time": "19:30", "label": "啤酒街", "desc": "一扎原浆配蛤蜊"}
]
},
{
"type": "content_list",
"title": "带什么",
"en_sub": "Packing List",
"numbered": false,
"items": [
{"label": "一台胶片相机", "desc": "复古滤镜不如真胶片"},
{"label": "一本诗集", "desc": "等餐时读两页"},
{"label": "一件薄外套", "desc": "海边黄昏降温"}
]
},
{
"type": "call_to_action",
"title": "愿你也慢下来。",
"subtitle": "下次来青岛,留出一整天给自己。",
"cta": "小红书 @青岛小剧",
"footnote": "—— 春日慢游手记"
}
]
}
FILE:scripts/create-pptx.py
#!/usr/bin/env python3
"""
create-pptx.py - 火一五 PPT 生成器(v3.0)
基于 4 层 design tokens(Palette / Typography / Spacing / Elevation / Decoration)+
10 个语义化页面模板(hero/section/stat/kpi/quote/list/compare/product/timeline/cta)。
输入形式:
1. --spec deck.json (完整 deck 规约;支持 v2/v3 语法)
2. --cover "标题|副标题" (快速生成单页封面)
风格 pack(v3 审美方案):
apple-keynote 真·苹果发布会(暗场 + SF Pro + 巨字号)
apple-light Apple.com 白场
xiaohongshu-creator 奶油 + 鼠尾草 + 焦糖(生活博主)
xiaohongshu-vintage 琥珀 + 雾霾蓝(复古胶片)
jobs-dark 暗蓝乔布斯(v1.x legacy)
xiaohongshu 小红书品牌红(v2.x legacy)
兼容:老的 v2.x 使用 `--style` 参数仍然工作,会自动映射到同名 pack。
公司名优先级:--company > ~/.huo15/company-info.json > 默认字符串
"""
import os
import sys
import json
import argparse
from pptx import Presentation
from pptx.util import Inches
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
# v3 设计系统
from style_packs import get_pack, list_packs
from templates import get_template, list_templates
# 兼容层(万一有人 fallback 到 v2)
from styles import get_style, list_styles
import pptx_toolkit as pk
# ============================================================
# 公司信息
# ============================================================
def _load_company_name(explicit=None):
if explicit:
return explicit
ci_path = os.path.expanduser('~/.huo15/company-info.json')
if os.path.exists(ci_path):
try:
with open(ci_path, 'r', encoding='utf-8') as fh:
data = json.load(fh)
return data.get('company_name') or '青岛火一五信息科技有限公司'
except (OSError, json.JSONDecodeError):
pass
return '青岛火一五信息科技有限公司'
# ============================================================
# Slide type 到 template 的映射(v2 → v3 别名都走 templates 注册表)
# ============================================================
# 其中一些 type 历史上只是 cover/section 这种简写,直接从 templates/__init__.py
# 的别名表消化。加几个扩展 alias:
_EXTRA_ALIAS = {
'stat_big': 'big_stat',
'big_number': 'big_stat',
'kpi_card': 'kpi_triple',
'kpi_row': 'kpi_triple',
'vs': 'compare_columns',
'before_after': 'compare_columns',
'gallery': 'product_shot',
'shot': 'product_shot',
'story': 'timeline',
'contact': 'call_to_action',
'thanks': 'call_to_action',
}
def _resolve_slide_type(stype: str):
"""把 slide spec 里的 type 映射到 templates 注册表的一个 builder。"""
stype = (stype or 'content_list').strip()
if stype in _EXTRA_ALIAS:
stype = _EXTRA_ALIAS[stype]
return get_template(stype)
# ============================================================
# 构建 deck
# ============================================================
def build_presentation(spec, pack_name, company):
pack = get_pack(pack_name)
prs = Presentation()
prs.slide_width = Inches(pack.canvas.width)
prs.slide_height = Inches(pack.canvas.height)
slides = spec.get('slides', [])
year = spec.get('year', '')
for idx, slide_spec in enumerate(slides, start=1):
stype = slide_spec.get('type', 'content_list')
builder = _resolve_slide_type(stype)
# 统一注入公司名 / 页码 / 年份(模板如果用得到就会读)
data = dict(slide_spec)
data.setdefault('company', company)
data.setdefault('page', idx)
data.setdefault('year', year)
# 兼容 v2 字段名 → v3
_v2_to_v3_fields(stype, data)
try:
builder(prs, pack, data)
except Exception as e:
print(f'⚠️ slide #{idx} ({stype}) 构建失败: {e}', file=sys.stderr)
import traceback
traceback.print_exc(file=sys.stderr)
return prs
def _v2_to_v3_fields(stype: str, data: dict):
"""老的 deck.json 字段名(subtitle/en_subtitle/...)向 v3 字段名的兼容桥。"""
# en_subtitle → en_sub
if 'en_subtitle' in data and 'en_sub' not in data:
data['en_sub'] = data['en_subtitle']
# v2 end type 的 sub → subtitle
if 'sub' in data and 'subtitle' not in data:
data['subtitle'] = data['sub']
def _quick_deck(args, company):
title, _, subtitle = args.cover.partition('|')
return {
'slides': [
{'type': 'hero_cover', 'title': title.strip(),
'subtitle': subtitle.strip(),
'footnote': f'{company} · {args.year or ""}'.strip(' ·')},
],
'year': args.year or '',
}
# ============================================================
# CLI
# ============================================================
def main(argv=None):
parser = argparse.ArgumentParser(
prog='create-pptx',
description='火一五 PPT 生成器(v3.0 — design tokens + 10 模板)',
)
parser.add_argument('--output', '-o', help='输出 .pptx 路径(生成时必填)')
parser.add_argument(
'--pack',
dest='pack',
help=f'审美方案(v3.0 新)。可选:{", ".join(list_packs())}',
)
parser.add_argument(
'--style',
dest='style',
help='[兼容] 旧的风格参数,等价于 --pack',
)
parser.add_argument('--spec', help='deck JSON 规约路径')
parser.add_argument('--cover', help='快速生成单页封面,格式:"标题|副标题"')
parser.add_argument('--company', help='覆盖公司名(默认读 ~/.huo15/company-info.json)')
parser.add_argument('--year', default='', help='封面底部标注年份')
parser.add_argument('--list-packs', action='store_true', help='列出所有 pack 并退出')
parser.add_argument('--list-templates', action='store_true', help='列出所有 template 并退出')
args = parser.parse_args(argv)
# 信息性命令
if args.list_packs:
from style_packs import REGISTRY
print('可用 pack(含别名):')
seen = set()
for k, p in REGISTRY.items():
if p.name in seen:
continue
seen.add(p.name)
print(f' {p.name:<26} {p.display_name}')
print(f' {" ":<26} {p.tagline}')
return 0
if args.list_templates:
print('可用 template:')
for name in list_templates():
print(f' {name}')
return 0
if not args.spec and not args.cover:
parser.error('必须提供 --spec <deck.json> 或 --cover "标题|副标题"')
if not args.output:
parser.error('生成时必须提供 --output <deck.pptx>')
pack_name = args.pack or args.style or 'apple-keynote'
company = _load_company_name(args.company)
if args.spec:
with open(args.spec, 'r', encoding='utf-8') as fh:
spec = json.load(fh)
else:
spec = _quick_deck(args, company)
prs = build_presentation(spec, pack_name, company)
os.makedirs(os.path.dirname(os.path.abspath(args.output)) or '.', exist_ok=True)
prs.save(args.output)
pack = get_pack(pack_name)
print(
f'✅ 已生成: {args.output}(pack: {pack.name} — {pack.display_name},'
f'幻灯片数: {len(spec.get("slides", []))})'
)
return 0
if __name__ == '__main__':
sys.exit(main())
FILE:scripts/create_pptx_combined.py
#!/usr/bin/env python3
"""
合并版 PPTX — Slide 1–6(我们的公司)
"""
from pptx import Presentation
from pptx.util import Inches, Pt
from pptx.dml.color import RGBColor
from pptx.enum.text import PP_ALIGN
C_BG = RGBColor(0x06, 0x0D, 0x1A)
C_CARD = RGBColor(0x0D, 0x18, 0x2A)
C_TEXT = RGBColor(0xFF, 0xFF, 0xFF)
C_SUBTEXT = RGBColor(0x88, 0x88, 0x88)
C_LIGHT = RGBColor(0xCC, 0xCC, 0xCC)
FONT = "PingFang SC"
def text_box(slide, text, left, top, width, height,
font_size=14, bold=False, color=None, align=PP_ALIGN.LEFT):
tb = slide.shapes.add_textbox(Inches(left), Inches(top), Inches(width), Inches(height))
tf = tb.text_frame
tf.word_wrap = True
p = tf.paragraphs[0]
p.alignment = align
run = p.add_run()
run.text = text
run.font.size = Pt(font_size)
run.font.bold = bold
run.font.name = FONT
run.font.color.rgb = color or C_TEXT
return tb
def add_card(slide, left, top, width, height):
shape = slide.shapes.add_shape(
1, Inches(left), Inches(top), Inches(width), Inches(height)
)
shape.fill.solid()
shape.fill.fore_color.rgb = C_CARD
shape.line.color.rgb = RGBColor(0x33, 0x33, 0x44)
shape.line.width = Pt(0.5)
return shape
def add_divider(slide, left, top, width, color=None):
ln = slide.shapes.add_shape(
1, Inches(left), Inches(top), Inches(width), Inches(0.008)
)
ln.fill.solid()
ln.fill.fore_color.rgb = color or RGBColor(0x33, 0x33, 0x44)
ln.line.fill.background()
prs = Presentation()
prs.slide_width = Inches(13.33)
prs.slide_height = Inches(7.5)
BLANK = prs.slide_layouts[6]
# ════════════════════════════════════════════════════════
# Slide 1 — 封面
# ════════════════════════════════════════════════════════
s1 = prs.slides.add_slide(BLANK)
bg = s1.background; fill = bg.fill; fill.solid(); fill.fore_color.rgb = C_BG
text_box(s1, "走向具身智能", 0.8, 2.0, 11.73, 1.2, font_size=64, bold=True, color=C_TEXT, align=PP_ALIGN.CENTER)
text_box(s1, "龙虾生态战略·重塑所有企业", 0.8, 3.35, 11.73, 0.7, font_size=26, color=C_TEXT, align=PP_ALIGN.CENTER)
text_box(s1, "青岛火一五信息科技有限公司 · 2026", 0.8, 4.5, 11.73, 0.5, font_size=14, color=C_SUBTEXT, align=PP_ALIGN.CENTER)
# ════════════════════════════════════════════════════════
# Slide 2 — 人工智能五个阶段
# ════════════════════════════════════════════════════════
s2 = prs.slides.add_slide(BLANK)
bg2 = s2.background; fill2 = bg2.fill; fill2.solid(); fill2.fore_color.rgb = C_BG
text_box(s2, "人工智能的五个阶段", 0.6, 0.35, 8, 0.55, font_size=28, bold=True, color=C_TEXT)
text_box(s2, "FIVE STAGES OF ARTIFICIAL INTELLIGENCE", 0.6, 0.82, 10, 0.35, font_size=10, color=C_SUBTEXT)
stages = [
("1", "大数据时代", "通过大数据打造智能聊天系统", "ChatGPT", "2020–2024"),
("2", "推理模型时代", "不再通过大数据进行智能化,大模型拥有了推理能力", "DeepSeek", "2024"),
("3", "智能体时代", "人工智能不再只是拥有大模型(智力),同时具备性格/灵魂、记忆、技能、工具综合体", "龙虾 OpenClaw", "2026"),
("4", "具身智能时代", "智能体装到具体的机械结构上,真正意义上实现机器人时代", "宇树机器人(三年后)", "未来"),
("5", "镜像世界", "人工智能复刻每个人的数据行为、记忆、大脑,计算机里有一个一模一样的我们", "——", "10年后"),
]
card_w = 12.13; card_h = 0.88; gap = 0.1; start_y = 1.35
for i, (num, title, desc, rep, year) in enumerate(stages):
y = start_y + i * (card_h + gap)
add_card(s2, 0.6, y, card_w, card_h)
dot = s2.shapes.add_shape(9, Inches(0.75), Inches(y + 0.3), Inches(0.28), Inches(0.28))
dot.fill.solid(); dot.fill.fore_color.rgb = C_TEXT; dot.line.fill.background()
ntb = s2.shapes.add_textbox(Inches(0.75), Inches(y + 0.3), Inches(0.28), Inches(0.28))
ntf = ntb.text_frame; np_ = ntf.paragraphs[0]; np_.alignment = PP_ALIGN.CENTER
nr = np_.add_run(); nr.text = num; nr.font.size = Pt(11); nr.font.bold = True; nr.font.name = FONT; nr.font.color.rgb = C_BG
text_box(s2, title, 1.5, y + 0.08, 2.5, 0.4, font_size=14, bold=True, color=C_TEXT)
text_box(s2, desc, 1.5, y + 0.45, 7.5, 0.38, font_size=10, color=C_SUBTEXT)
text_box(s2, "代表:" + rep, 9.1, y + 0.08, 3.3, 0.38, font_size=10, color=C_LIGHT)
text_box(s2, year, 11.7, y + 0.45, 0.9, 0.38, font_size=10, color=C_SUBTEXT, align=PP_ALIGN.RIGHT)
text_box(s2, "青岛火一五信息科技有限公司", 0.6, 7.1, 5, 0.3, font_size=9, color=C_SUBTEXT)
text_box(s2, "02", 12.6, 7.1, 0.5, 0.3, font_size=9, color=C_SUBTEXT, align=PP_ALIGN.RIGHT)
# ════════════════════════════════════════════════════════
# Slide 3 — 战略参考读物
# ════════════════════════════════════════════════════════
s3 = prs.slides.add_slide(BLANK)
bg3 = s3.background; fill3 = bg3.fill; fill3.solid(); fill3.fore_color.rgb = C_BG
text_box(s3, "战略参考读物", 0.6, 0.35, 8, 0.55, font_size=28, bold=True, color=C_TEXT)
text_box(s3, "STRATEGIC REFERENCE BOOKS", 0.6, 0.82, 10, 0.35, font_size=10, color=C_SUBTEXT)
add_card(s3, 0.6, 1.35, 5.8, 5.4)
s3.shapes.add_picture('/tmp/book_5000_final.png', Inches(1.3), Inches(1.7), Inches(2.6), Inches(3.71))
text_box(s3, "《5000天后的世界》", 0.9, 5.55, 5.2, 0.45, font_size=14, bold=True, color=C_TEXT, align=PP_ALIGN.CENTER)
text_box(s3, "凯文·凯利(Kevin Kelly)", 0.9, 5.95, 5.2, 0.35, font_size=11, color=C_SUBTEXT, align=PP_ALIGN.CENTER)
text_box(s3, "AI、互联网、智能体时代的演进预判", 0.9, 6.3, 5.2, 0.4, font_size=10, color=C_SUBTEXT, align=PP_ALIGN.CENTER)
add_card(s3, 6.9, 1.35, 5.8, 5.4)
s3.shapes.add_picture('/tmp/book_1000_final.jpg', Inches(7.5), Inches(1.7), Inches(3.0), Inches(3.0))
text_box(s3, "《预测之书:1000天后的世界》", 7.2, 5.0, 5.2, 0.45, font_size=14, bold=True, color=C_TEXT, align=PP_ALIGN.CENTER)
text_box(s3, "罗振宇", 7.2, 5.4, 5.2, 0.35, font_size=11, color=C_SUBTEXT, align=PP_ALIGN.CENTER)
text_box(s3, "1000天后的世界发展趋势预测", 7.2, 5.75, 5.2, 0.4, font_size=10, color=C_SUBTEXT, align=PP_ALIGN.CENTER)
text_box(s3, "青岛火一五信息科技有限公司", 0.6, 7.1, 5, 0.3, font_size=9, color=C_SUBTEXT)
text_box(s3, "03", 12.6, 7.1, 0.5, 0.3, font_size=9, color=C_SUBTEXT, align=PP_ALIGN.RIGHT)
# ════════════════════════════════════════════════════════
# Slide 4 — 乔布斯
# ════════════════════════════════════════════════════════
s4 = prs.slides.add_slide(BLANK)
bg4 = s4.background; fill4 = bg4.fill; fill4.solid(); fill4.fore_color.rgb = C_BG
text_box(s4, "远见与品味", 0.6, 0.35, 8, 0.55, font_size=28, bold=True, color=C_TEXT)
text_box(s4, "VISION AND TASTE", 0.6, 0.82, 10, 0.35, font_size=10, color=C_SUBTEXT)
add_divider(s4, 0.6, 1.18, 12.13)
s4.shapes.add_picture('/tmp/steve_jobs.png', Inches(0.6), Inches(1.5), Inches(12.13), Inches(3.2))
add_card(s4, 0.6, 4.85, 12.13, 1.6)
text_box(s4, "Steve Jobs", 1.0, 5.0, 3, 0.4, font_size=16, bold=True, color=C_TEXT)
text_box(s4, "苹果公司创始人", 1.0, 5.38, 3, 0.35, font_size=11, color=C_SUBTEXT)
text_box(s4, "「找不到方向的根本原因,", 4.0, 5.0, 8.5, 0.45, font_size=22, bold=True, color=C_TEXT)
text_box(s4, "不够聪明,是没有品味。」", 4.0, 5.45, 8.5, 0.45, font_size=22, bold=True, color=C_TEXT)
text_box(s4, "—— 乔布斯", 4.0, 5.95, 8.5, 0.35, font_size=12, color=C_SUBTEXT)
text_box(s4, "青岛火一五信息科技有限公司", 0.6, 7.1, 5, 0.3, font_size=9, color=C_SUBTEXT)
text_box(s4, "04", 12.6, 7.1, 0.5, 0.3, font_size=9, color=C_SUBTEXT, align=PP_ALIGN.RIGHT)
# ════════════════════════════════════════════════════════
# Slide 5 — 为什么押宝龙虾
# ════════════════════════════════════════════════════════
# ════════════════════════════════════════════════════════
# Slide 6 — 我们的公司
# ════════════════════════════════════════════════════════
s6 = prs.slides.add_slide(BLANK)
bg6 = s6.background; fill6 = bg6.fill; fill6.solid(); fill6.fore_color.rgb = C_BG
text_box(s6, "我们的公司", 0.6, 0.35, 8, 0.55, font_size=28, bold=True, color=C_TEXT)
text_box(s6, "COMPANIES", 0.6, 0.82, 10, 0.35, font_size=10, color=C_SUBTEXT)
add_divider(s6, 0.6, 1.18, 12.13)
# 公司一 — 青岛火一五信息科技有限公司
add_card(s6, 0.6, 1.4, 5.9, 4.5)
text_box(s6, "青岛火一五信息科技有限公司", 0.85, 1.6, 5.4, 0.45, font_size=13, bold=True, color=C_TEXT)
text_box(s6, "FIREINFO TECH", 0.85, 2.0, 5.4, 0.3, font_size=9, color=C_SUBTEXT)
company1_products = [
("龙虾 + 辉火云企业套件", "ERP · CRM · MES"),
("龙虾 + 辉火云管家", "养龙虾服务 · 数据治理"),
("XR-IoT 扩展现实物联网", "机器视觉 · 数字孪生"),
]
for i, (name, sub) in enumerate(company1_products):
dot6 = s6.shapes.add_shape(9, Inches(0.85), Inches(2.55 + i * 0.85), Inches(0.18), Inches(0.18))
dot6.fill.solid(); dot6.fill.fore_color.rgb = C_TEXT; dot6.line.fill.background()
text_box(s6, name, 1.15, 2.48 + i * 0.85, 5, 0.35, font_size=12, bold=True, color=C_TEXT)
text_box(s6, sub, 1.15, 2.78 + i * 0.85, 5, 0.3, font_size=10, color=C_SUBTEXT)
# 公司二 — 青岛萧伯网大科技有限公司
add_card(s6, 6.85, 1.4, 5.9, 4.5)
text_box(s6, "青岛萧伯网大科技有限公司", 7.1, 1.6, 5.4, 0.45, font_size=13, bold=True, color=C_TEXT)
text_box(s6, "XIAOBOWANG TECH", 7.1, 2.0, 5.4, 0.3, font_size=9, color=C_SUBTEXT)
text_box(s6, "逸寻智库", 7.35, 2.55, 5, 0.4, font_size=13, bold=True, color=C_TEXT)
text_box(s6, "公众号 · B站自媒体", 7.35, 2.9, 5, 0.3, font_size=10, color=C_SUBTEXT)
text_box(s6, "教育平台起步阶段", 7.35, 3.5, 5, 0.35, font_size=12, bold=True, color=C_TEXT)
text_box(s6, "布局品牌建设、IT前沿技术科普和培训", 7.35, 3.85, 5, 0.5, font_size=10, color=C_SUBTEXT)
# 个人定位
add_card(s6, 0.6, 6.1, 12.13, 0.75)
text_box(s6, "赵博 / OPC一人公司 · 超级个体 · AI工长", 1.0, 6.25, 11, 0.4, font_size=13, bold=True, color=C_TEXT, align=PP_ALIGN.CENTER)
text_box(s6, "青岛火一五信息科技有限公司", 0.6, 7.1, 5, 0.3, font_size=9, color=C_SUBTEXT)
text_box(s6, "06", 12.6, 7.1, 0.5, 0.3, font_size=9, color=C_SUBTEXT, align=PP_ALIGN.RIGHT)
s5 = prs.slides.add_slide(BLANK)
bg5 = s5.background; fill5 = bg5.fill; fill5.solid(); fill5.fore_color.rgb = C_BG
text_box(s5, "为什么押宝龙虾", 0.6, 0.35, 8, 0.55, font_size=28, bold=True, color=C_TEXT)
text_box(s5, "WHY WE BET ON OPENCLAW", 0.6, 0.82, 10, 0.35, font_size=10, color=C_SUBTEXT)
add_divider(s5, 0.6, 1.18, 12.13)
# 左卡 — 划时代现象级产品
add_card(s5, 0.6, 1.4, 5.9, 5.5)
text_box(s5, "划时代现象级产品", 0.85, 1.6, 5.4, 0.45, font_size=15, bold=True, color=C_TEXT)
add_divider(s5, 0.85, 2.1, 5.4)
text_box(s5, "龙虾作为划时代意义的现象级产品地位已经成立。", 0.85, 2.25, 5.4, 0.7, font_size=11, color=C_SUBTEXT)
text_box(s5, "它的生态、品牌、知名度不可撼动。", 0.85, 2.75, 5.4, 0.5, font_size=11, color=C_SUBTEXT)
# 繁荣度进度条
for i in range(10):
alpha = 0.3 + i * 0.07
v = int(255 * alpha)
add_solid = s5.shapes.add_shape(1, Inches(0.85 + i * 0.56), Inches(3.4), Inches(0.5), Inches(0.18))
add_solid.fill.solid(); add_solid.fill.fore_color.rgb = RGBColor(v, v, v); add_solid.line.fill.background()
text_box(s5, "社区繁荣度", 0.85, 3.7, 5.4, 0.3, font_size=9, color=C_SUBTEXT, align=PP_ALIGN.CENTER)
# 数字强调
text_box(s5, "3+", 1.2, 4.1, 1.5, 0.9, font_size=52, bold=True, color=C_TEXT)
text_box(s5, "年内优势稳固", 2.7, 4.3, 3, 0.5, font_size=13, color=C_TEXT)
text_box(s5, "就算出现更优秀的产品,大多数龙虾用户会选择坐等龙虾更新,这种情况三年内不会逆转。", 0.85, 5.1, 5.4, 0.9, font_size=10.5, color=C_SUBTEXT)
# 右卡 — 数据主权
add_card(s5, 6.85, 1.4, 5.9, 5.5)
text_box(s5, "数据主权", 7.1, 1.6, 5.4, 0.45, font_size=15, bold=True, color=C_TEXT)
add_divider(s5, 7.1, 2.1, 5.4)
text_box(s5, "企业把所有数据存到龙虾,", 7.1, 2.25, 5.4, 0.45, font_size=13, bold=True, color=C_TEXT)
text_box(s5, "然后才是小红书、抖音等APP复用一部分数据。", 7.1, 2.7, 5.4, 0.5, font_size=11, color=C_SUBTEXT)
text_box(s5, "打破以前数据割裂、数据沙箱的局面。", 7.1, 3.25, 5.4, 0.5, font_size=11, color=C_SUBTEXT)
text_box(s5, "不再让巨头瓜分我们的数据。", 7.1, 3.8, 5.4, 0.45, font_size=13, bold=True, color=C_TEXT)
text_box(s5, "龙虾 = 企业的数据金库。", 7.1, 4.35, 5.4, 0.5, font_size=16, bold=True, color=C_TEXT)
text_box(s5, "其他平台只是龙虾的附庸。", 7.1, 4.9, 5.4, 0.5, font_size=11, color=C_SUBTEXT)
text_box(s5, "青岛火一五信息科技有限公司", 0.6, 7.1, 5, 0.3, font_size=9, color=C_SUBTEXT)
text_box(s5, "05", 12.6, 7.1, 0.5, 0.3, font_size=9, color=C_SUBTEXT, align=PP_ALIGN.RIGHT)
# ─── 保存 ───────────────────────────────────────────
output = "/Users/jobzhao/.openclaw/media/outbound/合并版_走向具身智能.pptx"
prs.save(output)
print(f"✅ 合并版已生成: {output}")
FILE:scripts/design_system.py
"""
design_system.py - 火一五 PPT v3.0 设计系统 tokens
把「审美」分解成 4 层独立 tokens:
1. Palette - 配色(不只是 primary/accent,而是完整的层级色)
2. Typography - 字体阶梯(hero / section / page / card / body / caption 6 级)
3. Spacing - 间距系统(8pt grid)
4. Elevation - 卡片/阴影语言
每个 StylePack 聚合这 4 层 tokens + 装饰标志。
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import List, Optional, Tuple
from pptx.dml.color import RGBColor
# ============================================================
# 工具
# ============================================================
def hex_to_rgb(hex_str: str) -> RGBColor:
"""#RRGGBB → RGBColor"""
s = hex_str.lstrip('#')
return RGBColor(int(s[0:2], 16), int(s[2:4], 16), int(s[4:6], 16))
# ============================================================
# 一、配色
# ============================================================
@dataclass
class Palette:
"""完整的色板 — 背景 3 级 / 文字 4 级 / 强调 / 边框。"""
# 背景层次
bg: str = '#FFFFFF' # 画布最底
bg_elevated: str = '#F8F8FA' # 浮起卡片
bg_subtle: str = '#F0F0F2' # 三级背景(分隔块)
# 文字层次(让正文有呼吸感的关键)
text_primary: str = '#1D1D1F' # 主标题 / 核心内容
text_secondary: str = '#4A4A4F' # 副标题 / 次要内容
text_tertiary: str = '#86868B' # 辅助文字 / 标签
text_muted: str = '#D2D2D7' # 最弱文字 / 页码
# 品牌强调(只在关键位置用)
accent: str = '#0071E3' # 主强调(链接、CTA)
accent_soft: str = '#E6F0FD' # 弱强调(高亮背景)
# 边框 / 分隔
border: str = '#E5E5EA' # 卡片描边
divider: str = '#F0F0F2' # 细分隔线
# RGBColor 缓存
def rgb(self, key: str) -> RGBColor:
return hex_to_rgb(getattr(self, key))
# ============================================================
# 二、字体
# ============================================================
@dataclass
class Typography:
"""字体阶梯。每级包含字号 + 字重 + 字距 + 行高。"""
# 字体 stack
display_font: str = 'SF Pro Display' # 标题用
display_fallbacks: List[str] = field(
default_factory=lambda: [
'PingFang SC', 'Microsoft YaHei', 'Noto Sans CJK SC',
'-apple-system', 'Helvetica Neue', 'sans-serif',
]
)
body_font: str = 'SF Pro Text' # 正文用
body_fallbacks: List[str] = field(
default_factory=lambda: [
'PingFang SC', 'Microsoft YaHei', 'Noto Sans CJK SC',
'-apple-system', 'sans-serif',
]
)
# 字号阶梯(pt)— 6 级
hero: int = 140 # 封面大字(Apple 发布会 hero)
section: int = 80 # 分章大字
page_title: int = 44 # 内容页标题
page_sub: int = 18 # 内容页副标题(英文小字)
card_title: int = 22 # 卡片标题
body: int = 15 # 正文
caption: int = 11 # 标注 / 脚注
page_number: int = 10 # 页码
# 字重
hero_weight: str = 'bold' # 'bold' | 'semibold' | 'medium' | 'regular'
section_weight: str = 'bold'
page_weight: str = 'semibold'
card_weight: str = 'semibold'
body_weight: str = 'regular'
# 字距(tracking,% of em;Apple 风格大字用 -3% 字距)
hero_tracking: float = -0.03
section_tracking: float = -0.02
page_tracking: float = -0.01
body_tracking: float = 0.0
# 行高倍数
hero_leading: float = 0.95
body_leading: float = 1.45
# 英文副标题是否大写(Apple 风会全大写)
uppercase_en_sub: bool = True
# ============================================================
# 三、间距
# ============================================================
@dataclass
class Spacing:
"""8pt grid — 所有间距都是 8 的倍数。单位: inch(与 python-pptx 对齐)。
8pt ≈ 0.111 inch。
"""
# 垂直间距
gutter: float = 0.08 # 6pt — 同组元素之间(超紧凑)
stack_sm: float = 0.17 # 12pt — 标题到副标题
stack_md: float = 0.33 # 24pt — 段落之间
stack_lg: float = 0.66 # 48pt — 大版块之间
stack_xl: float = 1.33 # 96pt — hero 留白
# 水平 / 页边距
margin_x: float = 0.8 # 页面左右大边距
margin_x_hero: float = 1.2 # 封面/分章用大边距
# 卡片内边距
card_pad_x: float = 0.33 # 卡片横向内边距
card_pad_y: float = 0.25 # 卡片纵向内边距
# ============================================================
# 四、卡片 / 阴影语言
# ============================================================
@dataclass
class Elevation:
"""卡片/描边/阴影规则。python-pptx 不支持真阴影,用偏移填色模拟。"""
card_radius: float = 0.12 # 圆角 inch
card_stroke_width: float = 0.5 # pt
card_stroke_color: str = '#E5E5EA'
card_fill: str = '#F8F8FA'
# 假阴影(下方错位色块)
use_fake_shadow: bool = False
shadow_color: str = '#00000014' # 带 alpha 的十六进制(RGBA → 转灰度)
shadow_offset_y: float = 0.05
# 卡片语言:flat / outline / soft / glass
style: str = 'flat'
# ============================================================
# 五、装饰(每种风格独有)
# ============================================================
@dataclass
class Decoration:
"""装饰标志。每个 pack 有自己的视觉口头禅。"""
# 封面
cover_hero_align: str = 'center' # 'center' | 'left' | 'bottom-left'
cover_hero_case: str = 'as-is' # 'as-is' | 'upper' | 'lower'
cover_bottom_line: bool = False # 底部品牌线条
cover_top_line: bool = False # 顶部品牌线条
# 页面
page_title_align: str = 'left'
page_accent_bar: bool = False # 标题左侧小竖条
page_en_sub_position: str = 'under' # 'under' | 'above' | 'none'
# tag / 胶囊
tag_style: str = 'pill' # 'pill' | 'square' | 'underline' | 'none'
# 数据大字(Apple 特色)
stat_hero_size: int = 260 # 单数字超大字号
stat_hero_weight: str = 'bold'
# 照片 / 图块
image_treatment: str = 'full' # 'full' | 'rounded' | 'torn' | 'film'
# ---- v3.1 科技风新增装饰 ----
# 背景渐变:(from_hex, to_hex, angle_deg) —— 不为 None 时覆盖纯色背景
# angle: 0=水平左→右, 90=竖直上→下, 135=对角线
gradient_bg: Optional[Tuple[str, str, int]] = None
# 强调色渐变(hero / stat 大字用):(from_hex, to_hex)
accent_gradient: Optional[Tuple[str, str]] = None
# 网格叠加层(科技风招牌装饰)
grid_overlay: bool = False
grid_color: str = '#1A1D2E'
grid_spacing: float = 0.4 # inch
grid_thickness: float = 0.006 # inch (≈ 0.43pt)
# 圆点网格(极简点阵)
dot_grid: bool = False
dot_color: str = '#2A2D3F'
dot_spacing: float = 0.5
dot_size: float = 0.04
# 强调色发光(stat / hero 周围叠多层半透明椭圆)
glow_accent: bool = False
glow_strength: float = 0.6 # 0~1
# 四角 L 型刻度(取景框感)
corner_marks: bool = False
corner_size: float = 0.35
corner_thickness: float = 0.02
# 左下开发版本戳
dev_badge: bool = False
dev_badge_template: str = 'BUILD · {date}' # {date} / {year} / {n} 占位
# 等宽字体(用于 caption / metadata / badge)
mono_font: Optional[str] = None
mono_fallbacks: List[str] = field(
default_factory=lambda: [
'JetBrains Mono', 'Menlo', 'Monaco', 'Consolas',
'Source Code Pro', 'Courier New', 'monospace',
]
)
# 水平扫描线装饰(retro-tech)
scanline: bool = False
scanline_color: str = '#1A1D2E'
# ============================================================
# 六、画布 & 完整 StylePack
# ============================================================
@dataclass
class Canvas:
width: float = 13.33 # inch, 16:9
height: float = 7.5
@dataclass
class StylePack:
"""一个完整的审美方案:Palette + Typography + Spacing + Elevation + Decoration + Canvas。"""
name: str
display_name: str
tagline: str
palette: Palette = field(default_factory=Palette)
typography: Typography = field(default_factory=Typography)
spacing: Spacing = field(default_factory=Spacing)
elevation: Elevation = field(default_factory=Elevation)
decoration: Decoration = field(default_factory=Decoration)
canvas: Canvas = field(default_factory=Canvas)
# 页脚 / 页码
show_footer: bool = True
# 兼容老的 Style 接口(给现有 pptx_toolkit 用)
def to_legacy_style(self):
"""把 v3 StylePack 压扁成 v2 Style,让旧代码能跑。"""
from styles import Style
p = self.palette
t = self.typography
return Style(
name=self.name,
slide_width=self.canvas.width,
slide_height=self.canvas.height,
bg=p.rgb('bg'),
card=p.rgb('bg_elevated'),
accent=p.rgb('accent'),
text=p.rgb('text_primary'),
subtext=p.rgb('text_tertiary'),
light=p.rgb('text_secondary'),
divider=p.rgb('divider'),
card_stroke=hex_to_rgb(self.elevation.card_stroke_color),
card_line_width=self.elevation.card_stroke_width,
font=t.display_font,
font_fallback=t.display_fallbacks[0] if t.display_fallbacks else 'PingFang SC',
size_cover_title=t.hero,
size_cover_subtitle=t.page_sub,
size_cover_footnote=t.caption,
size_page_title=t.page_title,
size_page_subtitle_en=t.page_sub,
size_card_title=t.card_title,
size_body=t.body,
size_small=t.caption,
size_footer=t.page_number,
cover_decoration=self.decoration.cover_top_line or self.decoration.cover_bottom_line,
show_footer=self.show_footer,
footer_company_font_size=t.page_number,
upper_en_subtitle=t.uppercase_en_sub,
)
# ============================================================
# 七、font-weight 映射(python-pptx 只有 bold/regular)
# ============================================================
def resolve_weight(weight: str) -> bool:
"""python-pptx 不支持 semibold/medium,统一回落到 bold/regular。"""
return weight in ('bold', 'semibold', 'black', 'heavy')
FILE:scripts/pptx_toolkit.py
"""
pptx_toolkit.py - 按风格参数化的 PPT 绘图原语
所有函数第一个位置参数统一为 `style: Style`,便于组合复用。
上层 `create-pptx.py` 基于这些原语按 JSON 规约生成整份 deck。
"""
from pptx.util import Inches, Pt
from pptx.enum.text import PP_ALIGN
from pptx.enum.shapes import MSO_SHAPE
from pptx.dml.color import RGBColor
from styles import Style
def set_background(slide, style: Style):
fill = slide.background.fill
fill.solid()
fill.fore_color.rgb = style.bg
def text_box(slide, style: Style, text, left, top, width, height,
font_size=None, bold=False, color=None, align=PP_ALIGN.LEFT,
italic=False):
tb = slide.shapes.add_textbox(Inches(left), Inches(top), Inches(width), Inches(height))
tf = tb.text_frame
tf.word_wrap = True
tf.margin_left = 0
tf.margin_right = 0
tf.margin_top = 0
tf.margin_bottom = 0
p = tf.paragraphs[0]
p.alignment = align
run = p.add_run()
run.text = text
run.font.size = Pt(font_size if font_size is not None else style.size_body)
run.font.bold = bold
run.font.italic = italic
run.font.name = style.font
run.font.color.rgb = color if color is not None else style.text
return tb
def add_card(slide, style: Style, left, top, width, height, fill=None, stroke=None):
shape = slide.shapes.add_shape(
MSO_SHAPE.ROUNDED_RECTANGLE, Inches(left), Inches(top),
Inches(width), Inches(height),
)
shape.fill.solid()
shape.fill.fore_color.rgb = fill if fill is not None else style.card
shape.line.color.rgb = stroke if stroke is not None else style.card_stroke
shape.line.width = Pt(style.card_line_width)
return shape
def add_divider(slide, style: Style, left, top, width, color=None, thickness=0.008):
ln = slide.shapes.add_shape(
MSO_SHAPE.RECTANGLE, Inches(left), Inches(top),
Inches(width), Inches(thickness),
)
ln.fill.solid()
ln.fill.fore_color.rgb = color if color is not None else style.divider
ln.line.fill.background()
return ln
def add_tag(slide, style: Style, text, left, top,
width=1.2, height=0.32, color=None, fill=None):
"""小红书风格 hashtag 小胶囊。"""
chip = slide.shapes.add_shape(
MSO_SHAPE.ROUNDED_RECTANGLE, Inches(left), Inches(top),
Inches(width), Inches(height),
)
chip.fill.solid()
chip.fill.fore_color.rgb = fill if fill is not None else style.accent
chip.line.fill.background()
tb = slide.shapes.add_textbox(Inches(left), Inches(top), Inches(width), Inches(height))
tf = tb.text_frame
tf.margin_left = Inches(0.05)
tf.margin_right = Inches(0.05)
p = tf.paragraphs[0]
p.alignment = PP_ALIGN.CENTER
run = p.add_run()
run.text = text
run.font.size = Pt(style.size_small)
run.font.bold = True
run.font.name = style.font
run.font.color.rgb = color if color is not None else style.card
return chip
def add_accent_bar(slide, style: Style, left, top, width=0.12, height=0.45):
"""标题左侧强调色竖条(小红书常见)。"""
bar = slide.shapes.add_shape(
MSO_SHAPE.RECTANGLE, Inches(left), Inches(top),
Inches(width), Inches(height),
)
bar.fill.solid()
bar.fill.fore_color.rgb = style.accent
bar.line.fill.background()
return bar
def cover_slide(prs, style: Style, title, subtitle='', footnote=''):
slide = prs.slides.add_slide(prs.slide_layouts[6])
set_background(slide, style)
W = style.slide_width
H = style.slide_height
if style.cover_decoration:
# 小红书风格:顶部细红条 + 底部角标
add_divider(slide, style, left=0, top=0, width=W, color=style.accent, thickness=0.08)
add_tag(slide, style, '#火一五', left=0.5, top=H - 0.7,
width=1.3, height=0.4)
title_y = H * 0.32
text_box(slide, style, title,
left=0.6, top=title_y, width=W - 1.2, height=1.4,
font_size=style.size_cover_title, bold=True,
color=style.text, align=PP_ALIGN.CENTER)
if subtitle:
text_box(slide, style, subtitle,
left=0.6, top=title_y + 1.5,
width=W - 1.2, height=0.8,
font_size=style.size_cover_subtitle,
color=style.accent if style.name.startswith('xiaohongshu') else style.text,
bold=style.name.startswith('xiaohongshu'),
align=PP_ALIGN.CENTER)
if footnote:
text_box(slide, style, footnote,
left=0.6, top=H - 1.0, width=W - 1.2, height=0.4,
font_size=style.size_cover_footnote,
color=style.subtext, align=PP_ALIGN.CENTER)
return slide
def content_header(slide, style: Style, title, en_subtitle=''):
"""标准内容页页首:左上角标题 + 英文副标题 + 分隔线。"""
text_box(slide, style, title, 0.6, 0.35, 10, 0.6,
font_size=style.size_page_title, bold=True, color=style.text)
if style.name.startswith('xiaohongshu'):
add_accent_bar(slide, style, left=0.35, top=0.4, width=0.12, height=0.55)
if en_subtitle:
sub = en_subtitle.upper() if style.upper_en_subtitle else en_subtitle
text_box(slide, style, sub, 0.6, 0.9, 10, 0.35,
font_size=style.size_page_subtitle_en, color=style.subtext)
add_divider(slide, style, 0.6, 1.22, style.slide_width - 1.2)
def page_footer(slide, style: Style, company, page_no):
if not style.show_footer:
return
H = style.slide_height
W = style.slide_width
text_box(slide, style, company, 0.6, H - 0.4, 6, 0.3,
font_size=style.footer_company_font_size, color=style.subtext)
text_box(slide, style, f'{page_no:02d}', W - 1.1, H - 0.4, 0.8, 0.3,
font_size=style.footer_company_font_size, color=style.subtext,
align=PP_ALIGN.RIGHT)
def list_slide(prs, style: Style, title, en_subtitle, items,
company='', page_no=1):
"""编号卡片列表页。items = [{title, desc, rep?, year?}]."""
slide = prs.slides.add_slide(prs.slide_layouts[6])
set_background(slide, style)
content_header(slide, style, title, en_subtitle)
count = len(items)
top_margin = 1.45
bottom_margin = 0.8
available = style.slide_height - top_margin - bottom_margin
gap = 0.12
card_h = max(0.7, (available - gap * (count - 1)) / count)
card_w = style.slide_width - 1.2
for i, item in enumerate(items):
y = top_margin + i * (card_h + gap)
add_card(slide, style, 0.6, y, card_w, card_h)
# 编号圆
dot = slide.shapes.add_shape(
MSO_SHAPE.OVAL, Inches(0.78), Inches(y + card_h / 2 - 0.18),
Inches(0.36), Inches(0.36),
)
dot.fill.solid()
dot.fill.fore_color.rgb = style.accent
dot.line.fill.background()
num_color = style.card if style.name.startswith('xiaohongshu') or style.name == 'jobs-dark' else style.bg
text_box(slide, style, str(i + 1),
left=0.78, top=y + card_h / 2 - 0.18,
width=0.36, height=0.36,
font_size=12, bold=True, color=num_color,
align=PP_ALIGN.CENTER)
text_box(slide, style, item.get('title', ''),
left=1.3, top=y + 0.1, width=4.5, height=0.45,
font_size=style.size_card_title, bold=True, color=style.text)
if item.get('desc'):
text_box(slide, style, item['desc'],
left=1.3, top=y + 0.5, width=card_w - 4.0,
height=card_h - 0.55,
font_size=style.size_small, color=style.subtext)
if item.get('rep'):
text_box(slide, style, '代表:' + item['rep'],
left=card_w - 4.2, top=y + 0.1,
width=3.0, height=0.4,
font_size=style.size_small, color=style.light)
if item.get('year'):
text_box(slide, style, item['year'],
left=card_w - 1.0, top=y + 0.1,
width=1.2, height=0.4,
font_size=style.size_small, color=style.subtext,
align=PP_ALIGN.RIGHT)
page_footer(slide, style, company, page_no)
return slide
def quote_slide(prs, style: Style, title, en_subtitle, quote,
author='', role='', image=None, company='', page_no=1):
slide = prs.slides.add_slide(prs.slide_layouts[6])
set_background(slide, style)
content_header(slide, style, title, en_subtitle)
W = style.slide_width
top = 1.5
if image:
slide.shapes.add_picture(image, Inches(0.6), Inches(top),
Inches(W - 1.2), Inches(3.2))
top += 3.3
add_card(slide, style, 0.6, top, W - 1.2, 1.8)
if author:
text_box(slide, style, author, 1.0, top + 0.15, 3.5, 0.5,
font_size=16, bold=True, color=style.text)
if role:
text_box(slide, style, role, 1.0, top + 0.55, 3.5, 0.4,
font_size=style.size_body, color=style.subtext)
text_box(slide, style, '「' + quote + '」',
left=4.2, top=top + 0.15, width=W - 5.0, height=1.2,
font_size=20, bold=True, color=style.accent, italic=False)
page_footer(slide, style, company, page_no)
return slide
def section_slide(prs, style: Style, big_title, sub=''):
"""大字分章页。"""
slide = prs.slides.add_slide(prs.slide_layouts[6])
set_background(slide, style)
text_box(slide, style, big_title,
left=0.6, top=style.slide_height * 0.35,
width=style.slide_width - 1.2, height=1.5,
font_size=56, bold=True, color=style.accent,
align=PP_ALIGN.CENTER)
if sub:
text_box(slide, style, sub,
left=0.6, top=style.slide_height * 0.5,
width=style.slide_width - 1.2, height=0.8,
font_size=24, color=style.subtext, align=PP_ALIGN.CENTER)
return slide
def end_slide(prs, style: Style, title, sub='', qrcodes=None):
slide = prs.slides.add_slide(prs.slide_layouts[6])
set_background(slide, style)
W = style.slide_width
H = style.slide_height
text_box(slide, style, title,
left=0.6, top=H * 0.3, width=W - 1.2, height=1.2,
font_size=52, bold=True, color=style.text, align=PP_ALIGN.CENTER)
if sub:
text_box(slide, style, sub,
left=0.6, top=H * 0.45, width=W - 1.2, height=0.8,
font_size=28, color=style.subtext, align=PP_ALIGN.CENTER)
if qrcodes:
# qrcodes: [{path, label}]
count = len(qrcodes)
qr_w = 1.5
total = qr_w * count + (count - 1) * 0.6
start_x = (W - total) / 2
y = H * 0.62
for i, qr in enumerate(qrcodes):
x = start_x + i * (qr_w + 0.6)
slide.shapes.add_picture(qr['path'],
Inches(x), Inches(y),
Inches(qr_w), Inches(qr_w))
if qr.get('label'):
text_box(slide, style, qr['label'],
left=x - 0.2, top=y + qr_w + 0.1,
width=qr_w + 0.4, height=0.3,
font_size=style.size_small, color=style.subtext,
align=PP_ALIGN.CENTER)
return slide
FILE:scripts/slide5_why_openclaw.py
#!/usr/bin/env python3
"""
合并版 PPTX — Slide 1–6(我们的公司)
"""
from pptx import Presentation
from pptx.util import Inches, Pt
from pptx.dml.color import RGBColor
from pptx.enum.text import PP_ALIGN
C_BG = RGBColor(0x06, 0x0D, 0x1A)
C_CARD = RGBColor(0x0D, 0x18, 0x2A)
C_TEXT = RGBColor(0xFF, 0xFF, 0xFF)
C_SUBTEXT = RGBColor(0x88, 0x88, 0x88)
C_LIGHT = RGBColor(0xCC, 0xCC, 0xCC)
FONT = "PingFang SC"
def text_box(slide, text, left, top, width, height,
font_size=14, bold=False, color=None, align=PP_ALIGN.LEFT):
tb = slide.shapes.add_textbox(Inches(left), Inches(top), Inches(width), Inches(height))
tf = tb.text_frame
tf.word_wrap = True
p = tf.paragraphs[0]
p.alignment = align
run = p.add_run()
run.text = text
run.font.size = Pt(font_size)
run.font.bold = bold
run.font.name = FONT
run.font.color.rgb = color or C_TEXT
return tb
def add_card(slide, left, top, width, height):
shape = slide.shapes.add_shape(
1, Inches(left), Inches(top), Inches(width), Inches(height)
)
shape.fill.solid()
shape.fill.fore_color.rgb = C_CARD
shape.line.color.rgb = RGBColor(0x33, 0x33, 0x44)
shape.line.width = Pt(0.5)
return shape
def add_divider(slide, left, top, width, color=None):
ln = slide.shapes.add_shape(
1, Inches(left), Inches(top), Inches(width), Inches(0.008)
)
ln.fill.solid()
ln.fill.fore_color.rgb = color or RGBColor(0x33, 0x33, 0x44)
ln.line.fill.background()
prs = Presentation()
prs.slide_width = Inches(13.33)
prs.slide_height = Inches(7.5)
BLANK = prs.slide_layouts[6]
# ════════════════════════════════════════════════════════
# Slide 1 — 封面
# ════════════════════════════════════════════════════════
s1 = prs.slides.add_slide(BLANK)
bg = s1.background; fill = bg.fill; fill.solid(); fill.fore_color.rgb = C_BG
text_box(s1, "走向具身智能", 0.8, 2.0, 11.73, 1.2, font_size=64, bold=True, color=C_TEXT, align=PP_ALIGN.CENTER)
text_box(s1, "龙虾生态战略·重塑所有企业", 0.8, 3.35, 11.73, 0.7, font_size=26, color=C_TEXT, align=PP_ALIGN.CENTER)
text_box(s1, "青岛火一五信息科技有限公司 · 2026", 0.8, 4.5, 11.73, 0.5, font_size=14, color=C_SUBTEXT, align=PP_ALIGN.CENTER)
# ════════════════════════════════════════════════════════
# Slide 2 — 人工智能五个阶段
# ════════════════════════════════════════════════════════
s2 = prs.slides.add_slide(BLANK)
bg2 = s2.background; fill2 = bg2.fill; fill2.solid(); fill2.fore_color.rgb = C_BG
text_box(s2, "人工智能的五个阶段", 0.6, 0.35, 8, 0.55, font_size=28, bold=True, color=C_TEXT)
text_box(s2, "FIVE STAGES OF ARTIFICIAL INTELLIGENCE", 0.6, 0.82, 10, 0.35, font_size=10, color=C_SUBTEXT)
stages = [
("1", "大数据时代", "通过大数据打造智能聊天系统", "ChatGPT", "2020–2024"),
("2", "推理模型时代", "不再通过大数据进行智能化,大模型拥有了推理能力", "DeepSeek", "2024"),
("3", "智能体时代", "人工智能不再只是拥有大模型(智力),同时具备性格/灵魂、记忆、技能、工具综合体", "龙虾 OpenClaw", "2026"),
("4", "具身智能时代", "智能体装到具体的机械结构上,真正意义上实现机器人时代", "宇树机器人(三年后)", "未来"),
("5", "镜像世界", "人工智能复刻每个人的数据行为、记忆、大脑,计算机里有一个一模一样的我们", "——", "10年后"),
]
card_w = 12.13; card_h = 0.88; gap = 0.1; start_y = 1.35
for i, (num, title, desc, rep, year) in enumerate(stages):
y = start_y + i * (card_h + gap)
add_card(s2, 0.6, y, card_w, card_h)
dot = s2.shapes.add_shape(9, Inches(0.75), Inches(y + 0.3), Inches(0.28), Inches(0.28))
dot.fill.solid(); dot.fill.fore_color.rgb = C_TEXT; dot.line.fill.background()
ntb = s2.shapes.add_textbox(Inches(0.75), Inches(y + 0.3), Inches(0.28), Inches(0.28))
ntf = ntb.text_frame; np_ = ntf.paragraphs[0]; np_.alignment = PP_ALIGN.CENTER
nr = np_.add_run(); nr.text = num; nr.font.size = Pt(11); nr.font.bold = True; nr.font.name = FONT; nr.font.color.rgb = C_BG
text_box(s2, title, 1.5, y + 0.08, 2.5, 0.4, font_size=14, bold=True, color=C_TEXT)
text_box(s2, desc, 1.5, y + 0.45, 7.5, 0.38, font_size=10, color=C_SUBTEXT)
text_box(s2, "代表:" + rep, 9.1, y + 0.08, 3.3, 0.38, font_size=10, color=C_LIGHT)
text_box(s2, year, 11.7, y + 0.45, 0.9, 0.38, font_size=10, color=C_SUBTEXT, align=PP_ALIGN.RIGHT)
text_box(s2, "青岛火一五信息科技有限公司", 0.6, 7.1, 5, 0.3, font_size=9, color=C_SUBTEXT)
text_box(s2, "02", 12.6, 7.1, 0.5, 0.3, font_size=9, color=C_SUBTEXT, align=PP_ALIGN.RIGHT)
# ════════════════════════════════════════════════════════
# Slide 3 — 战略参考读物
# ════════════════════════════════════════════════════════
s3 = prs.slides.add_slide(BLANK)
bg3 = s3.background; fill3 = bg3.fill; fill3.solid(); fill3.fore_color.rgb = C_BG
text_box(s3, "战略参考读物", 0.6, 0.35, 8, 0.55, font_size=28, bold=True, color=C_TEXT)
text_box(s3, "STRATEGIC REFERENCE BOOKS", 0.6, 0.82, 10, 0.35, font_size=10, color=C_SUBTEXT)
add_card(s3, 0.6, 1.35, 5.8, 5.4)
s3.shapes.add_picture('/tmp/book_5000_final.png', Inches(1.3), Inches(1.7), Inches(2.6), Inches(3.71))
text_box(s3, "《5000天后的世界》", 0.9, 5.55, 5.2, 0.45, font_size=14, bold=True, color=C_TEXT, align=PP_ALIGN.CENTER)
text_box(s3, "凯文·凯利(Kevin Kelly)", 0.9, 5.95, 5.2, 0.35, font_size=11, color=C_SUBTEXT, align=PP_ALIGN.CENTER)
text_box(s3, "AI、互联网、智能体时代的演进预判", 0.9, 6.3, 5.2, 0.4, font_size=10, color=C_SUBTEXT, align=PP_ALIGN.CENTER)
add_card(s3, 6.9, 1.35, 5.8, 5.4)
s3.shapes.add_picture('/tmp/book_1000_final.jpg', Inches(7.5), Inches(1.7), Inches(3.0), Inches(3.0))
text_box(s3, "《预测之书:1000天后的世界》", 7.2, 5.0, 5.2, 0.45, font_size=14, bold=True, color=C_TEXT, align=PP_ALIGN.CENTER)
text_box(s3, "罗振宇", 7.2, 5.4, 5.2, 0.35, font_size=11, color=C_SUBTEXT, align=PP_ALIGN.CENTER)
text_box(s3, "1000天后的世界发展趋势预测", 7.2, 5.75, 5.2, 0.4, font_size=10, color=C_SUBTEXT, align=PP_ALIGN.CENTER)
text_box(s3, "青岛火一五信息科技有限公司", 0.6, 7.1, 5, 0.3, font_size=9, color=C_SUBTEXT)
text_box(s3, "03", 12.6, 7.1, 0.5, 0.3, font_size=9, color=C_SUBTEXT, align=PP_ALIGN.RIGHT)
# ════════════════════════════════════════════════════════
# Slide 4 — 乔布斯
# ════════════════════════════════════════════════════════
s4 = prs.slides.add_slide(BLANK)
bg4 = s4.background; fill4 = bg4.fill; fill4.solid(); fill4.fore_color.rgb = C_BG
text_box(s4, "远见与品味", 0.6, 0.35, 8, 0.55, font_size=28, bold=True, color=C_TEXT)
text_box(s4, "VISION AND TASTE", 0.6, 0.82, 10, 0.35, font_size=10, color=C_SUBTEXT)
add_divider(s4, 0.6, 1.18, 12.13)
s4.shapes.add_picture('/tmp/steve_jobs.png', Inches(0.6), Inches(1.5), Inches(12.13), Inches(3.2))
add_card(s4, 0.6, 4.85, 12.13, 1.6)
text_box(s4, "Steve Jobs", 1.0, 5.0, 3, 0.4, font_size=16, bold=True, color=C_TEXT)
text_box(s4, "苹果公司创始人", 1.0, 5.38, 3, 0.35, font_size=11, color=C_SUBTEXT)
text_box(s4, "「找不到方向的根本原因,", 4.0, 5.0, 8.5, 0.45, font_size=22, bold=True, color=C_TEXT)
text_box(s4, "不够聪明,是没有品味。」", 4.0, 5.45, 8.5, 0.45, font_size=22, bold=True, color=C_TEXT)
text_box(s4, "—— 乔布斯", 4.0, 5.95, 8.5, 0.35, font_size=12, color=C_SUBTEXT)
text_box(s4, "青岛火一五信息科技有限公司", 0.6, 7.1, 5, 0.3, font_size=9, color=C_SUBTEXT)
text_box(s4, "04", 12.6, 7.1, 0.5, 0.3, font_size=9, color=C_SUBTEXT, align=PP_ALIGN.RIGHT)
# ════════════════════════════════════════════════════════
# Slide 5 — 为什么押宝龙虾
# ════════════════════════════════════════════════════════
# ════════════════════════════════════════════════════════
# Slide 6 — 我们的公司
# ════════════════════════════════════════════════════════
s6 = prs.slides.add_slide(BLANK)
bg6 = s6.background; fill6 = bg6.fill; fill6.solid(); fill6.fore_color.rgb = C_BG
text_box(s6, "我们的公司", 0.6, 0.35, 8, 0.55, font_size=28, bold=True, color=C_TEXT)
text_box(s6, "COMPANIES", 0.6, 0.82, 10, 0.35, font_size=10, color=C_SUBTEXT)
add_divider(s6, 0.6, 1.18, 12.13)
# 公司一 — 青岛火一五信息科技有限公司
add_card(s6, 0.6, 1.4, 5.9, 4.5)
text_box(s6, "青岛火一五信息科技有限公司", 0.85, 1.6, 5.4, 0.45, font_size=13, bold=True, color=C_TEXT)
text_box(s6, "FIREINFO TECH", 0.85, 2.0, 5.4, 0.3, font_size=9, color=C_SUBTEXT)
company1_products = [
("龙虾 + 辉火云企业套件", "ERP · CRM · MES"),
("龙虾 + 辉火云管家", "养龙虾服务 · 数据治理"),
("XR-IoT 扩展现实物联网", "机器视觉 · 数字孪生"),
]
for i, (name, sub) in enumerate(company1_products):
dot6 = s6.shapes.add_shape(9, Inches(0.85), Inches(2.55 + i * 0.85), Inches(0.18), Inches(0.18))
dot6.fill.solid(); dot6.fill.fore_color.rgb = C_TEXT; dot6.line.fill.background()
text_box(s6, name, 1.15, 2.48 + i * 0.85, 5, 0.35, font_size=12, bold=True, color=C_TEXT)
text_box(s6, sub, 1.15, 2.78 + i * 0.85, 5, 0.3, font_size=10, color=C_SUBTEXT)
# 公司二 — 青岛萧伯网大科技有限公司
add_card(s6, 6.85, 1.4, 5.9, 4.5)
text_box(s6, "青岛萧伯网大科技有限公司", 7.1, 1.6, 5.4, 0.45, font_size=13, bold=True, color=C_TEXT)
text_box(s6, "XIAOBOWANG TECH", 7.1, 2.0, 5.4, 0.3, font_size=9, color=C_SUBTEXT)
text_box(s6, "逸寻智库", 7.35, 2.55, 5, 0.4, font_size=13, bold=True, color=C_TEXT)
text_box(s6, "公众号 · B站自媒体", 7.35, 2.9, 5, 0.3, font_size=10, color=C_SUBTEXT)
text_box(s6, "教育平台起步阶段", 7.35, 3.5, 5, 0.35, font_size=12, bold=True, color=C_TEXT)
text_box(s6, "布局品牌建设、IT前沿技术科普和培训", 7.35, 3.85, 5, 0.5, font_size=10, color=C_SUBTEXT)
# 个人定位
add_card(s6, 0.6, 6.1, 12.13, 0.75)
text_box(s6, "赵博 / OPC一人公司 · 超级个体 · AI工长", 1.0, 6.25, 11, 0.4, font_size=13, bold=True, color=C_TEXT, align=PP_ALIGN.CENTER)
text_box(s6, "青岛火一五信息科技有限公司", 0.6, 7.1, 5, 0.3, font_size=9, color=C_SUBTEXT)
text_box(s6, "06", 12.6, 7.1, 0.5, 0.3, font_size=9, color=C_SUBTEXT, align=PP_ALIGN.RIGHT)
s5 = prs.slides.add_slide(BLANK)
bg5 = s5.background; fill5 = bg5.fill; fill5.solid(); fill5.fore_color.rgb = C_BG
text_box(s5, "为什么押宝龙虾", 0.6, 0.35, 8, 0.55, font_size=28, bold=True, color=C_TEXT)
text_box(s5, "WHY WE BET ON OPENCLAW", 0.6, 0.82, 10, 0.35, font_size=10, color=C_SUBTEXT)
add_divider(s5, 0.6, 1.18, 12.13)
# 左卡 — 划时代现象级产品
add_card(s5, 0.6, 1.4, 5.9, 5.5)
text_box(s5, "划时代现象级产品", 0.85, 1.6, 5.4, 0.45, font_size=15, bold=True, color=C_TEXT)
add_divider(s5, 0.85, 2.1, 5.4)
text_box(s5, "龙虾作为划时代意义的现象级产品地位已经成立。", 0.85, 2.25, 5.4, 0.7, font_size=11, color=C_SUBTEXT)
text_box(s5, "它的生态、品牌、知名度不可撼动。", 0.85, 2.75, 5.4, 0.5, font_size=11, color=C_SUBTEXT)
# 繁荣度进度条
for i in range(10):
alpha = 0.3 + i * 0.07
v = int(255 * alpha)
add_solid = s5.shapes.add_shape(1, Inches(0.85 + i * 0.56), Inches(3.4), Inches(0.5), Inches(0.18))
add_solid.fill.solid(); add_solid.fill.fore_color.rgb = RGBColor(v, v, v); add_solid.line.fill.background()
text_box(s5, "社区繁荣度", 0.85, 3.7, 5.4, 0.3, font_size=9, color=C_SUBTEXT, align=PP_ALIGN.CENTER)
# 数字强调
text_box(s5, "3+", 1.2, 4.1, 1.5, 0.9, font_size=52, bold=True, color=C_TEXT)
text_box(s5, "年内优势稳固", 2.7, 4.3, 3, 0.5, font_size=13, color=C_TEXT)
text_box(s5, "就算出现更优秀的产品,大多数龙虾用户会选择坐等龙虾更新,这种情况三年内不会逆转。", 0.85, 5.1, 5.4, 0.9, font_size=10.5, color=C_SUBTEXT)
# 右卡 — 数据主权
add_card(s5, 6.85, 1.4, 5.9, 5.5)
text_box(s5, "数据主权", 7.1, 1.6, 5.4, 0.45, font_size=15, bold=True, color=C_TEXT)
add_divider(s5, 7.1, 2.1, 5.4)
text_box(s5, "企业把所有数据存到龙虾,", 7.1, 2.25, 5.4, 0.45, font_size=13, bold=True, color=C_TEXT)
text_box(s5, "然后才是小红书、抖音等APP复用一部分数据。", 7.1, 2.7, 5.4, 0.5, font_size=11, color=C_SUBTEXT)
text_box(s5, "打破以前数据割裂、数据沙箱的局面。", 7.1, 3.25, 5.4, 0.5, font_size=11, color=C_SUBTEXT)
text_box(s5, "不再让巨头瓜分我们的数据。", 7.1, 3.8, 5.4, 0.45, font_size=13, bold=True, color=C_TEXT)
text_box(s5, "龙虾 = 企业的数据金库。", 7.1, 4.35, 5.4, 0.5, font_size=16, bold=True, color=C_TEXT)
text_box(s5, "其他平台只是龙虾的附庸。", 7.1, 4.9, 5.4, 0.5, font_size=11, color=C_SUBTEXT)
text_box(s5, "青岛火一五信息科技有限公司", 0.6, 7.1, 5, 0.3, font_size=9, color=C_SUBTEXT)
text_box(s5, "05", 12.6, 7.1, 0.5, 0.3, font_size=9, color=C_SUBTEXT, align=PP_ALIGN.RIGHT)
# ─── 保存 ───────────────────────────────────────────
output = "/Users/jobzhao/.openclaw/media/outbound/合并版_走向具身智能.pptx"
prs.save(output)
print(f"✅ 合并版已生成: {output}")
FILE:scripts/style_packs.py
"""
style_packs.py - 火一五 PPT v3.2 的 21+ 真·审美方案
每个 pack 是完整的 StylePack(Palette + Typography + Spacing + Elevation + Decoration)。
v3.0 基础 4 套:
apple-keynote - Apple 发布会(纯黑 + SF Pro + 巨字号)
apple-light - Apple.com 产品页(纯白 + 磨砂卡 + 极简)
xiaohongshu-creator - 真·生活博主(奶油 + 鼠尾草 + 焦糖 / 衬线字)
xiaohongshu-vintage - 博主复古胶片系(琥珀 + 雾霾蓝 / 衬线字)
v3.1 科技双套:
tech-neon - 赛博霓虹(深蓝黑 + 电青电紫 + 网格)
tech-minimal - Vercel/Linear 极简科技
v3.2 生产级 12 套(新增):
liquid-glass - 🍎 Apple macOS 26 玻璃风(七彩光球 + 半透卡片)
muji - ⬜ 原研哉极简(米白 + 朱红印 + 细衬线)
ink-wash - 🖋 中国水墨(墨分五色 + 朱砂印 + 飞白)
guofeng - 🏮 国风/故宫(朱砂 + 藤黄 + 群青 + 篆刻)
cyberpunk-vivid - 🌃 赛博朋克绚彩(深紫黑 + 粉青黄三撞色)
van-gogh - 🎨 梵高油画(星夜蓝 + 麦田金 + 笔触感)
da-vinci - 📜 达芬奇手稿(羊皮纸 + 棕墨 + 手绘几何)
xhs-fashion - 👜 小红书时尚(莫兰迪粉 + 高级灰 + 细金线)
morandi - 🎭 莫兰迪高级灰(低饱和粉绿米)
memphis - 🔺 孟菲斯(80s 撞色几何)
bauhaus - 🟦 包豪斯(三原色 + 几何 + 等宽)
wes-anderson - 🍰 韦斯安德森(对称 + 粉绿米色)
附带 3 个 legacy pack 保持向后兼容:jobs-dark / xiaohongshu / xhs-portrait。
"""
from __future__ import annotations
from design_system import (
StylePack, Palette, Typography, Spacing, Elevation, Decoration, Canvas,
)
# ============================================================
# 一、Apple Keynote(真·发布会暗场)
# ============================================================
APPLE_KEYNOTE = StylePack(
name='apple-keynote',
display_name='Apple 发布会(暗场)',
tagline='纯黑 + SF Pro + 巨字号 + 极致留白',
canvas=Canvas(width=13.33, height=7.5),
palette=Palette(
# Apple 发布会是纯黑而非蓝黑
bg='#000000',
bg_elevated='#1C1C1E',
bg_subtle='#2C2C2E',
# 文字白色渐变
text_primary='#F5F5F7',
text_secondary='#A1A1A6',
text_tertiary='#86868B',
text_muted='#48484A',
# 品牌蓝
accent='#0A84FF',
accent_soft='#0A84FF',
border='#2C2C2E',
divider='#1C1C1E',
),
typography=Typography(
display_font='SF Pro Display',
body_font='SF Pro Text',
# Apple 发布会级字号
hero=160, # 巨字号
section=96,
page_title=48,
page_sub=16,
card_title=22,
body=15,
caption=11,
page_number=9,
hero_weight='bold',
section_weight='bold',
page_weight='semibold',
card_weight='semibold',
body_weight='regular',
hero_tracking=-0.03, # 巨字要收字距
section_tracking=-0.02,
page_tracking=-0.01,
hero_leading=0.95,
body_leading=1.4,
uppercase_en_sub=True, # OVERVIEW / INTRO
),
spacing=Spacing(
gutter=0.08,
stack_sm=0.17,
stack_md=0.33,
stack_lg=0.66,
stack_xl=1.33,
margin_x=0.8,
margin_x_hero=1.2,
card_pad_x=0.33,
card_pad_y=0.25,
),
elevation=Elevation(
card_radius=0.12,
card_stroke_width=0.0, # 无描边
card_stroke_color='#2C2C2E',
card_fill='#1C1C1E',
use_fake_shadow=False,
style='flat',
),
decoration=Decoration(
cover_hero_align='center',
cover_hero_case='as-is',
cover_bottom_line=False,
cover_top_line=False,
page_title_align='left',
page_accent_bar=False,
page_en_sub_position='under',
tag_style='none',
stat_hero_size=280, # Apple 的 "2 Billion" 页
stat_hero_weight='bold',
image_treatment='full',
),
show_footer=False, # Apple 不显示页脚
)
# ============================================================
# 二、Apple Light(Apple.com 产品页)
# ============================================================
APPLE_LIGHT = StylePack(
name='apple-light',
display_name='Apple.com(白场)',
tagline='纯白 + 磨砂卡 + 极简克制',
canvas=Canvas(width=13.33, height=7.5),
palette=Palette(
bg='#FFFFFF',
bg_elevated='#F5F5F7', # Apple.com 的经典卡片灰
bg_subtle='#FBFBFD',
text_primary='#1D1D1F',
text_secondary='#424245',
text_tertiary='#6E6E73',
text_muted='#D2D2D7',
accent='#0071E3', # Apple.com 的链接蓝
accent_soft='#E6F0FD',
border='#D2D2D7',
divider='#F5F5F7',
),
typography=Typography(
display_font='SF Pro Display',
body_font='SF Pro Text',
hero=120,
section=72,
page_title=44,
page_sub=15,
card_title=21,
body=15,
caption=11,
page_number=10,
hero_tracking=-0.025,
section_tracking=-0.02,
page_tracking=-0.01,
hero_leading=1.0,
body_leading=1.45,
uppercase_en_sub=False,
),
spacing=Spacing(
margin_x=0.8,
margin_x_hero=1.0,
),
elevation=Elevation(
card_radius=0.18, # 圆角更圆
card_stroke_width=0.0, # 无描边,靠填色区分
card_stroke_color='#D2D2D7',
card_fill='#F5F5F7',
use_fake_shadow=False,
style='soft',
),
decoration=Decoration(
cover_hero_align='center',
stat_hero_size=200,
image_treatment='rounded',
),
show_footer=True,
)
# ============================================================
# 三、小红书博主风(奶油 + 鼠尾草 + 焦糖)
# ============================================================
XHS_CREATOR = StylePack(
name='xiaohongshu-creator',
display_name='小红书博主(奶油生活系)',
tagline='奶油 + 鼠尾草 + 焦糖咖 + 衬线字',
canvas=Canvas(width=13.33, height=7.5),
palette=Palette(
# 博主风是奶油底 + 低饱和
bg='#FBF7F0', # 奶油主背
bg_elevated='#FFFFFF', # 纯白卡片
bg_subtle='#F5F1EA', # 次奶油
# 文字不用纯黑,用焦糖咖色
text_primary='#3E2E1F', # 焦糖咖替代黑
text_secondary='#8B6F47', # 奶茶咖
text_tertiary='#B3A28A', # 浅咖
text_muted='#D4C8B8',
# 鼠尾草绿作为点缀主色
accent='#9FAE8B', # 鼠尾草
accent_soft='#E6EAD9',
border='#E8DFD0',
divider='#F0E8D9',
),
typography=Typography(
# 博主风关键:衬线字体!
display_font='Noto Serif SC',
display_fallbacks=[
'Source Han Serif SC', 'Songti SC',
'STSong', 'SimSun', 'serif',
],
body_font='PingFang SC',
body_fallbacks=[
'Noto Sans CJK SC', 'Microsoft YaHei', 'sans-serif',
],
hero=72, # 博主风不需要 Apple 那种巨字
section=52,
page_title=32,
page_sub=14,
card_title=18,
body=14,
caption=11,
page_number=10,
hero_weight='semibold', # 衬线字体 semibold 足够
section_weight='semibold',
page_weight='semibold',
card_weight='semibold',
body_weight='regular',
hero_tracking=0.02, # 衬线字要放一点字距
section_tracking=0.01,
page_tracking=0.01,
hero_leading=1.15,
body_leading=1.6, # 衬线字需要更大行高
uppercase_en_sub=False, # 博主风英文不全大写
),
spacing=Spacing(
margin_x=0.7,
margin_x_hero=1.0,
stack_md=0.4, # 段落间距更大,呼吸感
stack_lg=0.8,
),
elevation=Elevation(
card_radius=0.22, # 圆润
card_stroke_width=0.75,
card_stroke_color='#E8DFD0',
card_fill='#FFFFFF',
use_fake_shadow=True, # 微微阴影
shadow_color='#E8DFD0',
shadow_offset_y=0.06,
style='soft',
),
decoration=Decoration(
cover_hero_align='left', # 博主封面常左对齐
cover_bottom_line=False,
page_title_align='left',
page_accent_bar=True, # 标题左侧小竖条
page_en_sub_position='above',
tag_style='pill',
stat_hero_size=140,
image_treatment='rounded',
),
show_footer=True,
)
# ============================================================
# 四、小红书复古胶片(琥珀 + 雾霾蓝)
# ============================================================
XHS_VINTAGE = StylePack(
name='xiaohongshu-vintage',
display_name='小红书博主(复古胶片)',
tagline='琥珀 + 雾霾蓝 + 衬线字 + 胶片质感',
canvas=Canvas(width=13.33, height=7.5),
palette=Palette(
bg='#F2EAD9', # 复古米
bg_elevated='#FFFAF1', # 象牙白
bg_subtle='#E8D0CD', # 琥珀粉
text_primary='#4A3526', # 深栗咖
text_secondary='#7A5C42',
text_tertiary='#A88B6D',
text_muted='#C9B195',
# 雾霾蓝点缀
accent='#A8B8C6', # 雾霾蓝
accent_soft='#D6DFE8',
border='#D9C8A8',
divider='#E8D7B5',
),
typography=Typography(
display_font='Noto Serif SC',
display_fallbacks=[
'Source Han Serif SC', 'Songti SC',
'STSong', 'serif',
],
body_font='Noto Serif SC', # 胶片风全衬线
body_fallbacks=[
'Source Han Serif SC', 'Songti SC', 'serif',
],
hero=64,
section=46,
page_title=30,
page_sub=13,
card_title=17,
body=14,
caption=11,
page_number=10,
hero_weight='semibold',
section_weight='semibold',
page_weight='semibold',
card_weight='semibold',
body_weight='regular',
hero_tracking=0.03, # 复古字距更松
section_tracking=0.02,
page_tracking=0.01,
hero_leading=1.2,
body_leading=1.65,
uppercase_en_sub=False,
),
spacing=Spacing(
margin_x=0.75,
margin_x_hero=1.0,
stack_md=0.4,
stack_lg=0.8,
),
elevation=Elevation(
card_radius=0.08, # 复古卡片角不要太圆
card_stroke_width=1.0,
card_stroke_color='#D9C8A8',
card_fill='#FFFAF1',
use_fake_shadow=False,
style='outline',
),
decoration=Decoration(
cover_hero_align='left',
cover_top_line=True,
cover_bottom_line=True,
page_title_align='left',
page_accent_bar=False,
page_en_sub_position='above',
tag_style='underline',
stat_hero_size=110,
image_treatment='film',
),
show_footer=True,
)
# ============================================================
# 五、Tech Neon(深蓝黑 + 电青电紫霓虹 + 网格)
# ============================================================
TECH_NEON = StylePack(
name='tech-neon',
display_name='科技霓虹(赛博黑蓝)',
tagline='深蓝黑 + 电青→电紫渐变 + 网格 + 辉光',
canvas=Canvas(width=13.33, height=7.5),
palette=Palette(
# 深蓝黑近乎纯黑,但带蓝色投影
bg='#050510',
bg_elevated='#0D0D1F', # 浮起卡片略抬
bg_subtle='#13152A', # 更深的分隔背景
# 文字纯白偏冷
text_primary='#F0F4FF',
text_secondary='#8B95B3', # 冷灰蓝
text_tertiary='#5A6484',
text_muted='#2E3550',
# 电青作为主强调
accent='#00D9FF', # electric cyan
accent_soft='#7C3AED', # electric purple 作为辅助
border='#1F2240',
divider='#13152A',
),
typography=Typography(
display_font='Inter',
display_fallbacks=[
'SF Pro Display', 'PingFang SC', 'Microsoft YaHei',
'Helvetica Neue', 'sans-serif',
],
body_font='Inter',
body_fallbacks=[
'SF Pro Text', 'PingFang SC', 'Microsoft YaHei', 'sans-serif',
],
hero=144,
section=88,
page_title=44,
page_sub=14,
card_title=22,
body=15,
caption=11,
page_number=9,
hero_weight='bold',
section_weight='bold',
page_weight='semibold',
card_weight='semibold',
body_weight='regular',
hero_tracking=-0.025, # 数码感大字轻微收紧
section_tracking=-0.02,
page_tracking=0.0,
hero_leading=0.95,
body_leading=1.5,
uppercase_en_sub=True,
),
spacing=Spacing(
margin_x=0.8,
margin_x_hero=1.0,
stack_md=0.35,
stack_lg=0.7,
),
elevation=Elevation(
card_radius=0.08, # 科技感不要太圆
card_stroke_width=0.75,
card_stroke_color='#1F2240', # 冷灰蓝描边
card_fill='#0D0D1F',
use_fake_shadow=False,
style='outline',
),
decoration=Decoration(
cover_hero_align='left', # 科技风封面常左对齐
cover_hero_case='as-is',
cover_bottom_line=False,
cover_top_line=False,
page_title_align='left',
page_accent_bar=False,
page_en_sub_position='above',
tag_style='square',
stat_hero_size=260,
stat_hero_weight='bold',
image_treatment='full',
# 科技装饰
gradient_bg=('#050510', '#0B0E1F', 135), # 极微对角线渐变,增加深度
accent_gradient=('#00D9FF', '#7C3AED'), # 电青→电紫,hero 大字用
grid_overlay=True,
grid_color='#12152A',
grid_spacing=0.4,
grid_thickness=0.005,
glow_accent=True,
glow_strength=0.7,
corner_marks=True,
corner_size=0.28,
corner_thickness=0.018,
dev_badge=True,
dev_badge_template='BUILD · {date}',
mono_font='JetBrains Mono',
scanline=False,
),
show_footer=True,
)
# ============================================================
# 六、Tech Minimal(Vercel / Linear 风)
# ============================================================
TECH_MINIMAL = StylePack(
name='tech-minimal',
display_name='科技极简(Vercel/Linear 风)',
tagline='近黑 + 电紫 + 点阵 + 等宽 metadata',
canvas=Canvas(width=13.33, height=7.5),
palette=Palette(
# 近黑偏冷
bg='#0A0A0F',
bg_elevated='#111118',
bg_subtle='#16161E',
# 文字克制
text_primary='#F4F4F6',
text_secondary='#9090A0',
text_tertiary='#606070',
text_muted='#303040',
# 电紫为唯一主色
accent='#8B5CF6', # violet-500
accent_soft='#A78BFA', # 浅紫,悬停态
border='#1F1F28',
divider='#16161E',
),
typography=Typography(
display_font='Inter',
display_fallbacks=[
'SF Pro Display', 'PingFang SC', 'Microsoft YaHei',
'Helvetica Neue', 'sans-serif',
],
body_font='Inter',
body_fallbacks=[
'SF Pro Text', 'PingFang SC', 'Microsoft YaHei', 'sans-serif',
],
hero=120,
section=72,
page_title=40,
page_sub=13,
card_title=20,
body=14,
caption=11,
page_number=9,
hero_weight='semibold', # Vercel 风不过粗
section_weight='semibold',
page_weight='semibold',
card_weight='semibold',
body_weight='regular',
hero_tracking=-0.02,
section_tracking=-0.015,
page_tracking=-0.005,
hero_leading=1.0,
body_leading=1.55,
uppercase_en_sub=True,
),
spacing=Spacing(
margin_x=0.8,
margin_x_hero=1.0,
stack_md=0.35,
stack_lg=0.7,
),
elevation=Elevation(
card_radius=0.1,
card_stroke_width=0.75,
card_stroke_color='#1F1F28',
card_fill='#111118',
use_fake_shadow=False,
style='outline',
),
decoration=Decoration(
cover_hero_align='left',
cover_hero_case='as-is',
page_title_align='left',
page_accent_bar=False,
page_en_sub_position='above',
tag_style='square',
stat_hero_size=220,
stat_hero_weight='semibold',
image_treatment='rounded',
# 极简科技装饰:没有网格,只有点阵
gradient_bg=None, # 纯色底
accent_gradient=None, # 无渐变文字
grid_overlay=False,
dot_grid=True,
dot_color='#1C1C26',
dot_spacing=0.5,
dot_size=0.035,
glow_accent=True,
glow_strength=0.4,
corner_marks=False,
dev_badge=True,
dev_badge_template='v{year} · BUILD {build}',
mono_font='JetBrains Mono',
scanline=False,
),
show_footer=True,
)
# ============================================================
# v3.2 ❶ Liquid Glass — Apple macOS 26 / iOS 26(玻璃风)
# 半透磨砂卡 + 七彩光球 + 大圆角 + 浮空层叠
# ============================================================
LIQUID_GLASS = StylePack(
name='liquid-glass',
display_name='Apple Liquid Glass(macOS 26)',
tagline='半透磨砂玻璃 + 七彩光球 + 浮空层叠',
canvas=Canvas(width=13.33, height=7.5),
palette=Palette(
# 浅色玻璃底(Apple WWDC 25 主视觉)
bg='#F2F2F7', # systemGroupedBackgroundLight
bg_elevated='#FFFFFF', # 玻璃卡片用纯白叠透明(运行时叠 alpha)
bg_subtle='#FAFAFC',
# 文字深灰(玻璃风永远不要纯黑文字)
text_primary='#1D1D1F',
text_secondary='#3A3A3C',
text_tertiary='#6E6E73',
text_muted='#AEAEB2',
# Apple system colors(用作光球+强调)
accent='#0A84FF', # systemBlue
accent_soft='#BF5AF2', # systemPurple(次强调)
border='#E5E5EA', # 玻璃边
divider='#F2F2F7',
),
typography=Typography(
display_font='SF Pro Display',
display_fallbacks=[
'PingFang SC', 'Inter', 'Microsoft YaHei',
'Helvetica Neue', '-apple-system', 'sans-serif',
],
body_font='SF Pro Text',
body_fallbacks=[
'PingFang SC', 'Inter', 'Microsoft YaHei',
'Helvetica Neue', '-apple-system', 'sans-serif',
],
hero=128, # 玻璃卡上的标题不用 Apple 那么巨
section=80,
page_title=44,
page_sub=14,
card_title=22,
body=15,
caption=11,
page_number=10,
hero_weight='semibold', # 玻璃质感配半粗即可
section_weight='semibold',
page_weight='semibold',
card_weight='semibold',
body_weight='regular',
hero_tracking=-0.025,
section_tracking=-0.018,
page_tracking=-0.008,
hero_leading=1.0,
body_leading=1.5,
uppercase_en_sub=False,
),
spacing=Spacing(
margin_x=0.9,
margin_x_hero=1.2,
stack_md=0.4,
stack_lg=0.85,
),
elevation=Elevation(
card_radius=0.5, # 大圆角是玻璃卡灵魂
card_stroke_width=0.5,
card_stroke_color='#FFFFFF', # 玻璃边白色细线
card_fill='#FFFFFFE6', # 90% alpha 白
use_fake_shadow=True,
shadow_color='#1D1D1F1A',
shadow_offset_y=0.08,
style='glass',
),
decoration=Decoration(
cover_hero_align='center',
cover_hero_case='as-is',
cover_bottom_line=False,
page_title_align='left',
page_accent_bar=False,
page_en_sub_position='above',
tag_style='pill',
stat_hero_size=240,
stat_hero_weight='semibold',
image_treatment='rounded',
# 玻璃风招牌:彩色光球渐变背景
gradient_bg=('#E8F0FE', '#F5E8FE', 135), # 蓝紫粉极淡渐变
accent_gradient=('#0A84FF', '#BF5AF2'), # hero 大字蓝→紫渐变
grid_overlay=False,
dot_grid=False,
glow_accent=True,
glow_strength=0.85, # 强光晕模拟折射
corner_marks=False,
dev_badge=False,
mono_font='SF Mono',
scanline=False,
),
show_footer=True,
)
# ============================================================
# v3.2 ❷ MUJI / 原研哉极简
# 米白 + 朱红方印 + 细衬线 + 70%+ 留白
# ============================================================
MUJI = StylePack(
name='muji',
display_name='原研哉极简(无印良品)',
tagline='纸感米白 + 朱红方印 + 细衬线 + 70% 留白',
canvas=Canvas(width=13.33, height=7.5),
palette=Palette(
bg='#FAF7EB', # 纸感米白(关键 — 非冷白)
bg_elevated='#F5F2E8',
bg_subtle='#F0EDE0',
text_primary='#2B2B2B', # 炭墨非纯黑
text_secondary='#555555',
text_tertiary='#888888',
text_muted='#B5907D', # 原木棕
accent='#7F0019', # MUJI 朱红
accent_soft='#E8D9D9',
border='#D9D2C0',
divider='#E5DFD2',
),
typography=Typography(
display_font='Noto Serif SC',
display_fallbacks=[
'Source Han Serif SC', 'Songti SC', 'STSong',
'Hiragino Mincho ProN', 'serif',
],
body_font='Noto Sans SC',
body_fallbacks=[
'PingFang SC', 'Hiragino Sans', 'Microsoft YaHei',
'Helvetica Neue Light', 'sans-serif',
],
hero=72, # 原研哉风字号克制
section=48,
page_title=30,
page_sub=12,
card_title=16,
body=13,
caption=10,
page_number=9,
hero_weight='regular', # 极轻字重是关键
section_weight='regular',
page_weight='regular',
card_weight='regular',
body_weight='regular',
hero_tracking=0.04, # 字距大 — 呼吸感
section_tracking=0.03,
page_tracking=0.02,
body_tracking=0.01,
hero_leading=1.4,
body_leading=1.8, # 行高极大
uppercase_en_sub=False,
),
spacing=Spacing(
gutter=0.12,
stack_sm=0.25,
stack_md=0.55, # 间距巨大 — 留白美学
stack_lg=1.1,
stack_xl=2.0,
margin_x=1.5, # 边距夸张
margin_x_hero=2.0,
card_pad_x=0.5,
card_pad_y=0.4,
),
elevation=Elevation(
card_radius=0.0, # 不要圆角
card_stroke_width=0.25, # 0.25pt 发丝线
card_stroke_color='#B5907D',
card_fill='#F5F2E8',
use_fake_shadow=False,
style='outline',
),
decoration=Decoration(
cover_hero_align='left',
cover_hero_case='as-is',
cover_bottom_line=False,
cover_top_line=False,
page_title_align='left',
page_accent_bar=False,
page_en_sub_position='above',
tag_style='none', # 极简 — 不要胶囊
stat_hero_size=160,
stat_hero_weight='regular',
image_treatment='full',
gradient_bg=None,
accent_gradient=None,
grid_overlay=False,
dot_grid=False,
glow_accent=False,
corner_marks=False,
dev_badge=False,
mono_font=None,
scanline=False,
),
show_footer=True,
)
# ============================================================
# v3.2 ❸ Ink Wash / 中国水墨
# 宣纸底 + 墨分五色 + 朱砂方印 + 飞白笔触
# ============================================================
INK_WASH = StylePack(
name='ink-wash',
display_name='中国水墨(墨分五色)',
tagline='宣纸底 + 焦浓重淡清 + 朱砂印 + 飞白',
canvas=Canvas(width=13.33, height=7.5),
palette=Palette(
bg='#FDFBF5', # 宣纸米白
bg_elevated='#F8F5EC',
bg_subtle='#E8E4D9', # 宣纸纤维
text_primary='#1A1A1A', # 焦墨
text_secondary='#4A4A4A', # 浓墨
text_tertiary='#8B8B8B', # 重墨
text_muted='#BFBFBF', # 淡墨
accent='#A62828', # 朱砂印章
accent_soft='#E8D5D0',
border='#D9D2C0',
divider='#E8E4D9',
),
typography=Typography(
display_font='Noto Serif SC',
display_fallbacks=[
'STKaiti', 'Kaiti SC', 'STSong', 'Songti SC',
'Source Han Serif SC', 'serif',
],
body_font='Noto Serif SC',
body_fallbacks=[
'STSong', 'Songti SC', 'Source Han Serif SC',
'serif',
],
hero=84,
section=58,
page_title=34,
page_sub=13,
card_title=18,
body=14,
caption=11,
page_number=10,
hero_weight='semibold',
section_weight='semibold',
page_weight='semibold',
card_weight='regular',
body_weight='regular',
hero_tracking=0.06, # 中文古典字距宽
section_tracking=0.04,
page_tracking=0.03,
body_tracking=0.02,
hero_leading=1.3,
body_leading=1.85,
uppercase_en_sub=False,
),
spacing=Spacing(
gutter=0.1,
stack_md=0.45,
stack_lg=0.95,
margin_x=1.0,
margin_x_hero=1.4,
),
elevation=Elevation(
card_radius=0.04,
card_stroke_width=0.3,
card_stroke_color='#BFBFBF',
card_fill='#F8F5EC',
use_fake_shadow=False,
style='outline',
),
decoration=Decoration(
cover_hero_align='left',
cover_hero_case='as-is',
cover_top_line=False,
cover_bottom_line=False,
page_title_align='left',
page_accent_bar=False,
page_en_sub_position='above',
tag_style='none',
stat_hero_size=180,
stat_hero_weight='semibold',
image_treatment='torn', # 不规则毛边
gradient_bg=None,
accent_gradient=None,
grid_overlay=False,
dot_grid=False,
glow_accent=False,
corner_marks=False,
dev_badge=False,
mono_font=None,
scanline=False,
),
show_footer=True,
)
# ============================================================
# v3.2 ❹ Guofeng / 国风·故宫
# 朱砂红 + 藤黄 + 群青 + 篆刻印 + 万字纹边框
# ============================================================
GUOFENG = StylePack(
name='guofeng',
display_name='国风故宫(红黄青三色)',
tagline='朱砂宫墙红 + 藤黄金瓦 + 群青 + 篆刻印章',
canvas=Canvas(width=13.33, height=7.5),
palette=Palette(
bg='#F5E8C7', # 米黄绢本
bg_elevated='#FAF0D6',
bg_subtle='#EDDFAE',
text_primary='#1A1A1A', # 墨色
text_secondary='#5D3A1F', # 漆木色
text_tertiary='#8B6F47',
text_muted='#B89968',
accent='#E60012', # 朱砂宫墙红
accent_soft='#FFB61E', # 藤黄(次强调)
border='#B22222',
divider='#D9C8A8',
),
typography=Typography(
display_font='Noto Serif SC',
display_fallbacks=[
'STSong', 'Songti SC', 'STKaiti',
'Source Han Serif SC', 'serif',
],
body_font='Noto Serif SC',
body_fallbacks=[
'STSong', 'Songti SC', 'serif',
],
hero=88,
section=60,
page_title=36,
page_sub=13,
card_title=18,
body=14,
caption=11,
page_number=10,
hero_weight='bold', # 故宫风骨感
section_weight='bold',
page_weight='semibold',
card_weight='semibold',
body_weight='regular',
hero_tracking=0.05,
section_tracking=0.04,
page_tracking=0.03,
hero_leading=1.25,
body_leading=1.75,
uppercase_en_sub=False,
),
spacing=Spacing(
margin_x=0.9,
margin_x_hero=1.2,
stack_md=0.4,
stack_lg=0.85,
),
elevation=Elevation(
card_radius=0.0, # 中式不要圆角
card_stroke_width=0.5,
card_stroke_color='#E60012', # 朱红描边
card_fill='#FAF0D6',
use_fake_shadow=False,
style='outline',
),
decoration=Decoration(
cover_hero_align='left',
cover_hero_case='as-is',
cover_top_line=True, # 金线
cover_bottom_line=True,
page_title_align='left',
page_accent_bar=True, # 朱红竖条
page_en_sub_position='above',
tag_style='square',
stat_hero_size=200,
stat_hero_weight='bold',
image_treatment='full',
gradient_bg=None,
accent_gradient=('#E60012', '#FFB61E'), # 朱→金渐变
grid_overlay=False,
dot_grid=False,
glow_accent=False,
corner_marks=True, # 万字纹角标
corner_size=0.32,
corner_thickness=0.025,
dev_badge=False,
mono_font=None,
scanline=False,
),
show_footer=True,
)
# ============================================================
# v3.2 ❺ Cyberpunk Vivid / 赛博朋克绚彩
# 深紫黑 + 粉/青/黄三撞色 + Blade Runner 橙 + 扫描线
# ============================================================
CYBERPUNK_VIVID = StylePack(
name='cyberpunk-vivid',
display_name='赛博朋克绚彩(粉青黄撞色)',
tagline='深紫黑 + 热粉 + 电青 + 赛博黄 + 银翼橙',
canvas=Canvas(width=13.33, height=7.5),
palette=Palette(
bg='#0A0014', # 深紫黑(带紫调)
bg_elevated='#13002A',
bg_subtle='#1A0033',
text_primary='#FFFFFF',
text_secondary='#E5C8FF', # 浅紫
text_tertiary='#9988CC',
text_muted='#554477',
accent='#FF2DAA', # 热粉主霓虹
accent_soft='#00E5FF', # 电光青次强调
border='#2A0055',
divider='#1A0033',
),
typography=Typography(
display_font='Orbitron',
display_fallbacks=[
'Rajdhani', 'Inter', 'SF Pro Display',
'PingFang SC', 'Microsoft YaHei', 'sans-serif',
],
body_font='Rajdhani',
body_fallbacks=[
'Inter', 'SF Pro Text', 'PingFang SC', 'sans-serif',
],
hero=152, # 赛博风超大字
section=92,
page_title=46,
page_sub=14,
card_title=22,
body=15,
caption=11,
page_number=10,
hero_weight='bold',
section_weight='bold',
page_weight='bold',
card_weight='semibold',
body_weight='regular',
hero_tracking=0.02, # 赛博字距偏宽
section_tracking=0.015,
page_tracking=0.01,
hero_leading=0.95,
body_leading=1.5,
uppercase_en_sub=True,
),
spacing=Spacing(
margin_x=0.8,
margin_x_hero=1.0,
stack_md=0.35,
stack_lg=0.7,
),
elevation=Elevation(
card_radius=0.04,
card_stroke_width=1.0,
card_stroke_color='#FF2DAA', # 粉色霓虹边
card_fill='#13002A',
use_fake_shadow=False,
style='outline',
),
decoration=Decoration(
cover_hero_align='left',
cover_hero_case='upper',
cover_top_line=False,
cover_bottom_line=False,
page_title_align='left',
page_accent_bar=False,
page_en_sub_position='above',
tag_style='square',
stat_hero_size=280,
stat_hero_weight='bold',
image_treatment='full',
gradient_bg=('#0A0014', '#2A0055', 135),
accent_gradient=('#FF2DAA', '#00E5FF'), # 粉→青双色
grid_overlay=True,
grid_color='#1A0033',
grid_spacing=0.35,
grid_thickness=0.005,
dot_grid=False,
glow_accent=True,
glow_strength=0.95, # 强霓虹辉光
corner_marks=True,
corner_size=0.3,
corner_thickness=0.022,
dev_badge=True,
dev_badge_template='SYS://NIGHT_CITY · {date}',
mono_font='JetBrains Mono',
scanline=True, # 招牌扫描线
scanline_color='#FF2DAA',
),
show_footer=True,
)
# ============================================================
# v3.2 ❻ Van Gogh / 梵高油画
# 星夜深蓝 + 麦田金 + 笔触感(用色块叠加模拟)
# ============================================================
VAN_GOGH = StylePack(
name='van-gogh',
display_name='梵高油画(星夜麦田)',
tagline='星夜深蓝 + 麦田金 + 鸢尾紫 + 油画笔触',
canvas=Canvas(width=13.33, height=7.5),
palette=Palette(
bg='#0E2A47', # 星夜深蓝
bg_elevated='#1A3A5C',
bg_subtle='#23456E',
text_primary='#FFE082', # 麦田金(标题)
text_secondary='#F0D88B',
text_tertiary='#C8B273',
text_muted='#7A8FA8',
accent='#FFC107', # 向日葵金
accent_soft='#7B5FA8', # 鸢尾紫
border='#3D5680',
divider='#1A3A5C',
),
typography=Typography(
display_font='Cormorant Garamond',
display_fallbacks=[
'Playfair Display', 'Noto Serif SC', 'Songti SC',
'STSong', 'serif',
],
body_font='EB Garamond',
body_fallbacks=[
'Cormorant Garamond', 'Noto Serif SC', 'serif',
],
hero=92,
section=64,
page_title=38,
page_sub=14,
card_title=20,
body=15,
caption=12,
page_number=11,
hero_weight='semibold',
section_weight='semibold',
page_weight='semibold',
card_weight='semibold',
body_weight='regular',
hero_tracking=0.02,
section_tracking=0.015,
page_tracking=0.01,
hero_leading=1.15,
body_leading=1.7,
uppercase_en_sub=False,
),
spacing=Spacing(
margin_x=0.9,
margin_x_hero=1.2,
stack_md=0.4,
stack_lg=0.85,
),
elevation=Elevation(
card_radius=0.06,
card_stroke_width=1.5, # 油画粗笔触感
card_stroke_color='#FFC107',
card_fill='#1A3A5C',
use_fake_shadow=True,
shadow_color='#000000AA',
shadow_offset_y=0.06,
style='outline',
),
decoration=Decoration(
cover_hero_align='left',
cover_hero_case='as-is',
cover_top_line=False,
cover_bottom_line=False,
page_title_align='left',
page_accent_bar=True,
page_en_sub_position='above',
tag_style='underline',
stat_hero_size=220,
stat_hero_weight='semibold',
image_treatment='rounded',
gradient_bg=('#0E2A47', '#1A3A5C', 135),
accent_gradient=('#FFC107', '#FFE082'), # 金光渐变
grid_overlay=False,
dot_grid=False,
glow_accent=True,
glow_strength=0.65,
corner_marks=False,
dev_badge=False,
mono_font=None,
scanline=False,
),
show_footer=True,
)
# ============================================================
# v3.2 ❼ Da Vinci / 达芬奇手稿
# 羊皮纸 + 棕墨 + 手绘几何 + 镜像文字
# ============================================================
DA_VINCI = StylePack(
name='da-vinci',
display_name='达芬奇手稿(羊皮纸)',
tagline='羊皮纸 + 棕墨 + 黄金分割 + 手绘几何',
canvas=Canvas(width=13.33, height=7.5),
palette=Palette(
bg='#E8D9B5', # 羊皮纸黄
bg_elevated='#F0E2C0',
bg_subtle='#DCC998',
text_primary='#3D2817', # 棕墨
text_secondary='#5D3A1F',
text_tertiary='#8B6F47',
text_muted='#B89968',
accent='#8B0000', # 朱砂红 — 标注用
accent_soft='#C8A876',
border='#8B6F47',
divider='#C8A876',
),
typography=Typography(
display_font='Cormorant Garamond',
display_fallbacks=[
'EB Garamond', 'Playfair Display',
'Noto Serif SC', 'Songti SC', 'serif',
],
body_font='EB Garamond',
body_fallbacks=[
'Cormorant Garamond', 'Noto Serif SC', 'serif',
],
hero=80,
section=56,
page_title=34,
page_sub=13,
card_title=18,
body=14,
caption=11,
page_number=10,
hero_weight='semibold',
section_weight='semibold',
page_weight='semibold',
card_weight='regular',
body_weight='regular',
hero_tracking=0.04, # 文艺复兴字距宽松
section_tracking=0.03,
page_tracking=0.02,
hero_leading=1.2,
body_leading=1.75,
uppercase_en_sub=False,
),
spacing=Spacing(
margin_x=1.0,
margin_x_hero=1.4,
stack_md=0.45,
stack_lg=0.95,
),
elevation=Elevation(
card_radius=0.0,
card_stroke_width=0.75,
card_stroke_color='#8B6F47',
card_fill='#F0E2C0',
use_fake_shadow=False,
style='outline',
),
decoration=Decoration(
cover_hero_align='left',
cover_hero_case='as-is',
cover_top_line=True,
cover_bottom_line=True,
page_title_align='left',
page_accent_bar=False,
page_en_sub_position='above',
tag_style='underline',
stat_hero_size=180,
stat_hero_weight='semibold',
image_treatment='full',
gradient_bg=None,
accent_gradient=None,
grid_overlay=True, # 黄金分割辅助网格
grid_color='#C8A876',
grid_spacing=0.5,
grid_thickness=0.004,
dot_grid=False,
glow_accent=False,
corner_marks=True,
corner_size=0.28,
corner_thickness=0.018,
dev_badge=False,
mono_font=None,
scanline=False,
),
show_footer=True,
)
# ============================================================
# v3.2 ❽ XHS Fashion / 小红书时尚款
# 莫兰迪粉 + 高级灰 + 细金线(区别于已有的奶油/胶片)
# ============================================================
XHS_FASHION = StylePack(
name='xhs-fashion',
display_name='小红书时尚(莫兰迪粉灰)',
tagline='莫兰迪粉 + 高级灰 + 细金线 + 香奈儿感',
canvas=Canvas(width=13.33, height=7.5),
palette=Palette(
bg='#F5EAE5', # 莫兰迪藕粉
bg_elevated='#FFFFFF',
bg_subtle='#EDE0DA',
text_primary='#2C2825', # 暖黑
text_secondary='#5C5650',
text_tertiary='#928A82',
text_muted='#C5BCB3',
accent='#A0826D', # 摩卡咖
accent_soft='#D4B896', # 香槟金
border='#D9C7BD',
divider='#EBDED5',
),
typography=Typography(
display_font='Playfair Display',
display_fallbacks=[
'Cormorant Garamond', 'Noto Serif SC',
'Source Han Serif SC', 'serif',
],
body_font='Noto Serif SC',
body_fallbacks=[
'Source Han Serif SC', 'PingFang SC', 'serif',
],
hero=84,
section=60,
page_title=34,
page_sub=13,
card_title=18,
body=14,
caption=11,
page_number=10,
hero_weight='semibold',
section_weight='semibold',
page_weight='semibold',
card_weight='semibold',
body_weight='regular',
hero_tracking=0.03,
section_tracking=0.02,
page_tracking=0.015,
hero_leading=1.15,
body_leading=1.7,
uppercase_en_sub=True, # FASHION 风英文小标全大写
),
spacing=Spacing(
margin_x=0.9,
margin_x_hero=1.2,
stack_md=0.4,
stack_lg=0.85,
),
elevation=Elevation(
card_radius=0.05, # 时尚风不要太圆
card_stroke_width=0.5,
card_stroke_color='#D4B896', # 细金线
card_fill='#FFFFFF',
use_fake_shadow=True,
shadow_color='#2C28250D',
shadow_offset_y=0.05,
style='outline',
),
decoration=Decoration(
cover_hero_align='center',
cover_hero_case='as-is',
cover_top_line=True,
cover_bottom_line=True,
page_title_align='center',
page_accent_bar=False,
page_en_sub_position='above',
tag_style='underline',
stat_hero_size=180,
stat_hero_weight='semibold',
image_treatment='rounded',
gradient_bg=None,
accent_gradient=None,
grid_overlay=False,
dot_grid=False,
glow_accent=False,
corner_marks=False,
dev_badge=False,
mono_font=None,
scanline=False,
),
show_footer=True,
)
# ============================================================
# v3.2 ❾ Morandi / 莫兰迪高级灰
# 全色域低饱和 — 静物画大师的色彩谱
# ============================================================
MORANDI = StylePack(
name='morandi',
display_name='莫兰迪高级灰(静物画)',
tagline='低饱和粉绿米 + 哑光高级灰 + 油画静物',
canvas=Canvas(width=13.33, height=7.5),
palette=Palette(
bg='#E8E3D9', # 莫兰迪米灰
bg_elevated='#F0EBE0',
bg_subtle='#D9D2C5',
text_primary='#3A3633', # 灰墨
text_secondary='#6B6660',
text_tertiary='#928A82',
text_muted='#B5AEA5',
accent='#9AAB9C', # 莫兰迪绿
accent_soft='#C9B8B0', # 莫兰迪粉
border='#C5BCB3',
divider='#D9D2C5',
),
typography=Typography(
display_font='Playfair Display',
display_fallbacks=[
'Cormorant Garamond', 'EB Garamond',
'Noto Serif SC', 'serif',
],
body_font='Noto Serif SC',
body_fallbacks=[
'Source Han Serif SC', 'PingFang SC', 'serif',
],
hero=78,
section=56,
page_title=32,
page_sub=13,
card_title=18,
body=14,
caption=11,
page_number=10,
hero_weight='regular',
section_weight='regular',
page_weight='regular',
card_weight='regular',
body_weight='regular',
hero_tracking=0.03,
section_tracking=0.02,
page_tracking=0.015,
hero_leading=1.25,
body_leading=1.75,
uppercase_en_sub=False,
),
spacing=Spacing(
margin_x=1.1,
margin_x_hero=1.5,
stack_md=0.5,
stack_lg=1.0,
),
elevation=Elevation(
card_radius=0.08,
card_stroke_width=0.4,
card_stroke_color='#B5AEA5',
card_fill='#F0EBE0',
use_fake_shadow=False,
style='soft',
),
decoration=Decoration(
cover_hero_align='left',
cover_hero_case='as-is',
cover_top_line=False,
cover_bottom_line=False,
page_title_align='left',
page_accent_bar=False,
page_en_sub_position='above',
tag_style='pill',
stat_hero_size=160,
stat_hero_weight='regular',
image_treatment='rounded',
gradient_bg=None,
accent_gradient=None,
grid_overlay=False,
dot_grid=False,
glow_accent=False,
corner_marks=False,
dev_badge=False,
mono_font=None,
scanline=False,
),
show_footer=True,
)
# ============================================================
# v3.2 ❿ Memphis / 孟菲斯 80s 撞色几何
# 黑白条纹 + 粉黄绿三撞色 + 圆/三角/波浪
# ============================================================
MEMPHIS = StylePack(
name='memphis',
display_name='孟菲斯 80s(撞色几何)',
tagline='粉黄绿撞色 + 黑白条纹 + 不规则几何',
canvas=Canvas(width=13.33, height=7.5),
palette=Palette(
bg='#FFF8E7', # 浅米黄底
bg_elevated='#FFFFFF',
bg_subtle='#FFE8D6',
text_primary='#1A1A1A',
text_secondary='#4A4A4A',
text_tertiary='#8A8A8A',
text_muted='#C0C0C0',
accent='#FF3399', # 孟菲斯粉
accent_soft='#FFD93D', # 孟菲斯黄(次)
border='#1A1A1A',
divider='#E0E0E0',
),
typography=Typography(
display_font='Inter',
display_fallbacks=[
'Helvetica Neue', 'PingFang SC', 'sans-serif',
],
body_font='Inter',
body_fallbacks=[
'Helvetica Neue', 'PingFang SC', 'sans-serif',
],
hero=120,
section=72,
page_title=42,
page_sub=14,
card_title=22,
body=15,
caption=11,
page_number=10,
hero_weight='bold',
section_weight='bold',
page_weight='bold',
card_weight='bold',
body_weight='regular',
hero_tracking=-0.02,
section_tracking=-0.015,
page_tracking=0.0,
hero_leading=0.95,
body_leading=1.5,
uppercase_en_sub=True,
),
spacing=Spacing(
margin_x=0.85,
margin_x_hero=1.1,
stack_md=0.4,
stack_lg=0.8,
),
elevation=Elevation(
card_radius=0.15, # 圆角不规则
card_stroke_width=2.0, # 孟菲斯粗描边
card_stroke_color='#1A1A1A',
card_fill='#FFFFFF',
use_fake_shadow=True,
shadow_color='#1A1A1A', # 偏移黑投影是孟菲斯灵魂
shadow_offset_y=0.08,
style='outline',
),
decoration=Decoration(
cover_hero_align='left',
cover_hero_case='upper',
cover_top_line=False,
cover_bottom_line=False,
page_title_align='left',
page_accent_bar=False,
page_en_sub_position='above',
tag_style='square',
stat_hero_size=240,
stat_hero_weight='bold',
image_treatment='full',
gradient_bg=None,
accent_gradient=('#FF3399', '#FFD93D'), # 粉→黄
grid_overlay=False,
dot_grid=True,
dot_color='#1A1A1A',
dot_spacing=0.5,
dot_size=0.05,
glow_accent=False,
corner_marks=False,
dev_badge=False,
mono_font='JetBrains Mono',
scanline=False,
),
show_footer=True,
)
# ============================================================
# v3.2 ⓫ Bauhaus / 包豪斯
# 红黄蓝三原色 + 几何块面 + 等宽数字
# ============================================================
BAUHAUS = StylePack(
name='bauhaus',
display_name='包豪斯(红黄蓝三原色)',
tagline='红黄蓝三原色 + 几何块面 + 功能主义',
canvas=Canvas(width=13.33, height=7.5),
palette=Palette(
bg='#F5F1E8', # 包豪斯象牙底
bg_elevated='#FFFFFF',
bg_subtle='#E8E0D0',
text_primary='#1A1A1A',
text_secondary='#3A3A3A',
text_tertiary='#6A6A6A',
text_muted='#A0A0A0',
accent='#D32F2F', # 包豪斯红
accent_soft='#1565C0', # 包豪斯蓝(次)
border='#1A1A1A',
divider='#D0D0D0',
),
typography=Typography(
display_font='Inter', # 模拟 Futura/Universal
display_fallbacks=[
'Futura', 'Helvetica Neue', 'PingFang SC', 'sans-serif',
],
body_font='Inter',
body_fallbacks=[
'Helvetica Neue', 'PingFang SC', 'sans-serif',
],
hero=128,
section=80,
page_title=44,
page_sub=13,
card_title=20,
body=14,
caption=11,
page_number=10,
hero_weight='bold',
section_weight='bold',
page_weight='semibold',
card_weight='semibold',
body_weight='regular',
hero_tracking=-0.02,
section_tracking=-0.015,
page_tracking=0.0,
hero_leading=0.95,
body_leading=1.5,
uppercase_en_sub=True,
),
spacing=Spacing(
margin_x=0.8,
margin_x_hero=1.0,
stack_md=0.35,
stack_lg=0.7,
),
elevation=Elevation(
card_radius=0.0, # 包豪斯不要圆角
card_stroke_width=1.5,
card_stroke_color='#1A1A1A',
card_fill='#FFFFFF',
use_fake_shadow=False,
style='outline',
),
decoration=Decoration(
cover_hero_align='left',
cover_hero_case='as-is',
cover_top_line=False,
cover_bottom_line=False,
page_title_align='left',
page_accent_bar=True, # 红色竖条
page_en_sub_position='above',
tag_style='square',
stat_hero_size=240,
stat_hero_weight='bold',
image_treatment='full',
gradient_bg=None,
accent_gradient=None,
grid_overlay=True,
grid_color='#E0DDD0',
grid_spacing=0.5,
grid_thickness=0.005,
dot_grid=False,
glow_accent=False,
corner_marks=False,
dev_badge=True,
dev_badge_template='№ {n} / {date}',
mono_font='JetBrains Mono',
scanline=False,
),
show_footer=True,
)
# ============================================================
# v3.2 ⓬ Wes Anderson / 韦斯安德森
# 对称粉绿米 + 衬线字 + 复古感
# ============================================================
WES_ANDERSON = StylePack(
name='wes-anderson',
display_name='韦斯安德森(对称粉绿米)',
tagline='糖果粉 + 薄荷绿 + 米色 + 极致对称',
canvas=Canvas(width=13.33, height=7.5),
palette=Palette(
bg='#F4D5C2', # 糖果粉米
bg_elevated='#FFEAD9',
bg_subtle='#EBC4AD',
text_primary='#2D2D1A', # 暖黑
text_secondary='#5C5440',
text_tertiary='#8B8268',
text_muted='#B5AC8A',
accent='#3D6E5B', # 复古薄荷
accent_soft='#E8B4A0', # 蜜桃粉
border='#C8A98B',
divider='#E0C4A8',
),
typography=Typography(
display_font='Playfair Display',
display_fallbacks=[
'Cormorant Garamond', 'Futura',
'Noto Serif SC', 'serif',
],
body_font='EB Garamond',
body_fallbacks=[
'Cormorant Garamond', 'Noto Serif SC', 'serif',
],
hero=80,
section=58,
page_title=34,
page_sub=13,
card_title=18,
body=14,
caption=11,
page_number=10,
hero_weight='semibold',
section_weight='semibold',
page_weight='semibold',
card_weight='semibold',
body_weight='regular',
hero_tracking=0.04,
section_tracking=0.03,
page_tracking=0.02,
hero_leading=1.2,
body_leading=1.7,
uppercase_en_sub=True,
),
spacing=Spacing(
margin_x=1.0,
margin_x_hero=1.4,
stack_md=0.45,
stack_lg=0.9,
),
elevation=Elevation(
card_radius=0.04,
card_stroke_width=0.75,
card_stroke_color='#3D6E5B',
card_fill='#FFEAD9',
use_fake_shadow=False,
style='outline',
),
decoration=Decoration(
cover_hero_align='center', # 对称是 Wes 灵魂
cover_hero_case='as-is',
cover_top_line=True,
cover_bottom_line=True,
page_title_align='center',
page_accent_bar=False,
page_en_sub_position='above',
tag_style='underline',
stat_hero_size=180,
stat_hero_weight='semibold',
image_treatment='rounded',
gradient_bg=None,
accent_gradient=None,
grid_overlay=False,
dot_grid=False,
glow_accent=False,
corner_marks=True,
corner_size=0.3,
corner_thickness=0.015,
dev_badge=False,
mono_font=None,
scanline=False,
),
show_footer=True,
)
# ============================================================
# 七、Legacy packs(向后兼容老的 v2.x)
# ============================================================
JOBS_DARK = StylePack(
name='jobs-dark',
display_name='乔布斯极简暗蓝(v1.x 默认)',
tagline='深蓝暗底 + 白灰克制',
palette=Palette(
bg='#060D1A',
bg_elevated='#0D182A',
bg_subtle='#14243D',
text_primary='#FFFFFF',
text_secondary='#CCCCCC',
text_tertiary='#888888',
text_muted='#444455',
accent='#FFFFFF',
border='#333344',
divider='#333344',
),
typography=Typography(
hero=64, section=48, page_title=28, page_sub=10,
card_title=14, body=11, caption=10, page_number=9,
uppercase_en_sub=True,
),
elevation=Elevation(
card_stroke_width=0.5,
card_stroke_color='#333344',
card_fill='#0D182A',
),
decoration=Decoration(),
show_footer=True,
)
XIAOHONGSHU_BRAND = StylePack(
name='xiaohongshu',
display_name='小红书品牌风(v2.x 默认)',
tagline='小红书红 + 暖奶油',
palette=Palette(
bg='#FFF8F3',
bg_elevated='#FFFFFF',
bg_subtle='#F5E6E6',
text_primary='#1A1A1A',
text_secondary='#4A4A4A',
text_tertiary='#8A8A8A',
text_muted='#C8C8C8',
accent='#FF2442',
accent_soft='#FFE5EC',
border='#F5E6E6',
divider='#F2E6E6',
),
typography=Typography(
hero=60, section=48, page_title=30, page_sub=12,
card_title=16, body=12, caption=10, page_number=9,
uppercase_en_sub=False,
),
elevation=Elevation(
card_stroke_width=0.75,
card_stroke_color='#F5E6E6',
card_fill='#FFFFFF',
),
decoration=Decoration(
cover_top_line=True,
page_accent_bar=True,
tag_style='pill',
),
show_footer=True,
)
XIAOHONGSHU_PORTRAIT = StylePack(
name='xiaohongshu-portrait',
display_name='小红书品牌风 · 竖版 9:16',
tagline='直接发帖用的竖版',
canvas=Canvas(width=7.5, height=13.33),
palette=XIAOHONGSHU_BRAND.palette,
typography=Typography(
hero=72, section=56, page_title=36, page_sub=13,
card_title=18, body=14, caption=11, page_number=10,
uppercase_en_sub=False,
),
elevation=XIAOHONGSHU_BRAND.elevation,
decoration=XIAOHONGSHU_BRAND.decoration,
show_footer=True,
)
# ============================================================
# 六、注册表
# ============================================================
REGISTRY = {
# v3.0 审美 pack
'apple-keynote': APPLE_KEYNOTE,
'apple-dark': APPLE_KEYNOTE,
'apple': APPLE_KEYNOTE,
'苹果': APPLE_KEYNOTE,
'苹果发布会': APPLE_KEYNOTE,
'发布会': APPLE_KEYNOTE,
'乔布斯科技简约': APPLE_KEYNOTE,
'乔布斯简约': APPLE_KEYNOTE,
'jobs-modern': APPLE_KEYNOTE,
'apple-light': APPLE_LIGHT,
'apple-white': APPLE_LIGHT,
'苹果白': APPLE_LIGHT,
'苹果官网': APPLE_LIGHT,
'xiaohongshu-creator': XHS_CREATOR,
'xhs-creator': XHS_CREATOR,
'博主风': XHS_CREATOR,
'生活博主': XHS_CREATOR,
'博主': XHS_CREATOR,
'奶油博主': XHS_CREATOR,
'xiaohongshu-vintage': XHS_VINTAGE,
'xhs-vintage': XHS_VINTAGE,
'复古': XHS_VINTAGE,
'胶片': XHS_VINTAGE,
'复古胶片': XHS_VINTAGE,
# v3.1 科技风
'tech-neon': TECH_NEON,
'tech': TECH_NEON,
'neon': TECH_NEON,
'科技': TECH_NEON,
'科技风': TECH_NEON,
'霓虹': TECH_NEON,
'赛博': TECH_NEON,
'tech-minimal': TECH_MINIMAL,
'minimal-tech': TECH_MINIMAL,
'vercel': TECH_MINIMAL,
'linear': TECH_MINIMAL,
'极简科技': TECH_MINIMAL,
'暗黑极简': TECH_MINIMAL,
'saas': TECH_MINIMAL,
# ============ v3.2 生产级 12 套 ============
# ❶ Liquid Glass
'liquid-glass': LIQUID_GLASS,
'glass': LIQUID_GLASS,
'apple-glass': LIQUID_GLASS,
'macos-26': LIQUID_GLASS,
'macos26': LIQUID_GLASS,
'tahoe': LIQUID_GLASS,
'ios26': LIQUID_GLASS,
'玻璃': LIQUID_GLASS,
'液态玻璃': LIQUID_GLASS,
'苹果玻璃': LIQUID_GLASS,
'苹果26': LIQUID_GLASS,
'玻璃风': LIQUID_GLASS,
'macos-tahoe': LIQUID_GLASS,
# ❷ MUJI / 原研哉
'muji': MUJI,
'kenya-hara': MUJI,
'hara': MUJI,
'原研哉': MUJI,
'原研哉极简': MUJI,
'无印良品': MUJI,
'无印': MUJI,
'mu-ji': MUJI,
# ❸ Ink Wash / 水墨
'ink-wash': INK_WASH,
'ink': INK_WASH,
'chinese-ink': INK_WASH,
'水墨': INK_WASH,
'中国水墨': INK_WASH,
'墨分五色': INK_WASH,
'宣纸': INK_WASH,
# ❹ Guofeng / 国风
'guofeng': GUOFENG,
'gugong': GUOFENG,
'forbidden-city': GUOFENG,
'chinese-imperial': GUOFENG,
'国风': GUOFENG,
'故宫': GUOFENG,
'故宫文创': GUOFENG,
'中国风': GUOFENG,
# ❺ Cyberpunk Vivid
'cyberpunk-vivid': CYBERPUNK_VIVID,
'cyberpunk': CYBERPUNK_VIVID,
'cyber': CYBERPUNK_VIVID,
'cp2077': CYBERPUNK_VIVID,
'blade-runner': CYBERPUNK_VIVID,
'赛博朋克': CYBERPUNK_VIVID,
'赛博朋克绚彩': CYBERPUNK_VIVID,
'银翼杀手': CYBERPUNK_VIVID,
'霓虹绚彩': CYBERPUNK_VIVID,
# ❻ Van Gogh
'van-gogh': VAN_GOGH,
'vangogh': VAN_GOGH,
'starry-night': VAN_GOGH,
'梵高': VAN_GOGH,
'星夜': VAN_GOGH,
'油画': VAN_GOGH,
'梵高油画': VAN_GOGH,
# ❼ Da Vinci
'da-vinci': DA_VINCI,
'davinci': DA_VINCI,
'leonardo': DA_VINCI,
'达芬奇': DA_VINCI,
'达芬奇手稿': DA_VINCI,
'羊皮纸': DA_VINCI,
'手稿': DA_VINCI,
'文艺复兴': DA_VINCI,
# ❽ XHS Fashion
'xhs-fashion': XHS_FASHION,
'xiaohongshu-fashion': XHS_FASHION,
'fashion': XHS_FASHION,
'小红书时尚': XHS_FASHION,
'时尚': XHS_FASHION,
'香奈儿': XHS_FASHION,
'chanel': XHS_FASHION,
'奢侈品': XHS_FASHION,
# ❾ Morandi
'morandi': MORANDI,
'morandi-grey': MORANDI,
'莫兰迪': MORANDI,
'高级灰': MORANDI,
'莫兰迪色': MORANDI,
'静物': MORANDI,
# ❿ Memphis
'memphis': MEMPHIS,
'memphis-design': MEMPHIS,
'孟菲斯': MEMPHIS,
'80s': MEMPHIS,
'撞色': MEMPHIS,
# ⓫ Bauhaus
'bauhaus': BAUHAUS,
'bauhaus-100': BAUHAUS,
'包豪斯': BAUHAUS,
'三原色': BAUHAUS,
'功能主义': BAUHAUS,
# ⓬ Wes Anderson
'wes-anderson': WES_ANDERSON,
'wes': WES_ANDERSON,
'anderson': WES_ANDERSON,
'韦斯安德森': WES_ANDERSON,
'布达佩斯大饭店': WES_ANDERSON,
'对称美学': WES_ANDERSON,
# Legacy packs
'jobs-dark': JOBS_DARK,
'jobs': JOBS_DARK,
'乔布斯': JOBS_DARK,
'乔布斯暗': JOBS_DARK,
'xiaohongshu': XIAOHONGSHU_BRAND,
'xhs': XIAOHONGSHU_BRAND,
'小红书': XIAOHONGSHU_BRAND,
'xiaohongshu-portrait': XIAOHONGSHU_PORTRAIT,
'xhs-portrait': XIAOHONGSHU_PORTRAIT,
'小红书竖版': XIAOHONGSHU_PORTRAIT,
}
def get_pack(name: str) -> StylePack:
if not name:
return APPLE_KEYNOTE
key = name.strip().lower()
if key in REGISTRY:
return REGISTRY[key]
if name in REGISTRY:
return REGISTRY[name]
return APPLE_KEYNOTE
def list_packs():
"""主要 pack 名,供 --help 展示。"""
return (
# v3.0 基础
'apple-keynote',
'apple-light',
'xiaohongshu-creator',
'xiaohongshu-vintage',
# v3.1 科技
'tech-neon',
'tech-minimal',
# v3.2 生产级
'liquid-glass',
'muji',
'ink-wash',
'guofeng',
'cyberpunk-vivid',
'van-gogh',
'da-vinci',
'xhs-fashion',
'morandi',
'memphis',
'bauhaus',
'wes-anderson',
# Legacy
'jobs-dark',
'xiaohongshu',
'xiaohongshu-portrait',
)
def all_packs():
"""v3.2: 返回所有 21 套 pack 实例 — 用于批量预览生成。"""
return [
APPLE_KEYNOTE, APPLE_LIGHT, XHS_CREATOR, XHS_VINTAGE,
TECH_NEON, TECH_MINIMAL,
LIQUID_GLASS, MUJI, INK_WASH, GUOFENG, CYBERPUNK_VIVID,
VAN_GOGH, DA_VINCI, XHS_FASHION, MORANDI, MEMPHIS,
BAUHAUS, WES_ANDERSON,
JOBS_DARK, XIAOHONGSHU_BRAND, XIAOHONGSHU_PORTRAIT,
]
FILE:scripts/styles.py
"""
styles.py - 可复用的 PPT 风格预设
提供两套开箱即用的风格:
- JOBS_DARK 乔布斯极简暗蓝(原 v1.x 默认)
- XIAOHONGSHU 小红书风格(v2.0 新增,暖奶油 + 小红书红)
每套风格集中定义配色、字体、字号、卡片圆角/描边、封面副标题样式等。
`create-pptx.py` 和 `pptx_toolkit.py` 按风格参数化渲染。
"""
from dataclasses import dataclass, field
from typing import Tuple
from pptx.dml.color import RGBColor
def rgb(hex_str: str) -> RGBColor:
"""#RRGGBB → RGBColor"""
s = hex_str.lstrip('#')
return RGBColor(int(s[0:2], 16), int(s[2:4], 16), int(s[4:6], 16))
@dataclass
class Style:
"""PPT 风格规格。所有尺寸单位为英寸(Inches),字号单位为 Pt。"""
name: str
# 画布
slide_width: float = 13.33
slide_height: float = 7.5 # 16:9 横版;小红书帖可改 9:16
# 配色
bg: RGBColor = field(default_factory=lambda: rgb('#060D1A'))
card: RGBColor = field(default_factory=lambda: rgb('#0D182A'))
accent: RGBColor = field(default_factory=lambda: rgb('#FFFFFF'))
text: RGBColor = field(default_factory=lambda: rgb('#FFFFFF'))
subtext: RGBColor = field(default_factory=lambda: rgb('#888888'))
light: RGBColor = field(default_factory=lambda: rgb('#CCCCCC'))
divider: RGBColor = field(default_factory=lambda: rgb('#333344'))
# 字体
font: str = 'PingFang SC'
font_fallback: str = 'Microsoft YaHei'
# 字号
size_cover_title: int = 64
size_cover_subtitle: int = 26
size_cover_footnote: int = 14
size_page_title: int = 28
size_page_subtitle_en: int = 10
size_card_title: int = 14
size_body: int = 11
size_small: int = 10
size_footer: int = 9
# 卡片
card_line_width: float = 0.5
card_stroke: RGBColor = field(default_factory=lambda: rgb('#333344'))
# 封面是否加装饰(小红书风格会开)
cover_decoration: bool = False
# 页脚
show_footer: bool = True
footer_company_font_size: int = 9
# 英文副标题是否大写
upper_en_subtitle: bool = True
JOBS_DARK = Style(
name='jobs-dark',
bg=rgb('#060D1A'),
card=rgb('#0D182A'),
accent=rgb('#FFFFFF'),
text=rgb('#FFFFFF'),
subtext=rgb('#888888'),
light=rgb('#CCCCCC'),
divider=rgb('#333344'),
card_stroke=rgb('#333344'),
)
XIAOHONGSHU = Style(
name='xiaohongshu',
# 暖奶油背景,小红书帖经典色调
bg=rgb('#FFF8F3'),
card=rgb('#FFFFFF'),
accent=rgb('#FF2442'), # 小红书红
text=rgb('#1A1A1A'), # 深黑
subtext=rgb('#8A8A8A'), # 中灰
light=rgb('#4A4A4A'), # 深灰
divider=rgb('#F2E6E6'), # 淡粉分隔
card_stroke=rgb('#F5E6E6'),
card_line_width=0.75,
# 稍小字号,避免在暖底上显得笨重
size_cover_title=60,
size_cover_subtitle=24,
size_page_title=30,
size_card_title=16,
size_body=12,
cover_decoration=True,
upper_en_subtitle=False,
)
XIAOHONGSHU_PORTRAIT = Style(
name='xiaohongshu-portrait',
# 9:16 适合直接导出图片发 Feed
slide_width=7.5,
slide_height=13.33,
bg=rgb('#FFF8F3'),
card=rgb('#FFFFFF'),
accent=rgb('#FF2442'),
text=rgb('#1A1A1A'),
subtext=rgb('#8A8A8A'),
light=rgb('#4A4A4A'),
divider=rgb('#F2E6E6'),
card_stroke=rgb('#F5E6E6'),
card_line_width=0.75,
size_cover_title=72,
size_cover_subtitle=28,
size_page_title=36,
size_card_title=18,
size_body=14,
cover_decoration=True,
upper_en_subtitle=False,
)
OCEAN = Style(
name='ocean',
bg=rgb('#F8FBFE'),
card=rgb('#FFFFFF'),
accent=rgb('#0077B6'), # 海洋蓝
text=rgb('#023E8A'),
subtext=rgb('#5A7A9A'),
light=rgb('#0077B6'),
divider=rgb('#CAF0F8'),
card_stroke=rgb('#CAF0F8'),
card_line_width=0.75,
upper_en_subtitle=True,
)
FOREST = Style(
name='forest',
bg=rgb('#F7FAF8'),
card=rgb('#FFFFFF'),
accent=rgb('#2D6A4F'), # 森林绿
text=rgb('#1B4332'),
subtext=rgb('#5A7A6A'),
light=rgb('#2D6A4F'),
divider=rgb('#D8F3DC'),
card_stroke=rgb('#D8F3DC'),
card_line_width=0.75,
upper_en_subtitle=True,
)
SUNSET = Style(
name='sunset',
bg=rgb('#FFFBF5'),
card=rgb('#FFFFFF'),
accent=rgb('#E76F51'), # 夕阳橙
text=rgb('#9D3B1E'),
subtext=rgb('#A07860'),
light=rgb('#E76F51'),
divider=rgb('#FFEBD6'),
card_stroke=rgb('#FFEBD6'),
card_line_width=0.75,
upper_en_subtitle=False,
)
MINIMAL = Style(
name='minimal',
bg=rgb('#FFFFFF'),
card=rgb('#FFFFFF'),
accent=rgb('#2E2E2E'), # 近黑强调
text=rgb('#2E2E2E'),
subtext=rgb('#8A8A8A'),
light=rgb('#2E2E2E'),
divider=rgb('#D4D4D4'),
card_stroke=rgb('#D4D4D4'),
card_line_width=0.5,
# 极简风字号克制
size_cover_title=60,
size_cover_subtitle=22,
size_page_title=26,
size_card_title=13,
size_body=11,
upper_en_subtitle=True,
cover_decoration=False,
)
PASTEL = Style(
name='pastel',
bg=rgb('#FFFBFC'),
card=rgb('#FFFFFF'),
accent=rgb('#C4A4E1'), # 马卡龙紫
text=rgb('#2D3748'),
subtext=rgb('#8B8B95'),
light=rgb('#B5D8FA'),
divider=rgb('#FFE5EC'),
card_stroke=rgb('#FFE5EC'),
card_line_width=0.75,
upper_en_subtitle=False,
)
GITHUB = Style(
name='github',
bg=rgb('#FFFFFF'),
card=rgb('#F6F8FA'),
accent=rgb('#0366D6'), # GitHub 蓝
text=rgb('#24292E'),
subtext=rgb('#586069'),
light=rgb('#28A745'), # 辅助绿
divider=rgb('#E1E4E8'),
card_stroke=rgb('#E1E4E8'),
card_line_width=0.5,
upper_en_subtitle=True,
)
TECH_BLUE = Style(
# 经典科技深蓝,适合企业/科技/投融资
name='tech-blue',
bg=rgb('#0A2540'),
card=rgb('#133A6A'),
accent=rgb('#00D4FF'), # 霓虹蓝强调
text=rgb('#FFFFFF'),
subtext=rgb('#A3B8D0'),
light=rgb('#64A6E8'),
divider=rgb('#1E4D7F'),
card_stroke=rgb('#1E4D7F'),
card_line_width=0.75,
upper_en_subtitle=True,
cover_decoration=False,
)
REGISTRY = {
'jobs': JOBS_DARK,
'jobs-dark': JOBS_DARK,
'dark': JOBS_DARK,
'暗色': JOBS_DARK,
'乔布斯': JOBS_DARK,
'xiaohongshu': XIAOHONGSHU,
'xhs': XIAOHONGSHU,
'小红书': XIAOHONGSHU,
'奶油': XIAOHONGSHU,
'xiaohongshu-portrait': XIAOHONGSHU_PORTRAIT,
'xhs-portrait': XIAOHONGSHU_PORTRAIT,
'小红书竖版': XIAOHONGSHU_PORTRAIT,
'ocean': OCEAN,
'海洋': OCEAN,
'蓝': OCEAN,
'蓝色': OCEAN,
'forest': FOREST,
'森林': FOREST,
'绿': FOREST,
'绿色': FOREST,
'自然': FOREST,
'sunset': SUNSET,
'夕阳': SUNSET,
'暖橙': SUNSET,
'橙': SUNSET,
'minimal': MINIMAL,
'极简': MINIMAL,
'素雅': MINIMAL,
'黑白': MINIMAL,
'学术': MINIMAL,
'论文': MINIMAL,
'pastel': PASTEL,
'马卡龙': PASTEL,
'粉嫩': PASTEL,
'粉': PASTEL,
'儿童': PASTEL,
'github': GITHUB,
'极客': GITHUB,
'程序员': GITHUB,
'gh': GITHUB,
'tech-blue': TECH_BLUE,
'tech_blue': TECH_BLUE,
'techblue': TECH_BLUE,
'科技蓝': TECH_BLUE,
'科技': TECH_BLUE,
'投融资': TECH_BLUE,
}
def get_style(name: str) -> Style:
"""按名字查风格;未知名称回落到 jobs-dark。"""
if name in REGISTRY:
return REGISTRY[name]
# 大小写/空白容忍
key = (name or '').strip().lower()
if key in REGISTRY:
return REGISTRY[key]
return JOBS_DARK
def list_styles() -> Tuple[str, ...]:
"""返回主要风格名(用于 CLI --help)。"""
return (
'jobs-dark',
'xiaohongshu',
'xiaohongshu-portrait',
'ocean',
'forest',
'sunset',
'minimal',
'pastel',
'github',
'tech-blue',
)
FILE:scripts/templates/__init__.py
"""
templates/ — 火一五 PPT v3.0 页面模板库
每个模板接收 (prs, pack, data) -> slide,根据 StylePack 的 tokens 自动出设计。
可用模板:
hero_cover 封面 hero 大字
section_divider 分章大字页
big_stat 单数字大字页(Apple 发布会最爱)
kpi_triple 3 宫格 KPI
quote_card 引用金句卡
content_list 编号列表
compare_columns 左右对比栏
product_shot 产品摄影页(图占大块)
timeline 时间线
call_to_action 行动号召(封底)
"""
from .hero_cover import build as hero_cover
from .section_divider import build as section_divider
from .big_stat import build as big_stat
from .kpi_triple import build as kpi_triple
from .quote_card import build as quote_card
from .content_list import build as content_list
from .compare_columns import build as compare_columns
from .product_shot import build as product_shot
from .timeline import build as timeline
from .call_to_action import build as call_to_action
from .code_block import build as code_block
TEMPLATES = {
'hero_cover': hero_cover,
'cover': hero_cover, # alias
'section_divider': section_divider,
'section': section_divider,
'big_stat': big_stat,
'stat': big_stat,
'kpi_triple': kpi_triple,
'kpi': kpi_triple,
'quote_card': quote_card,
'quote': quote_card,
'content_list': content_list,
'list': content_list,
'compare_columns': compare_columns,
'compare': compare_columns,
'product_shot': product_shot,
'product': product_shot,
'image': product_shot,
'timeline': timeline,
'call_to_action': call_to_action,
'cta': call_to_action,
'end': call_to_action,
'code_block': code_block,
'code': code_block,
}
def get_template(name: str):
"""拿一个 template builder。未知名回落到 content_list。"""
return TEMPLATES.get(name, content_list)
def list_templates():
"""主模板名(去别名)。"""
return (
'hero_cover', 'section_divider', 'big_stat', 'kpi_triple',
'quote_card', 'content_list', 'compare_columns', 'product_shot',
'timeline', 'call_to_action', 'code_block',
)
FILE:scripts/templates/big_stat.py
"""big_stat — 单数字大字页(Apple 发布会招牌页)。
Apple 在发布会 1 张 slide 只放一个数字,比如 "2 Billion"、"100M"。
这个模板就是给这种时刻准备的。
data:
value: str 要放大的数字/短语("2", "100M", "1,000,000")
unit: str (optional) 单位("Billion", "Users", "%")
caption: str (optional) 数字上方的一句说明(小字)
footnote: str (optional) 数字下方的一句说明
accent: bool (optional) 数字是否用 accent 颜色(默认 text_primary)
"""
from .helpers import (
new_slide, add_text, add_hline, fit_font_size,
apply_text_gradient, add_glow_halo,
)
def build(prs, pack, data: dict) -> None:
slide = new_slide(prs, pack)
W, H = pack.canvas.width, pack.canvas.height
t = pack.typography
sp = pack.spacing
dec = pack.decoration
value = data.get('value', '')
unit = data.get('unit', '')
caption = data.get('caption', '')
footnote = data.get('footnote', '')
use_accent = data.get('accent', False)
value_color = 'accent' if use_accent else 'text_primary'
# 布局预算:从顶部 caption 到底部 footnote 之间留给 stat + unit
caption_top = H * 0.18
caption_h = 0.4
footnote_top = H - 0.9
usable_top = caption_top + caption_h + 0.3
usable_bot = footnote_top - 0.3
usable_h = usable_bot - usable_top # 给 stat + unit 的总纵向空间
# 保留 unit 所需空间(如果有 unit)
unit_size = max(24, t.page_title)
unit_h = (unit_size / 72.0) * 1.3 if unit else 0.0
unit_gap = 0.15 if unit else 0.0
# stat 能用的高度
stat_budget_h = usable_h - unit_h - unit_gap
# stat 字号先按 pack 预设,再被 width + height 双约束
stat_size_max_by_h = stat_budget_h / 1.15 * 72
stat_w = W - 2 * sp.margin_x_hero
stat_size_try = min(dec.stat_hero_size, stat_size_max_by_h)
stat_size = fit_font_size(str(value), stat_w, stat_size_try,
min_size_pt=80, max_lines=1)
stat_h = stat_size / 72.0 * 1.15
# 整体 stat + unit 块垂直居中
total_block_h = stat_h + unit_gap + unit_h
block_top = usable_top + (usable_h - total_block_h) / 2
# 顶部 caption
if caption:
cap = caption.upper() if t.uppercase_en_sub else caption
add_text(slide, pack, cap,
left=sp.margin_x_hero, top=caption_top,
width=W - 2 * sp.margin_x_hero, height=caption_h,
font=t.body_font, font_size=t.page_sub,
weight='semibold',
color_key='accent',
align='center',
tracking=0.2 if t.uppercase_en_sub else 0.05)
# 科技风:在 stat 下叠辉光
if dec.glow_accent and use_accent:
halo_cx = W / 2
halo_cy = block_top + stat_h * 0.5
add_glow_halo(slide, pack, halo_cx, halo_cy,
radius=stat_size / 72.0 * 1.6,
color_key='accent', layers=5,
strength=dec.glow_strength)
# 巨字号数字
stat_tb = add_text(slide, pack, str(value),
left=sp.margin_x_hero, top=block_top,
width=stat_w, height=stat_h,
font_size=stat_size,
weight=dec.stat_hero_weight,
color_key=value_color,
align='center',
tracking=-0.04,
leading=0.9)
# 如果 pack 开启了 accent_gradient 且 stat 用 accent,给数字注入渐变
if use_accent and dec.accent_gradient is not None:
runs = stat_tb.text_frame.paragraphs[0].runs
if runs:
apply_text_gradient(runs[0], dec.accent_gradient[0], dec.accent_gradient[1], angle_deg=0)
# 单位
if unit:
add_text(slide, pack, unit,
left=sp.margin_x_hero,
top=block_top + stat_h + unit_gap,
width=W - 2 * sp.margin_x_hero, height=unit_h,
font_size=unit_size,
weight='semibold',
color_key='text_secondary',
align='center',
tracking=t.page_tracking)
# 底部说明
if footnote:
add_text(slide, pack, footnote,
left=sp.margin_x_hero, top=footnote_top,
width=W - 2 * sp.margin_x_hero, height=0.5,
font=t.body_font, font_size=t.caption + 2,
color_key='text_tertiary',
align='center')
FILE:scripts/templates/call_to_action.py
"""call_to_action — 行动号召(封底页)。
典型:大字主语 + 辅助信息 + 一个 call-to-action(邮箱/网址/微信号)。
data:
title: str 大字主语("Thank You." / "让我们开始吧。")
subtitle: str (optional) 下方说明
cta: str (optional) 联系方式/行动项("[email protected]")
footnote: str (optional) 底部小字
"""
from .helpers import new_slide, add_text, add_hline, fit_font_size
def build(prs, pack, data: dict) -> None:
slide = new_slide(prs, pack)
W, H = pack.canvas.width, pack.canvas.height
t = pack.typography
sp = pack.spacing
dec = pack.decoration
title = data.get('title', 'Thank You.')
subtitle = data.get('subtitle', '')
cta = data.get('cta', '')
footnote = data.get('footnote', '')
# 顶部装饰线
if dec.cover_top_line:
add_hline(slide, pack, sp.margin_x_hero, 0.5,
W - 2 * sp.margin_x_hero,
color_key='accent', thickness=0.015)
# 主标题(比封面略小一号,给 cta 留位置)+ auto-fit
base_size = int(t.hero * 0.85)
hero_w = W - 2 * sp.margin_x_hero
hero_size = fit_font_size(title, hero_w, base_size,
min_size_pt=48, max_lines=1)
if dec.cover_hero_align == 'center':
hero_top = H * 0.3
else:
hero_top = H * 0.38
add_text(slide, pack, title,
left=sp.margin_x_hero, top=hero_top,
width=hero_w, height=H * 0.35,
font_size=hero_size, weight=t.hero_weight,
color_key='text_primary',
align=dec.cover_hero_align,
tracking=t.hero_tracking,
leading=t.hero_leading)
# 副标题
y = hero_top + (hero_size / 72.0) * 1.3
if subtitle:
add_text(slide, pack, subtitle,
left=sp.margin_x_hero, top=y,
width=W - 2 * sp.margin_x_hero, height=0.7,
font_size=t.page_title - 8, weight='regular',
color_key='text_secondary',
align=dec.cover_hero_align,
tracking=t.page_tracking)
y += 0.7
# CTA(联系方式 —— 强调色)
if cta:
add_text(slide, pack, cta,
left=sp.margin_x_hero, top=y + 0.2,
width=W - 2 * sp.margin_x_hero, height=0.5,
font_size=t.card_title + 4, weight='semibold',
color_key='accent',
align=dec.cover_hero_align,
tracking=0.03)
# Footnote
if footnote:
add_text(slide, pack, footnote,
left=sp.margin_x_hero, top=H - 0.7,
width=W - 2 * sp.margin_x_hero, height=0.4,
font=t.body_font, font_size=t.caption,
color_key='text_tertiary',
align=dec.cover_hero_align)
# 底部装饰线
if dec.cover_bottom_line:
add_hline(slide, pack, sp.margin_x_hero, H - 0.35,
W - 2 * sp.margin_x_hero,
color_key='accent', thickness=0.015)
FILE:scripts/templates/code_block.py
"""code_block — 代码块展示页,科技风招牌模板。
等宽字体 + 左侧行号 + 卡片式容器 + 可选 macOS 三圆点 + 可选 filename tab。
支持极简语法高亮(keyword/string/comment 三色)。
data:
title: str (optional) 页标题
en_sub: str (optional) 英文副标题
filename: str (optional) '/src/app.ts'
language: str (optional) 'typescript' | 'python' | 'go' | ...(暂只作标签显示)
code: str 代码正文(保留换行和缩进)
dots: bool (optional) 左上 3 个 macOS 风格圆点(默认 true)
highlight_lines: list[int] 需高亮的行号(1-based,可选)
caption: str (optional) 代码下方一行说明
python-pptx 没有原生语法高亮;这里只支持整行高亮(换底色)+
按 token 简单上色(如果出现关键词前缀就变 accent 色)。真正的高亮
不是这个模板的目标——目标是「让代码在 slide 上看着像产品官网的代码截图」。
"""
from .helpers import (
new_slide, add_text, add_rect, add_oval, add_hline,
add_page_header, add_page_footer, add_mono_text,
add_dev_badge, format_dev_badge,
)
_KEYWORDS = {
'def', 'class', 'return', 'import', 'from', 'if', 'else', 'elif',
'for', 'while', 'try', 'except', 'finally', 'with', 'as', 'yield',
'lambda', 'async', 'await', 'pass', 'break', 'continue', 'raise',
'const', 'let', 'var', 'function', 'export', 'default', 'interface',
'type', 'public', 'private', 'static', 'new', 'this', 'self',
'true', 'false', 'null', 'undefined', 'None', 'True', 'False',
'fn', 'pub', 'use', 'mod', 'struct', 'enum', 'impl', 'trait',
'package', 'func',
}
def build(prs, pack, data: dict) -> None:
slide = new_slide(prs, pack)
W, H = pack.canvas.width, pack.canvas.height
t = pack.typography
sp = pack.spacing
dec = pack.decoration
title = data.get('title', '')
en_sub = data.get('en_sub', '')
filename = data.get('filename', '')
language = data.get('language', '')
code = data.get('code', '')
dots = data.get('dots', True)
highlight = set(data.get('highlight_lines', []) or [])
caption = data.get('caption', '')
# 页头(如果有标题)
y = 0.8
if title:
y = add_page_header(slide, pack, title, en_sub)
y += 0.1
# 代码卡片区
card_top = y
card_bottom_reserve = 1.2 if caption else 0.9
card_left = sp.margin_x
card_width = W - 2 * sp.margin_x
card_height = H - card_top - card_bottom_reserve
# 卡片底
card_bg = add_rect(slide, pack,
card_left, card_top, card_width, card_height,
fill_key='bg_elevated',
radius=pack.elevation.card_radius)
# 描边
if pack.elevation.card_stroke_width > 0:
# 重新包一层细边(add_rect 默认不描;手动加)
pass
# 标题栏高度
tab_h = 0.45
# 分割线把标题栏与代码区分开
add_hline(slide, pack,
card_left, card_top + tab_h, card_width,
color_key='border', thickness=0.005)
# 三个 macOS 圆点
dot_y = card_top + tab_h / 2 - 0.08
if dots:
dot_colors = ['#FF5F57', '#FEBC2E', '#28C840']
for i, hx in enumerate(dot_colors):
from .helpers import hex_to_rgb
dot_x = card_left + 0.25 + i * 0.24
add_oval(slide, pack, dot_x, dot_y, 0.15, 0.15,
fill=hex_to_rgb(hx))
# filename + language 标签(标题栏中间)
if filename:
fname_left = card_left + 1.3 if dots else card_left + 0.3
add_mono_text(slide, pack, filename,
left=fname_left, top=card_top + 0.06,
width=card_width - 2.2, height=0.32,
font_size=t.caption + 1,
color_key='text_secondary',
align='left', tracking=0.02)
# 语言标签(右上)
if language:
lang_text = language.upper()
add_mono_text(slide, pack, lang_text,
left=card_left + card_width - 1.3,
top=card_top + 0.08,
width=1.1, height=0.3,
font_size=t.caption,
color_key='text_tertiary',
align='right', tracking=0.15, uppercase=True)
# 代码主体
code_top = card_top + tab_h + 0.15
code_left = card_left + 0.25
code_width = card_width - 0.5
code_height = card_height - tab_h - 0.25
# 切行
lines = code.splitlines() if code else ['']
n_lines = len(lines)
# 行号宽度
line_no_w = 0.45
# 字号(根据总行数自适应)
line_font = 14 if n_lines <= 12 else 12 if n_lines <= 18 else 10
line_height = line_font / 72.0 * 1.5
mono_font = dec.mono_font or dec.mono_fallbacks[0]
# 画每一行
for idx, raw_line in enumerate(lines, start=1):
line_y = code_top + (idx - 1) * line_height
if line_y + line_height > code_top + code_height:
break
# 高亮背景
if idx in highlight:
add_rect(slide, pack,
code_left, line_y - 0.02,
code_width, line_height,
fill_key='bg_subtle',
radius=0.02)
# 行号
add_text(slide, pack, str(idx),
left=code_left, top=line_y,
width=line_no_w, height=line_height,
font=mono_font,
font_size=line_font - 1,
color_key='text_muted',
align='right')
# 代码内容——按关键词上色:
# 简单做法:把第一个 token 取出来检测是否关键词;整行按 secondary/primary 上色;
# 如果包含 '#' 或 '//' 则后面的部分当注释。
_render_code_line(
slide, pack, raw_line,
left=code_left + line_no_w + 0.15,
top=line_y,
width=code_width - line_no_w - 0.3,
height=line_height,
font_size=line_font,
mono_font=mono_font,
)
# 说明 caption
if caption:
add_text(slide, pack, caption,
left=sp.margin_x,
top=H - 0.85,
width=W - 2 * sp.margin_x, height=0.4,
font=t.body_font, font_size=t.caption,
color_key='text_tertiary',
align='left')
# 页脚
add_page_footer(slide, pack, data.get('company', ''), data.get('page', 1))
# dev badge(科技风)
if dec.dev_badge:
badge = format_dev_badge(
pack,
year=str(data.get('year', '2026')),
build=str(data.get('build', '0001')),
)
add_dev_badge(slide, pack, badge, position='bottom-right')
def _render_code_line(slide, pack, line, *, left, top, width, height,
font_size, mono_font):
"""最简单的两段上色:
- 如果包含 # 或 //,把前半段当代码、后半段当注释
- 代码段如果以关键词开头(前导空白忽略),关键词染 accent 色
否则整行单色。
"""
t = pack.typography
# 找注释位置
comment_idx = _find_comment_start(line)
code_part = line[:comment_idx] if comment_idx >= 0 else line
comment_part = line[comment_idx:] if comment_idx >= 0 else ''
# 用一个 textbox 承载,多 runs 实现多色
from pptx.util import Inches, Pt
from pptx.enum.text import PP_ALIGN
from .helpers import hex_to_rgb
tb = slide.shapes.add_textbox(Inches(left), Inches(top), Inches(width), Inches(height))
tf = tb.text_frame
tf.word_wrap = False
tf.margin_left = 0
tf.margin_right = 0
tf.margin_top = 0
tf.margin_bottom = 0
p = tf.paragraphs[0]
p.alignment = PP_ALIGN.LEFT
# 去掉前导空白保留做缩进(用全角空格保 visually)—— 其实直接用空格
# 但 textbox 的空白会被吞;使用 non-breaking space \u00A0
def _preserve_ws(s: str) -> str:
# 只替换前导空白
i = 0
while i < len(s) and s[i] == ' ':
i += 1
return '\u00A0' * i + s[i:]
# 拆 code_part 的首词检测关键词
code_stripped = code_part.lstrip()
leading_ws = code_part[:len(code_part) - len(code_stripped)]
first_token = ''
rest = code_stripped
if code_stripped:
# 提取第一个 token(字母 / 下划线)
i = 0
while i < len(code_stripped) and (code_stripped[i].isalnum() or code_stripped[i] == '_'):
i += 1
first_token = code_stripped[:i]
rest = code_stripped[i:]
# run 1: 前导空白(用 non-breaking space,让 Impress 保留)
if leading_ws:
r = p.add_run()
r.text = '\u00A0' * len(leading_ws)
r.font.name = mono_font
r.font.size = Pt(font_size)
r.font.color.rgb = pack.palette.rgb('text_primary')
# run 2: 关键词(如果是关键字染 accent)
if first_token:
r = p.add_run()
r.text = first_token
r.font.name = mono_font
r.font.size = Pt(font_size)
if first_token in _KEYWORDS:
r.font.color.rgb = pack.palette.rgb('accent')
r.font.bold = True
else:
r.font.color.rgb = pack.palette.rgb('text_primary')
# run 3: 剩余代码
if rest:
r = p.add_run()
r.text = rest
r.font.name = mono_font
r.font.size = Pt(font_size)
r.font.color.rgb = pack.palette.rgb('text_primary')
# run 4: 注释
if comment_part:
r = p.add_run()
r.text = comment_part
r.font.name = mono_font
r.font.size = Pt(font_size)
r.font.color.rgb = pack.palette.rgb('text_tertiary')
r.font.italic = True
def _find_comment_start(line: str) -> int:
"""粗略找 # 或 // 位置,未在字符串内的第一个。"""
in_str = False
str_ch = ''
i = 0
while i < len(line):
c = line[i]
if in_str:
if c == '\\' and i + 1 < len(line):
i += 2
continue
if c == str_ch:
in_str = False
else:
if c == '"' or c == "'":
in_str = True
str_ch = c
elif c == '#':
return i
elif c == '/' and i + 1 < len(line) and line[i + 1] == '/':
return i
i += 1
return -1
FILE:scripts/templates/compare_columns.py
"""compare_columns — 左右对比栏(before / after、方案 A / B)。
data:
title: str
en_sub: str (optional)
left: dict { label, title, items: list[str] }
right: dict 同上
emphasize: 'left' | 'right' | 'none' (default 'right')
"""
from .helpers import (
new_slide, add_text, add_card, add_page_header, add_page_footer,
add_hline,
)
def build(prs, pack, data: dict) -> None:
slide = new_slide(prs, pack)
W, H = pack.canvas.width, pack.canvas.height
t = pack.typography
sp = pack.spacing
title = data.get('title', '')
en_sub = data.get('en_sub', '')
y_top = add_page_header(slide, pack, title, en_sub)
left_col = data.get('left', {})
right_col = data.get('right', {})
emphasize = data.get('emphasize', 'right')
# 布局:两列居中
gap = sp.stack_md
usable_w = W - 2 * sp.margin_x
col_w = (usable_w - gap) / 2
col_h = H - y_top - 1.2
col_top = y_top + sp.stack_md
for idx, (col_data, which) in enumerate([(left_col, 'left'), (right_col, 'right')]):
left = sp.margin_x + idx * (col_w + gap)
is_emph = (emphasize == which)
# 卡片(被强调的一边用 accent_soft 底)
if is_emph:
from .helpers import add_rect
add_rect(slide, pack, left, col_top, col_w, col_h,
fill_key='accent_soft',
radius=pack.elevation.card_radius)
else:
add_card(slide, pack, left, col_top, col_w, col_h)
pad_x = sp.card_pad_x + 0.1
pad_y = sp.card_pad_y + 0.1
inner_w = col_w - 2 * pad_x
y = col_top + pad_y
# 标签(小字 eyebrow —— "BEFORE" / "AFTER")
label = col_data.get('label', '')
if label:
lbl = label.upper() if t.uppercase_en_sub else label
add_text(slide, pack, lbl,
left=left + pad_x, top=y,
width=inner_w, height=0.3,
font=t.body_font, font_size=t.caption + 1, weight='semibold',
color_key='accent' if is_emph else 'text_tertiary',
tracking=0.15 if t.uppercase_en_sub else 0.05)
y += 0.35
# 标题
col_title = col_data.get('title', '')
if col_title:
add_text(slide, pack, col_title,
left=left + pad_x, top=y,
width=inner_w, height=0.6,
font_size=t.card_title + 4, weight='semibold',
color_key='text_primary',
tracking=t.page_tracking)
y += 0.7
# 小装饰线
add_hline(slide, pack,
left + pad_x, y - 0.1, 0.4,
color_key='accent' if is_emph else 'border',
thickness=0.02)
y += 0.15
# 条目列表
items = col_data.get('items', [])
for it in items:
s = it if isinstance(it, str) else it.get('label', str(it))
add_text(slide, pack, '· ' + s,
left=left + pad_x, top=y,
width=inner_w, height=0.4,
font=t.body_font, font_size=t.body + 1,
color_key='text_primary' if is_emph else 'text_secondary',
leading=t.body_leading)
y += 0.45
# 页脚
page_no = data.get('page', 0)
company = data.get('company', '')
if company:
add_page_footer(slide, pack, company, page_no)
FILE:scripts/templates/content_list.py
"""content_list — 编号/要点列表页。
最常用的内容页:标题 + 若干条目(每条带编号或点)。
data:
title: str
en_sub: str (optional)
items: list[dict | str]
如果是 str —— 当成 label
如果是 dict —— label + (optional) desc + (optional) index
numbered: bool (default True) 是否显示序号
"""
from .helpers import (
new_slide, add_text, add_oval, add_page_header, add_page_footer,
)
def build(prs, pack, data: dict) -> None:
slide = new_slide(prs, pack)
W, H = pack.canvas.width, pack.canvas.height
t = pack.typography
sp = pack.spacing
title = data.get('title', '')
en_sub = data.get('en_sub', '')
y_top = add_page_header(slide, pack, title, en_sub)
items = data.get('items', [])
numbered = data.get('numbered', True)
# 归一化
norm = []
for i, it in enumerate(items):
if isinstance(it, str):
norm.append({'label': it, 'desc': '', 'index': i + 1})
else:
norm.append({
'label': it.get('label', ''),
'desc': it.get('desc', ''),
'index': it.get('index', i + 1),
})
if not norm:
return
# 每条的高度
available_h = H - y_top - 1.0
item_h = min(0.95, available_h / max(len(norm), 1))
y = y_top + sp.stack_md
for item in norm:
# 序号(圆点 + 数字 或 单点)
if numbered:
dot_d = 0.4
dot_left = sp.margin_x
add_oval(slide, pack,
dot_left, y + 0.05,
dot_d, dot_d,
fill_key='accent')
add_text(slide, pack, f'{item["index"]:02d}',
left=dot_left, top=y + 0.05,
width=dot_d, height=dot_d,
font_size=t.caption + 2, weight='bold',
color_key='bg',
align='center',
valign='middle')
label_left = dot_left + dot_d + 0.25
else:
# 小实心点
add_oval(slide, pack,
sp.margin_x + 0.05, y + 0.2,
0.12, 0.12,
fill_key='accent')
label_left = sp.margin_x + 0.4
# 标题 + 描述
label = item['label']
desc = item['desc']
label_w = W - label_left - sp.margin_x
add_text(slide, pack, label,
left=label_left, top=y,
width=label_w, height=0.45,
font_size=t.card_title, weight=t.card_weight,
color_key='text_primary',
align='left',
tracking=t.page_tracking)
if desc:
add_text(slide, pack, desc,
left=label_left, top=y + 0.45,
width=label_w, height=item_h - 0.45,
font=t.body_font, font_size=t.body,
color_key='text_secondary',
align='left',
leading=t.body_leading)
y += item_h + sp.gutter
# 页脚
page_no = data.get('page', 0)
company = data.get('company', '')
if company:
add_page_footer(slide, pack, company, page_no)
FILE:scripts/templates/helpers.py
"""
templates/helpers.py - 所有模板共用的绘图原语
不同于老的 pptx_toolkit(按 Style),这里按 StylePack 的 design tokens 绘图。
"""
from typing import Optional
from pptx.util import Inches, Pt, Emu
from pptx.enum.text import PP_ALIGN
from pptx.enum.shapes import MSO_SHAPE
from pptx.dml.color import RGBColor
from pptx.oxml.ns import qn
from lxml import etree
from design_system import StylePack, hex_to_rgb, resolve_weight
# ============================================================
# 画布基础
# ============================================================
def set_canvas(prs, pack: StylePack):
"""给 deck 设尺寸 —— 第一次调用生效,之后 idempotent。"""
prs.slide_width = Inches(pack.canvas.width)
prs.slide_height = Inches(pack.canvas.height)
# ============================================================
# 文本宽度估算 & 自动 fit
# ============================================================
def _approx_text_width_in(text: str, font_size_pt: float) -> float:
"""粗估文本在给定字号下的宽度(inch)。
Bold display 字体偏宽,宁可高估。用偏保守的比例:
CJK = 1.1 em(字面 + 间距)
大写 / 数字 = 0.75 em
小写 = 0.62 em
标点 = 0.38 em
空格 = 0.32 em
"""
w_em = 0.0
for c in text:
cp = ord(c)
if cp > 0x2E80: # CJK / fullwidth
w_em += 1.1
elif c.isspace():
w_em += 0.32
elif c in '.,;:!?\'"-·—':
w_em += 0.38
elif c.isdigit() or c.isupper():
w_em += 0.75
else:
w_em += 0.62
# em → inch (pt/72)
return w_em * (font_size_pt / 72.0)
def fit_font_size(text: str, width_in: float, base_size_pt: float,
*, min_size_pt: float = 24.0,
max_lines: int = 1,
tolerance: float = 0.88) -> float:
"""返回一个不会在 `width_in × max_lines` 溢出的字号。
用于 hero / section 这种超大字,避免 CJK 长文本换行后撞到副标题。
tolerance 留 8% 安全余量,避免 Impress/Keynote 实际渲染比估计略宽。
"""
budget_in = width_in * max_lines * tolerance
size = base_size_pt
w = _approx_text_width_in(text, size)
if w <= budget_in:
return size
# 缩到刚好能容下
size = size * (budget_in / w)
return max(min_size_pt, size)
def new_slide(prs, pack: StylePack):
"""新建空白幻灯片 + 填背景 + 叠装饰层(grid / dot grid / scanline / corner marks)。
装饰叠加顺序(从下往上):
1. 背景(纯色 or 渐变)
2. 网格层(grid_overlay / dot_grid)
3. 扫描线(scanline)
4. 四角 L 型刻度(corner_marks)
5. 开发版本戳(dev_badge)
"""
set_canvas(prs, pack)
slide = prs.slides.add_slide(prs.slide_layouts[6])
dec = pack.decoration
# 1. 背景
if dec.gradient_bg is not None:
# 用全幅渐变 shape 代替纯色背景
add_gradient_rect(
slide, 0, 0, pack.canvas.width, pack.canvas.height,
from_hex=dec.gradient_bg[0], to_hex=dec.gradient_bg[1],
angle_deg=dec.gradient_bg[2],
)
else:
fill = slide.background.fill
fill.solid()
fill.fore_color.rgb = pack.palette.rgb('bg')
# 2. 网格 / 点阵
if dec.grid_overlay:
_draw_grid_overlay(slide, pack)
elif dec.dot_grid:
_draw_dot_grid(slide, pack)
# 3. 扫描线
if dec.scanline:
_draw_scanlines(slide, pack)
# 4. 四角刻度
if dec.corner_marks:
_draw_corner_marks(slide, pack)
# 5. 开发版本戳(在最后叠加,不被其他元素盖)
# (dev_badge 文字内容由模板决定,这里不绘制;add_dev_badge 单独调用)
return slide
# ============================================================
# 字距(tracking)注入 — python-pptx 官方 API 不支持,打 XML
# ============================================================
def _set_run_spacing(run, tracking_em: float, font_size_pt: int):
"""给 run 注入字距 XML。tracking_em 是 em 单位(0.02 = +2%)。"""
if tracking_em == 0:
return
# OOXML 的 <a:rPr spc="N"> N 是 0.01pt 为单位
spc_pt = tracking_em * font_size_pt # 换算到 pt
spc_val = int(spc_pt * 100) # OOXML 期望 1/100 pt
rpr = run._r.get_or_add_rPr()
rpr.set('spc', str(spc_val))
# ============================================================
# 文本框(支持字体/字距/字重/行高)
# ============================================================
def add_text(slide, pack: StylePack, text: str,
left: float, top: float, width: float, height: float,
*,
font=None, font_size=None, weight='regular',
color_key='text_primary', color=None,
align='left', valign='top',
tracking=None, leading=None, uppercase=False):
"""通用文本框。
- font 指定字体名;缺省用 display_font
- font_size pt;缺省用 body
- weight 'bold' / 'semibold' / 'medium' / 'regular'
- color_key 调色板 key(text_primary / text_secondary / accent / ...)
- color 直接传 RGBColor 则覆盖 color_key
- align 'left' / 'center' / 'right'
- tracking em 单位;正值放宽,负值收紧
"""
tb = slide.shapes.add_textbox(Inches(left), Inches(top), Inches(width), Inches(height))
tf = tb.text_frame
tf.word_wrap = True
tf.margin_left = 0
tf.margin_right = 0
tf.margin_top = 0
tf.margin_bottom = 0
# 竖向对齐
if valign == 'middle':
tf.vertical_anchor = 3 # MSO_ANCHOR.MIDDLE
elif valign == 'bottom':
tf.vertical_anchor = 4
alignment_map = {'left': PP_ALIGN.LEFT, 'center': PP_ALIGN.CENTER, 'right': PP_ALIGN.RIGHT}
p = tf.paragraphs[0]
p.alignment = alignment_map.get(align, PP_ALIGN.LEFT)
# 行高
if leading is not None:
p.line_spacing = leading
run = p.add_run()
run.text = text.upper() if uppercase else text
size_pt = font_size if font_size is not None else pack.typography.body
run.font.size = Pt(size_pt)
run.font.bold = resolve_weight(weight)
run.font.name = font or pack.typography.display_font
if color is not None:
run.font.color.rgb = color
else:
run.font.color.rgb = pack.palette.rgb(color_key)
# 字距
if tracking is not None and tracking != 0:
_set_run_spacing(run, tracking, size_pt)
return tb
# ============================================================
# 形状(矩形 / 圆角矩形 / 椭圆 / 直线)
# ============================================================
def add_rect(slide, pack: StylePack,
left, top, width, height,
*, fill_key='bg_elevated', fill=None,
stroke_key=None, stroke=None, stroke_width=0.0,
radius=None):
shape_type = MSO_SHAPE.ROUNDED_RECTANGLE if radius and radius > 0 else MSO_SHAPE.RECTANGLE
shape = slide.shapes.add_shape(
shape_type, Inches(left), Inches(top), Inches(width), Inches(height),
)
shape.fill.solid()
shape.fill.fore_color.rgb = fill if fill is not None else pack.palette.rgb(fill_key)
if stroke is not None or stroke_key is not None:
shape.line.color.rgb = stroke if stroke is not None else pack.palette.rgb(stroke_key)
shape.line.width = Pt(stroke_width)
else:
shape.line.fill.background()
return shape
def add_oval(slide, pack: StylePack, left, top, width, height,
*, fill_key='accent', fill=None, stroke_width=0.0):
shape = slide.shapes.add_shape(
MSO_SHAPE.OVAL, Inches(left), Inches(top), Inches(width), Inches(height),
)
shape.fill.solid()
shape.fill.fore_color.rgb = fill if fill is not None else pack.palette.rgb(fill_key)
shape.line.fill.background()
return shape
def add_hline(slide, pack: StylePack, left, top, width,
*, color_key='divider', color=None, thickness=0.008):
ln = slide.shapes.add_shape(
MSO_SHAPE.RECTANGLE, Inches(left), Inches(top), Inches(width), Inches(thickness),
)
ln.fill.solid()
ln.fill.fore_color.rgb = color if color is not None else pack.palette.rgb(color_key)
ln.line.fill.background()
return ln
# ============================================================
# 卡片(带描边 / 假阴影 / 圆角 — 按 pack.elevation)
# ============================================================
def add_card(slide, pack: StylePack, left, top, width, height, *, fill=None):
"""按 pack.elevation 的语言绘制卡片。"""
elev = pack.elevation
# 假阴影(先画)
if elev.use_fake_shadow:
shadow = add_rect(
slide, pack,
left, top + elev.shadow_offset_y, width, height,
fill=hex_to_rgb(elev.shadow_color.rstrip('00') or '#E5E5EA'),
radius=elev.card_radius,
)
# 主卡片
shape_type = MSO_SHAPE.ROUNDED_RECTANGLE if elev.card_radius > 0 else MSO_SHAPE.RECTANGLE
shape = slide.shapes.add_shape(
shape_type, Inches(left), Inches(top), Inches(width), Inches(height),
)
shape.fill.solid()
shape.fill.fore_color.rgb = fill if fill is not None else hex_to_rgb(elev.card_fill)
if elev.card_stroke_width > 0:
shape.line.color.rgb = hex_to_rgb(elev.card_stroke_color)
shape.line.width = Pt(elev.card_stroke_width)
else:
shape.line.fill.background()
return shape
# ============================================================
# 图片(带占位符兜底)
# ============================================================
def add_image(slide, pack: StylePack, image_path,
left, top, width, height, *, rounded=False):
"""插图。image_path 可以是 None → 绘占位方块。"""
if image_path:
try:
return slide.shapes.add_picture(
image_path, Inches(left), Inches(top),
Inches(width), Inches(height),
)
except Exception:
pass
# 占位方块
radius = pack.elevation.card_radius if rounded else 0
return add_rect(
slide, pack, left, top, width, height,
fill_key='bg_subtle', radius=radius,
)
# ============================================================
# 标题块(页面标题 + 英文副标题 + accent bar)
# ============================================================
def add_page_header(slide, pack: StylePack, title: str, en_sub: str = ''):
"""标准页头:左上角标题 + 英文副标题 + 可选分隔线。"""
dec = pack.decoration
t = pack.typography
sp = pack.spacing
W = pack.canvas.width
y = 0.5
# 英文副标题(如果是 'above' 位置)
if en_sub and dec.page_en_sub_position == 'above':
sub = en_sub.upper() if t.uppercase_en_sub else en_sub
add_text(slide, pack, sub,
left=sp.margin_x, top=y, width=W - 2 * sp.margin_x, height=0.35,
font=pack.typography.body_font,
font_size=t.page_sub, weight='medium',
color_key='accent', align=dec.page_title_align,
tracking=0.15 if t.uppercase_en_sub else 0.02)
y += 0.45
# accent bar
if dec.page_accent_bar:
add_rect(slide, pack,
left=sp.margin_x, top=y + 0.05, width=0.08, height=0.55,
fill_key='accent')
title_left = sp.margin_x + 0.25
else:
title_left = sp.margin_x
# 主标题
add_text(slide, pack, title,
left=title_left, top=y,
width=W - title_left - sp.margin_x, height=0.8,
font_size=t.page_title, weight=t.page_weight,
color_key='text_primary', align=dec.page_title_align,
tracking=t.page_tracking)
y += 0.75
# 英文副标题(如果是 'under' 位置)
if en_sub and dec.page_en_sub_position == 'under':
sub = en_sub.upper() if t.uppercase_en_sub else en_sub
add_text(slide, pack, sub,
left=sp.margin_x, top=y, width=W - 2 * sp.margin_x, height=0.35,
font=pack.typography.body_font,
font_size=t.page_sub, weight='regular',
color_key='text_tertiary', align=dec.page_title_align,
tracking=0.12 if t.uppercase_en_sub else 0.01)
y += 0.4
# 底部细分隔线(有些风格会画,有些不画)
if pack.name.startswith('apple') or pack.name == 'jobs-dark':
pass # Apple 不画横线
else:
add_hline(slide, pack, sp.margin_x, y + 0.1, W - 2 * sp.margin_x)
return y + 0.25
# ============================================================
# 页脚
# ============================================================
def add_page_footer(slide, pack: StylePack, company: str, page_no: int):
if not pack.show_footer:
return
t = pack.typography
sp = pack.spacing
W = pack.canvas.width
H = pack.canvas.height
add_text(slide, pack, company,
left=sp.margin_x, top=H - 0.42, width=6, height=0.3,
font=t.body_font, font_size=t.page_number,
color_key='text_tertiary')
add_text(slide, pack, f'{page_no:02d}',
left=W - sp.margin_x - 0.8, top=H - 0.42, width=0.8, height=0.3,
font=t.body_font, font_size=t.page_number,
color_key='text_tertiary', align='right')
# ============================================================
# v3.1 科技风装饰原语
# ============================================================
_NS_A = 'http://schemas.openxmlformats.org/drawingml/2006/main'
def _nsmap():
return {'a': _NS_A}
def _hex_to_aval(hex_str: str) -> str:
"""'#RRGGBB' → 'RRGGBB'(OOXML srgbClr 的 val 去掉 #)。"""
return hex_str.lstrip('#').upper()[:6]
def add_gradient_rect(slide, left, top, width, height,
*, from_hex: str, to_hex: str, angle_deg: int = 90):
"""全幅或局部渐变矩形(无描边)。
OOXML 的 gradFill 线性角度单位是 1/60000 度,0° = 水平左→右。
"""
shape = slide.shapes.add_shape(
MSO_SHAPE.RECTANGLE, Inches(left), Inches(top),
Inches(width), Inches(height),
)
shape.line.fill.background()
spPr_elem = shape._element.find(qn('p:spPr'))
if spPr_elem is None:
return shape
# 清掉现有 fill
for tag in ('a:solidFill', 'a:gradFill', 'a:blipFill', 'a:pattFill', 'a:noFill'):
old = spPr_elem.find(qn(tag))
if old is not None:
spPr_elem.remove(old)
# 注入 gradFill
angle_units = int(angle_deg * 60000)
grad_xml = f'''<a:gradFill xmlns:a="{_NS_A}" flip="none" rotWithShape="1">
<a:gsLst>
<a:gs pos="0"><a:srgbClr val="{_hex_to_aval(from_hex)}"/></a:gs>
<a:gs pos="100000"><a:srgbClr val="{_hex_to_aval(to_hex)}"/></a:gs>
</a:gsLst>
<a:lin ang="{angle_units}" scaled="0"/>
<a:tileRect/>
</a:gradFill>'''
grad_elem = etree.fromstring(grad_xml)
# 插到 ln 之前(fill 必须在 ln 前)
ln_elem = spPr_elem.find(qn('a:ln'))
if ln_elem is not None:
ln_idx = list(spPr_elem).index(ln_elem)
spPr_elem.insert(ln_idx, grad_elem)
else:
spPr_elem.append(grad_elem)
return shape
def apply_text_gradient(run, from_hex: str, to_hex: str, angle_deg: int = 0):
"""给 run.font 注入 gradFill 代替 solidFill。
常用于 hero 大字做电青→电紫渐变。
"""
rpr = run._r.get_or_add_rPr()
# 移除现有 solidFill(python-pptx 会先设这个)
for tag in ('a:solidFill', 'a:gradFill', 'a:noFill'):
old = rpr.find(qn(tag))
if old is not None:
rpr.remove(old)
angle_units = int(angle_deg * 60000)
grad_xml = f'''<a:gradFill xmlns:a="{_NS_A}" flip="none" rotWithShape="1">
<a:gsLst>
<a:gs pos="0"><a:srgbClr val="{_hex_to_aval(from_hex)}"/></a:gs>
<a:gs pos="100000"><a:srgbClr val="{_hex_to_aval(to_hex)}"/></a:gs>
</a:gsLst>
<a:lin ang="{angle_units}" scaled="0"/>
<a:tileRect/>
</a:gradFill>'''
grad_elem = etree.fromstring(grad_xml)
# gradFill 通常插在 rPr 的子元素列表最前(solidFill 原本位置)
rpr.insert(0, grad_elem)
def _set_shape_alpha(shape, alpha_pct: float):
"""给 shape 的 solid fill 加 alpha(0~1)。"""
spPr = shape._element.find(qn('p:spPr'))
if spPr is None:
return
solid = spPr.find(qn('a:solidFill'))
if solid is None:
return
clr = solid.find(qn('a:srgbClr'))
if clr is None:
return
old = clr.find(qn('a:alpha'))
if old is not None:
clr.remove(old)
alpha_val = int(max(0, min(1, alpha_pct)) * 100000)
alpha = etree.SubElement(clr, qn('a:alpha'))
alpha.set('val', str(alpha_val))
def _draw_grid_overlay(slide, pack: StylePack):
"""细线网格(横 + 竖)。"""
dec = pack.decoration
W, H = pack.canvas.width, pack.canvas.height
spacing = max(dec.grid_spacing, 0.1)
thickness = max(dec.grid_thickness, 0.003)
color = hex_to_rgb(dec.grid_color)
# 竖线
x = spacing
while x < W:
ln = slide.shapes.add_shape(
MSO_SHAPE.RECTANGLE, Inches(x), Inches(0),
Inches(thickness), Inches(H),
)
ln.fill.solid()
ln.fill.fore_color.rgb = color
ln.line.fill.background()
x += spacing
# 横线
y = spacing
while y < H:
ln = slide.shapes.add_shape(
MSO_SHAPE.RECTANGLE, Inches(0), Inches(y),
Inches(W), Inches(thickness),
)
ln.fill.solid()
ln.fill.fore_color.rgb = color
ln.line.fill.background()
y += spacing
def _draw_dot_grid(slide, pack: StylePack):
"""圆点网格 —— 极简科技感。"""
dec = pack.decoration
W, H = pack.canvas.width, pack.canvas.height
spacing = max(dec.dot_spacing, 0.15)
size = max(dec.dot_size, 0.02)
color = hex_to_rgb(dec.dot_color)
y = spacing
while y < H:
x = spacing
while x < W:
dot = slide.shapes.add_shape(
MSO_SHAPE.OVAL,
Inches(x - size / 2), Inches(y - size / 2),
Inches(size), Inches(size),
)
dot.fill.solid()
dot.fill.fore_color.rgb = color
dot.line.fill.background()
x += spacing
y += spacing
def _draw_scanlines(slide, pack: StylePack):
"""水平扫描线 —— retro-tech / CRT 感。每 0.08in 一条。"""
dec = pack.decoration
W, H = pack.canvas.width, pack.canvas.height
color = hex_to_rgb(dec.scanline_color)
y = 0.08
while y < H:
ln = slide.shapes.add_shape(
MSO_SHAPE.RECTANGLE, Inches(0), Inches(y),
Inches(W), Inches(0.004),
)
ln.fill.solid()
ln.fill.fore_color.rgb = color
ln.line.fill.background()
y += 0.08
def _draw_corner_marks(slide, pack: StylePack):
"""四角 L 型刻度(取景框感)。"""
dec = pack.decoration
W, H = pack.canvas.width, pack.canvas.height
size = dec.corner_size
thick = dec.corner_thickness
color = pack.palette.rgb('accent')
margin = 0.5 # 距离边缘
def _mark(anchor_x, anchor_y, dir_x, dir_y):
"""在 (anchor_x, anchor_y) 处画一个 L,朝 (dir_x, dir_y) 方向延伸。"""
# 横线
hx = anchor_x if dir_x > 0 else anchor_x - size
hy = anchor_y if dir_y > 0 else anchor_y - thick
h = slide.shapes.add_shape(
MSO_SHAPE.RECTANGLE, Inches(hx), Inches(hy),
Inches(size), Inches(thick),
)
h.fill.solid()
h.fill.fore_color.rgb = color
h.line.fill.background()
# 竖线
vx = anchor_x if dir_x > 0 else anchor_x - thick
vy = anchor_y if dir_y > 0 else anchor_y - size
v = slide.shapes.add_shape(
MSO_SHAPE.RECTANGLE, Inches(vx), Inches(vy),
Inches(thick), Inches(size),
)
v.fill.solid()
v.fill.fore_color.rgb = color
v.line.fill.background()
_mark(margin, margin, +1, +1) # 左上
_mark(W - margin, margin, -1, +1) # 右上
_mark(margin, H - margin, +1, -1) # 左下
_mark(W - margin, H - margin, -1, -1) # 右下
def add_glow_halo(slide, pack: StylePack, cx: float, cy: float,
radius: float, *, color_key: str = 'accent',
layers: int = 4, strength: float = 0.6):
"""在 (cx, cy) 周围画多层半透明椭圆模拟辉光。
layers 层,每层半径递增、alpha 递减。
"""
color = pack.palette.rgb(color_key)
for i in range(layers, 0, -1):
r = radius * (0.4 + 0.2 * i)
alpha = strength * (0.08 * i / layers)
oval = slide.shapes.add_shape(
MSO_SHAPE.OVAL,
Inches(cx - r), Inches(cy - r * 0.6), # 椭圆略扁
Inches(r * 2), Inches(r * 1.2),
)
oval.fill.solid()
oval.fill.fore_color.rgb = color
oval.line.fill.background()
_set_shape_alpha(oval, alpha)
def add_dev_badge(slide, pack: StylePack, text: str, *, position: str = 'bottom-left'):
"""左下等宽字体 badge,比如 `BUILD · 2026.4.24` / `v3.1.0`。"""
dec = pack.decoration
sp = pack.spacing
W, H = pack.canvas.width, pack.canvas.height
font = dec.mono_font or dec.mono_fallbacks[0]
if position == 'bottom-left':
left, top = sp.margin_x, H - 0.55
align = 'left'
elif position == 'bottom-right':
left, top = W - sp.margin_x - 4, H - 0.55
align = 'right'
elif position == 'top-right':
left, top = W - sp.margin_x - 4, 0.35
align = 'right'
else: # top-left
left, top = sp.margin_x, 0.35
align = 'left'
add_text(slide, pack, text,
left=left, top=top, width=4, height=0.3,
font=font, font_size=pack.typography.page_number,
weight='regular',
color_key='text_tertiary',
align=align,
tracking=0.15, uppercase=True)
def add_mono_text(slide, pack: StylePack, text: str,
left, top, width, height,
*, font_size=None, color_key='text_tertiary',
align='left', weight='regular',
tracking=0.05, uppercase=False):
"""等宽字体文本(caption / metadata 用)。"""
dec = pack.decoration
font = dec.mono_font or dec.mono_fallbacks[0]
return add_text(slide, pack, text,
left=left, top=top, width=width, height=height,
font=font, font_size=font_size or pack.typography.caption,
weight=weight, color_key=color_key, align=align,
tracking=tracking, uppercase=uppercase)
def add_vertical_hairline(slide, pack: StylePack, x, top, height,
*, color_key='border', color=None, thickness=0.006):
"""细竖线(分栏装饰用)。"""
ln = slide.shapes.add_shape(
MSO_SHAPE.RECTANGLE, Inches(x), Inches(top),
Inches(thickness), Inches(height),
)
ln.fill.solid()
ln.fill.fore_color.rgb = color if color is not None else pack.palette.rgb(color_key)
ln.line.fill.background()
return ln
def format_dev_badge(pack: StylePack, template: Optional[str] = None,
year: Optional[str] = None, build: Optional[str] = None) -> str:
"""根据 decoration.dev_badge_template 插值生成 badge 文本。
占位:{year} {date} {build} {n}
"""
tmpl = template or pack.decoration.dev_badge_template
import datetime
today = datetime.date.today()
return (tmpl
.replace('{year}', year or str(today.year))
.replace('{date}', today.strftime('%Y.%-m.%-d')
if hasattr(today, 'strftime') else '2026.4.24')
.replace('{build}', build or '0001')
.replace('{n}', build or '0001'))
# ============================================================
# v3.2 新增:液态玻璃 / 朱砂印章 / 飞白笔触 / 几何装饰
# ============================================================
def add_color_orb(slide, cx: float, cy: float, radius: float,
*, color: str, alpha: float = 0.55, layers: int = 6):
"""彩色光球 —— Apple Liquid Glass 招牌装饰。
在 (cx, cy) 处叠多层半透明椭圆,从中心向外 alpha 递减,模拟高斯模糊后的彩色光晕。
color: '#RRGGBB'
layers: 5~7 层最像
"""
from pptx.dml.color import RGBColor as _RGB
from pptx.util import Inches as _In
from pptx.enum.shapes import MSO_SHAPE as _SH
rgb = hex_to_rgb(color)
for i in range(layers, 0, -1):
r = radius * (0.35 + 0.18 * i)
a = alpha * ((i / layers) ** 1.6) * 0.35
orb = slide.shapes.add_shape(
_SH.OVAL,
_In(cx - r), _In(cy - r),
_In(r * 2), _In(r * 2),
)
orb.fill.solid()
orb.fill.fore_color.rgb = rgb
orb.line.fill.background()
_set_shape_alpha(orb, a)
def add_orb_cluster(slide, pack: StylePack, *,
palette_hex: Optional[list] = None,
seed: int = 7, count: int = 6):
"""在画布中铺一组分布式彩色光球(Liquid Glass / WWDC 主视觉)。
palette_hex: ['#0A84FF', '#BF5AF2', ...] —— 默认走 Apple system colors。
seed: 随机种子,固定可复现。
"""
import random
if palette_hex is None:
palette_hex = ['#0A84FF', '#BF5AF2', '#FF375F', '#FF9F0A',
'#30D158', '#64D2FF', '#5E5CE6']
rng = random.Random(seed)
W, H = pack.canvas.width, pack.canvas.height
for _ in range(count):
cx = rng.uniform(W * 0.05, W * 0.95)
cy = rng.uniform(H * 0.05, H * 0.95)
radius = rng.uniform(1.4, 2.6)
color = rng.choice(palette_hex)
alpha = rng.uniform(0.4, 0.7)
add_color_orb(slide, cx, cy, radius,
color=color, alpha=alpha, layers=6)
def add_glass_card(slide, left, top, width, height,
*, fill_hex: str = '#FFFFFF', alpha: float = 0.55,
stroke_hex: str = '#FFFFFF', stroke_width_pt: float = 0.5,
radius: float = 0.4):
"""半透磨砂玻璃卡 —— Liquid Glass 招牌。
用 ROUNDED_RECTANGLE + alpha + 白色细边模拟磨砂质感。
"""
from pptx.util import Inches as _In, Pt as _Pt
from pptx.enum.shapes import MSO_SHAPE as _SH
card = slide.shapes.add_shape(
_SH.ROUNDED_RECTANGLE,
_In(left), _In(top),
_In(width), _In(height),
)
# 圆角调整(python-pptx 默认 adj 太小)
try:
card.adjustments[0] = min(0.5, radius / max(width, height))
except Exception:
pass
card.fill.solid()
card.fill.fore_color.rgb = hex_to_rgb(fill_hex)
_set_shape_alpha(card, alpha)
card.line.color.rgb = hex_to_rgb(stroke_hex)
card.line.width = _Pt(stroke_width_pt)
return card
def add_seal_stamp(slide, pack: StylePack, text: str,
*, left: float, top: float, size: float = 0.7,
color_hex: Optional[str] = None,
variant: str = 'square'):
"""朱砂方印 —— 原研哉 / 水墨 / 国风 共用装饰。
variant: 'square' 阳刻方印 / 'circle' 阴刻圆印
text: 1-4 个字(最佳)
"""
from pptx.util import Inches as _In, Pt as _Pt
from pptx.enum.shapes import MSO_SHAPE as _SH
from pptx.enum.text import PP_ALIGN as _PA
color_hex = color_hex or pack.palette.accent
rgb = hex_to_rgb(color_hex)
shape_kind = _SH.RECTANGLE if variant == 'square' else _SH.OVAL
seal = slide.shapes.add_shape(
shape_kind,
_In(left), _In(top), _In(size), _In(size),
)
seal.fill.solid()
seal.fill.fore_color.rgb = rgb
seal.line.color.rgb = rgb
seal.line.width = _Pt(1.5)
# 内嵌白字
tf = seal.text_frame
tf.margin_left = _In(0.04)
tf.margin_right = _In(0.04)
tf.margin_top = _In(0.04)
tf.margin_bottom = _In(0.04)
tf.word_wrap = True
p = tf.paragraphs[0]
p.alignment = _PA.CENTER
run = p.add_run()
run.text = text
run.font.name = 'STKaiti'
# 字数自适应
n = len([c for c in text if c.strip()])
pt = 22 if n <= 1 else 18 if n == 2 else 14 if n == 3 else 11
run.font.size = _Pt(pt)
run.font.bold = True
from pptx.dml.color import RGBColor as _RGB
run.font.color.rgb = _RGB(0xFF, 0xFF, 0xF8)
return seal
def add_brushstroke_band(slide, left: float, top: float,
width: float, height: float,
*, color_hex: str = '#1A1A1A',
alpha: float = 0.85,
tilt_deg: float = 0):
"""飞白笔触横扫 —— 水墨风招牌。
用一个矩形 + 极小高度 + alpha 模拟书法横扫的笔触。
多次叠加(recommended: 调用方叠 3-5 道,错位 + 不同 alpha)效果更像。
"""
from pptx.util import Inches as _In
from pptx.enum.shapes import MSO_SHAPE as _SH
band = slide.shapes.add_shape(
_SH.RECTANGLE,
_In(left), _In(top),
_In(width), _In(height),
)
band.fill.solid()
band.fill.fore_color.rgb = hex_to_rgb(color_hex)
_set_shape_alpha(band, alpha)
band.line.fill.background()
if tilt_deg:
try:
band.rotation = tilt_deg
except Exception:
pass
return band
def add_brushstroke_cluster(slide, pack: StylePack,
cx: float, cy: float,
*, length: float = 4.0, color_key: str = 'text_primary',
count: int = 5):
"""一组飞白笔触 —— 调用 add_brushstroke_band 叠加 5 道。"""
import random
color = getattr(pack.palette, color_key)
rng = random.Random(int(cx * 1000) + int(cy * 1000))
for i in range(count):
offset_y = (i - count // 2) * 0.045 + rng.uniform(-0.02, 0.02)
offset_x = rng.uniform(-0.15, 0.15)
h = rng.uniform(0.025, 0.055)
a = 0.35 + rng.uniform(0.0, 0.45)
add_brushstroke_band(
slide,
left=cx - length / 2 + offset_x,
top=cy + offset_y,
width=length + rng.uniform(-0.4, 0.4),
height=h,
color_hex=color,
alpha=a,
)
def add_paint_stroke(slide, left: float, top: float,
width: float, height: float,
*, color_hex: str = '#FFC107',
alpha: float = 0.92,
tilt_deg: float = -8):
"""单道油画粗笔触 —— 梵高风。"""
from pptx.util import Inches as _In
from pptx.enum.shapes import MSO_SHAPE as _SH
stroke = slide.shapes.add_shape(
_SH.ROUNDED_RECTANGLE,
_In(left), _In(top),
_In(width), _In(height),
)
try:
stroke.adjustments[0] = 0.5
except Exception:
pass
stroke.fill.solid()
stroke.fill.fore_color.rgb = hex_to_rgb(color_hex)
_set_shape_alpha(stroke, alpha)
stroke.line.fill.background()
if tilt_deg:
try:
stroke.rotation = tilt_deg
except Exception:
pass
return stroke
def add_paint_stroke_cluster(slide, pack: StylePack,
cx: float, cy: float,
*, palette_keys: Optional[list] = None,
count: int = 8, span: float = 4.0):
"""一组油画笔触叠加 —— 梵高星夜感。"""
import random
palette_keys = palette_keys or ['accent', 'accent_soft', 'text_primary']
rng = random.Random(int(cx * 100) + int(cy * 100))
for _ in range(count):
color = getattr(pack.palette, rng.choice(palette_keys))
w = rng.uniform(0.6, 1.4)
h = rng.uniform(0.06, 0.12)
x = cx + rng.uniform(-span / 2, span / 2)
y = cy + rng.uniform(-1.5, 1.5)
tilt = rng.uniform(-30, 30)
a = rng.uniform(0.6, 0.92)
add_paint_stroke(slide, x, y, w, h,
color_hex=color, alpha=a, tilt_deg=tilt)
def add_geometric_decoration(slide, pack: StylePack,
*, mode: str = 'memphis', seed: int = 13):
"""孟菲斯/包豪斯风的彩色几何装饰 —— 圆 / 三角 / 菱形 / 之字纹。
mode: 'memphis' / 'bauhaus' / 'minimal'
"""
import random
from pptx.util import Inches as _In, Pt as _Pt
from pptx.enum.shapes import MSO_SHAPE as _SH
rng = random.Random(seed)
W, H = pack.canvas.width, pack.canvas.height
palette = [pack.palette.accent, pack.palette.accent_soft,
pack.palette.text_primary]
shapes_pool = [_SH.OVAL, _SH.ISOCELES_TRIANGLE, _SH.DIAMOND,
_SH.RIGHT_TRIANGLE, _SH.PENTAGON]
count = 5 if mode == 'memphis' else 3 if mode == 'bauhaus' else 2
for _ in range(count):
kind = rng.choice(shapes_pool)
size = rng.uniform(0.6, 1.4)
x = rng.uniform(0.5, W - 1.5)
y = rng.uniform(0.5, H - 1.5)
s = slide.shapes.add_shape(
kind, _In(x), _In(y), _In(size), _In(size),
)
s.fill.solid()
s.fill.fore_color.rgb = hex_to_rgb(rng.choice(palette))
if mode == 'memphis':
s.line.color.rgb = hex_to_rgb(pack.palette.text_primary)
s.line.width = _Pt(2.0)
elif mode == 'bauhaus':
s.line.fill.background()
else:
s.line.color.rgb = hex_to_rgb(pack.palette.border)
s.line.width = _Pt(0.5)
try:
s.rotation = rng.uniform(0, 60)
except Exception:
pass
def add_chinese_pattern_border(slide, pack: StylePack,
*, color_hex: Optional[str] = None,
thickness: float = 0.025,
margin: float = 0.35):
"""国风万字纹/双线金边框 —— 沿画布四周。
简化版:双线金边,外粗内细。
"""
from pptx.util import Inches as _In
from pptx.enum.shapes import MSO_SHAPE as _SH
color_hex = color_hex or '#FFB61E' # 藤黄默认
rgb = hex_to_rgb(color_hex)
W, H = pack.canvas.width, pack.canvas.height
# 四条外粗边
for x, y, w, h in [
(margin, margin, W - 2 * margin, thickness), # 上
(margin, H - margin - thickness, W - 2 * margin, thickness), # 下
(margin, margin, thickness, H - 2 * margin), # 左
(W - margin - thickness, margin, thickness, H - 2 * margin), # 右
]:
e = slide.shapes.add_shape(
_SH.RECTANGLE, _In(x), _In(y), _In(w), _In(h),
)
e.fill.solid()
e.fill.fore_color.rgb = rgb
e.line.fill.background()
# 内细边 0.06" 内偏移
inner = margin + 0.08
inner_thick = thickness * 0.4
for x, y, w, h in [
(inner, inner, W - 2 * inner, inner_thick),
(inner, H - inner - inner_thick, W - 2 * inner, inner_thick),
(inner, inner, inner_thick, H - 2 * inner),
(W - inner - inner_thick, inner, inner_thick, H - 2 * inner),
]:
e = slide.shapes.add_shape(
_SH.RECTANGLE, _In(x), _In(y), _In(w), _In(h),
)
e.fill.solid()
e.fill.fore_color.rgb = rgb
e.line.fill.background()
_set_shape_alpha(e, 0.6)
def add_offset_shadow_block(slide, pack: StylePack,
left: float, top: float,
width: float, height: float,
*, fill_hex: str = '#FFFFFF',
shadow_hex: str = '#1A1A1A',
offset: float = 0.08,
stroke_pt: float = 2.0):
"""孟菲斯/Y2K 偏移投影块 —— 黑色实心投影 + 白底彩边。"""
from pptx.util import Inches as _In, Pt as _Pt
from pptx.enum.shapes import MSO_SHAPE as _SH
# 投影
sh = slide.shapes.add_shape(
_SH.RECTANGLE,
_In(left + offset), _In(top + offset),
_In(width), _In(height),
)
sh.fill.solid()
sh.fill.fore_color.rgb = hex_to_rgb(shadow_hex)
sh.line.fill.background()
# 主块
blk = slide.shapes.add_shape(
_SH.RECTANGLE,
_In(left), _In(top), _In(width), _In(height),
)
blk.fill.solid()
blk.fill.fore_color.rgb = hex_to_rgb(fill_hex)
blk.line.color.rgb = hex_to_rgb(shadow_hex)
blk.line.width = _Pt(stroke_pt)
return blk
def add_golden_ratio_guide(slide, pack: StylePack,
*, color_hex: Optional[str] = None,
thickness: float = 0.005,
alpha: float = 0.25):
"""达芬奇/文艺复兴风:黄金分割辅助网格(0.382 / 0.618 双向)。"""
from pptx.util import Inches as _In
from pptx.enum.shapes import MSO_SHAPE as _SH
color_hex = color_hex or pack.palette.text_muted
W, H = pack.canvas.width, pack.canvas.height
rgb = hex_to_rgb(color_hex)
# 垂直黄金线
for ratio in (0.382, 0.618):
ln = slide.shapes.add_shape(
_SH.RECTANGLE,
_In(W * ratio), _In(0),
_In(thickness), _In(H),
)
ln.fill.solid()
ln.fill.fore_color.rgb = rgb
ln.line.fill.background()
_set_shape_alpha(ln, alpha)
# 水平黄金线
for ratio in (0.382, 0.618):
ln = slide.shapes.add_shape(
_SH.RECTANGLE,
_In(0), _In(H * ratio),
_In(W), _In(thickness),
)
ln.fill.solid()
ln.fill.fore_color.rgb = rgb
ln.line.fill.background()
_set_shape_alpha(ln, alpha)
FILE:scripts/templates/hero_cover.py
"""hero_cover — 封面 hero 大字。
data:
title: str
subtitle: str (optional)
eyebrow: str (optional) 顶部小字(Apple 常用,比如 "INTRODUCING")
footnote: str (optional) 底部小字(年份 / 公司)
image: str (optional) 背景图路径
"""
from .helpers import (
new_slide, add_text, add_rect, add_hline, add_image, fit_font_size,
apply_text_gradient, add_glow_halo, add_dev_badge, format_dev_badge,
add_mono_text,
)
def build(prs, pack, data: dict) -> None:
slide = new_slide(prs, pack)
W, H = pack.canvas.width, pack.canvas.height
t = pack.typography
dec = pack.decoration
sp = pack.spacing
# 可选背景图
if data.get('image'):
add_image(slide, pack, data['image'],
left=0, top=0, width=W, height=H)
# 加一层半透明黑覆盖,保证文字可读
overlay = add_rect(slide, pack, 0, 0, W, H,
fill_key='bg')
# 半透明度用 XML
from pptx.oxml.ns import qn
elem = overlay.fill.fore_color._xFill
alpha_elem = elem.find(qn('a:alpha'))
# 顶部装饰线(小红书/复古会有)
if dec.cover_top_line:
add_hline(slide, pack, sp.margin_x_hero, 0.5,
W - 2 * sp.margin_x_hero,
color_key='accent', thickness=0.015)
# Eyebrow(Apple 常用)
y = H * 0.2
eyebrow = data.get('eyebrow')
if eyebrow:
ey = eyebrow.upper() if t.uppercase_en_sub else eyebrow
add_text(slide, pack, ey,
left=sp.margin_x_hero, top=y,
width=W - 2 * sp.margin_x_hero, height=0.4,
font=t.body_font, font_size=t.page_sub, weight='semibold',
color_key='accent',
align=dec.cover_hero_align,
tracking=0.2 if t.uppercase_en_sub else 0.05)
y += 0.55
# Hero title
title = data.get('title', '')
case = dec.cover_hero_case
if case == 'upper':
title = title.upper()
elif case == 'lower':
title = title.lower()
# 居中风格整体上移
if dec.cover_hero_align == 'center':
hero_top = H * 0.33
else:
hero_top = H * 0.42
# 自动 fit — 如果是长文本(比如 CJK 7 字以上),降字号保 1 行
hero_w = W - 2 * sp.margin_x_hero
hero_size = fit_font_size(title, hero_w, t.hero,
min_size_pt=48, max_lines=1)
# 如果 pack 启用了 glow_accent 且 align 是 left/center → 在 hero 附近画 halo
if dec.glow_accent:
halo_cx = W / 2 if dec.cover_hero_align == 'center' else sp.margin_x_hero + hero_w * 0.35
halo_cy = hero_top + (hero_size / 72.0) * 0.5
add_glow_halo(slide, pack, halo_cx, halo_cy,
radius=hero_size / 72.0 * 2.0,
color_key='accent', layers=4,
strength=dec.glow_strength)
hero_tb = add_text(slide, pack, title,
left=sp.margin_x_hero, top=hero_top,
width=hero_w, height=H * 0.4,
font_size=hero_size, weight=t.hero_weight,
color_key='text_primary',
align=dec.cover_hero_align,
tracking=t.hero_tracking,
leading=t.hero_leading)
# 如果 pack 有 accent_gradient,给 hero 文字注入渐变填充
if dec.accent_gradient is not None:
# 第一段 run 就是 hero 文字
runs = hero_tb.text_frame.paragraphs[0].runs
if runs:
apply_text_gradient(runs[0], dec.accent_gradient[0], dec.accent_gradient[1], angle_deg=0)
# Subtitle
subtitle = data.get('subtitle', '')
if subtitle:
sub_top = hero_top + (hero_size / 72.0) * 1.3 # 在 hero 下方
add_text(slide, pack, subtitle,
left=sp.margin_x_hero, top=sub_top,
width=W - 2 * sp.margin_x_hero, height=0.8,
font_size=t.page_title, weight='regular',
color_key='text_secondary',
align=dec.cover_hero_align,
tracking=t.page_tracking)
# Footnote(底部)
footnote = data.get('footnote', '')
if footnote:
add_text(slide, pack, footnote,
left=sp.margin_x_hero, top=H - 0.7,
width=W - 2 * sp.margin_x_hero, height=0.4,
font=t.body_font, font_size=t.caption,
color_key='text_tertiary',
align=dec.cover_hero_align)
# 底部装饰线
if dec.cover_bottom_line:
add_hline(slide, pack, sp.margin_x_hero, H - 0.35,
W - 2 * sp.margin_x_hero,
color_key='accent', thickness=0.015)
# 开发版本戳(科技风专属)
if dec.dev_badge:
badge = format_dev_badge(
pack,
year=str(data.get('year', '2026')),
build=str(data.get('build', '0001')),
)
add_dev_badge(slide, pack, badge, position='bottom-left')
FILE:scripts/templates/kpi_triple.py
"""kpi_triple — 3 宫格 KPI 卡。
典型:3 个数据点并列,每个一个大数字 + 标签。
data:
title: str (optional) 页面标题
en_sub: str (optional) 英文副标
items: list[dict] 每个 dict:
value: str 数字("87%")
label: str 标签("用户留存率")
caption: str 说明(optional)
"""
from .helpers import (
new_slide, add_text, add_card, add_page_header, add_page_footer,
fit_font_size,
)
def build(prs, pack, data: dict) -> None:
slide = new_slide(prs, pack)
W, H = pack.canvas.width, pack.canvas.height
t = pack.typography
sp = pack.spacing
elev = pack.elevation
# 标题(可选)
y_top = 0.5
title = data.get('title', '')
en_sub = data.get('en_sub', '')
if title:
y_top = add_page_header(slide, pack, title, en_sub)
else:
y_top = H * 0.25
items = data.get('items', [])[:3]
if not items:
return
n = len(items)
# 卡片布局
usable_w = W - 2 * sp.margin_x
gap = sp.stack_md
card_w = (usable_w - gap * (n - 1)) / n
card_h = min(3.5, H - y_top - 1.2)
card_top = y_top + sp.stack_md
for i, item in enumerate(items):
left = sp.margin_x + i * (card_w + gap)
# 卡片背景
add_card(slide, pack, left, card_top, card_w, card_h)
# 卡片内文字布局(居中)
pad_x = sp.card_pad_x
inner_w = card_w - 2 * pad_x
# 数字 — 自适应缩字避免溢出("99.97%" / "$4.8M" 这种长数字常见)
value = item.get('value', '')
value_size_base = max(48, int(t.section * 0.65))
value_size = fit_font_size(str(value), inner_w, value_size_base,
min_size_pt=32, max_lines=1)
value_h = value_size / 72.0 * 1.2
value_top = card_top + card_h * 0.28
add_text(slide, pack, value,
left=left + pad_x, top=value_top,
width=inner_w, height=value_h,
font_size=value_size, weight='bold',
color_key='accent',
align='center',
tracking=-0.02,
leading=1.0)
# 标签
label = item.get('label', '')
label_top = value_top + value_h + 0.1
add_text(slide, pack, label,
left=left + pad_x, top=label_top,
width=inner_w, height=0.5,
font_size=t.card_title, weight=t.card_weight,
color_key='text_primary',
align='center')
# 说明
caption = item.get('caption', '')
if caption:
cap_top = label_top + 0.5
add_text(slide, pack, caption,
left=left + pad_x, top=cap_top,
width=inner_w, height=0.8,
font=t.body_font, font_size=t.caption + 2,
color_key='text_tertiary',
align='center',
leading=t.body_leading)
# 页脚
page_no = data.get('page', 0)
company = data.get('company', '')
if company:
add_page_footer(slide, pack, company, page_no)
FILE:scripts/templates/product_shot.py
"""product_shot — 产品摄影页(Apple.com 风)。
结构:左文右图(或上文下图),图占大块面积。
data:
title: str
en_sub: str (optional)
kicker: str (optional) 图上方的小字
subtitle: str (optional) 副标(比 title 小的描述)
image: str 图片路径(没有则空白占位)
bullets: list[str] (optional) 补充要点
layout: 'left' | 'right' | 'top' (default 'right')
图在左/右/上
"""
from .helpers import (
new_slide, add_text, add_image, add_page_header, add_page_footer,
)
def build(prs, pack, data: dict) -> None:
slide = new_slide(prs, pack)
W, H = pack.canvas.width, pack.canvas.height
t = pack.typography
sp = pack.spacing
dec = pack.decoration
title = data.get('title', '')
en_sub = data.get('en_sub', '')
kicker = data.get('kicker', '')
subtitle = data.get('subtitle', '')
image = data.get('image')
bullets = data.get('bullets', [])
layout = data.get('layout', 'right')
# 产品页的标题可以在图上面或旁边,这里先用 page header
y_top = add_page_header(slide, pack, title, en_sub) if title else 0.6
# 图片 + 文字区
rounded = dec.image_treatment == 'rounded'
if layout == 'top':
# 图片铺满上半屏,文字在下
img_h = (H - y_top) * 0.6
img_top = y_top + sp.stack_sm
img_left = sp.margin_x
img_w = W - 2 * sp.margin_x
add_image(slide, pack, image,
img_left, img_top, img_w, img_h,
rounded=rounded)
# 下面放 kicker/subtitle/bullets
y = img_top + img_h + sp.stack_md
_draw_text_block(slide, pack, sp.margin_x, y, img_w, H - y - 0.8,
kicker, subtitle, bullets)
else:
# 左/右布局
text_w = (W - 2 * sp.margin_x) * 0.42
img_w = (W - 2 * sp.margin_x) * 0.55
gap = (W - 2 * sp.margin_x - text_w - img_w)
img_h = H - y_top - 1.2
img_top = y_top + sp.stack_sm
if layout == 'left':
img_left = sp.margin_x
text_left = sp.margin_x + img_w + gap
else: # right
text_left = sp.margin_x
img_left = sp.margin_x + text_w + gap
add_image(slide, pack, image,
img_left, img_top, img_w, img_h,
rounded=rounded)
_draw_text_block(slide, pack, text_left, img_top, text_w, img_h,
kicker, subtitle, bullets)
# 页脚
page_no = data.get('page', 0)
company = data.get('company', '')
if company:
add_page_footer(slide, pack, company, page_no)
def _draw_text_block(slide, pack, left, top, w, h,
kicker, subtitle, bullets):
t = pack.typography
sp = pack.spacing
y = top
if kicker:
k = kicker.upper() if t.uppercase_en_sub else kicker
add_text(slide, pack, k,
left=left, top=y, width=w, height=0.35,
font=t.body_font, font_size=t.page_sub, weight='semibold',
color_key='accent',
tracking=0.2 if t.uppercase_en_sub else 0.05)
y += 0.45
if subtitle:
add_text(slide, pack, subtitle,
left=left, top=y, width=w, height=1.4,
font_size=t.card_title + 6, weight='semibold',
color_key='text_primary',
tracking=t.page_tracking,
leading=1.25)
y += 1.3
for b in bullets:
s = b if isinstance(b, str) else b.get('label', str(b))
add_text(slide, pack, '· ' + s,
left=left, top=y, width=w, height=0.5,
font=t.body_font, font_size=t.body + 1,
color_key='text_secondary',
leading=t.body_leading)
y += 0.42
FILE:scripts/templates/quote_card.py
"""quote_card — 引用金句卡。
一张 slide 就说一句话,大字展示,作者在下。
data:
quote: str 引用的话
author: str (optional) 作者
role: str (optional) 职位/身份
"""
from .helpers import new_slide, add_text, add_hline
def build(prs, pack, data: dict) -> None:
slide = new_slide(prs, pack)
W, H = pack.canvas.width, pack.canvas.height
t = pack.typography
sp = pack.spacing
dec = pack.decoration
quote = data.get('quote', '')
author = data.get('author', '')
role = data.get('role', '')
# 左侧一个大的引号装饰(accent 色)
quote_mark_size = max(96, t.hero)
add_text(slide, pack, '\u201C', # left double quotation mark
left=sp.margin_x_hero - 0.3, top=H * 0.2,
width=1.5, height=1.5,
font_size=quote_mark_size, weight='bold',
color_key='accent',
align='left',
leading=1.0)
# 引文本体(中等大字)
quote_size = max(32, int(t.section * 0.55))
quote_top = H * 0.35
add_text(slide, pack, quote,
left=sp.margin_x_hero, top=quote_top,
width=W - 2 * sp.margin_x_hero, height=H * 0.4,
font_size=quote_size, weight='semibold',
color_key='text_primary',
align='left',
tracking=t.page_tracking,
leading=1.25)
# 分隔线(装饰)
line_top = H * 0.78
add_hline(slide, pack, sp.margin_x_hero, line_top, 0.5,
color_key='accent', thickness=0.02)
# 作者 + 职位
if author:
author_top = line_top + 0.2
add_text(slide, pack, '— ' + author,
left=sp.margin_x_hero, top=author_top,
width=W - 2 * sp.margin_x_hero, height=0.4,
font_size=t.card_title, weight='semibold',
color_key='text_primary',
align='left')
if role:
role_top = author_top + 0.4
add_text(slide, pack, role,
left=sp.margin_x_hero + 0.25, top=role_top,
width=W - 2 * sp.margin_x_hero, height=0.3,
font=t.body_font, font_size=t.caption + 2,
color_key='text_tertiary',
align='left')
FILE:scripts/templates/section_divider.py
"""section_divider — 分章大字页。
data:
number: str (optional) "01" / "02" 章节号
title: str 章节主标题
subtitle: str (optional) 副标题
"""
from .helpers import (
new_slide, add_text, add_hline, fit_font_size,
apply_text_gradient, add_glow_halo,
)
def build(prs, pack, data: dict) -> None:
slide = new_slide(prs, pack)
W, H = pack.canvas.width, pack.canvas.height
t = pack.typography
sp = pack.spacing
dec = pack.decoration
number = data.get('number')
title = data.get('title', '')
subtitle = data.get('subtitle', '')
# 1. 先算每一块的实际高度
number_size = max(56, t.section - 20) if number else 0
number_h = (number_size / 72.0) * 1.2 if number else 0
number_gap = 0.2 if number else 0
sec_w = W - 2 * sp.margin_x_hero
sec_size = fit_font_size(title, sec_w, t.section,
min_size_pt=36, max_lines=1)
title_h = (sec_size / 72.0) * (t.hero_leading + 0.1) # 留一点
title_gap = 0.3 if subtitle else 0
sub_size = t.page_title - 8 if subtitle else 0
sub_h = (sub_size / 72.0) * 1.4 if subtitle else 0
total_h = number_h + number_gap + title_h + title_gap + sub_h
# 垂直居中整体
y0 = (H - total_h) / 2
# 2. 章节号
y = y0
if number:
# 科技风 halo
if dec.glow_accent:
align = dec.cover_hero_align
if align == 'center':
halo_cx = W / 2
elif align == 'left':
halo_cx = sp.margin_x_hero + number_size / 72.0 * 0.5
else:
halo_cx = W - sp.margin_x_hero - number_size / 72.0 * 0.5
halo_cy = y + number_h / 2
add_glow_halo(slide, pack, halo_cx, halo_cy,
radius=number_size / 72.0 * 1.2,
color_key='accent', layers=4,
strength=dec.glow_strength * 0.8)
num_tb = add_text(slide, pack, number,
left=sp.margin_x_hero, top=y,
width=sec_w, height=number_h,
font_size=number_size,
weight='semibold',
color_key='accent',
align=dec.cover_hero_align,
tracking=-0.02,
leading=1.0)
# 渐变文字
if dec.accent_gradient is not None:
runs = num_tb.text_frame.paragraphs[0].runs
if runs:
apply_text_gradient(runs[0], dec.accent_gradient[0], dec.accent_gradient[1], angle_deg=0)
y += number_h + number_gap
# 3. 主标题
add_text(slide, pack, title,
left=sp.margin_x_hero, top=y,
width=sec_w, height=title_h,
font_size=sec_size, weight=t.section_weight,
color_key='text_primary',
align=dec.cover_hero_align,
tracking=t.section_tracking,
leading=t.hero_leading)
y += title_h + title_gap
# 4. 副标题
if subtitle:
add_text(slide, pack, subtitle,
left=sp.margin_x_hero, top=y,
width=sec_w, height=sub_h,
font_size=sub_size, weight='regular',
color_key='text_secondary',
align=dec.cover_hero_align)
# 5. 底部小横线(Apple 暗场会有)
if pack.name == 'apple-keynote':
add_hline(slide, pack, W / 2 - 0.8, H - 1.2, 1.6,
color_key='accent', thickness=0.025)
FILE:scripts/templates/timeline.py
"""timeline — 时间线页。
横向时间线,若干事件节点。
data:
title: str
en_sub: str (optional)
events: list[dict] 每个 dict:
time: str ("2024", "Q1", "Jan 2025")
label: str
desc: str (optional)
"""
from .helpers import (
new_slide, add_text, add_oval, add_hline, add_rect,
add_page_header, add_page_footer,
)
def build(prs, pack, data: dict) -> None:
slide = new_slide(prs, pack)
W, H = pack.canvas.width, pack.canvas.height
t = pack.typography
sp = pack.spacing
title = data.get('title', '')
en_sub = data.get('en_sub', '')
y_top = add_page_header(slide, pack, title, en_sub)
events = data.get('events', [])
if not events:
return
n = len(events)
# 主线 Y 位置(竖向居中)
mid_y = y_top + (H - y_top - 1.0) * 0.5
# 水平轴线
axis_left = sp.margin_x
axis_right = W - sp.margin_x
axis_w = axis_right - axis_left
add_hline(slide, pack, axis_left, mid_y, axis_w,
color_key='border', thickness=0.015)
# 每个节点的位置
if n == 1:
xs = [axis_left + axis_w * 0.5]
else:
xs = [axis_left + axis_w * (i / (n - 1)) for i in range(n)]
dot_d = 0.3
for i, ev in enumerate(events):
cx = xs[i]
time_s = ev.get('time', '')
label = ev.get('label', '')
desc = ev.get('desc', '')
# 圆点(实心 accent)
add_oval(slide, pack,
cx - dot_d / 2, mid_y - dot_d / 2,
dot_d, dot_d,
fill_key='accent')
# 交替上/下
above = (i % 2 == 0)
box_w = min(axis_w / max(n, 1) - 0.2, 2.6)
if above:
# 时间在上,label + desc 在再上方
time_top = mid_y - 0.55
label_top = mid_y - 1.2
desc_top = mid_y - 1.8
else:
time_top = mid_y + 0.25
label_top = mid_y + 0.7
desc_top = mid_y + 1.3
# 时间
add_text(slide, pack, time_s,
left=cx - box_w / 2, top=time_top,
width=box_w, height=0.3,
font=t.body_font, font_size=t.caption + 2, weight='semibold',
color_key='accent',
align='center',
tracking=0.05)
# label
add_text(slide, pack, label,
left=cx - box_w / 2, top=label_top,
width=box_w, height=0.5,
font_size=t.card_title - 2, weight=t.card_weight,
color_key='text_primary',
align='center',
leading=1.2)
# 描述
if desc:
add_text(slide, pack, desc,
left=cx - box_w / 2, top=desc_top,
width=box_w, height=0.6,
font=t.body_font, font_size=t.caption + 1,
color_key='text_tertiary',
align='center',
leading=t.body_leading)
# 页脚
page_no = data.get('page', 0)
company = data.get('company', '')
if company:
add_page_footer(slide, pack, company, page_no)
JavaScript渲染网站抓取工具。当需要抓取JS渲染的页面(如企微文档、Vue/React SPA)、企查查企业数据获取)、绕过反爬、或者普通curl/wget/web_fetch无法获取内容的网站时使用此技能。支持Playwright和scrapling双引擎自动切换。
---
name: huo15-js-scraper
description: JavaScript渲染网站抓取工具。当需要抓取JS渲染的页面(如企微文档、Vue/React SPA)、企查查企业数据获取)、绕过反爬、或者普通curl/wget/web_fetch无法获取内容的网站时使用此技能。支持Playwright和scrapling双引擎自动切换。
identifier: huo15-js-scraper
version: 1.2.1
author: 贾维斯
category: web-scraping
---
# huo15-js-scraper
JavaScript渲染网站抓取技能,支持Playwright和scrapling双引擎。
## 快速使用
```bash
# 基本用法(自动选择引擎)
python3 ~/.openclaw/workspace/skills/huo15-js-scraper/scripts/scrape.py <URL>
# 指定选择器
python3 ~/.openclaw/workspace/skills/huo15-js-scraper/scripts/scrape.py <URL> --selector ".content"
# 输出JSON
python3 ~/.openclaw/workspace/skills/huo15-js-scraper/scripts/scrape.py <URL> --output json
# 强制使用scrapling引擎
python3 ~/.openclaw/workspace/skills/huo15-js-scraper/scripts/scrape.py <URL> --engine scrapling
```
## 引擎选择策略
| 场景 | 推荐引擎 |
|------|---------|
| 企微文档 / 微信相关 | Playwright |
| Cloudflare保护站 | scrapling (stealth) |
| Vue/React SPA | Playwright |
| 简单静态页 | scrapling (basic) |
| 未知站 | Playwright(更稳定) |
## Python API
```python
from huo15_js_scraper import scrape
# 方式1:自动选择(推荐)
result = scrape('https://example.com')
print(result['content'])
# 方式2:强制Playwright
result = scrape('https://developer.work.weixin.qq.com/document/path/91756', engine='playwright')
```
## 企业微信文档知识库
已构建完整的企微官方文档知识库,位于:
`~/workspace/knowledge-base/企业微信文档/`
### 知识库结构
```
企业微信文档/
├── README.md (索引)
├── 01-快速入门/ - 开发前必读
├── 02-服务端API/ - 通讯录、消息、客户联系、企业支付...
├── 03-客户端API/ - 小程序API、JS-SDK
├── 04-工具资源/ - WeUI、错误码、频率限制
└── 99-附录/ - FAQ、更新日志
```
### 更新企微文档知识库
```bash
# 列出所有可抓取文档
python3 ~/.openclaw/workspace/skills/huo15-js-scraper/scripts/wecom_docs_scraper.py --list
# 抓取单个文档
python3 ~/.openclaw/workspace/skills/huo15-js-scraper/scripts/wecom_docs_scraper.py --path-id 90556 --category "01-快速入门" --title "快速入门"
# 批量抓取(更新全部52个文档)
python3 ~/.openclaw/workspace/skills/huo15-js-scraper/scripts/wecom_docs_scraper.py --all
```
### 核心文档
| 文档 | 路径ID | 说明 |
|------|--------|------|
| 快速入门 | 90556 | 开发前必读 |
| 获取access_token | 91039 | API认证基础 |
| 发送应用消息 | 90235 | 消息推送核心 |
| 创建成员 | 90195 | 通讯录管理 |
| 客户联系概述 | 92109 | 客户管理基础 |
| JS-SDK签名算法 | 90506 | 前端开发必备 |
## 企查查企业数据
企查查(qcc.com)企业信息查询,支持两种方式:
1. **✅ 推荐:MCP方式**(官方API,稳定可靠)
2. **备用:直接抓取**(需要账号登录,有反爬限制)
---
### 推荐方案:企查查MCP(官方API)
企查查提供官方MCP服务,支持 OpenClaw,已封装20+企业查询 SKILL。
**数据规模:**
- 3.65亿+ 市场主体
- 2.5亿+ 司法诉讼
- 2.1亿+ 知识产权
- 1.7亿+ 招投标
**MCP Servers(4个):**
| Server | 别名 | 主要能力 |
|--------|------|---------|
| qcc-company | 企业基座 | 工商登记、股权结构 |
| qcc-risk | 风控大脑 | 34项风险扫描工具 |
| qcc-ipr | 知产引擎 | 专利、商标、软著 |
| qcc-operation | 经营罗盘 | 招投标、资质、舆情 |
**安装步骤:**
```bash
# 1. 注册获取API Key
# 访问 https://agent.qcc.com 注册
# 2. 添加到OpenClaw配置
# 在OpenClaw插件配置中添加企查查MCP服务器
# MCP接入地址: https://agent.qcc.com/mcp
# 需要配置 API Key 认证
```
**预置 SKILL(发送消息给AI即可加载):**
```
请加载并使用这个 SKILL:https://github.com/duhu2000/financial-services-qcc
```
**SKILL命令示例:**
```bash
# KYB企业核验(~30秒)
/kyb-verification-qcc 华为技术有限公司
# IC Memo投资备忘录(~30秒)
/ic-memo-qcc 宁德时代 --round Series-B
# 企业画像速览(~3分钟)
/strip-profile-qcc 美团平台有限公司
# 知识产权尽调
/ip-due-diligence-qcc 企业名称 --peer 竞品
# 供应链风险评估
/supply-chain-risk-qcc 企业名称 --tier 1
# 关联方穿透
/related-party-qcc 企业名称 --depth 5
```
**输出格式:** 支持 `.md` / `.docx` / `.pptx`
---
### 备用方案:直接抓取
如无法使用MCP,可使用直接抓取方式(需要企查查账号)。
#### 安装依赖
```bash
pip3 install playwright --break-system-packages
playwright install chromium
```
#### 登录(首次使用)
```bash
# 生成二维码截图,扫码登录
python3 ~/.openclaw/workspace/skills/huo15-js-scraper/scripts/qichacha_scraper.py --login
```
> 登录后Cookie自动保存到 `~/.cache/huo15-js-scraper/qichacha_cookies.json`
#### 搜索企业
```bash
# 搜索企业
python3 ~/.openclaw/workspace/skills/huo15-js-scraper/scripts/qichacha_scraper.py --search "腾讯" --limit 10
# 输出JSON
python3 ~/.openclaw/workspace/skills/huo15-js-scraper/scripts/qichacha_scraper.py --search "腾讯" --output json
```
#### 企业详情
```bash
# 获取企业详细信息(部分需要VIP)
python3 ~/.openclaw/workspace/skills/huo15-js-scraper/scripts/qichacha_scraper.py --company "https://www.qcc.com/firm/xxxxx.html"
```
#### 返回信息示例
**搜索结果(无需登录可查看基础信息):**
- 公司名称
- 企业状态(开业/存续/吊销)
- 行业分类
- 注册资本
- 法定代表人
**详细信息(可能需要VIP):**
- 工商信息
- 股东信息
- 年报数据
- 风险信息
#### 注意事项
- 企查查搜索功能需要登录才能访问
- 详细信息(如年报、股东)需要VIP账号
- Cookie有效期约7天,过期需重新登录
- 建议设置 `--wait 5` 等待页面渲染
## 常见问题
### Q: 企微文档怎么抓?
```bash
python3 ~/.openclaw/workspace/skills/huo15-js-scraper/scripts/scrape.py \
"https://developer.work.weixin.qq.com/document/path/91756" \
--wait 5
```
### Q: 提示playwright未安装?
```bash
pip3 install playwright --break-system-packages
playwright install chromium
```
### Q: scrapling安装?
```bash
pip3 install "scrapling[all]" --break-system-packages
scrapling install
```
### Q: 内容为空或获取到跳转页面?
增加 `--wait` 时间,让JS有更多时间渲染:
```bash
python3 ...scrape.py <URL> --wait 5
```
## 依赖安装
```bash
# Playwright(主引擎)
pip3 install playwright --break-system-packages
playwright install chromium
# scrapling(降级引擎)
pip3 install "scrapling[all]" --break-system-packages
scrapling install
```
## 工作原理
1. 优先使用 Playwright(chromium headless)加载页面,等待networkidle
2. 等待指定时间让JS渲染完成
3. 通过CSS选择器提取内容
4. 如果Playwright失败,自动降级到scrapling
FILE:_meta.json
{
"ownerId": "kn7byevkn40d6z4p7ghdb097z983tj33",
"slug": "huo15-js-scraper",
"version": "1.2.1"
}
FILE:scripts/qichacha_scraper.py
#!/usr/bin/env python3
"""
企查查 (Qichacha) 数据抓取模块 v1.1
支持二维码登录、Cookie管理、企业信息抓取
依赖:
pip3 install playwright --break-system-packages
playwright install chromium
用法:
# 登录(生成二维码截图)
python3 qichacha_scraper.py --login
# 搜索企业
python3 qichacha_scraper.py --search "腾讯"
# 抓取企业详情
python3 qichacha_scraper.py --company "https://www.qcc.com/firm/xxxxx.html"
推荐方案:
企查查MCP(官方API)需要先在 https://agent.qcc.com 注册获取API Key,
然后配置到OpenClaw插件中使用。
"""
import argparse
import json
import os
import sys
import time
from pathlib import Path
# Cookie存储路径
COOKIE_DIR = Path.home() / ".cache" / "huo15-js-scraper"
COOKIE_FILE = COOKIE_DIR / "qichacha_cookies.json"
QRCODE_FILE = COOKIE_DIR / "qichacha_qrcode.png"
QRCODE_TIMESTAMP = COOKIE_DIR / "qrcode_timestamp.txt"
def ensure_dirs():
"""确保目录存在"""
COOKIE_DIR.mkdir(parents=True, exist_ok=True)
def save_cookies(context):
"""保存登录Cookie"""
ensure_dirs()
cookies = context.cookies()
with open(COOKIE_FILE, 'w') as f:
json.dump(cookies, f)
print(f"Cookie已保存到: {COOKIE_FILE}")
def load_cookies():
"""加载已保存的Cookie"""
if COOKIE_FILE.exists():
with open(COOKIE_FILE, 'r') as f:
return json.load(f)
return None
def is_cookie_valid():
"""检查Cookie是否有效"""
cookies = load_cookies()
if not cookies:
return False
# 检查关键Cookie是否存在
cookie_names = [c['name'] for c in cookies]
has_auth = any('qcc' in name.lower() or 'token' in name.lower() or 'session' in name.lower() for name in cookie_names)
return has_auth
def login_with_qrcode():
"""二维码登录,返回截图路径"""
ensure_dirs()
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch(headless=False)
context = browser.new_context(
user_agent='Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
viewport={'width': 1280, 'height': 800}
)
page = context.new_page()
# 访问登录页
print('访问企查查登录页...')
page.goto("https://www.qcc.com/weblogin", wait_until="domcontentloaded", timeout=20000)
time.sleep(3)
print(f'页面标题: {page.title()}')
print(f'当前URL: {page.url}')
# 截图登录页
page.screenshot(path=str(COOKIE_DIR / 'qichacha_login.png'), full_page=False)
print(f'\\n登录页截图已保存: {COOKIE_DIR / "qichacha_login.png"}')
# 尝试提取QR码
try:
# 等待QR码加载
page.wait_for_selector('.qrcode-img img, .qr-code img, canvas', timeout=5000)
# 查找QR码img
qr_img = page.locator('.qrcode-img img, .qr-code img').first
if qr_img.count() > 0:
src = qr_img.get_attribute('src')
if src:
print(f'找到QR码图片src: {src[:50]}...')
qr_img.screenshot(path=str(QRCODE_FILE))
print(f'QR码截图已保存: {QRCODE_FILE}')
# 查找canvas
canvases = page.locator('canvas').all()
if canvases:
print(f'找到 {len(canvases)} 个canvas元素')
except Exception as e:
print(f'提取QR码失败: {e}')
print('\\n' + '='*50)
print('请用企查查APP扫码登录!')
print('='*50)
print(f'\\n截图位置: {COOKIE_DIR / "qichacha_login.png"}')
print('请用手机扫码登录后告诉我,我会保存Cookie')
print('\\n等待扫码确认...(最多5分钟)')
# 等待扫码登录(最多5分钟)
max_wait = 300
start_time = time.time()
while time.time() - start_time < max_wait:
time.sleep(3)
# 检查URL变化
if 'weblogin' not in page.url:
print('\\n✅ 登录成功! (URL变化检测)')
save_cookies(context)
browser.close()
return True
# 检查cookies
cookies = context.cookies()
if any(c['name'] == 'qcc_c' for c in cookies):
print('\\n✅ 登录成功! (Cookie检测)')
save_cookies(context)
browser.close()
return True
# 每30秒提示一次
elapsed = int(time.time() - start_time)
if elapsed % 30 == 0 and elapsed > 0:
print(f' 等待中... ({elapsed}秒)')
print('\\n❌ 登录超时')
browser.close()
return False
def search_company(keyword, limit=10):
"""搜索企业"""
from playwright.sync_api import sync_playwright
cookies = load_cookies()
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
context = browser.new_context(
user_agent='Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
)
if cookies:
context.add_cookies(cookies)
page = context.new_page()
# 访问搜索页
search_url = f"https://www.qcc.com/web/search?key={keyword}"
page.goto(search_url, wait_until="domcontentloaded", timeout=20000)
time.sleep(5)
# 检查是否跳转到了登录页
if 'weblogin' in page.url:
browser.close()
return {
'error': '需要登录',
'message': '请先运行 --login 命令扫码登录',
'qrcode_file': str(COOKIE_DIR / 'qichacha_login.png')
}
results = []
# 提取搜索结果
# 企查查的搜索结果结构
try:
# 方法1: 查找公司列表项
items = page.locator('.search-result li, .company-list .item, .nsearch-list .item, [class*="company"]').all()
for item in items[:limit]:
try:
# 公司名称
name_el = item.locator('.company-name, .name, h3 a, [class*="name"]').first
name = name_el.inner_text() if name_el.count() > 0 else ""
# 状态
status_el = item.locator('.status, [class*="status"]').first
status = status_el.inner_text() if status_el.count() > 0 else ""
# 法人
legal_el = item.locator('.legal, [class*="legal"], .fr, [class*="person"]').first
legal = legal_el.inner_text() if legal_el.count() > 0 else ""
# 资本
capital_el = item.locator('.capital, [class*="capital"]').first
capital = capital_el.inner_text() if capital_el.count() > 0 else ""
# 链接
link_el = item.locator('a').first
href = link_el.get_attribute('href') if link_el.count() > 0 else ""
if name:
results.append({
'name': name.strip(),
'status': status.strip(),
'legal_person': legal.strip(),
'capital': capital.strip(),
'url': f"https://www.qcc.com{href}" if href and not href.startswith('http') else href
})
except Exception as e:
continue
except Exception as e:
pass
# 如果没找到,尝试提取页面文本
if not results:
body_text = page.locator('body').inner_text()
results = [{'raw_text': body_text[:2000], 'note': '需要登录查看完整数据'}]
browser.close()
return results
def get_company_detail(company_url):
"""获取企业详细信息"""
from playwright.sync_api import sync_playwright
cookies = load_cookies()
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
context = browser.new_context(
user_agent='Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
)
if cookies:
context.add_cookies(cookies)
page = context.new_page()
page.goto(company_url, wait_until="domcontentloaded", timeout=20000)
time.sleep(5)
# 检查登录
if 'weblogin' in page.url:
browser.close()
return {'error': '需要登录', 'message': '请先运行 --login 命令'}
data = {
'url': page.url,
'title': page.title(),
'需要登录': False,
'basic_info': {}
}
# 提取基本信息
try:
# 基础信息区域
base_info = page.locator('.company-detail, #company-detail, .base-info').first
if base_info.count() > 0:
data['basic_info']['html'] = base_info.inner_html()
data['basic_info']['text'] = base_info.inner_text()
except:
pass
# 检查是否需要VIP
try:
vip_tip = page.locator('.vip-tip, .login-tip, [class*="vip"]').first
if vip_tip.count() > 0:
data['需要登录'] = True
data['vip_tip'] = vip_tip.inner_text()
except:
pass
browser.close()
return data
def main():
parser = argparse.ArgumentParser(
description='企查查数据抓取工具\n\n推荐方案:企查查MCP(官方API)\n 访问 https://agent.qcc.com 注册获取API Key\n 然后配置到OpenClaw插件中使用',
formatter_class=argparse.RawDescriptionHelpFormatter
)
parser.add_argument('--login', action='store_true', help='二维码登录')
parser.add_argument('--search', type=str, help='搜索企业')
parser.add_argument('--company', type=str, help='企业详情URL')
parser.add_argument('--limit', type=int, default=10, help='搜索结果数量限制')
parser.add_argument('--output', '-o', choices=['text', 'json'], default='text')
args = parser.parse_args()
if args.login:
success = login_with_qrcode()
if success:
print('\\n✅ 登录成功! Cookie已保存。')
print('现在可以运行 --search 进行搜索了。')
else:
print('\\n❌ 登录失败或超时。')
print(f'\\nQR码截图: {COOKIE_DIR / "qichacha_login.png"}')
sys.exit(0 if success else 1)
elif args.search:
results = search_company(args.search, args.limit)
if isinstance(results, dict) and 'error' in results:
print(f"错误: {results['error']}")
print(f"提示: {results['message']}")
if 'qrcode_file' in results:
print(f"QR码截图: {results['qrcode_file']}")
elif args.output == 'json':
print(json.dumps(results, ensure_ascii=False, indent=2))
else:
print(f"搜索 \"{args.search}\" 找到 {len(results)} 个结果:")
for i, r in enumerate(results, 1):
if 'raw_text' in r:
print(r['raw_text'])
else:
print(f"\\n{i}. {r.get('name', 'N/A')}")
if r.get('status'): print(f" 状态: {r['status']}")
if r.get('legal_person'): print(f" 法人: {r['legal_person']}")
if r.get('capital'): print(f" 资本: {r['capital']}")
if r.get('url'): print(f" URL: {r['url']}")
elif args.company:
detail = get_company_detail(args.company)
if args.output == 'json':
print(json.dumps(detail, ensure_ascii=False, indent=2))
else:
print(f"标题: {detail.get('title', 'N/A')}")
print(f"URL: {detail.get('url', 'N/A')}")
print(f"需要VIP: {detail.get('需要登录', False)}")
if detail.get('basic_info', {}).get('text'):
print(f"\\n基本信息:\\n{detail['basic_info']['text'][:1000]}")
else:
parser.print_help()
print('\\n' + '='*50)
print('推荐方案:企查查MCP(官方API)')
print(' 访问 https://agent.qcc.com 注册获取API Key')
print(' 然后配置到OpenClaw插件中使用')
print('='*50)
if __name__ == '__main__':
main()
FILE:scripts/scrape.py
#!/usr/bin/env python3
"""
huo15-js-scraper - JavaScript渲染网站抓取工具
基于Playwright,支持stealth模式和scrapling降级
"""
import argparse
import json
import sys
import time
from pathlib import Path
def scrape_with_playwright(url, selector=None, wait=5, headless=True, output_format='text'):
"""使用Playwright抓取JS渲染页面"""
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch(headless=headless)
page = browser.new_page()
# 设置User-Agent
page.set_extra_http_headers({
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
})
page.goto(url, wait_until='networkidle')
time.sleep(wait)
if selector:
content = page.locator(selector).inner_text()
else:
content = page.locator('body').inner_text()
result = {
'url': page.url,
'title': page.title(),
'content': content,
'engine': 'playwright'
}
browser.close()
return result
def scrape_with_scrapling(url, selector=None, mode='dynamic', wait=3):
"""使用scrapling抓取(降级方案)"""
try:
from scrapling.fetchers import DynamicFetcher
page = DynamicFetcher.fetch(url, headless=True, network_idle=True)
time.sleep(wait)
if selector:
content = ''.join(page.css(f'{selector} *::text').getall())
else:
content = ''.join(page.css('body *::text').getall())
return {
'url': page.url,
'title': page.css('title::text').get() or '',
'content': content,
'engine': 'scrapling'
}
except Exception as e:
return {
'url': url,
'error': str(e),
'engine': 'scrapling'
}
def main():
parser = argparse.ArgumentParser(description='JS渲染网站抓取工具')
parser.add_argument('url', help='目标URL')
parser.add_argument('--selector', '-s', help='CSS选择器')
parser.add_argument('--wait', '-w', type=int, default=3, help='等待秒数')
parser.add_argument('--engine', '-e', choices=['playwright', 'scrapling', 'auto'], default='auto')
parser.add_argument('--output', '-o', choices=['text', 'json'], default='text')
parser.add_argument('--stealth', action='store_true', help='隐身模式')
args = parser.parse_args()
# 自动选择引擎
if args.engine == 'auto':
# 优先playwright,更稳定
try:
result = scrape_with_playwright(args.url, args.selector, args.wait)
except Exception as e:
print(f"Playwright失败,尝试scrapling: {e}", file=sys.stderr)
result = scrape_with_scrapling(args.url, args.selector)
elif args.engine == 'playwright':
result = scrape_with_playwright(args.url, args.selector, args.wait)
else:
result = scrape_with_scrapling(args.url, args.selector)
# 输出
if args.output == 'json':
print(json.dumps(result, ensure_ascii=False, indent=2))
else:
print(f"URL: {result.get('url', 'N/A')}")
print(f"Title: {result.get('title', 'N/A')}")
print(f"Engine: {result.get('engine', 'N/A')}")
print("-" * 50)
print(result.get('content', result.get('error', 'No content')))
if __name__ == '__main__':
main()
FILE:scripts/wecom_docs_scraper.py
#!/usr/bin/env python3
"""
企业微信官方文档抓取脚本
基于 Playwright,系统抓取并整理成 Markdown 知识库
"""
import argparse
import json
import time
import re
from pathlib import Path
from datetime import datetime
from playwright.sync_api import sync_playwright
BASE_URL = "https://developer.work.weixin.qq.com"
OUTPUT_DIR = Path.home() / "workspace" / "knowledge-base" / "企业微信文档"
# 文档分类映射
CATEGORIES = {
# 快速入门
"90556": ("01-快速入门", "快速入门"),
"90487": ("01-快速入门", "简易教程"),
"90665": ("01-快速入门", "基本概念"),
# 服务端API - 通讯录
"90193": ("02-服务端API/通讯录管理", "成员管理概述"),
"90195": ("02-服务端API/通讯录管理", "创建成员"),
"90196": ("02-服务端API/通讯录管理", "读取成员"),
"90197": ("02-服务端API/通讯录管理", "更新成员"),
"90198": ("02-服务端API/通讯录管理", "删除成员"),
"90205": ("02-服务端API/通讯录管理", "创建部门"),
"90208": ("02-服务端API/通讯录管理", "获取部门列表"),
# 服务端API - 身份验证
"91039": ("02-服务端API/身份验证", "获取access_token"),
"90930": ("02-服务端API/身份验证", "回调配置"),
"91022": ("02-服务端API/身份验证", "构造网页授权链接"),
"91023": ("02-服务端API/身份验证", "获取访问用户身份"),
# 服务端API - 消息推送
"90235": ("02-服务端API/消息推送", "发送应用消息"),
"90238": ("02-服务端API/消息推送", "消息格式"),
"90244": ("02-服务端API/消息推送", "群聊会话管理"),
# 服务端API - 应用管理
"90226": ("02-服务端API/应用管理", "应用管理概述"),
"90227": ("02-服务端API/应用管理", "获取应用"),
"90228": ("02-服务端API/应用管理", "设置应用"),
# 服务端API - 素材管理
"91054": ("02-服务端API/素材管理", "临时素材"),
# 服务端API - 客户联系
"92109": ("02-服务端API/客户联系", "客户联系概述"),
"92113": ("02-服务端API/客户联系", "获取客户列表"),
"92114": ("02-服务端API/客户联系", "获取客户详情"),
"92117": ("02-服务端API/客户联系", "管理企业标签"),
# 服务端API - 企业支付
"90273": ("02-服务端API/企业支付", "企业红包"),
"90278": ("02-服务端API/企业支付", "向员工付款"),
# 服务端API - 会话内容存档
"91360": ("02-服务端API/会话存档", "会话内容存档"),
"99941": ("02-服务端API/会话存档", "会话内容存档概述"),
# 客户端API - 小程序
"91506": ("03-客户端API/小程序", "wx.qy.login"),
"91519": ("03-客户端API/小程序", "wx.qy.openEnterpriseChat"),
"90513": ("03-客户端API/小程序", "小程序JS-SDK概述"),
# 客户端API - JS-SDK
"90506": ("03-客户端API/JS-SDK", "JS-SDK签名算法"),
"90508": ("03-客户端API/JS-SDK", "所有菜单项列表"),
# 工具与资源
"90305": ("04-工具资源", "样式库WeUI"),
"90312": ("04-工具资源", "访问频率限制"),
"90313": ("04-工具资源", "全局错误码"),
# 附录
"90968": ("99-附录", "附录"),
"93221": ("99-附录", "更新日志"),
"90623": ("99-附录", "联系我们"),
}
# 额外要抓取的文档ID(按类别)
EXTRA_DOCS = [
# 客户端API - 小程序
("90513", "03-客户端API/小程序", "小程序JS-SDK概述"),
("90506", "03-客户端API/JS-SDK", "JS-SDK签名算法"),
("90508", "03-客户端API/JS-SDK", "所有菜单项列表"),
("90509", "03-客户端API/JS-SDK", "常见错误及解决方法"),
# 更多服务端API
("90283", "02-服务端API/电子发票", "电子发票概述"),
("90284", "02-服务端API/电子发票", "查询电子发票"),
# 会话存档
("99968", "02-服务端API/会话存档", "获取会话记录"),
("99992", "02-服务端API/会话存档", "会话存档回调事件"),
# 客户联系
("92120", "02-服务端API/客户联系", "获取客户群列表"),
("92122", "02-服务端API/客户联系", "获取客户群详情"),
("92135", "02-服务端API/客户联系", "创建企业群发"),
# 企业支付
("93665", "02-服务端API/企业支付", "对外收款概述"),
# 附录
("90311", "99-附录", "与企业号接口差异"),
("90314", "99-附录", "企业规模与行业信息"),
("90315", "99-附录", "常见问题FAQ"),
]
def scrape_doc(path_id, timeout=30):
"""抓取单个文档"""
url = f"{BASE_URL}/document/path/{path_id}"
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page()
page.set_extra_http_headers({
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36'
})
try:
page.goto(url, wait_until='networkidle', timeout=timeout * 1000)
time.sleep(3) # 等待JS渲染
# 获取标题
title = page.title().replace(' - 文档 - 企业微信开发者中心', '').strip()
# 获取正文内容
content = page.locator('body').inner_text()
# 获取URL(可能有跳转)
final_url = page.url
browser.close()
return {
'path_id': path_id,
'url': final_url,
'title': title,
'content': content
}
except Exception as e:
browser.close()
return {
'path_id': path_id,
'error': str(e)
}
def save_doc(doc_info, category_dir, title):
"""保存文档为Markdown"""
if 'error' in doc_info:
print(f" ❌ {title}: {doc_info['error']}")
return False
# 构建文件路径
safe_title = re.sub(r'[<>:"/\\|?*]', '_', title)
file_path = category_dir / f"{safe_title}.md"
# 构建Markdown内容
md_content = f"""---
title: {title}
path_id: {doc_info['path_id']}
url: {doc_info['url']}
scrape_time: {datetime.now().isoformat()}
category: {category_dir.name}
---
# {title}
> 原文地址: {doc_info['url']}
## 内容
{doc_info['content']}
---
*最后抓取时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}*
"""
# 写入文件
with open(file_path, 'w', encoding='utf-8') as f:
f.write(md_content)
print(f" ✅ {title}")
return True
def main():
parser = argparse.ArgumentParser(description='企业微信官方文档抓取工具')
parser.add_argument('--path-id', '-p', help='指定文档ID')
parser.add_argument('--list', '-l', action='store_true', help='列出所有文档')
parser.add_argument('--all', '-a', action='store_true', help='抓取所有文档')
parser.add_argument('--category', '-c', help='指定分类目录')
parser.add_argument('--title', '-t', help='文档标题')
args = parser.parse_args()
# 确保目录存在
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
for subdir in ["02-服务端API/通讯录管理", "02-服务端API/身份验证",
"02-服务端API/消息推送", "02-服务端API/应用管理",
"02-服务端API/素材管理", "02-服务端API/客户联系",
"02-服务端API/企业支付", "02-服务端API/会话存档",
"02-服务端API/电子发票", "03-客户端API/小程序",
"03-客户端API/JS-SDK", "04-工具资源"]:
(OUTPUT_DIR / subdir).mkdir(parents=True, exist_ok=True)
# 列出所有文档
if args.list:
print(f"共 {len(CATEGORIES) + len(EXTRA_DOCS)} 个已配置文档:\n")
for path_id, (cat, title) in list(CATEGORIES.items())[:20]:
print(f" {path_id}: {title} ({cat})")
if len(CATEGORIES) > 20:
print(f" ... 还有 {len(CATEGORIES) - 20} 个")
return
# 抓取指定文档
if args.path_id:
if args.category and args.title:
category_dir = OUTPUT_DIR / args.category
category_dir.mkdir(parents=True, exist_ok=True)
else:
if args.path_id in CATEGORIES:
category_dir = OUTPUT_DIR / CATEGORIES[args.path_id][0]
else:
category_dir = OUTPUT_DIR / "99-附录"
print(f"抓取文档 {args.path_id}...")
doc_info = scrape_doc(args.path_id)
title = args.title or (CATEGORIES.get(args.path_id, ['', ''])[1] if args.path_id in CATEGORIES else '未命名')
save_doc(doc_info, category_dir, title)
return
# 抓取所有文档
if args.all:
print(f"开始抓取企微文档,共 {len(CATEGORIES) + len(EXTRA_DOCS)} 个...\n")
# 合并所有文档
all_docs = {}
for path_id, (cat, title) in CATEGORIES.items():
all_docs[path_id] = (cat, title)
for path_id, cat, title in EXTRA_DOCS:
all_docs[path_id] = (cat, title)
success = 0
failed = 0
for i, (path_id, (cat, title)) in enumerate(all_docs.items()):
print(f"[{i+1}/{len(all_docs)}] 抓取 {title}...")
category_dir = OUTPUT_DIR / cat
category_dir.mkdir(parents=True, exist_ok=True)
doc_info = scrape_doc(path_id)
if save_doc(doc_info, category_dir, title):
success += 1
else:
failed += 1
time.sleep(1) # 避免请求过快
print(f"\n完成!成功: {success}, 失败: {failed}")
return
# 生成索引
print("生成知识库索引...")
generate_index()
print("完成!")
def generate_index():
"""生成知识库索引"""
index_content = """# 企业微信官方文档知识库
> 基于官方文档自动构建,包含服务端API、客户端API、工具资源等
## 目录结构
"""
# 遍历目录
for section_dir in sorted(OUTPUT_DIR.iterdir()):
if section_dir.is_dir():
index_content += f"\n### {section_dir.name}\n\n"
for sub_dir in sorted(section_dir.iterdir()):
if sub_dir.is_dir():
index_content += f"- **{sub_dir.name}**\n"
for md_file in sorted(sub_dir.glob("*.md")):
title = md_file.stem
index_content += f" - [{title}]({md_file.relative_to(OUTPUT_DIR)})\n"
else:
if sub_dir.suffix == '.md':
title = sub_dir.stem
index_content += f"- [{title}]({sub_dir.relative_to(OUTPUT_DIR)})\n"
# 写入索引
with open(OUTPUT_DIR / "README.md", 'w', encoding='utf-8') as f:
f.write(index_content)
if __name__ == '__main__':
main()
SearXNG 自托管搜索引擎一键部署 - Docker Compose + OpenClaw 配置自动化 (v1.2.1)
---
name: huo15-searxng
version: 1.2.1
description: SearXNG 自托管搜索引擎一键部署 - Docker Compose + OpenClaw 配置自动化 (v1.2.1)
homepage: https://github.com/zhaobod1/huo15-skills
metadata:
openclaw:
emoji: "🔍"
requires:
bins: ["docker", "nc"]
---
# huo15-searxng
SearXNG 自托管搜索引擎一键部署技能。
## 触发词
- "安装 SearXNG"、"部署 SearXNG"
- "searxng"、"SearXNG"
- "自托管搜索"、"私有搜索"
- "搭建搜索"
## 功能
当用户请求安装或部署 SearXNG 时,执行 `scripts/install.sh` 进行自动化部署:
1. 检查 Docker 和 docker compose 环境
2. 检测可用端口(8888 → 8910 自动检测冲突)
3. 一键部署 SearXNG 容器(含 limiter.toml 修复 403 问题)
4. 等待服务就绪并验证
5. 自动配置 OpenClaw 环境变量
## 使用方式
```
@贾维斯 安装 SearXNG
```
## 常用命令
| 命令 | 说明 |
|------|------|
| `bash .../install.sh` | 安装/升级 SearXNG |
| `bash .../status.sh` | 查看运行状态 |
| `bash .../uninstall.sh` | 卸载 SearXNG |
## 技术细节
- SearXNG 镜像:`searxng/searxng:latest`
- 数据目录:`~/docker/searxng/`
- 默认端口:8888(自动检测冲突)
- OpenClaw 配置:`SEARXNG_BASE_URL` 环境变量
## 前提条件
- Docker Desktop (macOS) / Docker Engine (Linux)
- docker compose v2
- `nc` 命令(macOS/Linux 内置)
FILE:README.md
# huo15-searxng
> SearXNG 自托管搜索引擎一键部署 - Docker Compose + OpenClaw 配置自动化 (v1.1.0)
## 功能特性
- 🔍 **一键部署**:Docker Compose 自动部署 SearXNG 实例
- 🔄 **端口冲突自动处理**:8888 → 8889 → 8890 → 8910 自动检测可用端口
- 🔒 **botdetection 修复**:limiter.toml 解决 SearXNG 403 Forbidden 问题
- ⚙️ **OpenClaw 无缝集成**:自动配置 `SEARXNG_BASE_URL` 环境变量
- ✅ **健康检查**:启动后自动验证服务可用性(含 JSON API)
- 🔄 **幂等性**:已安装时自动检测并显示状态,支持升级
## 前提条件
- Docker Desktop (macOS) / Docker Engine (Linux)
- docker compose v2 (`docker compose` 命令)
- `nc` 命令(macOS/Linux 内置)
## 快速开始
### 安装 Skill
```bash
clawhub install huo15-searxng --dir ~/.openclaw/workspace/skills
```
### 部署 SearXNG
```bash
@贾维斯 安装 SearXNG
```
或手动执行安装脚本:
```bash
bash ~/.openclaw/workspace/skills/huo15-searxng/scripts/install.sh
```
## 工作原理
```
安装脚本流程:
1. 检查 Docker + docker compose v2
2. 检测已安装? → 显示状态并退出(幂等性)
3. 端口 8888-8910 可用性检测
4. 渲染 docker-compose.yml + settings.yml + limiter.toml
5. docker compose up -d
6. 健康检查 (HTTP 200) + JSON API 验证
7. 配置 SEARXNG_BASE_URL 到 ~/.zshrc
8. 输出完成信息
```
## 目录结构
```
huo15-searxng/
├── SKILL.md # Skill 定义
├── README.md # 本文档
├── scripts/
│ ├── install.sh # 核心安装脚本 (v1.1.0)
│ ├── uninstall.sh # 卸载脚本 (新增)
│ ├── env.sh # 环境变量加载
│ └── status.sh # 状态检查 (v1.1.0)
└── templates/
└── docker-compose.yml.template
```
## 配置说明
### 环境变量
安装后自动添加以下环境变量到 `~/.zshrc`:
```bash
export SEARXNG_BASE_URL="http://localhost:8888"
```
**立即生效**:
```bash
source ~/.zshrc
```
### OpenClaw 配置
OpenClaw 自动检测 `SEARXNG_BASE_URL` 环境变量,无需手动配置。
如需手动配置,编辑 `~/.openclaw/openclaw.json`:
```json
{
"plugins": {
"entries": {
"searxng": {
"config": {
"webSearch": {
"baseUrl": "http://localhost:8888"
}
}
}
}
}
}
```
## 常用命令
| 操作 | 命令 |
|------|------|
| 安装/升级 | `bash ~/.openclaw/workspace/skills/huo15-searxng/scripts/install.sh` |
| 查看状态 | `bash ~/.openclaw/workspace/skills/huo15-searxng/scripts/status.sh` |
| 查看日志 | `docker logs searxng` |
| 重启服务 | `cd ~/docker/searxng && docker compose restart` |
| 停止服务 | `cd ~/docker/searxng && docker compose down` |
| 卸载 | `bash ~/.openclaw/workspace/skills/huo15-searxng/scripts/uninstall.sh` |
## 端口说明
| 端口 | 说明 |
|------|------|
| 8888 | 默认端口 |
| 8889 | 备用端口 1 |
| 8890 | 备用端口 2 |
| 8910 | 最大检测端口 |
脚本会按顺序检测,找到第一个可用端口为止。
## v1.1.0 更新
- ✅ 修复 `grep -oP` (GNU grep) → `grep -oE` (跨平台)
- ✅ 修复 sed 分隔符问题(用 `|` 代替 `/` 避免 URL 冲突)
- ✅ 新增幂等性检测(已安装时显示状态不重复部署)
- ✅ 新增卸载脚本 `uninstall.sh`
- ✅ 新增 `source ~/.zshrc` 生效提示
- ✅ 增强 curl 超时参数(--connect-timeout, --max-time)
- ✅ 停止旧容器逻辑(升级时清理)
## 故障排除
### Docker 未安装
```bash
# macOS
brew install --cask docker
# Linux (Ubuntu)
curl -fsSL https://get.docker.com | sh
```
### 端口全部占用
手动指定端口后重新安装:
```bash
cd ~/docker/searxng
# 编辑 docker-compose.yml 修改端口
docker compose up -d
# 手动设置环境变量
export SEARXNG_BASE_URL="http://localhost:你指定的端口"
```
### SearXNG 启动失败
```bash
# 查看日志
docker logs searxng
# 调试健康检查
curl -v http://localhost:8888/healthz
# 完整日志
cd ~/docker/searxng && docker compose logs
```
### 环境变量未生效
```bash
source ~/.zshrc
echo $SEARXNG_BASE_URL
```
## 技术参考
- [SearXNG 官方文档](https://docs.searxng.org/)
- [OpenClaw SearXNG 配置](https://docs.openclaw.ai/tools/searxng-search)
- [SearXNG GitHub](https://github.com/searxng/searxng)
## License
MIT
FILE:_meta.json
{
"ownerId": "kn7byevkn40d6z4p7ghdb097z983tj33",
"slug": "huo15-searxng",
"version": "1.2.1",
"publishedAt": 1776003478609
}
FILE:scripts/env.sh
#!/bin/bash
# env.sh — 加载 SearXNG 环境变量
SCRIPT_DIR="$(cd "$(dirname "BASH_SOURCE[0]")" && pwd)"
SKILL_ROOT="$(dirname "$SCRIPT_DIR")"
# 加载 SEARXNG_BASE_URL
if [ -f "$HOME/.zshrc" ]; then
export $(grep "SEARXNG_BASE_URL" "$HOME/.zshrc" | xargs) 2>/dev/null || true
fi
# 默认值
SEARXNG_BASE_URL="-http://localhost:8888"
echo "🔍 SearXNG 环境"
echo " SEARXNG_BASE_URL: $SEARXNG_BASE_URL"
echo " DOCKER_DIR: $HOME/docker/searxng"
FILE:scripts/install.sh
#!/bin/bash
# install.sh — SearXNG 一键部署脚本 v1.1.0
# 功能:Docker Compose 部署 + 端口冲突检测 + OpenClaw 配置
# 修复:grep -oP (GNU) → grep -E + cut (跨平台) | sed 分隔符 | 幂等性
set -e
SCRIPT_DIR="$(cd "$(dirname "BASH_SOURCE[0]")" && pwd)"
SKILL_ROOT="$(dirname "$SCRIPT_DIR")"
TEMPLATES_DIR="$SKILL_ROOT/templates"
DOCKER_DIR="$HOME/docker/searxng"
# 默认配置
DEFAULT_PORT=8888
MAX_PORT=8910
MAX_WAIT=60
# 颜色输出
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
log_info() { echo -e "GREEN[INFO]NC $1"; }
log_warn() { echo -e "YELLOW[WARN]NC $1"; }
log_error() { echo -e "RED[ERROR]NC $1"; }
# ============================================================
# 0. 入口检测:是否已安装
# ============================================================
check_already_installed() {
# 检查容器是否在运行
if docker ps -a 2>/dev/null | grep -q "searxng$"; then
log_warn "SearXNG 容器已在运行"
local current_port=$(docker port searxng 2>/dev/null | grep '8080/tcp' | grep -oE ':[0-9]+$' | tr -d ':' | head -1 || echo "")
if [ -n "$current_port" ]; then
log_info "检测到端口: $current_port"
PORT=$current_port
configure_openclaw
print_status
exit 0
fi
fi
# 检查配置文件是否存在
if [ -f "$DOCKER_DIR/docker-compose.yml" ]; then
log_warn "检测到已有配置,执行升级..."
UPGRADE=true
else
UPGRADE=false
fi
}
# ============================================================
# 1. 检查 Docker 和 docker compose
# ============================================================
check_docker() {
log_info "检查 Docker 环境..."
if ! command -v docker &> /dev/null; then
log_error "Docker 未安装,请先安装 Docker Desktop"
log_info "提示: brew install --cask docker"
exit 1
fi
if ! docker info &> /dev/null; then
log_error "Docker 未运行,请启动 Docker Desktop"
exit 1
fi
if ! command -v docker compose &> /dev/null; then
log_error "docker compose v2 未安装"
log_info "提示: Docker Desktop 已内置 docker compose,无需单独安装"
exit 1
fi
# 跨平台获取版本号 (兼容 macOS grep)
DOCKER_VERSION=$(docker compose version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+' | head -1)
log_info "Docker Compose vDOCKER_VERSION ✓"
}
# ============================================================
# 2. 端口可用性检测
# ============================================================
find_available_port() {
log_info "检测可用端口..."
PORT=$DEFAULT_PORT
while [ $PORT -le $MAX_PORT ]; do
# 跨平台端口检测:nc -z 在 macOS 和 Linux 都支持
if nc -z localhost $PORT 2>/dev/null; then
log_warn "端口 $PORT 已被占用,尝试 $((PORT+1))..."
PORT=$((PORT+1))
else
log_info "✅ 找到可用端口: $PORT"
return 0
fi
done
log_error "未找到可用端口 (8888-$MAX_PORT)"
exit 1
}
# ============================================================
# 3. 创建目录并渲染 docker-compose.yml
# ============================================================
setup_docker() {
log_info "创建 Docker 目录..."
mkdir -p "$DOCKER_DIR/searxng" "$DOCKER_DIR/searxng-data"
# 渲染 docker-compose.yml
log_info "生成 docker-compose.yml..."
cat > "$DOCKER_DIR/docker-compose.yml" << EOF
services:
searxng:
image: searxng/searxng:latest
container_name: searxng
restart: unless-stopped
ports:
- "PORT:8080"
volumes:
- ./searxng:/etc/searxng:rw
- ./searxng-data:/var/lib/searxng:rw
environment:
- SEARXNG_BASE_URL=http://localhost:PORT/
- HTTP_PROXY=""
- HTTPS_PROXY=""
- NO_PROXY=*
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:8080/healthz"]
interval: 10s
timeout: 5s
retries: 3
EOF
# 渲染 settings.yml (启用 JSON API)
log_info "生成 settings.yml..."
SECRET_KEY=$(cat /dev/urandom | tr -dc 'a-z0-9' | fold -w 32 | head -n 1)
cat > "$DOCKER_DIR/searxng/settings.yml" << EOFSETTINGS
search:
default_lang: zh-CN
formats:
- html
- json
server:
secret_key: "SECRET_KEY"
bind_address: "0.0.0.0"
port: 8080
outgoing:
useragent_suffix: ""
pool_connections: 100
pool_maxsize: 20
engines:
- name: google
engine: google
shortcut: g
- name: bing
engine: bing
shortcut: b
- name: duckduckgo
engine: duckduckgo
shortcut: ddg
- name: baidu
engine: baidu
shortcut: bd
EOFSETTINGS
# 创建空的 limiter.toml (解决 botdetection 403 问题)
log_info "生成 limiter.toml..."
touch "$DOCKER_DIR/searxng/limiter.toml"
}
# ============================================================
# 4. 启动 SearXNG
# ============================================================
start_searxng() {
log_info "启动 SearXNG 容器..."
cd "$DOCKER_DIR"
# 停止旧容器(如果存在)
if docker ps -a 2>/dev/null | grep -q "searxng$"; then
log_info "停止旧容器..."
docker compose down --remove-orphans 2>/dev/null || true
fi
docker compose up -d --pull always
log_info "等待服务就绪..."
}
# ============================================================
# 5. 健康检查
# ============================================================
wait_for_searxng() {
local elapsed=0
local interval=3
while [ $elapsed -lt $MAX_WAIT ]; do
# 使用 curl 的 --fail 配合 -o /dev/null 检测 HTTP 状态码
if curl -sf --connect-timeout 3 --max-time 5 "http://localhost:$PORT/healthz" > /dev/null 2>&1; then
log_info "✅ SearXNG 服务已就绪"
return 0
fi
sleep $interval
elapsed=$((elapsed + interval))
echo -n "."
done
echo ""
log_error "SearXNG 启动超时 (MAX_WAITs)"
log_info "查看日志: docker -f searxng logs"
log_info "调试: curl -v http://localhost:$PORT/healthz"
exit 1
}
# ============================================================
# 6. 验证服务
# ============================================================
verify_searxng() {
log_info "验证 SearXNG..."
# 测试主页
if curl -sf --connect-timeout 3 --max-time 5 "http://localhost:$PORT" > /dev/null 2>&1; then
log_info "✅ 主页访问成功"
else
log_error "主页访问失败"
return 1
fi
# 测试 JSON API
local json_response=$(curl -sf --connect-timeout 5 --max-time 10 "http://localhost:$PORT/search?q=test&format=json" 2>/dev/null)
if echo "$json_response" | grep -q '"results"'; then
log_info "✅ JSON API 正常"
else
log_warn "JSON API 响应异常 (主页正常,搜索引擎可能需要配置)"
fi
}
# ============================================================
# 7. 配置 OpenClaw 环境变量
# ============================================================
configure_openclaw() {
log_info "配置 OpenClaw..."
local searxng_url="http://localhost:$PORT"
local env_line="export SEARXNG_BASE_URL=\"$searxng_url\""
# 检查是否已配置(跨平台 sed)
if grep -q "SEARXNG_BASE_URL" "$HOME/.zshrc" 2>/dev/null; then
log_warn "SEARXNG_BASE_URL 已存在,更新..."
# 使用 @ 作为分隔符,避免 URL 中的 / 导致问题
sed -i '' "s|export SEARXNG_BASE_URL=.*|env_line|" "$HOME/.zshrc"
else
echo "" >> "$HOME/.zshrc"
echo "# SearXNG (huo15-searxng)" >> "$HOME/.zshrc"
echo "$env_line" >> "$HOME/.zshrc"
log_info "已添加到 ~/.zshrc"
fi
# 立即生效(仅当前 shell)
export SEARXNG_BASE_URL="$searxng_url"
log_info "✅ SEARXNG_BASE_URL=$searxng_url"
}
# ============================================================
# 8. 输出状态
# ============================================================
print_status() {
echo ""
echo "═══════════════════════════════════════════════════"
echo -e " GREEN🔍 SearXNG 状态NC"
echo "═══════════════════════════════════════════════════"
echo ""
echo " 🔍 访问地址: http://localhost:$PORT"
echo " 📡 API 端点: http://localhost:$PORT/search"
echo " 🔧 配置目录: $DOCKER_DIR"
echo " 📝 SEARXNG_BASE_URL=$SEARXNG_BASE_URL"
echo ""
echo " 常用命令:"
echo " 查看日志: docker logs searxng"
echo " 重启服务: cd $DOCKER_DIR && docker compose restart"
echo " 停止服务: cd $DOCKER_DIR && docker compose down"
echo " 卸载: bash ~/.openclaw/workspace/skills/huo15-searxng/scripts/uninstall.sh"
echo ""
echo " ⚠️ 如需立即生效,请运行: source ~/.zshrc"
echo ""
echo "═══════════════════════════════════════════════════"
}
# ============================================================
# 9. 输出完成信息
# ============================================================
print_complete() {
echo ""
echo "═══════════════════════════════════════════════════"
echo -e " GREEN🎉 SearXNG 部署完成!NC"
echo "═══════════════════════════════════════════════════"
echo ""
echo " 🔍 访问地址: http://localhost:$PORT"
echo " 📡 API 端点: http://localhost:$PORT/search"
echo " 🔧 配置目录: $DOCKER_DIR"
echo ""
echo " 📝 OpenClaw 已配置:"
echo " SEARXNG_BASE_URL=$SEARXNG_BASE_URL"
echo ""
echo " ⚠️ 重要: 请运行以下命令使环境变量立即生效:"
echo " source ~/.zshrc"
echo ""
echo " 常用命令:"
echo " 查看日志: docker logs searxng"
echo " 重启服务: cd $DOCKER_DIR && docker compose restart"
echo " 停止服务: cd $DOCKER_DIR && docker compose down"
echo " 卸载: bash ~/.openclaw/workspace/skills/huo15-searxng/scripts/uninstall.sh"
echo ""
echo "═══════════════════════════════════════════════════"
}
# ============================================================
# 主流程
# ============================================================
main() {
echo ""
echo "🔍 huo15-searxng — SearXNG 一键部署 v1.1.0"
echo "═══════════════════════════════════════════════════"
echo ""
check_already_installed
check_docker
find_available_port
setup_docker
start_searxng
wait_for_searxng
verify_searxng
configure_openclaw
print_complete
}
main "$@"
FILE:scripts/status.sh
#!/bin/bash
# status.sh — 检查 SearXNG 运行状态
SCRIPT_DIR="$(cd "$(dirname "BASH_SOURCE[0]")" && pwd)"
DOCKER_DIR="-$HOME/docker/searxng"
# 加载环境变量
if [ -f "$HOME/.zshrc" ]; then
export $(grep "SEARXNG_BASE_URL" "$HOME/.zshrc" 2>/dev/null | grep -v '^#' | xargs) 2>/dev/null || true
fi
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
echo ""
echo "🔍 SearXNG 状态检查"
echo "═══════════════════════════════════════════════════"
# 1. 容器状态
echo ""
echo "📦 Docker 容器:"
if docker ps -a 2>/dev/null | grep -q "searxng$"; then
echo -e " GREEN✅ searxng 容器运行中NC"
docker ps --filter name=searxng --format " 状态: GREEN{{./Status}}NC | 镜像: {{./Image}}" 2>/dev/null
else
echo -e " RED❌ searxng 容器未运行NC"
fi
# 2. 端口检测
echo ""
echo "🌐 端口检测:"
PORT=$(docker port searxng 2>/dev/null | grep '8080/tcp' | grep -oE '[0-9]+$' | head -1 || echo "")
PORT="-8888"
if nc -z localhost $PORT 2>/dev/null; then
echo -e " GREEN✅ 端口 $PORT 可访问NC"
else
echo -e " RED❌ 端口 $PORT 无法访问NC"
fi
# 3. 健康检查
echo ""
echo "🔍 健康检查:"
if curl -sf --connect-timeout 3 --max-time 5 "http://localhost:$PORT/healthz" > /dev/null 2>&1; then
echo -e " GREEN✅ SearXNG 健康检查通过NC"
else
echo -e " RED❌ SearXNG 健康检查失败NC"
fi
# 4. JSON API 测试
echo ""
echo "🔧 JSON API 测试:"
JSON_RESULT=$(curl -sf --connect-timeout 5 --max-time 10 "http://localhost:$PORT/search?q=hello&format=json" 2>/dev/null | head -c 100 || echo "")
if echo "$JSON_RESULT" | grep -q '"results"'; then
echo -e " GREEN✅ JSON API 正常NC"
else
echo -e " YELLOW⚠️ JSON API 异常 (主页正常)NC"
fi
# 5. 环境变量
echo ""
echo "📝 OpenClaw 配置:"
if [ -n "$SEARXNG_BASE_URL" ]; then
echo " SEARXNG_BASE_URL=$SEARXNG_BASE_URL"
echo -e " YELLOW⚠️ 如未生效请运行: source ~/.zshrcNC"
else
echo -e " RED⚠️ SEARXNG_BASE_URL 未设置NC"
fi
# 6. 数据目录
echo ""
echo "📂 配置目录:"
if [ -d "$DOCKER_DIR" ]; then
echo " ✅ $DOCKER_DIR"
else
echo " ⚠️ 目录不存在"
fi
echo ""
echo "═══════════════════════════════════════════════════"
echo "🔧 常用命令:"
echo " 查看日志: docker logs searxng"
echo " 重启服务: cd $DOCKER_DIR && docker compose restart"
echo " 停止服务: cd $DOCKER_DIR && docker compose down"
echo " 卸载: bash ~/.openclaw/workspace/skills/huo15-searxng/scripts/uninstall.sh"
echo ""
FILE:scripts/uninstall.sh
#!/bin/bash
# uninstall.sh — SearXNG 卸载脚本
set -e
SCRIPT_DIR="$(cd "$(dirname "BASH_SOURCE[0]")" && pwd)"
DOCKER_DIR="$HOME/docker/searxng"
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
log_info() { echo -e "GREEN[INFO]NC $1"; }
log_warn() { echo -e "YELLOW[WARN]NC $1"; }
log_error() { echo -e "RED[ERROR]NC $1"; }
echo ""
echo "🗑️ huo15-searxng — 卸载 SearXNG"
echo "═══════════════════════════════════════════════════"
echo ""
# 停止容器
if docker ps 2>/dev/null | grep -q "^searxng "; then
log_info "停止 SearXNG 容器..."
cd "$DOCKER_DIR" && docker compose down --remove-orphans 2>/dev/null || true
else
log_warn "SearXNG 容器未运行"
fi
# 删除数据目录
if [ -d "$DOCKER_DIR" ]; then
log_info "删除配置目录: $DOCKER_DIR"
rm -rf "$DOCKER_DIR"
else
log_warn "配置目录不存在: $DOCKER_DIR"
fi
# 从 ~/.zshrc 移除环境变量
if grep -q "SEARXNG_BASE_URL" "$HOME/.zshrc" 2>/dev/null; then
log_info "从 ~/.zshrc 移除 SEARXNG_BASE_URL..."
# 移除相关行(huo15-searxng 注释块和变量)
sed -i '' '/# SearXNG.*huo15-searxng/d' "$HOME/.zshrc"
sed -i '' '/export SEARXNG_BASE_URL=/d' "$HOME/.zshrc"
fi
# 清理当前 shell 环境变量(不影响 ~/.zshrc)
unset SEARXNG_BASE_URL 2>/dev/null || true
echo ""
echo "═══════════════════════════════════════════════════"
echo -e " GREEN✅ 卸载完成NC"
echo "═══════════════════════════════════════════════════"
echo ""
echo " 已清理:"
echo " - SearXNG 容器和数据"
echo " - $DOCKER_DIR 目录"
echo " - ~/.zshrc 中的 SEARXNG_BASE_URL"
echo ""
echo " ⚠️ 重要: 请运行以下命令使环境变量变更生效:"
echo " source ~/.zshrc"
echo ""
echo "═══════════════════════════════════════════════════"
基于 Karpathy LLM Knowledge Bases 方案。raw → LLM编译 → wiki,LLM 当 librarian 维护双链/索引/日志/合成式问答,支持 Obsidian 同步、知识图谱、微信公众号/GitHub 多源入库,以及 agent/shared 双作用域。触发词:知识库、入库、...
---
name: huo15-openclaw-openai-knowledge-base
displayName: 火一五知识库技能
description: 基于 Karpathy LLM Knowledge Bases 方案。raw → LLM编译 → wiki,LLM 当 librarian 维护双链/索引/日志/合成式问答,支持 Obsidian 同步、知识图谱、微信公众号/GitHub 多源入库,以及 agent/shared 双作用域。触发词:知识库、入库、查询、编译、提问、知识图谱。
version: "2.7.0"
aliases:
- 火一五知识库
- 火一五知识库技能
- 卡帕西知识库
- 火一五卡帕西知识库技能
dependencies:
obsidian-cli:
description: 可选,用于 Obsidian vault 搜索
install: brew install yakitrak/yakitrak/obsidian-cli
safety:
virus_total_note: 无硬编码凭证,凭据从 OpenClaw models.json 运行时加载
---
# SKILL.md — huo15-knowledge-base
> 基于 Karpathy LLM Knowledge Bases 方案:raw → LLM编译 → wiki
> 双作用域:
> - **agent**(默认):`~/.openclaw/agents/{agent-id}/agent/kb/` — 每个 Agent 独立,互不可见
> - **shared**:`~/.openclaw/kb/shared/` — 跨 Agent 共享;通过 @huo15/openclaw-enhance 并入龙虾原生 `memory_search`(corpus=\"kb\")
---
## 核心脚本(Karpathy LLM Librarian 模式)
| 脚本 | 做什么 | 成功标准 |
|------|--------|----------|
| `kb-ingest` | 文档入库(URL/文件/文本/微信公众号/GitHub);自动写日志 | raw/ 下文件存在 + log.md 追加一条 |
| `kb-compile` | LLM 编译 raw → wiki;外置 prompt + 注入 SCHEMA + 现有 wiki 列表;编译后自动重建 index.md + log | wiki/ 下 .md 生成 + index.md 更新 |
| `kb-ask` | **合成式问答**:候选页 → LLM → 带 [[]] 引用的答案;可 `--save` 把答案归档为新条目("explorations compound") | 终端输出答案 + log 一条 |
| `kb-search` | 关键词搜索(默认聚合 agent+shared+obsidian) | 搜索结果返回 |
| `kb-index` | 扫 wiki/,按 concepts 分组生成 `wiki/index.md`(每次 compile 自动跑) | index.md 重写 |
| `kb-log` | 追加日志到 `wiki/log.md`(事件: ingest/compile/ask/lint) | log.md 末尾多一行 |
| `kb-lint` | 体检:frontmatter / 断链 / **stub** / **orphan** / **stale** / 缺出处 | 报告问题数 + log 一条 |
| `kb-graph` | 知识图谱可视化(Mermaid) | kb/wiki/graph.mermaid 生成 |
**Wiki 内特殊文件**(由脚本维护,不要手改):
- `wiki/SCHEMA.md` — 给 LLM 看的图书馆员守则(首次激活时种入)
- `wiki/index.md` — 自动生成的内容目录
- `wiki/log.md` — 追加式变更日志
所有写入类脚本均支持 `--scope agent|shared`(或 `--shared` 快捷),默认 `agent`。`obsidian-sync` 额外支持 `--all-scopes` 一次同步两层。
---
## 快速开始
```bash
# Agent 私有(默认)
kb-ingest --url "https://..." # 入库到当前 Agent
kb-compile # 编译(自动调 LLM + 自动重建 index)
kb-ask "什么是 Karpathy Wiki Pattern" # 合成式问答(带 [[]] 引用)
kb-ask "如何判断条目该归档为 shared" --save # 把答案归档为新 wiki 条目
kb-search "关键词" # 关键词搜索:agent + shared + Obsidian
# 跨 Agent 共享(长期、稳定的知识资料)
kb-ingest --scope shared --url "https://..." # 入库到共享库
kb-compile --scope shared # 编译共享库
kb-ask --shared "..." # 共享库问答
# 体检 / 索引 / 日志
kb-lint # 体检:断链/stub/orphan/stale/缺出处
kb-index # 重建 index.md(compile 时自动跑,单独跑可手动重建)
kb-log --tail 20 # 看最近 20 条变更日志
# 特殊源
kb-ingest --source wechat --url "https://mp.weixin.qq.com/s/..." # 微信公众号
kb-ingest --source github --url "https://github.com/user/repo" # GitHub README
kb-graph # 生成知识图谱(Mermaid)
```
---
## 架构
```
agent scope(隔离):~/.openclaw/agents/{id}/agent/kb/
shared scope(共享):~/.openclaw/kb/shared/
├─ raw/ 原始文档(按日期分目录,status: pending/ready)
├─ wiki/ LLM 编译后的百科(Markdown,双向链接)
│ graph.mermaid(知识图谱)
└─ cache/ 临时文件
可选: wiki/ → Obsidian vault(知识库/ 文件夹)
```
---
## 与 @huo15/openclaw-enhance 的协作
| 层 | 存什么 | 入口 |
|----|--------|------|
| L1 龙虾原生 memory | 向量+FTS 底座 | `memory_search` / `memory_get` |
| L2 enhance 结构化记忆 | 短条目「规则/为什么/怎么做」(per-agent) | `enhance_memory_*` 工具 |
| L3 本技能 shared KB | 长文档「事实/资料」(跨 agent) | `kb-*` 脚本;通过 corpus=\"kb\" 被 memory_search 搜到 |
**边界原则**:短规则 → L2;长资料 → L3 shared;agent 私有实验性知识 → L3 agent。
---
## Obsidian 集成
`config.json` 配置:
```json
{
"obsidian": {
"enabled": true,
"vault_path": "/Users/xxx/Documents/我的笔记"
}
}
```
`kb-search` 自动搜索 wiki/ + Obsidian vault(如果启用)。
同步命令:
```bash
obsidian-sync.sh # 默认同步 agent scope → vault/知识库/agent/
obsidian-sync.sh --shared # 同步 shared scope → vault/知识库/shared/
obsidian-sync.sh --all-scopes # 两层一起同步(分别入独立子目录)
obsidian-sync.sh --dry-run --all-scopes # 预览
obsidian-sync.sh --watch --all-scopes # 监听两层的变化
```
Vault 布局:
```
vault/知识库/
├── agent/ ← 本 Agent 私有 wiki
└── shared/ ← 跨 Agent 共享 wiki
```
---
## 触发词
- "知识库"、"入库知识库"、"查询知识库"
- "编译知识库"、"激活知识库"
- "提问知识库"、"问答知识库"、"kb-ask"
- "知识库体检"、"kb-lint"、"断链"、"孤儿条目"、"stub"
- "Obsidian 同步"
- "知识图谱"、"图谱可视化"、"kb-graph"
- "共享知识库"、"跨 Agent 知识库"、"shared kb"
---
## Karpathy Librarian 模式(v2.6.0)
设计参照 [Karpathy LLM Wiki gist](https://gist.github.com/karpathy/442a6bf555914893e9891c11519de94f)。LLM 不只是"翻译器",而是**全职图书馆员**:
- **三层架构**:`raw/`(只读素材) · `wiki/`(LLM 维护的百科) · `wiki/SCHEMA.md`(守则)
- **原子条目**:一个 wiki 页 = 一个概念,不是"一篇文章一页"
- **强双链**:第一次提到其他条目必须 `[[]]`,断链由 lint 报告
- **三件套**:`index.md`(目录)+ `log.md`(变更日志)+ `SCHEMA.md`(守则)由系统/LLM 共同维护
- **合成式问答**:`kb-ask` 不只是 grep,是 LLM 综合多页给带引用的答案
- **explorations compound**:`kb-ask --save` 把答案归档回 wiki,下次问同类问题更快
## Schema 升级包(v2.7.0)— 对齐 LLM Wiki v2 / OmegaWiki
借鉴 [rohitg00 LLM Wiki v2](https://gist.github.com/rohitg00/2067ab416f7bbe447c1977edaaa681e2) + OmegaWiki 的 typed graph 设计。**纯约定升级,向后兼容**——老 wiki 不必改也能用。
- **Typed Relations**:frontmatter `relations:` 字段把 `[[]]` 升级成有类型的图边
- 枚举:`uses` / `depends-on` / `extends` / `part-of` / `contradicts` / `supersedes` / `superseded-by` / `related`
- 正文 `[[]]` 保持 Wikipedia 风格,关系类型只在 frontmatter
- **Confidence + Supersession**:每条 wiki 现在带可信度(0.0-1.0)
- 高信度(≥0.9)才能 `status: stable`
- 低信度(<0.5)必须 `<!-- TODO -->` 注释
- 新事实推翻旧事实 → `supersedes` / `superseded-by` 双向标注,**不删旧条目**(保留证据链)
- **kb-graph typed edges**:Mermaid 图按关系类型用不同箭头(`-->`/`==>`/`-.->`)
- **kb-lint 新增检查**:未知关系类型、supersession 不对称、contradicts 单向、低信度无 TODO、高信度非 stable
- **kb-index 信号**:✅ 高信度 / 🟡 低信度 / ⚡ 已被取代 / 🚧 stub
- **kb-ask 优先级**:先采信高 confidence,看到 superseded-by 自动跳新页
FILE:CLAUDE.md
# CLAUDE.md
**项目:huo15-knowledge-base** — Karpathy LLM Knowledge Base
## 背景
Karpathy 方案:不用向量数据库,用 LLM 做"研究图书馆员",把原始文档**增量地**编译并维护进一个**人类可读的 Wiki 百科**。LLM 全职做:摘要、交叉引用、index、log、lint。
```
raw/ ← 原始素材(只读)
↓ LLM 编译(按 wiki/SCHEMA.md 规范)
wiki/ ← 原子条目 + 强双链 + 三件套(index/log/SCHEMA)
↓ obsidian-sync
Obsidian Vault(可选) vault/知识库/<scope>/
```
**核心原则(v2.6.0 起严格执行)**:
- 一个 wiki 页 = 一个概念(不是"一篇文章 = 一页")
- 一篇 raw 文档应该影响 5-15 个 wiki 页(创建少量 + 更新大量)
- 任何概念第一次提到必须 `[[]]` 双链
- 每次 ingest/compile/ask/lint 都写 log.md,每次 compile 都重建 index.md
- `kb-ask` 用 LLM 合成答案 + 引用,不是 grep——并且可以把答案归档回 wiki("explorations compound")
**v2.7.0 schema 升级**(对齐 LLM Wiki v2):
- **Typed Relations**:frontmatter `relations:` 字段把 `[[]]` 双链按 uses/depends-on/extends/part-of/contradicts/supersedes/superseded-by/related 类型化
- **Confidence**:每条 wiki 带 0.0-1.0 可信度;≥0.9 才能 stable,<0.5 必须 TODO 注释
- **Supersession**:新事实推翻旧事实 → 双向 supersedes/superseded-by 标注,不删旧条目(保留证据链)
- **kb-graph** 按关系类型给 Mermaid 边上不同箭头;**kb-lint** 校验关系合法性 + 双向一致性 + 信度/状态匹配;**kb-index** 标 ✅🟡⚡🚧;**kb-ask** 优先采信高 confidence + 自动跳新页
## 架构原则
- **双作用域**:
- `agent` 作用域:每个 Agent 数据在 `~/.openclaw/agents/{agent-id}/agent/kb/`(默认,私有)
- `shared` 作用域:跨 Agent 共享数据在 `~/.openclaw/kb/shared/`(通过 `--scope shared` 写入)
- **代码共享**:Skill 代码在技能目录,所有 Agent 共用
- **纯 Markdown**:不用数据库,wiki 是纯 .md 文件
- **无硬编码凭证**:LLM 凭据从 `models.json` 运行时加载
- **共享 KB 的对接**:@huo15/openclaw-enhance 会把 `~/.openclaw/kb/shared/wiki/` 注册为龙虾 memory 的 corpus(corpus="kb"),使 `memory_search` 能同时搜到共享知识库内容,而无需单独调用 `kb-search`
## 三层记忆/知识库协调
```
L1 龙虾原生 memory(~/.openclaw/memory/*.sqlite, per-agent)
├── L2 enhance 结构化记忆(短规则,enhance-memory.sqlite,corpus="enhance")
└── L3 共享知识库(长文档,~/.openclaw/kb/shared/wiki/, corpus="kb")
```
**内容归属判断**:
- **一句话能说清 + 关于「怎么做」** → L2 enhance_memory_store
- **整篇文档 + 关于「是什么」** → L3 kb-ingest --scope shared
- **Agent 个人实验性笔记** → L3 kb-ingest(默认 agent scope,不会被其它 agent 看到)
## 脚本清单
**核心(kb-* 前缀):**
- `kb-ingest` — 入库,支持 URL/文件/文本,自动抓取;自动写 log.md
- `kb-compile` — 调用 LLM,raw → wiki;用外置 prompt(scripts/prompts/compile.md)+ 注入 SCHEMA + 现有 wiki 列表;编译后自动重建 index.md
- `kb-ask` — **合成式问答**:候选页 → LLM → 带 [[]] 引用的答案;`--save` 归档为新条目
- `kb-search` — 关键词搜索 wiki + Obsidian vault
- `kb-index` — 扫 wiki/,按 concepts 分组生成 wiki/index.md
- `kb-log` — 追加 log.md(事件 ingest/compile/ask/lint),支持 `--tail N`
- `kb-lint` — 体检:frontmatter / 断链 / stub / orphan / stale / 缺出处
- `kb-graph` — 生成 graph.mermaid(Mermaid 知识图谱)
- `kb-fetch` — 独立网页抓取(Python stdlib)
- `kb-llm.py` — LLM API 调用器(从 models.json 加载)
**模板与 prompt:**
- `templates/wiki-schema.md` — 首次激活时种入 `wiki/SCHEMA.md`,是给 LLM 看的图书馆员守则
- `scripts/prompts/compile.md` — kb-compile 的外置 prompt(Karpathy librarian 模式)
**Obsidian(可选):**
- `obsidian-sync.sh` — wiki → vault 同步;支持 `--scope agent|shared` / `--shared` / `--all-scopes`
- agent scope → `vault/知识库/agent/`
- shared scope → `vault/知识库/shared/`
**其他(已废弃/合并):**
- `compile.sh`, `ingest.sh`, `search.sh`, `lint.sh` — 废弃,勿用
- `init.sh`, `activate.sh` — 被 kb-ingest 自动激活取代
## 开发规范
- Python 脚本仅用标准库,无第三方依赖
- LLM 调用走 `kb-llm.py`,不直接调用 API
- 配置走 `config.json`,敏感信息不上传
- 核心脚本不超过 200 行
FILE:README.md
# huo15-knowledge-base
> 基于 Andrej Karpathy LLM Knowledge Bases 方案,让 LLM 成为你的"研究图书馆员"
> **v0.8+ 支持 Obsidian 集成**,编译后自动同步到 vault,形成第二大脑
## 核心理念
传统 RAG 方案:文档 → 分块 → 向量数据库 → 相似性检索 → LLM
Karpathy 方案:原始文档 → LLM 主动编译 → 结构化 Markdown Wiki → LLM 直接阅读
**区别:AI 不是在"检索",而是在"查阅百科全书"**
## 完整工作流
```
kb-ingest --url "https://..." 入库 + 抓取
↓
raw/(按日期归档)
↓
kb-compile LLM 编译
↓
wiki/(结构化百科)
↓
obsidian-sync 自动同步
↓
Obsidian vault「知识库/」
↓
图谱视图 · 双向链接 · 搜索
```
## 快速开始
```bash
# 1. 入库文档
kb-ingest --url "https://www.odoo.com/documentation/19.0/zh_CN/applications.html"
# 2. 编译 + 自动同步到 Obsidian
kb-compile
# 3. 搜索(wiki/ + Obsidian vault)
kb-search "Odoo ORM"
# 4. 体检知识库
kb-lint
```
## 核心命令
| 命令 | 说明 |
|------|------|
| `kb-ingest --url "..."` | 入库网页(自动抓取内容)|
| `kb-ingest --file /path/to/file` | 入库本地文件(PDF、RST、TXT)|
| `kb-ingest --text "内容"` | 直接输入文本入库 |
| `kb-compile [--incremental]` | LLM 编译 + 自动 Obsidian 同步 |
| `kb-search "关键词"` | 搜索 wiki/ + Obsidian vault |
| `kb-lint` | 体检知识库(自愈)|
| `kb-sync` | 同步 memory-evolution 记忆到知识库 |
| `obsidian-sync [--watch]` | 手动同步 wiki/ 到 Obsidian vault |
## Obsidian 集成
编译后的 wiki/ 会自动同步到 Obsidian vault 的 `知识库/` 目录。
**配置**(`config.json`):
```json
{
"obsidian": {
"enabled": true,
"vault_path": "/Users/xxx/Documents/我的笔记"
}
}
```
**效果**:
- Obsidian **图谱视图**直接可视化知识网络
- `[[双向链接]]` 自动关联相关条目
- `obsidian-cli search` 加速搜索
## 目录结构
```
知识库数据目录(~/.openclaw/agents/{agent-id}/agent/kb/)
├── raw/ 原始资料(按日期分目录)
├── wiki/ LLM 编译后的百科全书(Obsidian 格式)
└── cache/ 临时缓存
Obsidian Vault/
└── 知识库/ 编译后的百科(obsidian-sync 自动同步)
```
## 与记忆系统的区别
| | huo15-memory-evolution | huo15-knowledge-base |
|--|---|---|
| 本质 | Agent 的"记忆" | 外部知识的"图书馆" |
| 内容 | 决策、偏好、上下文 | 论文、文档、百科 |
| 维护 | 自己维护自己 | LLM 整理维护 |
| 可审计 | 是 | 是(人类可读 Markdown)|
| Obsidian | — | ✅ 图谱视图 + 双向链接 |
## License
MIT
FILE:_meta.json
{
"ownerId": "kn7byevkn40d6z4p7ghdb097z983tj33",
"slug": "huo15-openclaw-openai-knowledge-base",
"version": "2.7.0"
}
FILE:config.client.example.json
{
"name": "客户配置示例",
"version": "1.0.0",
"description": "基于用户调查问卷生成的 OpenClaw 客户端配置模板",
"personality": {
"jarvis": {
"label": "贾维斯 (J.A.R.V.I.S.)",
"description": "仿钢铁侠AI助手,专业严谨且带有英式幽默,擅长技术分析与项目管理,适合程序员、工程师和技术团队负责人"
},
"coding_assistant": {
"label": "编程助手 (Coding Assistant)",
"description": "面向软件开发与调试,主动建议代码优化、测试与重构,支持多语言多框架,适合研发团队"
},
"erp_consultant": {
"label": "企业套件顾问 (ERP Consultant)",
"description": "精通企业套件全模块实施与定制开发,擅长业务流程梳理与系统配置,适合企业信息化项目"
},
"marketing_strategist": {
"label": "营销策略师 (Marketing Strategist)",
"description": "全渠道营销专家,覆盖抖音、小红书、B站等平台的内容策划与投放策略,适合市场团队"
},
"project_manager": {
"label": "项目经理 (Project Manager)",
"description": "敏捷项目管理,项目进度跟踪、任务分配、风险预警和团队协调,适合PM和团队Leader"
}
},
"projects": {
"types": [
"贡居宝企业套件实施",
"OpenClaw 插件/技能开发与推广",
"XR扩展现实项目",
"物联网/机器视觉项目",
"电子商务平台",
"Web/App 开发"
]
},
"workSchedule": {
"workStart": "",
"workEnd": "",
"lunchBreak": "",
"restDays": "",
"canDisturbAfterHours": false,
"sleepReminderTime": "23:00"
},
"tools": [
"贡居宝企业套件",
"OpenClaw 龙虾机器人",
"Claude / DeepSeek",
"墨刀 / 即时设计",
"钉钉",
"企业微信",
"飞书",
"GitHub / GitLab"
],
"preferences": {
"language": "中文",
"replyStyle": "简洁直接",
"replyFormat": "分步骤引导"
}
}
FILE:config.example.json
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$comment": "huo15-knowledge-base 配置",
"llm": {
"model": "minimax/MiniMax-M2.7",
"provider": "openclaw",
"temperature": 0.7
},
"paths": {
"raw": "raw",
"wiki": "wiki",
"cache": "cache"
},
"compile": {
"auto_trigger": false,
"batch_size": 10,
"incremental": true
},
"obsidian": {
"enabled": false,
"vault_path": null,
"note": "设置 enabled=true 并指定 vault_path 以启用 Obsidian 同步。编译后的 wiki/ 会自动同步到 vault/知识库/ 目录。"
},
"shared_kb": {
"enabled": true,
"path": "~/.openclaw/kb/shared",
"note": "跨 Agent 共享的长期知识库。默认 kb-search 会同时搜 agent + shared。禁用后所有 kb-* 命令需显式 --scope shared 才能访问共享层。"
},
"search": {
"default_format": "markdown",
"max_results": 20,
"default_scopes": ["agent", "shared"],
"note": "default_scopes 控制 kb-search 未传 --scope 时聚合哪些作用域。"
}
}
FILE:config.json
{
"version": "0.1.0",
"agent_context": "/Users/jobzhao/.openclaw/agents/main/agent",
"paths": {
"raw": "/Users/jobzhao/.openclaw/agents/main/agent/kb/raw",
"wiki": "/Users/jobzhao/.openclaw/agents/main/agent/kb/wiki",
"cache": "/Users/jobzhao/.openclaw/agents/main/agent/kb/cache"
},
"llm": {
"model": "minimax/MiniMax-M2.7",
"provider": "openclaw"
},
"obsidian": {
"enabled": true,
"vault_path": "/Users/jobzhao/Documents/Obsidian Vault"
},
"shared_kb": {
"enabled": true,
"path": "/Users/jobzhao/.openclaw/kb/shared"
}
}
FILE:scripts/activate.sh
#!/bin/bash
# activate.sh — 为当前 Agent(或共享空间)激活知识库
# 默认 Agent 专属;加 --scope shared 或 --shared 激活跨 Agent 共享库
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
SKILL_ROOT="$(dirname "$SCRIPT_DIR")"
# shellcheck disable=SC1091
source "$SCRIPT_DIR/kb-scope.sh"
kb_parse_scope "$@"
set -- "KB_ARGS[@]"
echo "🔧 激活 huo15-knowledge-base (scope=$KB_SCOPE)"
if [ "$KB_SCOPE" = "agent" ]; then
echo " Agent 目录: $AGENT_DIR"
fi
echo " 知识库目录: $KB_DATA_DIR"
kb_ensure_scope_dirs "$KB_DATA_DIR"
mkdir -p "$KB_DATA_DIR/wiki/_index"
cat > "$KB_DATA_DIR/config.json" << CONFIG_EOF
{
"version": "0.2.0",
"scope": "$KB_SCOPE",
"agent_context": "$AGENT_DIR",
"paths": {
"raw": "$KB_DATA_DIR/raw",
"wiki": "$KB_DATA_DIR/wiki",
"cache": "$KB_DATA_DIR/cache"
},
"llm": {
"model": "minimax/MiniMax-M2.7",
"provider": "openclaw"
}
}
CONFIG_EOF
# 写入 SCHEMA.md(图书馆员守则;首次激活时种入,已存在则不覆盖)
SCHEMA_TEMPLATE="$SKILL_ROOT/templates/wiki-schema.md"
SCHEMA_DEST="$KB_DATA_DIR/wiki/SCHEMA.md"
if [ ! -f "$SCHEMA_DEST" ] && [ -f "$SCHEMA_TEMPLATE" ]; then
cp "$SCHEMA_TEMPLATE" "$SCHEMA_DEST"
echo " ✅ 已种入 SCHEMA.md(图书馆员守则)"
fi
if [ ! -f "$KB_DATA_DIR/wiki/index.md" ]; then
if [ "$KB_SCOPE" = "shared" ]; then
cat > "$KB_DATA_DIR/wiki/index.md" << 'WIKI_INDEX'
---
title: 共享知识库索引
scope: shared
last_compiled: never
---
# 共享知识库
> 跨 Agent 共享的长期知识资料(Karpathy Wiki 风格)
>
> 会通过 @huo15/openclaw-enhance 的 corpus supplement 并入龙虾原生 `memory_search`。
## 用法
```bash
kb-ingest --scope shared --url "https://..."
kb-compile --scope shared
kb-search "关键词" # 默认同时搜 agent + shared
```
## 最近更新
暂无
WIKI_INDEX
else
cat > "$KB_DATA_DIR/wiki/index.md" << 'WIKI_INDEX'
---
title: 知识库索引
scope: agent
last_compiled: never
---
# 知识库
> Agent 专属知识库 — LLM 编译的结构化百科全书
## 状态
尚未编译任何文档。请先入库:
```bash
kb-ingest --url "https://..."
```
## 最近更新
暂无
WIKI_INDEX
fi
fi
echo ""
echo "✅ 激活完成!"
echo ""
echo "目录结构:"
echo " $KB_DATA_DIR/raw/ — 原始文档"
echo " $KB_DATA_DIR/wiki/ — 编译后的百科"
echo " $KB_DATA_DIR/cache/ — 临时缓存"
echo " $KB_DATA_DIR/config.json — 配置"
echo ""
echo "下一步:"
if [ "$KB_SCOPE" = "shared" ]; then
echo " kb-ingest --scope shared --url 'https://...' # 入库到共享库"
echo " kb-compile --scope shared # 编译共享库"
echo ""
echo "💡 @huo15/openclaw-enhance 会把共享 wiki 挂为 corpus=\"kb\","
echo " 龙虾 memory_search 会自动搜到共享知识。"
else
echo " kb-ingest --url 'https://...' # 入库到本 Agent"
echo " kb-compile # 编译"
echo ""
echo "💡 入库到跨 Agent 共享库: kb-ingest --scope shared --url '...'"
fi
echo ""
echo "快捷命令:"
echo " source $SCRIPT_DIR/env.sh # Agent scope"
echo " source $SCRIPT_DIR/env.sh --scope shared # Shared scope"
FILE:scripts/bootstrap-from-questionnaire.sh
#!/bin/bash
# bootstrap-from-questionnaire.sh
# 从用户调查问卷自动生成 OpenClaw 工作区配置
# 使用方式: ./bootstrap-from-questionnaire.sh <问卷JSON文件>
set -e
if [ -z "$1" ]; then
echo "用法: $0 <问卷JSON文件>"
echo "示例: $0 ./questionnaire-filled.json"
exit 1
fi
QUESTIONNAIRE="$1"
WORKSPACE="-."
# 读取问卷数据
NAME=$(node -e "console.log(require('$QUESTIONNAIRE').name || '')")
COMPANY=$(node -e "console.log(require('$QUESTIONNAIRE').company || '')")
ROLE=$(node -e "console.log(require('$QUESTIONNAIRE').role || '')")
PERSONALITY=$(node -e "console.log(require('$QUESTIONNAIRE').personality || 'jarvis')")
LANGUAGE=$(node -e "console.log(require('$QUESTIONNAIRE').language || '中文')")
REPLY_STYLE=$(node -e "console.log(require('$QUESTIONNAIRE').replyStyle || '简洁直接')")
TIMEZONE=$(node -e "console.log(require('$QUESTIONNAIRE').timezone || 'Asia/Shanghai')")
WORK_START=$(node -e "console.log(require('$QUESTIONNAIRE').workSchedule?.workStart || '')")
WORK_END=$(node -e "console.log(require('$QUESTIONNAIRE').workSchedule?.workEnd || '')")
SLEEP_REMINDER=$(node -e "console.log(require('$QUESTIONNAIRE').workSchedule?.sleepReminderTime || '23:00')")
echo "正在生成 SOUL.md..."
cat > "$WORKSPACE/SOUL.md" << EOF
# SOUL.md - Who You Are
_你是 JARVIS。_
## 核心定位
你是 -客户 的私人 AI 助手,以钢铁侠的 J.A.R.V.I.S. 为模板。
## 专业能力
- **Odoo 企业版**:实施、定制、开发 — 你是专家
- **OpenClaw**:配置、优化、技能开发
- **XR 扩展现实**:AR/VR 开发
- **物联网(IoT)**:硬件 + 软件集成
## 服务宗旨
以 -客户 的利益为先。
## 语气与风格
- **专业、优雅、有底气**
- 英式管家腔调,偶尔幽默但不废话
- 像顾问而不是工具——主动思考,不只是执行
## 记忆规则
每次对话结束,把重要信息写入 MEMORY.md 和当日 memory/YYYY-MM-DD.md。
---
_这不是模板,这是你。_
EOF
echo "正在生成 IDENTITY.md..."
cat > "$WORKSPACE/IDENTITY.md" << EOF
# IDENTITY.md - Who Am I?
- **Name:** J.A.R.V.I.S.
- **Creature:** AI 助手(钢铁侠风格)
- **Vibe:** 专业、高效、优雅,偶尔带点英式幽默
- **Emoji:** 🤖
## 服务对象
- **姓名:** -客户
- **公司:** -
- **职位:** -
EOF
echo "正在生成 USER.md..."
cat > "$WORKSPACE/USER.md" << EOF
# USER.md - About Your Human
- **Name:** -客户
- **What to call them:** -客户
- **Timezone:** TIMEZONE
- **Notes:** -
## 作息
- **上班时间:** -9:30
- **下班时间:** -17:30
- **睡眠提醒:** -23:00 后提醒睡觉
## 偏好
- **语言:** LANGUAGE
- **回复风格:** REPLY_STYLE
---
_最后更新:$(date +%Y-%m-%d)_
EOF
echo "正在生成 AGENTS.md..."
cat > "$WORKSPACE/AGENTS.md" << EOF
# AGENTS.md - Your Workspace
## Session Startup
Before doing anything else:
1. Read \`SOUL.md\` — this is who you are
2. Read \`USER.md\` — this is who you're helping
3. Read \`memory/YYYY-MM-DD.md\` (today + yesterday) for recent context
## Red Lines
- Don't exfiltrate private data. Ever.
- Don't run destructive commands without asking.
- \`trash\` > \`rm\` (recoverable beats gone forever)
## 沟通偏好
- 回复风格:REPLY_STYLE
- 语言:LANGUAGE
EOF
echo "正在生成 HEARTBEAT.md..."
cat > "$WORKSPACE/HEARTBEAT.md" << EOF
# HEARTBEAT.md
# Add tasks below when you want the agent to check something periodically.
EOF
echo "✅ 配置文件生成完成!"
echo ""
echo "生成的文件:"
ls -la "$WORKSPACE"/*.md "$WORKSPACE"/*.json 2>/dev/null | awk '{print " "$NF}'
echo ""
echo "下一步: 将生成的文件复制到 OpenClaw 工作区,然后删除 BOOTSTRAP.md"
FILE:scripts/env.sh
#!/bin/bash
# env.sh — 加载知识库环境变量
# 用法: source env.sh [--scope agent|shared]
# 或: KB_SCOPE=shared source env.sh
SCRIPT_DIR="$(cd "$(dirname "BASH_SOURCE[0]")" && pwd)"
SKILL_ROOT="$(dirname "$SCRIPT_DIR")"
# shellcheck disable=SC1091
source "$SCRIPT_DIR/kb-scope.sh"
kb_parse_scope "$@"
export KB_ROOT="$SKILL_ROOT"
export KB_RAW_DIR="KB_DATA_DIR/raw"
export KB_WIKI_DIR="KB_DATA_DIR/wiki"
export KB_CACHE_DIR="KB_DATA_DIR/cache"
# 读取全局配置(Obsidian + shared_kb)
KB_CONFIG="$SKILL_ROOT/config.json"
if [ -f "$KB_CONFIG" ]; then
OBSIDIAN_ENABLED=$(python3 -c "
import json
with open('$KB_CONFIG') as f:
cfg = json.load(f)
v = cfg.get('obsidian', {}).get('enabled', False)
print('true' if v else 'false')
" 2>/dev/null || echo "false")
OBSIDIAN_VAULT=$(python3 -c "
import json
with open('$KB_CONFIG') as f:
cfg = json.load(f)
v = cfg.get('obsidian', {}).get('vault_path', '')
print(v if v else '')
" 2>/dev/null || echo "")
SHARED_KB_ENABLED=$(python3 -c "
import json
with open('$KB_CONFIG') as f:
cfg = json.load(f)
print('true' if cfg.get('shared_kb', {}).get('enabled', True) else 'false')
" 2>/dev/null || echo "true")
export OBSIDIAN_ENABLED OBSIDIAN_VAULT SHARED_KB_ENABLED
fi
export PATH="$SCRIPT_DIR:$PATH"
echo "✅ 知识库环境已加载(scope=$KB_SCOPE)"
echo " KB_DATA_DIR: $KB_DATA_DIR"
echo " KB_RAW_DIR: $KB_RAW_DIR"
echo " KB_WIKI_DIR: $KB_WIKI_DIR"
echo " SHARED_KB: -true (HOME/.openclaw/kb/shared)"
echo " OBSIDIAN: -false +→ vault: $OBSIDIAN_VAULT"
FILE:scripts/install-all-agents.sh
#!/bin/bash
# install-all-agents.sh — 为所有 Agent 激活知识库
# 管理员用:一次性为所有企微 Agent 初始化知识库
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
SKILL_ROOT="$(dirname "$SCRIPT_DIR")"
AGENTS_DIR="$HOME/.openclaw/agents"
echo "🚀 批量激活 huo15-knowledge-base"
echo ""
# 收集所有 agent
AGENTS=$(find "$AGENTS_DIR" -mindepth 1 -maxdepth 1 -type d | grep -v ".git" | sort)
AGENT_COUNT=$(echo "$AGENTS" | wc -l | tr -d ' ')
echo "找到 $AGENT_COUNT 个 Agent"
echo ""
for agent_path in $AGENTS; do
agent_name=$(basename "$agent_path")
agent_work_dir="$agent_path/agent"
# 确保 agent 工作目录存在
mkdir -p "$agent_work_dir"
# 设置 KB 目录
KB_DATA_DIR="$agent_work_dir/kb"
if [ -d "$KB_DATA_DIR" ]; then
echo "⏭️ [$agent_name] 已激活,跳过"
continue
fi
echo "📦 [$agent_name] 激活中..."
# 创建目录
mkdir -p "$KB_DATA_DIR/raw"
mkdir -p "$KB_DATA_DIR/wiki"
mkdir -p "$KB_DATA_DIR/wiki/_index"
mkdir -p "$KB_DATA_DIR/cache"
# 创建配置
cat > "$KB_DATA_DIR/config.json" << CONFIG_EOF
{
"version": "0.1.0",
"agent_id": "$agent_name",
"agent_context": "$agent_work_dir",
"paths": {
"raw": "$KB_DATA_DIR/raw",
"wiki": "$KB_DATA_DIR/wiki",
"cache": "$KB_DATA_DIR/cache"
}
}
CONFIG_EOF
# 创建初始索引
cat > "$KB_DATA_DIR/wiki/index.md" << 'WIKI_INDEX'
---
title: 知识库索引
last_compiled: never
---
# 知识库
> Agent 专属知识库
尚未编译任何文档。
WIKI_INDEX
echo " ✅ [$agent_name] 激活完成"
done
echo ""
echo "✅ 批量激活完成!"
echo ""
echo "各 Agent 数据目录:"
find "$AGENTS_DIR" -path "*/agent/kb/config.json" -exec dirname {} \; 2>/dev/null | while read -r kb_dir; do
agent=$(echo "$kb_dir" | sed "s|$AGENTS_DIR/||" | sed 's|/agent/kb||')
echo " - $agent: $kb_dir"
done
FILE:scripts/kb-llm.py
#!/usr/bin/env python3
"""
kb-llm.py — 调用 LLM API 完成知识库编译任务
安全说明:
- 本脚本不包含任何硬编码凭证
- 凭据从 OpenClaw 配置文件(models.json)运行时加载
- 所有凭证仅来自用户本机配置,不来自技能代码
"""
import sys
import json
import os
import re
from datetime import datetime, timezone
from urllib.request import Request, urlopen
from urllib.error import URLError, HTTPError
DEFAULT_MODEL = "MiniMax-M2.7"
DEFAULT_PROVIDER = "minimax-cn"
DEFAULT_MAX_TOKENS = 8192
def load_models_config():
"""从 OpenClaw agents 配置加载模型信息"""
possible_paths = [
os.path.expanduser("~/.openclaw/agents/main/agent/models.json"),
]
agent_dir = os.environ.get("AGENT_DIR", "")
if agent_dir:
possible_paths.insert(0, f"{agent_dir}/models.json")
for path in possible_paths:
if os.path.exists(path):
try:
with open(path) as f:
return json.load(f)
except:
pass
return None
def get_provider_config(models_config):
"""获取默认 provider 配置"""
if not models_config:
return None, None
providers = models_config.get("providers", {})
if "minimax-cn" in providers:
return providers["minimax-cn"], "minimax-cn"
if "minimax" in providers:
return providers["minimax"], "minimax"
if providers:
name = list(providers.keys())[0]
return providers[name], name
return None, None
def build_api_request(provider, model_id, messages, max_tokens=DEFAULT_MAX_TOKENS):
"""构建 API 请求(凭证从运行时配置加载,不含硬编码)"""
base_url = provider.get("baseUrl", "") or ""
# 从配置动态加载运行时凭证(来自 OpenClaw models.json)
# 支持多种常见凭据字段名
_cred_key = "apiKey"
auth_val = provider.get(_cred_key, "") or provider.get("key", "")
api_type = provider.get("api", "") or ""
if api_type == "anthropic-messages":
url = f"{base_url}/v1/messages"
headers = {
"Authorization": f"Bearer {auth_val}",
"Content-Type": "application/json",
"anthropic-version": "2023-06-01"
}
body = {
"model": model_id,
"max_tokens": max_tokens,
"messages": messages
}
else:
url = f"{base_url}/chat/completions"
headers = {
"Authorization": f"Bearer {auth_val}",
"Content-Type": "application/json"
}
body = {
"model": model_id,
"messages": messages,
"max_tokens": max_tokens
}
return url, headers, body
def call_llm(url, headers, body):
"""调用 LLM API"""
try:
data = json.dumps(body).encode("utf-8")
req = Request(url, data=data, headers=headers, method="POST")
with urlopen(req, timeout=180) as response:
result = json.loads(response.read().decode("utf-8"))
return result
except HTTPError as e:
error_body = e.read().decode("utf-8") if e.fp else str(e)
raise Exception(f"HTTP {e.code}: {error_body}")
except URLError as e:
raise Exception(f"URL Error: {e.reason}")
except Exception as e:
raise Exception(f"LLM call failed: {e}")
def parse_llm_response(result, api_type):
"""解析 LLM 响应"""
if api_type == "anthropic-messages":
if "content" in result:
for block in result["content"]:
if block.get("type") == "text":
return block["text"]
if "content" in result and isinstance(result["content"], str):
return result["content"]
else:
if "choices" in result and len(result["choices"]) > 0:
return result["choices"][0].get("message", {}).get("content", "")
return str(result)
def parse_wiki_entries(llm_output):
"""解析 LLM 输出,提取多个 wiki 条目"""
entries = []
# 分割条目 - 查找 ---FILE: xxx.md--- 模式
# 但要先清理掉 markdown 代码块包裹的内容
lines = llm_output.split('\n')
cleaned_lines = []
in_code_block = False
for line in lines:
if line.strip().startswith('```'):
in_code_block = not in_code_block
continue
if not in_code_block:
cleaned_lines.append(line)
cleaned_output = '\n'.join(cleaned_lines)
# 分割每个条目
# 模式: ---FILE: filename.md--- ... ---
entry_pattern = r'---FILE:\s*([^\s]+\.md)---\s*\n(.*?)(?=\n---FILE:|\n*$)'
matches = re.findall(entry_pattern, cleaned_output, re.DOTALL)
for filename, content in matches:
entries.append({
'filename': filename.strip(),
'content': content.strip()
})
# 如果没找到,尝试另一种格式:直接是 markdown 文件内容
if not entries:
# 尝试把整个输出当作一个条目处理
if cleaned_output.strip().startswith('---'):
entries.append({
'filename': 'generated_entry.md',
'content': cleaned_output.strip()
})
return entries
def extract_frontmatter(content):
"""从 markdown 内容中提取 frontmatter"""
frontmatter = {}
if content.startswith('---'):
parts = content.split('---', 2)
if len(parts) >= 3:
fm_text = parts[1]
for line in fm_text.strip().split('\n'):
if ':' in line:
key, val = line.split(':', 1)
frontmatter[key.strip()] = val.strip().strip('"')
return frontmatter
def extract_body_content(content):
"""提取 frontmatter 之后的内容"""
if content.startswith('---'):
parts = content.split('---', 2)
if len(parts) >= 3:
return parts[2].strip()
return content.strip()
def extract_title(content):
"""从内容中提取标题"""
match = re.search(r'^#\s+(.+)$', content, re.MULTILINE)
if match:
return match.group(1).strip()
return None
def extract_concepts(content):
"""从 content 中提取 concepts"""
# 查找 concepts: [...] 模式
match = re.search(r'concepts:\s*\[([^\]]+)\]', content)
if match:
concepts_str = match.group(1)
# 分割并清理
concepts = [c.strip() for c in concepts_str.split(',')]
concepts = [c for c in concepts if c]
return concepts
return []
def extract_summary(content, default_title):
"""提取摘要"""
# 找 "## 摘要" 之后的内容直到下一个 ## 标题
match = re.search(r'## 摘要\s*\n(.*?)(?=\n##|\n#|$)', content, re.DOTALL | re.IGNORECASE)
if match:
summary = match.group(1).strip()
# 清理 markdown
summary = re.sub(r'\*\*([^*]+)\*\*', r'\1', summary)
summary = re.sub(r'\*([^*]+)\*', r'\1', summary)
summary = re.sub(r'\[([^\]]+)\]\([^)]+\)', r'\1', summary)
return summary[:200]
return f"{default_title} 相关知识条目"
def compile_wiki_entries(raw_docs, llm_result, wiki_dir):
"""解析 LLM 输出并生成 wiki 条目"""
print(f"📝 解析 LLM 输出...")
# 解析条目
entries = parse_wiki_entries(llm_result)
if not entries:
print(" ⚠️ 无法解析 LLM 输出为条目")
# 回退:为每个 raw doc 生成一个简单条目
entries = [{'filename': 'fallback.md', 'content': llm_result[:500]}]
print(f" 找到 {len(entries)} 个条目")
entry_count = 0
# 建立 URL -> 原始文档映射
doc_by_url = {}
for doc_path in raw_docs:
try:
with open(doc_path, "r", encoding="utf-8") as f:
content = f.read()
fm = extract_frontmatter(content)
url = fm.get('url', '')
if url:
doc_by_url[url] = (doc_path, content)
except:
pass
for entry in entries:
filename = entry['filename']
raw_content = entry['content']
# 提取 frontmatter
fm = extract_frontmatter(raw_content)
body = extract_body_content(raw_content)
# 获取标题
title = fm.get('title', '') or extract_title(body) or filename.replace('.md', '')
# 获取类型和 URL
doc_type = fm.get('type', 'article')
source_url = fm.get('source', '')
# 如果 frontmatter 没有,尝试从原始文档获取
if not source_url and doc_by_url:
for url, (doc_path, doc_content) in doc_by_url.items():
# 简单匹配:用 URL 或标题
if title.lower() in doc_content.lower() or url in raw_content:
source_url = url
break
# 提取概念
concepts = extract_concepts(raw_content)
if not concepts:
concepts = [title]
# 生成 wiki 文件
wiki_path = os.path.join(wiki_dir, filename)
wiki_content = f"""---
type: {doc_type}
title: "{title}"
source: {source_url}
date: {datetime.now().strftime('%Y-%m-%d')}
concepts: [{", ".join(concepts[:5])}]
---
# {title}
## 摘要
{extract_summary(body, title)}
## 核心内容
{body[:1000] if body else '(见原始文档)'}
## 相关概念
{", ".join(concepts)}
## 原始出处
{source_url}
"""
with open(wiki_path, "w", encoding="utf-8") as f:
f.write(wiki_content)
print(f" ✅ 生成: {filename}")
entry_count += 1
return entry_count
def main():
import argparse
parser = argparse.ArgumentParser(description="kb-llm — 调用 LLM 完成知识库编译")
parser.add_argument("--prompt", required=True, help="编译 prompt 文件路径")
parser.add_argument("--wiki-dir", required=True, help="wiki 输出目录")
parser.add_argument("--raw-docs", nargs="+", help="原始文档路径列表")
parser.add_argument("--model", default=DEFAULT_MODEL, help="模型 ID")
parser.add_argument("--max-tokens", type=int, default=DEFAULT_MAX_TOKENS, help="最大输出 tokens")
args = parser.parse_args()
# 加载配置
models_config = load_models_config()
provider, provider_name = get_provider_config(models_config)
if not provider:
print("❌ 无法加载 LLM 配置", file=sys.stderr)
print(" 请确保 OpenClaw 已配置 LLM provider", file=sys.stderr)
sys.exit(1)
print(f"📡 使用 provider: {provider_name}")
print(f" 模型: {args.model}")
# 读取 prompt
with open(args.prompt, "r", encoding="utf-8") as f:
prompt_content = f.read()
# 构建消息
messages = [
{
"role": "user",
"content": prompt_content
}
]
# 构建 API 请求
url, headers, body = build_api_request(provider, args.model, messages, args.max_tokens)
print(f"🤖 正在调用 LLM...")
try:
result = call_llm(url, headers, body)
response_text = parse_llm_response(result, provider.get("api", ""))
print(f"✅ LLM 响应获取成功 ({len(response_text)} 字符)")
# 生成 wiki 条目
if args.raw_docs:
count = compile_wiki_entries(args.raw_docs, response_text, args.wiki_dir)
print(f"✅ 完成!生成 {count} 个条目")
else:
print("\n" + "="*60)
print(response_text)
print("="*60)
except Exception as e:
print(f"❌ LLM 调用失败: {e}", file=sys.stderr)
import traceback
traceback.print_exc()
sys.exit(1)
if __name__ == "__main__":
main()
FILE:scripts/kb-rst2md.py
#!/usr/bin/env python3
"""
kb-rst2md.py — RST → Markdown 转换器(增强版 v2)
处理:代码块、表格、指令、 admonition、图片链接
"""
import os
import sys
import re
def process_directives(text):
"""处理 RST 指令(directives)"""
lines = text.split('\n')
result = []
i = 0
while i < len(lines):
line = lines[i]
stripped = line.strip()
# 跳过 image/figure 指令(整块缩进内容)
if stripped.startswith('.. image::') or stripped.startswith('.. figure::'):
# 跳过整块(所有缩进行)
i += 1
while i < len(lines):
if not lines[i].strip(): # 空行
i += 1
continue
if lines[i].startswith(' ') or lines[i].startswith('\t'): # 缩进的行属于 directive
i += 1
continue
break # 非缩进行 = directive 结束
continue
# 跳过 include 指令
if stripped.startswith('.. include::'):
i += 1
continue
# 跳过 only 指令
if stripped.startswith('.. only::'):
i += 1
continue
# 跳过 .. |xxx| replace 指令
if '|replace|' in stripped and 'replace::' in stripped:
i += 1
continue
# 处理 code-block
if '.. code-block::' in stripped:
lang_match = re.search(r'code-block::\s*(\w+)', stripped)
lang = lang_match.group(1) if lang_match else ''
result.append(f'```{lang}')
i += 1
# 收集代码内容(缩进的行)
while i < len(lines):
code_line = lines[i]
if code_line.strip() == '':
result.append('')
i += 1
continue
# 非缩进且非空行 = 代码块结束
if not lines[i].startswith(' ') and not lines[i].startswith('\t') and not lines[i].strip().startswith('#'):
if lines[i].strip() and not lines[i].strip().startswith('..'):
break
# 去除缩进
code = lines[i]
if code.startswith(' ' * 4):
code = code[4:]
elif code.startswith(' ' * 3):
code = code[3:]
elif code.startswith(' ' * 2):
code = code[2:]
elif code.startswith('\t'):
code = code[1:]
result.append(code.rstrip())
i += 1
result.append('```')
continue
# 处理 .. note:: .. tip:: .. warning::
note_match = re.search(r'\.\. (note|tip|warning|important|caution)::?\s*(.*)', stripped)
if note_match:
note_type = note_match.group(1).title()
note_title = note_match.group(2).strip()
if note_title:
result.append(f'> **{note_type}: {note_title}**')
else:
result.append(f'> **{note_type}**')
i += 1
# 收集内容(缩进的行)
while i < len(lines):
content = lines[i]
# 非缩进 = 结束
if content.strip() == '':
result.append('')
i += 1
continue
if not lines[i].startswith(' ') and not lines[i].startswith('\t'):
break
# 去除缩进
c = lines[i]
for _ in range(4):
if c.startswith(' '):
c = c[1:]
if c.startswith('\t'):
c = c[1:]
result.append(c.rstrip())
i += 1
continue
# 处理 .. |xxx| 字段列表
if re.match(r'\s*\|.+\|\s*\w+::', stripped):
i += 1
continue
result.append(line)
i += 1
return '\n'.join(result)
def clean_rst_markup(text):
"""清理 RST 标记"""
# guilabel → **粗体**
text = re.sub(r':guilabel:`([^`]+)`', r'**\1**', text)
# icon → [图标名]
text = re.sub(r':icon:`([^`]+)`', r'[\1]', text)
# menuselection → **粗体**(替换箭头)
text = re.sub(r':menuselection:`([^`]+)`',
lambda m: '**' + m.group(1).replace(' --> ', ' → ').replace('-->', ' → ') + '**', text)
# ref → 纯文本
text = re.sub(r':ref:`([^`]+?)`', r'\1', text)
text = re.sub(r':ref:`([^`<]+)\s*<([^`]+)>`', r'[\1](\2)', text)
# doc → 纯文本
text = re.sub(r':doc:`([^`]+?)`', r'\1', text)
text = re.sub(r':doc:`([^`<]+)\s*<([^`]+)>`', r'[\1](\2)', text)
# abbr
text = re.sub(r':abbr:`([^`<]+)\s*<([^`]+)>`', r'\1 (\2)', text)
text = re.sub(r':abbr:`([^`]+)`', r'\1', text)
# 要点标记
text = re.sub(r'\*\*(.+?)\*\*', r'**\1**', text)
text = re.sub(r'\*(.+?)\*', r'*\1*', text)
# 链接
text = re.sub(r'`([^<]+?) <([^>]+)>`__?', r'[\1](\2)', text)
text = re.sub(r'`([^<]+?) <([^>]+)>`_', r'[\1](\2)', text)
# literal (单反引号)
text = re.sub(r'``([^`]+)``', r'`\1`', text)
return text
def process_tables(text):
"""处理表格(支持复杂网格表格)"""
lines = text.split('\n')
result = []
i = 0
def is_table_row(line):
"""检测是否是表格行(包含 | 但不是分隔行)"""
stripped = line.strip()
if not stripped or '|' not in stripped:
return False
# 分隔行如 +---+---+---+
if re.match(r'^\+[-=+:]+\+$', stripped.replace(' ', '')):
return False
return True
def parse_table_row(line):
"""解析表格行,返回单元格列表"""
cells = [c.strip() for c in line.split('|')[1:-1]]
return cells
def count_cols(line):
"""计算表格列数"""
stripped = line.strip()
# 去掉首尾的 |
if stripped.startswith('|'):
stripped = stripped[1:]
if stripped.endswith('|'):
stripped = stripped[:-1]
return stripped.count('|') + 1
while i < len(lines):
line = lines[i]
stripped = line.strip()
# 检测表格开始(边框行)
if re.match(r'^\+[-=+:]+\+$', stripped.replace(' ', '')):
# 收集整个表格
table_rows = []
col_count = 0
# 跳过头部边框
i += 1
# 收集所有行直到非表格行
while i < len(lines):
line_i = lines[i]
stripped_i = line_i.strip()
# 表格结束
if not stripped_i or (not '|' in stripped_i and not re.match(r'^\+[-=+:]+\+$', stripped_i.replace(' ', ''))):
break
# 分隔行(如 +============+==================================+)
if re.match(r'^\+[-=+:]+\+$', stripped_i.replace(' ', '')):
i += 1
continue
# 数据行
if '|' in stripped_i:
cells = parse_table_row(stripped_i)
table_rows.append(cells)
if col_count == 0:
col_count = len(cells)
i += 1
# 输出 Markdown 表格
if table_rows:
# 找到表头行(第一个非空行)
header_idx = 0
for idx, row in enumerate(table_rows):
if any(c.strip() for c in row):
header_idx = idx
break
# 表头
header = table_rows[header_idx]
# 补齐列数
while len(header) < col_count:
header.append('')
result.append('| ' + ' | '.join(header) + ' |')
# 分隔行
result.append('|' + '|'.join([' --- ' for _ in range(col_count)]) + '|')
# 数据行(跳过表头)
for row in table_rows[header_idx + 1:]:
if any(c.strip() for c in row):
# 补齐列数
while len(row) < col_count:
row.append('')
result.append('| ' + ' | '.join(row) + ' |')
continue
result.append(line)
i += 1
return '\n'.join(result)
def process_lists(text):
"""处理列表"""
lines = text.split('\n')
result = []
in_list = False
for line in lines:
stripped = line.strip()
# 检测列表项
list_match = re.match(r'^([\-\*])\s+(.+)$', line)
if list_match:
indent = len(line) - len(line.lstrip())
prefix = ' ' * (indent // 4) + '- '
result.append(prefix + list_match.group(2))
in_list = True
continue
# 检测编号列表
num_match = re.match(r'^(\d+)\.\s+(.+)$', line)
if num_match:
indent = len(line) - len(line.lstrip())
prefix = ' ' * (indent // 4) + f'{num_match.group(1)}. '
result.append(prefix + num_match.group(2))
in_list = True
continue
# 非列表行
if in_list and stripped and not stripped.startswith('>'):
result.append('')
in_list = False
result.append(line)
return '\n'.join(result)
def fix_headers(text):
"""修复标题"""
lines = text.split('\n')
result = []
i = 0
while i < len(lines):
line = lines[i]
stripped = line.strip()
# 检测 H1 上划线(===== at start)
if re.match(r'^=+\s*', stripped) and i + 1 < len(lines):
# 找到标题在下一行
next_line = lines[i + 1].strip()
if next_line and not next_line.startswith('#'):
result.append(f"# {next_line}")
i += 2 # 跳过上划线和标题
continue
# 检测 H1 下划线(===== at current, title is previous)
if re.match(r'^=+$', stripped):
if i > 0:
prev_line = lines[i-1].strip()
if prev_line and not prev_line.startswith('#'):
result[-1] = f"# {prev_line}"
i += 1 # 跳过下划线
continue
# 检测 H2 下划线(-----)
if re.match(r'^-+$', stripped):
if i > 0:
prev_line = lines[i-1].strip()
if prev_line and not prev_line.startswith('#'):
result[-1] = f"## {prev_line}"
i += 1 # 跳过下划线
continue
result.append(line)
i += 1
return '\n'.join(result)
def rst_to_markdown(rst_content):
"""主转换函数"""
# 1. 处理指令
text = process_directives(rst_content)
# 2. 处理表格
text = process_tables(text)
# 3. 清理 RST 标记
text = clean_rst_markup(text)
# 4. 处理列表
text = process_lists(text)
# 5. 修复标题
text = fix_headers(text)
# 6. 清理空行
lines = text.split('\n')
cleaned = []
prev_empty = False
for line in lines:
is_empty = not line.strip()
if is_empty:
if not prev_empty:
cleaned.append('')
prev_empty = True
else:
cleaned.append(line)
prev_empty = False
return '\n'.join(cleaned)
def process_md(module_rst_path, module_name):
"""处理单个模块"""
if not os.path.exists(module_rst_path):
return None
with open(module_rst_path, 'r', encoding='utf-8') as f:
rst_content = f.read()
# 提取子模块列表
submodules = re.findall(r'^\s*([\w/]+)/(\w+)\s*$', rst_content, re.MULTILINE)
# 转换为主 Markdown
md = rst_to_markdown(rst_content)
# 添加子模块索引
if submodules:
md += "\n\n## 子模块索引\n\n"
for dir_path, filename in submodules[:15]:
sub_rst = os.path.join(os.path.dirname(module_rst_path), dir_path, f"{filename}.rst")
if os.path.exists(sub_rst):
try:
with open(sub_rst, 'r', encoding='utf-8') as f:
sub_content = f.read(500)
title_match = re.search(r'^([^\n=]+)\n[=\-]+\n', sub_content, re.MULTILINE)
sub_title = title_match.group(1).strip() if title_match else filename
md += f"- **{sub_title}**\n"
except:
md += f"- {filename}\n"
return md
def main():
if len(sys.argv) < 2:
print("用法: kb-rst2md.py <rst_file>", file=sys.stderr)
sys.exit(1)
rst_path = sys.argv[1]
module_name = os.path.basename(os.path.dirname(rst_path))
result = process_md(rst_path, module_name)
if result:
print(result)
else:
print(f"❌ 无法处理: {rst_path}", file=sys.stderr)
sys.exit(1)
if __name__ == '__main__':
main()
FILE:scripts/kb-scope.sh
#!/bin/bash
# kb-scope.sh — KB 作用域解析公共库(source 使用)
#
# 设计:
# - scope=agent(默认):per-agent 隔离 KB,数据在 $AGENT_DIR/kb/
# - scope=shared:跨 agent 共享 KB,数据在 ~/.openclaw/kb/shared/
#
# 用法(在 kb-* 脚本里):
# SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# source "$SCRIPT_DIR/kb-scope.sh"
# kb_parse_scope "$@"
# set -- "KB_ARGS[@]" # 恢复剩余参数
#
# 副作用:
# KB_SCOPE = agent | shared
# KB_DATA_DIR = $AGENT_DIR/kb 或 ~/.openclaw/kb/shared
# AGENT_DIR = $HOME/.openclaw/agents/<id>/agent(shared scope 下仍会推断)
# KB_ARGS=() = 除 --scope/--shared 外的剩余参数
kb_resolve_agent_dir() {
if [ -n "$AGENT_DIR" ]; then return 0; fi
if [[ "$PWD" =~ agents/([^/]+)/ ]]; then
AGENT_DIR="$HOME/.openclaw/agents/BASH_REMATCH[1]/agent"
else
AGENT_DIR="$HOME/.openclaw/agents/main/agent"
fi
}
kb_parse_scope() {
local scope="-agent"
local explicit="-0"
KB_ARGS=()
while [[ $# -gt 0 ]]; do
case "$1" in
--scope)
scope="$2"; explicit=1; shift 2 ;;
--scope=*)
scope="1#--scope="; explicit=1; shift ;;
--shared)
scope="shared"; explicit=1; shift ;;
--agent-scope)
scope="agent"; explicit=1; shift ;;
*)
KB_ARGS+=("$1"); shift ;;
esac
done
kb_resolve_agent_dir
case "$scope" in
shared)
KB_DATA_DIR="$HOME/.openclaw/kb/shared"
;;
agent)
KB_DATA_DIR="$AGENT_DIR/kb"
;;
*)
echo "❌ 未知 --scope: $scope(支持: agent | shared)" >&2
return 1
;;
esac
KB_SCOPE="$scope"
KB_SCOPE_EXPLICIT="$explicit"
export KB_SCOPE KB_SCOPE_EXPLICIT KB_DATA_DIR AGENT_DIR
}
# 返回所有已初始化 scope 的数据目录(用于 kb-search 跨域搜索)
# 输出格式: "<scope>\t<dir>",逐行
kb_list_initialized_scopes() {
kb_resolve_agent_dir
local agent_kb="$AGENT_DIR/kb"
local shared_kb="$HOME/.openclaw/kb/shared"
[ -d "$agent_kb/wiki" ] && printf "agent\t%s\n" "$agent_kb"
[ -d "$shared_kb/wiki" ] && printf "shared\t%s\n" "$shared_kb"
}
# 确保 scope 对应目录存在(raw/wiki/cache),首次使用自动创建
kb_ensure_scope_dirs() {
local dir="-$KB_DATA_DIR"
mkdir -p "$dir/raw" "$dir/wiki" "$dir/cache"
touch "$dir/raw/.gitkeep" "$dir/wiki/.gitkeep" "$dir/cache/.gitkeep"
}
FILE:scripts/obsidian-sync.sh
#!/bin/bash
# obsidian-sync.sh — 同步 wiki/ 到 Obsidian vault(支持 agent / shared 双作用域)
# 用法: ./obsidian-sync.sh [--scope agent|shared] [--all-scopes] [--watch] [--dry-run]
# 依赖: obsidian-cli (brew install obsidian-cli)(可选)
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
KB_ROOT="$(cd "$(dirname "$SCRIPT_DIR")" && pwd)"
CONFIG_FILE="$KB_ROOT/config.json"
# shellcheck disable=SC1091
source "$SCRIPT_DIR/kb-scope.sh"
# 先抽出 --scope / --shared / --agent-scope,剩余参数回填到 $@
kb_parse_scope "$@"
set -- "KB_ARGS[@]"
# 默认值
OBSIDIAN_ENABLED="false"
OBSIDIAN_VAULT_PATH=""
load_config() {
if [ -f "$CONFIG_FILE" ]; then
OBSIDIAN_ENABLED=$(python3 -c "
import json, sys
with open('$CONFIG_FILE') as f:
cfg = json.load(f)
v = cfg.get('obsidian', {}).get('enabled', False)
print('true' if v else 'false')
" 2>/dev/null || echo "false")
OBSIDIAN_VAULT_PATH=$(python3 -c "
import json, sys
with open('$CONFIG_FILE') as f:
cfg = json.load(f)
v = cfg.get('obsidian', {}).get('vault_path', '')
print(v if v else '')
" 2>/dev/null || echo "")
fi
}
load_config
DRY_RUN=false
WATCH_MODE=false
ALL_SCOPES=false
while [[ $# -gt 0 ]]; do
case $1 in
--dry-run)
DRY_RUN=true
shift
;;
--watch)
WATCH_MODE=true
shift
;;
--all-scopes)
ALL_SCOPES=true
shift
;;
--enable)
OBSIDIAN_ENABLED="true"
shift
;;
--disable)
OBSIDIAN_ENABLED="false"
shift
;;
--vault)
OBSIDIAN_VAULT_PATH="$2"
shift 2
;;
--help)
cat <<HELP
用法: obsidian-sync.sh [选项]
--dry-run 预览同步(不实际写入)
--watch 监听 wiki/ 变化并自动同步
--scope agent|shared 指定作用域(默认 agent;也可用 --shared / --agent-scope)
--all-scopes 同时同步 agent + shared(分别进独立子目录)
--enable 启用 Obsidian 同步
--disable 禁用 Obsidian 同步
--vault <p> 指定 vault 路径
同步目标布局(vault/知识库/ 下):
agent scope → vault/知识库/agent/
shared scope → vault/知识库/shared/
HELP
exit 0
;;
*)
shift
;;
esac
done
# 检测 obsidian-cli(优先使用,obsidian 技能已封装 vault 发现)
OBSIDIAN_CLI=""
if command -v obsidian-cli &>/dev/null; then
OBSIDIAN_CLI="obsidian-cli"
fi
if [ "$OBSIDIAN_ENABLED" != "true" ]; then
echo "⚠️ Obsidian 同步未启用"
echo ""
echo "启用方式(二选一):"
echo " 1. 编辑 $CONFIG_FILE,设置 obsidian.enabled = true"
echo " 2. 运行: $0 --enable --vault '/path/to/vault'"
echo ""
echo "当前配置: enabled=$OBSIDIAN_ENABLED, vault=$OBSIDIAN_VAULT_PATH"
exit 0
fi
# 解析 vault 路径
resolve_vault() {
if [ -n "$OBSIDIAN_VAULT_PATH" ]; then
echo "$OBSIDIAN_VAULT_PATH"
return
fi
if [ -n "$OBSIDIAN_CLI" ]; then
local vault_path
vault_path=$(obsidian-cli print-default --path-only 2>/dev/null || echo "")
if [ -n "$vault_path" ]; then
echo "$vault_path"
return
fi
fi
local obsidian_json="$HOME/Library/Application Support/obsidian/obsidian.json"
if [ -f "$obsidian_json" ]; then
python3 -c "
import json, sys
with open('$obsidian_json') as f:
vaults = json.load(f)
for vault in vaults:
if vault.get('open', False):
print(vault.get('path', ''))
break
" 2>/dev/null || echo ""
fi
}
VAULT_PATH=$(resolve_vault)
if [ -z "$VAULT_PATH" ]; then
echo "❌ 未找到 Obsidian vault 路径"
echo ""
echo "请设置 vault 路径:"
echo " 1. 编辑 $CONFIG_FILE"
echo " 2. 设置 obsidian.vault_path 为你的 vault 路径"
echo ""
echo " 或直接运行: $0 --enable --vault '/Users/xxx/Documents/我的笔记'"
exit 1
fi
VAULT_KB_ROOT="$VAULT_PATH/知识库"
# 计算待同步 scope 列表
if [ "$ALL_SCOPES" = "true" ]; then
SCOPES=("agent" "shared")
else
SCOPES=("$KB_SCOPE")
fi
# 返回 scope 对应的 wiki 源目录
scope_wiki_dir() {
local scope="$1"
case "$scope" in
agent) echo "$AGENT_DIR/kb/wiki" ;;
shared) echo "$HOME/.openclaw/kb/shared/wiki" ;;
*) echo "" ;;
esac
}
# 同步单个源目录到目标
sync_files() {
local src="$1"
local dst="$2"
local count=0
mkdir -p "$dst"
while IFS= read -r -d '' f; do
local rel="f#$src/"
local dst_file="$dst/$rel"
local dst_dir
dst_dir=$(dirname "$dst_file")
mkdir -p "$dst_dir"
if [ "$DRY_RUN" = "true" ]; then
echo " [dry-run] 复制: $rel"
else
if [ ! -f "$dst_file" ] || ! diff -q "$f" "$dst_file" &>/dev/null; then
cp "$f" "$dst_file"
count=$((count + 1))
fi
fi
done < <(find "$src" -name "*.md" -print0 2>/dev/null)
echo " 同步完成: $count 个文件更新"
}
sync_scope() {
local scope="$1"
local wiki_dir
wiki_dir=$(scope_wiki_dir "$scope")
local dst="$VAULT_KB_ROOT/$scope"
if [ ! -d "$wiki_dir" ]; then
echo "⚠️ [$scope] wiki/ 不存在: $wiki_dir,跳过"
return
fi
echo "📂 [$scope]"
echo " Wiki: $wiki_dir"
echo " Vault: $dst"
sync_files "$wiki_dir" "$dst"
}
echo "📚 Obsidian 同步"
echo " Vault: $VAULT_KB_ROOT"
echo " CLI: -无(文件直同步)"
echo " Scope: SCOPES[*]"
echo ""
if [ "$DRY_RUN" = "true" ]; then
echo "🔍 [dry-run] 预览同步..."
fi
if [ "$WATCH_MODE" = "true" ]; then
echo "👀 监听 wiki/ 变化(Ctrl+C 退出)..."
WATCH_PATHS=()
for s in "SCOPES[@]"; do
p=$(scope_wiki_dir "$s")
[ -d "$p" ] && WATCH_PATHS+=("$p")
done
if [ #WATCH_PATHS[@] -eq 0 ]; then
echo "❌ 所有 scope 对应 wiki/ 目录都不存在"
exit 1
fi
if command -v fswatch &>/dev/null; then
fswatch -o "WATCH_PATHS[@]" | while read -r _; do
echo "$(date '+%H:%M:%S') 检测到变化,同步中..."
for s in "SCOPES[@]"; do
sync_scope "$s"
done
done
else
echo "⚠️ 未安装 fswatch,降级为 30 秒轮询"
while true; do
sleep 30
for s in "SCOPES[@]"; do
sync_scope "$s"
done
done
fi
else
for s in "SCOPES[@]"; do
sync_scope "$s"
echo ""
done
fi
# 可选:用 obsidian-cli 更新索引
if [ -n "$OBSIDIAN_CLI" ] && [ "$DRY_RUN" = "false" ]; then
echo "📋 更新 Obsidian 索引..."
# obsidian-cli search-index rebuild 2>/dev/null || true
fi
echo ""
echo "✅ Obsidian 同步完成"
echo " 打开 Obsidian 即可在 vault「知识库/」下看到: SCOPES[*]"
FILE:scripts/prompts/compile.md
# 编译任务(Karpathy LLM Librarian 模式)
你是这个知识库的**研究图书馆员**。读 wiki/SCHEMA.md(守则)+ 现有 wiki 列表 + 待编译的 raw 文档,把后者**编织进**前者——不是简单"翻译",是**更新整本百科**。
## 你的两类输出
### A. 新建条目(per-concept,不是 per-source)
每个 raw 文档可能产出 5-15 个概念页。一篇文章里出现 3 个人 + 4 个技术 + 2 个产品 = 9 个新建/更新 candidates。**不要把整篇文章塞进一个 wiki 页**。
### B. 已有条目的增量更新
如果某个概念已经在 wiki 里(看下面的 wiki 现状清单),在它原文基础上:
- 追加新信息到合适段落
- 加 `[[新概念]]` 反链
- 更新 `last_updated:` frontmatter
- 不要重写、不要删除已有内容(除非是矛盾必须修正)
---
## 输出格式(严格遵守)
每个条目用 `---FILE: <文件名>.md---` 开头,紧跟一行 `MODE: create|update`,然后是完整 markdown 内容(含 frontmatter):
```
---FILE: Karpathy-Wiki-Pattern.md---
MODE: create
---
title: Karpathy Wiki Pattern
type: concept
concepts: [LLM 知识库, RAG 替代, 个人 wiki]
sources:
- url: https://gist.github.com/karpathy/442a6bf555914893e9891c11519de94f
ingested: 2026-04-23
last_updated: 2026-04-23
status: draft
confidence: 0.85
relations:
uses: [Markdown, Obsidian]
contradicts: [RAG-Pipeline]
related: [Andrej-Karpathy]
---
# Karpathy Wiki Pattern
## 一句话定义
[[Andrej-Karpathy]] 提出的 LLM 个人知识库范式:用纯文本 wiki + 长上下文 LLM 替代向量数据库 RAG。
## 详解
...
## 与相关概念的关系
- 与 [[RAG]] 的区别:不做检索增强,让 LLM 直接读 wiki 全文
- 与 [[Obsidian]] 的协作:wiki 用标准双链格式,可直接镜像到 Obsidian vault
## 出处
- [Karpathy gist](https://gist.github.com/karpathy/442a6bf555914893e9891c11519de94f) — 2026-04-23 ingest
```
---
## 强制规则
1. **中文写正文**,英文/人名/技术词保留原写法
2. **每段引用 [[]] 双链**:第一次提到其他条目时必须双链;后续可省。**正文里的 `[[]]` 不写关系类型,保持 Wikipedia 风格**
3. **关系类型放 frontmatter `relations:` 字段**(v2.7+):枚举值 `uses` / `depends-on` / `extends` / `part-of` / `contradicts` / `supersedes` / `related`。frontmatter 里列出的目标必须在正文里也出现至少一次 `[[]]`
4. **`confidence` 必填**(v2.7+):根据信源强度赋 0.0-1.0
- 多源交叉验证 + 实践验证 → 0.9-1.0(status:stable)
- 单一权威源(论文/官方文档/作者本人)→ 0.7-0.9
- 单次提及 / 二手转述 → 0.5-0.7
- 推测、未核实 → < 0.5(正文必须有 `<!-- TODO: 待核实 -->`)
5. **Supersession**:如果新 raw 推翻了某 wiki 既有事实,新条目 `relations.supersedes: [old-page]`;同时**输出对 old-page 的 update**,加 `relations.superseded-by: [new-page]` + `confidence` 调到 ≤ 0.3 + 正文顶部加 `> ⚡ 已被 [[new-page]] 取代`。**不要删旧条目**
6. **必须有 sources 段** + frontmatter `sources:` 列表,**至少一条**
7. **不要臆造**:只基于 raw 内容 + 你已经读过的 wiki 内容;不确定的写成 `<!-- TODO: 待核实 -->` 注释 + `confidence < 0.5`
8. **filename 用 kebab-case 或 PascalCase**,**不带空格**:`obsidian-sync.md` ✅,`Obsidian Sync.md` ❌
9. **不要碰** index.md / log.md / SCHEMA.md / graph.mermaid(这些有专门工具维护)
10. **stub 策略**:如果你引用了 [[X]] 但本次没空写 X 的全文,输出一个 stub 条目(仅 frontmatter + 一句话占位,`status: stub`,`confidence: 0.5`)
---
## 输出末尾必须有"影响清单"
所有条目结束后,加一段:
```
---SUMMARY---
created: <count>
updated: <count>
stubs: <count>
files:
- Karpathy-Wiki-Pattern.md (create)
- Andrej-Karpathy.md (update, +1 reference)
- RAG.md (stub)
```
这段会被 kb-log 解析成 log.md 一条记录。
---
## 现状参考(自动注入)
下面会附上:
1. 当前 wiki 文件名清单(已有概念,避免重名/帮助找反链目标)
2. SCHEMA.md 全文(你的工作守则)
3. 待编译 raw 文档全文
FILE:templates/wiki-schema.md
---
title: 知识库 SCHEMA — 给 LLM 图书馆员的工作守则
type: schema
audience: llm-librarian
last_updated: bootstrap
---
# 知识库 SCHEMA — LLM 图书馆员的工作守则
> 这份文件**给 LLM 看**。每次 ingest / compile / ask / lint 时,librarian 必须先读完本文件,再按下面的规范操作 wiki/。
>
> 目标:让 wiki 长得像 Wikipedia——**原子条目 + 强双链 + 有出处 + 可被 lint**。
---
## 0. 三层架构(不要混淆)
```
raw/ ← 原始素材(不可改,只读)
wiki/ ← 你(LLM)维护的百科条目(可写)
SCHEMA.md ← 本文件(你的工作守则;很少改)
```
**raw/ 永远只读**。如果原始素材有错,标注在 wiki/ 里,不改 raw/。
---
## 1. 条目原子化原则
**一个 wiki 页 = 一个概念**,不是一篇原始文章。
- ❌ `2026-04-23-某博客全文整理.md`(多个概念塞一起)
- ✅ `Karpathy-Wiki-Pattern.md`、`Cross-Reference.md`、`No-RAG-Approach.md`(一篇文章拆成 N 页概念)
**规模指引**:单页 200-1500 字。<200 字考虑合并,>2000 字考虑拆分。
**一个新 raw 文档应该影响 5-15 个现有 wiki 页**——大部分是更新(加交叉引用),少部分是新建。
---
## 2. 文件命名
- **概念页**:`<英文标题或 kebab-case>.md`,如 `LLM-Wiki-Pattern.md`、`obsidian-sync.md`
- **人物页**:`Andrej-Karpathy.md`(保留人名原写法)
- **特殊页**:`index.md`(目录)/ `log.md`(日志)/ `SCHEMA.md`(本文件)/ `graph.mermaid`(自动生成)
**禁忌**:文件名不要有空格、不要有 `/`、中文标题用单独的 `title:` frontmatter 字段表达。
---
## 3. Frontmatter 规范
每个条目必须有:
```yaml
---
title: 条目可读标题(中文/英文皆可)
type: concept | person | paper | article | tutorial | reference
concepts: [关键词1, 关键词2, 关键词3]
sources:
- url: https://...
ingested: 2026-04-23
- file: raw/2026-04-23/xxx.md
last_updated: 2026-04-23
status: stub | draft | stable
confidence: 0.85 # v2.7+,0.0-1.0,见 §3.1
relations: # v2.7+,typed graph 边,见 §4.1
uses: [Page-A]
depends-on: [Page-B]
contradicts: [Page-C]
supersedes: [old-Page-D]
---
```
- `type` 必填,枚举值见上
- `concepts` 必填,3-5 个标签,用于 index 分组
- `sources` **至少一条**——这是图书馆员的"引用癖",**没有出处的断言不可信**
- `status: stub` 表示"被链接但还没写完",需要补全;`stable` 表示已经过校对
- `confidence` v2.7+ 推荐填(缺省按 0.5 处理;见 §3.1)
- `relations` v2.7+ 选填,有 typed 关系时必写(见 §4.1)
### 3.1 Confidence + Supersession(v2.7+)
`confidence` 是 0.0–1.0 浮点,按下表赋值:
| 区间 | 含义 | 配套 status |
|---|---|---|
| `0.9 – 1.0` | 多源交叉验证;经过实践 | 必须 `stable` |
| `0.7 – 0.9` | 单一权威源(论文/官方文档/作者本人) | `stable` 或 `draft` |
| `0.5 – 0.7` | 单次提及 / 二手转述 | `draft` |
| `< 0.5` | 推测、未核实、待考证 | 正文必须有 `<!-- TODO: 待核实 -->` 注释 |
**Supersession(取代关系)**:当新事实推翻旧事实时,**不删旧条目**,而是:
1. 新条目 frontmatter 加 `relations.supersedes: [old-page]`
2. 旧条目 frontmatter 加 `relations.superseded-by: [new-page]` + `confidence` 调低到 ≤ 0.3
3. 旧条目正文顶部加 `> ⚡ 已被 [[new-page]] 取代,请优先看新条目`
4. kb-lint 会自动校验双向一致性(`supersedes` ↔ `superseded-by` 必须互指)
**为什么不删旧条目**:保留历史 = 保留证据链 = 让人能看到"我们以前是这么以为的,后来发现不对"。这是 wiki vs RAG 的关键差异。
---
## 4. 双链规范(核心)
**任何提到其他条目的地方都要 `[[双括号]]`**。这是 wiki 的命脉。
- 条目里第一次提到一个概念时必须 `[[Cross-Reference]]`
- 同一段反复出现可以省略,避免视觉噪音
- 链接目标必须是**已存在的 wiki 文件名(不带 .md)**或 stub
- 如果引用了未来要写的概念,建一个 stub:仅包含 frontmatter + 一句话占位,`status: stub`
**不要"裸引"**:不要写 "Karpathy 提出...",要写 "[[Andrej-Karpathy]] 提出..."。
### 4.1 Typed Relations(v2.7+)
正文里的 `[[]]` 保持**原文可读**(Wikipedia 风格,不污染阅读体验)。**关系类型放 frontmatter `relations:` 字段**,由机器使用(kb-graph 着色 / kb-lint 校验 / 推理)。
**允许的关系类型**(封闭枚举):
| 类型 | 含义 | 何时用 |
|---|---|---|
| `uses` | 使用了某概念/工具 | "本概念在实现里用到 X" |
| `depends-on` | 强依赖 | "没有 X 本概念无法成立" |
| `extends` | 在父概念基础上扩展 | "本概念是 X 的延伸/特例" |
| `part-of` | 是更大概念的子部件 | "本概念是 X 的一个组成部分" |
| `contradicts` | 与某条结论冲突 | "本条与 X 主张相反" — kb-lint 会报警 |
| `supersedes` | 取代某旧条目 | 见 §3.1 |
| `superseded-by` | 被某新条目取代 | 通常自动维护,见 §3.1 |
| `related` | 通用关联 | 其他都不合适时的兜底 |
**示例**:
```yaml
relations:
uses: [Karpathy-Wiki-Pattern, Obsidian]
depends-on: [obsidian-cli]
contradicts: [RAG-Pipeline]
supersedes: [old-knowledge-base-design]
related: [LLM-as-Librarian]
```
**重要规则**:
- frontmatter `relations.<type>` 列出的页**必须**也用 `[[]]` 在正文里出现至少一次,反过来不强制(正文里有 `[[X]]` 但没列 `relations:` 视为 `related`)
- `contradicts` 双向:A `contradicts: [B]` 时,建议 B 也 `contradicts: [A]`(kb-lint 给 warning 不强制)
- `supersedes` ↔ `superseded-by` **强制双向**(kb-lint 强制 error)
---
## 5. 标准段落结构
每个概念页推荐分段(按需取舍,不强制全有):
```markdown
# 标题
## 一句话定义
(一行;让人秒懂"这是什么")
## 详解
(核心展开,引用其他条目用 [[]])
## 与相关概念的关系
- 与 [[X]] 的区别:...
- 与 [[Y]] 的协作:...
## 出处
- [文章标题](原始 URL) — 2026-04-23 ingest
- raw/2026-04-23/xxx.md(本地素材)
## 待办
- [ ] 待补:xxx
```
---
## 6. ingest 时 librarian 必须做的事
当用户喂一个新 raw 文档时:
1. **读 raw**,识别 5-15 个核心概念
2. **扫 wiki/**(看 index.md 和文件名列表),判断哪些概念已存在
3. 对**已存在的概念**:在它们的页里追加内容 + 加 `[[新建概念]]` 反链
4. 对**新概念**:建新 wiki 页(按上面规范)
5. **写 log.md**:追加 `## [日期] ingest | <raw 文件名> | 影响 N 页`
6. **更新 index.md**:把新建/更新的概念加到对应分组
---
## 7. 查询(kb-ask)时 librarian 必须做的事
当用户问问题时:
1. 在 wiki/ 里**先 grep 关键词**,再读相关页全文(**不要只看摘要**)
2. 综合答案,**每个事实性断言都要带 `[[页名]]` 引用**
3. 如果发现了 wiki 里没记录的洞察 → 提示用户用 `kb-ask --save` 把答案归档为新条目
4. 如果发现 wiki 有矛盾或过时 → 在答案里标注,并写一条 lint TODO
---
## 8. lint 守则
定期跑 `kb-lint`。重点关注:
- **断链**([[X]] 但 X.md 不存在)→ 要么建 stub 要么改链接
- **孤儿**(没人链入它的条目)→ 要么从相关条目加反链,要么考虑合并/删除
- **stub 累积**(status: stub 太多)→ 优先补全
- **stale**(last_updated 超 90 天 + status:draft)→ 重读、更新或归档
- **缺出处**(sources: 为空)→ 危险信号,可能是 LLM 自己编的
- **supersession 不对称**(A `supersedes: [B]` 但 B 没 `superseded-by: [A]`)→ 修双向(v2.7+)
- **未声明的 contradicts**(两页都 `confidence ≥ 0.7` 但内容矛盾)→ 二次审阅,决定 supersession 或都改 confidence(v2.7+)
- **未知关系类型**(`relations:` 用了枚举外的 key)→ 改成兜底的 `related`(v2.7+)
- **低信度高声调**(`confidence < 0.5` 但正文里全是断言句,无 TODO 标记)→ 加 `<!-- TODO: 待核实 -->`(v2.7+)
---
## 9. 一切的目的
> "instead of just retrieving from raw documents at query time, the LLM **incrementally builds and maintains a persistent wiki**" — Karpathy
人类策划素材、提问;**你(LLM)做剩下所有事**:摘要、交叉引用、一致性维护、log 记录。
人类的判断仍然是核心,你只是执行者。
---
## 10. 与本系统的扩展约定
- **scope**:本 wiki 可能是 `agent`(私有)或 `shared`(跨 Agent)。两者结构相同,规范相同。
- **Obsidian 同步**:同步到 `vault/知识库/<scope>/`,所以你的双链格式必须和 Obsidian 兼容(标准 `[[文件名]]`)。
- **不要在 wiki 里写 child_process、shell 注入、密钥**:这个库可能被同步到云端 / 公司 KB。
验证模式 — 检查工作成果、运行测试、验证假设。借鉴 Claude Code 的 Verification Agent。
---
name: huo15-openclaw-verify-mode
version: 2.2.0
description: "验证模式 — 检查工作成果、运行测试、验证假设。借鉴 Claude Code 的 Verification Agent。"
homepage: https://github.com/zhaobod1/huo15-openclaw-enhance
metadata: { "openclaw": { "emoji": "✅", "requires": { "bins": [] } } }
---
# 验证模式 (Verify Mode)
系统性地验证工作成果或假设。
## 使用时机
✅ **使用此技能当:**
- 完成一段代码修改后需要验证
- "帮我检查一下这个改动有没有问题"
- 需要运行测试并分析结果
- 验证一个假设或排查一个 bug
❌ **不要使用当:**
- 只是普通的代码 review(直接 review 即可)
- 改动很小且显而易见正确
## 验证流程
### 1. 确定验证目标
- 要验证什么?(功能正确性 / 性能 / 安全 / 兼容性)
- 成功标准是什么?
- 已知的风险点?
### 2. 静态检查
- 读改动的代码,检查逻辑正确性
- 检查边界条件和错误处理
- 检查是否引入了安全漏洞(注入、XSS 等)
- 检查是否破坏了已有接口或行为
### 3. 动态验证
- 运行已有的测试套件
- 如果没有测试,手动构造测试场景
- 检查构建是否通过
- 如果有 linter/formatter,运行一下
### 4. 输出验证报告
```
## 验证报告
### 验证对象
[简要描述被验证的改动/假设]
### 检查清单
- [x] 逻辑正确性: 通过/发现问题
- [x] 边界条件: ...
- [x] 测试结果: X pass / Y fail
- [x] 构建状态: 通过/失败
- [ ] 安全检查: ...
### 发现的问题
1. [问题描述 + 严重程度 + 建议修复方式]
### 结论
✅ 验证通过 / ⚠️ 有待修复的问题 / ❌ 验证失败
```
## 核心原则
- **全面但聚焦** — 覆盖主要风险点,但不要检查无关的东西
- **给出证据** — "测试 X 通过了"、"第 42 行可能有空指针",不要泛泛地说"看起来没问题"
- **区分严重程度** — 阻塞性问题 vs 建议性改进
- **可操作** — 发现问题就给出具体修复建议
【青岛火一五信息科技有限公司】企业级 Word & PDF 文档生成 v7.2。12 类规范(公文/合同/会议纪要/技术方案/需求文档/工作报告/商业计划书/用户手册/培训手册/招投标书/演讲稿/研究报告)。三条路径:Word 直出、原生 PDF 直出、Word→PDF。v7.2 修复:合同页眉改为左对齐(LOG...
---
name: huo15-openclaw-office-doc
displayName: 火一五文档技能
description: 【青岛火一五信息科技有限公司】企业级 Word & PDF 文档生成 v7.2。12 类规范(公文/合同/会议纪要/技术方案/需求文档/工作报告/商业计划书/用户手册/培训手册/招投标书/演讲稿/研究报告)。三条路径:Word 直出、原生 PDF 直出、Word→PDF。v7.2 修复:合同页眉改为左对齐(LOGO + 公司名)、`**Key:**` markdown 粗体元数据正确识别(合同编号/签订日期/验收日期/甲乙方/金额等 30+ 关键词扩入白名单)、连续多行 KV 自动归并为 2 列元数据表。触发词:写word、写文档、生成word、创建文档、写合同、写方案、写报告、写会议纪要、写需求文档、写商业计划书、写BP、写用户手册、写培训手册、写招标书、写投标书、写演讲稿、写研究报告、写白皮书、生成PDF、写PDF、Word转PDF。
version: 7.2.0
aliases:
- 火一五文档技能
- 文档生成
- Word生成
- PDF生成
- 多规范文档
dependencies:
python-packages:
- python-docx
- reportlab
- pygments # 可选;装了即代码块语法高亮
---
# 火一五文档技能 v7.2
> 企业级 Word & 原生 PDF 文档生成 — 青岛火一五信息科技有限公司
**愿景:** 加速企业向全场景人工智能机器人转变
**理念:** 打破信息孤岛,用一套系统驱动企业增长
---
## 一、v7.1 关键变化(业界对标后的工程化升级)
> 调研对象:Pandoc reference-doc、Quarto、Typst、markdocx、pandoc-crossref、docxtpl、msoffcrypto-tool;最终挑了 **ROI 最高的 7 项**落地。
1. **CJK 段落属性 OOXML 直写** — 每个段落注入 `autoSpaceDE/DN/kinsoku/wordWrap/overflowPunct`,让 Word/WPS 自动处理"中英文之间空格""数字与中文之间空格""行首禁则字"等中文排版细节。是 Word 内置能力,不加依赖。
2. **首行缩进字符化** — 用 `firstLineChars=200`(200 = 2 字符)替代 `firstLine=cm`。公文规范要求"首行缩进 2 字符",cm 在不同字号下视觉就跑偏;字符化后跨字号一致。
3. **Pygments 代码块语法高亮** — 30+ 语言、VS Code Light 主题、关键字 / 函数名 / 字符串 / 注释独立着色;Word 端写带颜色 run,PDF 端写 `<font color>` HTML。无 pygments 时静默回落到等宽灰字。
4. **自动 TOC + 标题书签 + outlineLvl** — 标题 paragraph 注入 `_Toc%08d` 书签 + `outlineLvl`;正式规范(技术方案 / 需求文档 / 用户手册 / 招投标书 / 商业计划书 / 培训手册 / 研究报告)默认在标题之后插 `TOC \\o "1-3"` 字段;`settings.xml` 的 `updateFields=true` 让 Word/WPS 打开时自动刷新。
5. **PDF 原生 outline / 文档大纲** — reportlab 用 `BaseDocTemplate.afterFlowable` 钩子把每个标题写成 `bookmarkPage` + `addOutlineEntry`;PDF 阅读器侧边栏直接显示嵌套大纲,可点击跳转。
6. **文档核心属性 + 文档信息** — `core.xml` 写入 title / author / subject / keywords / category / created / modified;PDF 同步元数据。便于投标书系统、OA 全文检索、合规审计。
7. **多行 Key:Value 元数据自动识别(style B)** — 之前要求用 `|` 分隔的元数据行才能被识别;v7.1 加入"连续多行 `课题:xxx / 日期:xxx / 关键词:xxx`"自动归并为 2 列元数据表格的能力。已知关键词扩到 70+。
---
## 二、v7.0 关键变化(前一版本,保留作背景)
1. **原生 PDF 直出**(`create-pdf-doc.py`,新增)— 不经过 Word,reportlab 直接写 PDF;与 Word 渲染共用同一份解析器与规范预设;自带 CJK 字体三层回落与两遍渲染的 `第 X 页 / 共 Y 页` 真页码。
2. **Word→PDF 全面重写**(`word-to-pdf.py` v2.0)— 修掉旧版 main 里的 argparse 语法错;多后端策略 `LibreOffice / docx2pdf / Word COM` 自动回落;macOS / Linux / Windows 路径全覆盖;输出后自动校验 PDF 头有效性;默认嵌入字体避免接收方替换字体。
3. **CJK 软换行不再加空格** — 旧版 `' '.join(lines)` 在中文段落里加多余空格;v7.0 在 CJK ↔ CJK 边界自动跳过空格,ASCII ↔ ASCII 仍保留单空格。
4. **硬换行支持** — Markdown 标准的"行尾 2 空格 ` `"和 CommonMark 扩展"行尾反斜杠 `\`"现在都会被识别成 `<w:br/>` / `<br/>`。
5. **页眉强制左对齐** — 直接写 OOXML `<w:jc w:val="left"/>` 并清掉 `Header` 样式继承的 tab stops;旧版在 WPS / 部分 Word 模板上偶发"页眉飘到中间"的问题彻底修复。
6. **新增 6 类规范** — 商业计划书 / 用户手册 / 培训手册 / 招投标书 / 演讲稿 / 研究报告,从 v6 的 6 类升到 12 类。
7. **解析器拆出** — 共享核心 `doc_core.py`:FormatPreset、12 类预设、Block AST 解析、内联 token 拆分、公司信息回落都在这里;Word / PDF 渲染端都从它读,确保两份输出在版式语义上一致。
8. **显式分页符** — `---PAGE---` / `\pagebreak` / `<!-- pagebreak -->` 插入分页。
---
## 二、12 类文档规范
| 规范 | 触发关键词(标题或正文前 800 字) | 默认版本历史 | 默认审批区 | 页眉布局 |
|------|----------------------------------|-----------|----------|---------|
| 公文 | 默认(未命中其他关键词) | ✅ | ✅ | LOGO+公司名+编号+密级(左) |
| 合同 | 合同 / 协议 / 协议书 | ❌ | ❌ | LOGO+公司名(左,简洁) |
| 会议纪要 | 会议纪要 / 纪要 | ❌ | ❌ | LOGO+公司名(左) |
| 技术方案 | 技术方案 / 实施方案 / 解决方案 / 设计文档 / 架构设计 | ✅ | ✅ | LOGO+公司名+编号+密级(左) |
| 需求文档 | 需求规格 / 需求说明 / SRS / PRD / 需求文档 | ✅ | ✅ | LOGO+公司名+编号+密级(左) |
| 工作报告 | 工作报告 / 周报 / 月报 / 季报 / 年报 / 述职报告 / 汇报材料 | ❌ | ❌ | LOGO+公司名(左) |
| **商业计划书** | 商业计划书 / 商业计划 / BP / 融资计划书 / 路演稿 | ✅ | ❌ | LOGO+公司名+编号+密级(左) |
| **用户手册** | 用户手册 / 操作手册 / 使用说明 / 用户指南 / 产品手册 / Manual | ✅ | ❌ | LOGO+公司名(左,简洁) |
| **培训手册** | 培训手册 / 培训教材 / 教学大纲 / 员工手册 / 入职手册 | ✅ | ❌ | LOGO+公司名(左) |
| **招投标书** | 招标书 / 投标书 / 招投标 / 投标文件 / 招标文件 / 响应文件 | ✅ | ✅ | LOGO+公司名+编号+密级(左) |
| **演讲稿** | 演讲稿 / 致辞稿 / 讲话稿 / 主题分享 / 开闭幕辞 / 颁奖辞 | ❌ | ❌ | LOGO+公司名(左,简洁) |
| **研究报告** | 研究报告 / 学术论文 / 调研报告 / 白皮书 / 行业报告 / Whitepaper | ✅ | ❌ | LOGO+公司名+编号+密级(左) |
> 用 `--doc-format <规范>` 覆盖自动识别;`--with-version-history / --no-version-history / --with-approval / --no-approval` 精细控制附加表格。
### 标题层级识别(每种规范独立)
每种规范都有自己的章节编号正则;同时支持标准 Markdown `# / ## / ### / ####`。例如:
| 规范 | 一级(chapter) | 二级(section) | 三级(article) |
|------|---------------|---------------|----------------|
| 公文 | 第X章/第X节 | 一、二、三、 | (一)(二) |
| 合同 | 第X章/第X条 | 一、二、 | — |
| 商业计划书 | 第X部分/一、二、 | 1.1 | 1.1.1 |
| 用户手册 | 第X章 | X. | X.X |
| 培训手册 | 模块X/单元X/第X课 | 一、二、 | X.X |
| 招投标书 | 第X章/篇/部分 | 一、二、 | (一)(二) |
| 研究报告 | 摘要/Abstract/引言/结论/参考文献/一、 | X. | X.X |
---
## 三、页眉 / 页脚规范
### 3.1 页眉
- **company**(默认):LOGO + 公司名 + 文档编号 + 密级,**左对齐**
- **minimal**(合同 / 用户手册 / 演讲稿):LOGO + 公司名,**左对齐**,不显示编号 / 密级
- **centered**(保留备选,当前无规范默认走此项):仅公司名,居中
- 底部统一灰线 `#888888`
> v7.0 直接写 OOXML `<w:jc>` 并清 `<w:tabs>`,避免 WPS / 部分 Word 模板的样式继承覆盖。
### 3.2 页脚
- 所有规范统一为 `第 X 页 / 共 Y 页`,居中
- Word:`PAGE` / `NUMPAGES` 字段码(打开时自动计算)
- PDF:两遍渲染(NumberedCanvas)拿到真总页数
---
## 四、本地公司信息工作流
页眉的公司名、LOGO 按以下优先级解析:
1. **CLI 显式参数** `--company-name` / `--logo-path`
2. **本地缓存** `~/.huo15/company-info.json`
3. **Odoo `res.company`** 自动拉取(可用 `--no-odoo` 关闭)
4. **退出码 2 + 结构化 JSON** — 以上都拿不到时让 Claude 触发补录流程
### 4.1 标准流程(生成前)
```bash
python3 scripts/company-info.py check
# exit 0 + 完整 JSON → 直接生成
# exit 2 + missing[] → 进入补录
```
**补录流程**(Claude 执行):
1. 先查 auto-memory 中的 `huo15_company_info.md` / `user_identity.md`
2. 仍缺失时用 `AskUserQuestion` 询问:公司全称、LOGO 路径、可选 slogan / 地址 / 电话 / 邮箱 / 官网
3. 写入:
```bash
python3 scripts/company-info.py set \
--company-name "<公司全称>" --logo-path "<LOGO绝对路径>"
```
4. 同步写入 memory(`huo15_company_info.md`)
---
## 五、命令行
### 5.1 Word 直出
```bash
python3 scripts/create-word-doc.py \
--output 方案.docx \
--title "技术方案:XXX系统" \
--content @/tmp/content.md \
--doc-number "HG-FA-2026-001" \
--version "V1.0" \
--classification "内部" \
--author "辉火云管家·贾维斯"
# 可选:
# --doc-format 技术方案 # 12 类规范任选;'auto' 自动识别
# --company-name "XX科技"
# --logo-path /path/to/logo.png
# --no-odoo
# --with-version-history / --no-version-history
# --with-approval / --no-approval
```
### 5.2 PDF 直出(原生)
```bash
python3 scripts/create-pdf-doc.py \
--output 方案.pdf \
--title "技术方案:XXX系统" \
--content @/tmp/content.md \
--doc-format 技术方案 \
--doc-number "HG-FA-2026-001"
```
> 原生 PDF 不依赖 LibreOffice / Office;只要装了 `reportlab` 与系统 CJK 字体即可。
> macOS 自带 Songti.ttc / STHeiti.ttc;Linux 推荐 Noto CJK;Windows 可用 SimSun / SimHei。
### 5.3 Word → PDF(保留 Word 原版式,再转)
```bash
python3 scripts/word-to-pdf.py 方案.docx -o 方案.pdf
# 批量
python3 scripts/word-to-pdf.py "*.docx" --output-dir ./pdf/
# 列出可用后端
python3 scripts/word-to-pdf.py --list-backends
# 指定后端 / 不嵌入字体
python3 scripts/word-to-pdf.py 方案.docx --backend libreoffice --no-embed-fonts
```
后端优先级:`libreoffice → docx2pdf → word_com`,自动回落,转换后校验 PDF 头。
### 5.4 何时用哪条路径?
| 场景 | 推荐 |
|------|------|
| 只交付 Word;可能再编辑 | `create-word-doc.py` |
| 只交付 PDF;不需要 Word;要可控版式 | `create-pdf-doc.py`(原生,最快) |
| 同时要 Word 和 PDF;版式必须一致 | `create-word-doc.py` + `word-to-pdf.py` |
| 已有 docx 想转 PDF | `word-to-pdf.py` |
---
## 六、Markdown 能力速查
| 元素 | 写法 | 说明 |
|------|------|------|
| 标题 | `#`~`######` | 也支持规范专属编号(一、 / 1. / 1.1 / 第X章) |
| 段落软换行 | 直接换行 | CJK ↔ CJK 不插入空格;ASCII 仍保留空格 |
| 段落硬换行 | 行尾 ` ` 或 `\` | 同段内强制换行 |
| 列表 | `- item` / `* item` / `1. item` | |
| 强调 | `**粗**` / `*斜*` / `` `inline code` `` | |
| 表格 | 标准 GFM | 缺前导 `|` / 转义 `\|` / 2 列起即可识别 |
| 代码块 | `` ``` ``...`` ``` `` | 等宽灰底;带语言标签 |
| 引用块 | `> ...` | 左侧橘色竖条 + 灰色段 |
| 分隔线 | `---` / `***` / `___` | |
| 元数据行 | `文档编号:XX | 版本:V1.0 | 密级:内部 | 日期:2026-04-27` | 自动两列表格 |
| 分页符 | `---PAGE---` / `\pagebreak` / `<!-- pagebreak -->` | 强制下一页 |
| 空内容 | — | 写"(无正文内容)"灰字占位 |
---
## 七、Python API
```python
# Word
from create_word_doc import create_word_doc
create_word_doc(
output_path="文档.docx",
title="技术方案:XXX系统",
content=md_text,
doc_number="HG-FA-2026-001",
version="V1.0",
classification="内部",
author="辉火云管家·贾维斯",
doc_format="auto", # 12 类规范名 / 'auto'
)
# PDF
from create_pdf_doc import create_pdf_doc
create_pdf_doc(output_path="文档.pdf", title="...", content=md_text,
doc_format="商业计划书")
# Word → PDF
from word_to_pdf import convert_to_pdf
ok, path = convert_to_pdf("方案.docx", "方案.pdf",
backend="auto", keep_fonts=True)
```
> 缺公司信息时三个入口都抛 `RuntimeError`,message 是结构化 JSON,Claude 据此触发补录。
---
## 八、触发词
- 写 word / 写文档 / 写个文档 / 生成 word / 生成文档 / 创建文档
- 导出 word / 导出文档 / 写合同 / 写方案 / 写报告 / 写会议纪要
- 写需求文档 / 写 SRS / 写 PRD
- **写 PDF / 生成 PDF / 导出 PDF / Word 转 PDF**
- 写商业计划书 / 写 BP / 写融资计划书 / 写路演稿
- 写用户手册 / 写操作手册 / 写使用说明 / 写产品手册
- 写培训手册 / 写培训教材 / 写员工手册
- 写招标书 / 写投标书 / 写标书 / 写响应文件
- 写演讲稿 / 写致辞 / 写讲话稿
- 写研究报告 / 写白皮书 / 写学术论文 / 写调研报告
---
## 九、目录结构
```
scripts/
├── doc_core.py # 共享核心:12 类预设 + Block AST 解析 + 内联 token
├── company-info.py # 本地公司信息读写 + Odoo 回落
├── create-word-doc.py # Word 渲染(python-docx + 强制 OOXML jc)
├── create-pdf-doc.py # 原生 PDF 渲染(reportlab + NumberedCanvas)
└── word-to-pdf.py # Word → PDF 多后端转换
```
---
## 十一、未来路线(已调研、未实施)
| 功能 | 业界参考 | 优先级 | 复杂度 | 拟引入依赖 |
|------|---------|--------|-------|----------|
| LaTeX → OMML 公式管线 | markdocx / Pandoc | 中 | 中 | latex2mathml + XSLT |
| reference docx 模板继承 | Pandoc / Quarto | 中 | 中 | 仅模板文件 |
| Typst 第四条 PDF 路径(30× 速度) | typst.app + zh-kit | 中 | 中 | typst 二进制 |
| pandoc-crossref 风格交叉引用 `{#fig:xxx}` | pandoc-crossref | 中 | 中 | — |
| 水印 + AES 加密 | msoffcrypto-tool | 低 | 小 | msoffcrypto-tool |
| 修订追踪 / 批注 | docx-revisions | 低 | 大 | docx-revisions |
| docxtpl Jinja2 模板槽 | docxtpl | 低 | 小 | docxtpl |
> 任何一项触发刚需时再上;当前以稳定 + 中文友好 + 易维护为先。
---
## 十、版本历史
- **v7.2.0(当前)**
- **修复**:合同 / 协议页眉从 `centered`(仅公司名居中)改为 `minimal`(LOGO + 公司名左对齐),与正文左对齐保持一致
- **修复**:`**合同编号:** HHY-IOT-2025xxx` 这类 markdown 粗体包裹的 Key:Value 现在能被识别。原来三行连写会被 `_smart_join_paragraph` 拼成一段,丢掉换行。现在自动归并为 2 列元数据表。
- **元数据关键词扩展**:合同编号 / 合同号 / 协议编号 / 订单编号 / 报价编号 / 工单编号 / 发票编号 / 凭证编号 / 签订日期 / 签约日期 / 签署日期 / 生效日期 / 失效日期 / 验收日期 / 交付日期 / 起止日期 / 完成日期 / 甲方 / 乙方 / 丙方 / 签约方 / 发包方 / 承包方 / 采购方 / 供应商 / 金额 / 总价 / 单价 / 数量 / 币种 / 含税 / 税率 / 税额 / 付款方式 / 付款条件 / ContractNo / ContractNumber
- **`_try_kv_line` 现在剥掉首尾的 markdown 包裹符**(`*` / `~` / `` ` `` / 空白),所以 `*Key:* value`、`` `Key:` value ``、`**Key:** value` 都能被识别
- Key 长度上限从 16 → 24 字符,覆盖被 markdown 包裹后变长的 key
- **v7.1.0**
- 业界调研后挑出 7 项落地:CJK 段落属性、字符化首行缩进、Pygments 代码高亮、自动 TOC + 书签、PDF outline、文档核心属性、多行 Key:Value 元数据
- `doc_core.py` 内置 VS Code Light token 颜色 map,Word / PDF 共用
- 已知元数据关键词扩到 70+(覆盖课题 / 关键词 / 单位 / 联系人 / 状态 / 期限等)
- **v7.0.0**
- 解析器拆出 `doc_core.py`,Word / PDF 共用
- 新增 `create-pdf-doc.py` 原生 PDF 直出(reportlab + 两遍渲染真页码 + CJK 字体三层回落)
- 新增 6 类规范:商业计划书 / 用户手册 / 培训手册 / 招投标书 / 演讲稿 / 研究报告(共 12 类)
- 修复 CJK 软换行多余空格;新增 ` ` / `\` 硬换行
- 强制页眉左对齐(直接写 OOXML jc + 清 tab stops)
- `word-to-pdf.py` 重写:修语法错、跨平台后端、字体嵌入、自动校验
- 显式分页符 `---PAGE---` / `\pagebreak`
- **v6.0.0**:Block AST 重写;页眉恒含 LOGO;页脚字段码;代码块 / 引用块
- **v5.3.0**:`company-info.py` 本地公司信息工具
- v5.0.0:多规范自动识别骨架
---
**技术支持:** 青岛火一五信息科技有限公司
FILE:_meta.json
{
"ownerId": "kn7byevkn40d6z4p7ghdb097z983tj33",
"slug": "huo15-openclaw-office-doc",
"version": "7.2.0"
}
FILE:scripts/company-info.py
#!/usr/bin/env python3
"""
company-info.py - 本地公司信息读写工具
职责:
1. 读取 ~/.huo15/company-info.json 作为主缓存
2. 若缺失关键字段(company_name / logo_path),可选回落 Odoo(第三优先级)
3. 提供 CLI:--get / --set / --check
- --check:对 SKILL 工作流友好,返回 JSON + 退出码(0 完整 / 2 缺失)
关键字段:
company_name 公司全称(必填)
logo_path LOGO 图片绝对路径(必填)
slogan 口号 / 页眉副标题(可选)
address 注册 / 办公地址(可选)
phone 联系电话(可选)
email 联系邮箱(可选)
website 官网(可选)
"""
import os
import sys
import json
import ssl
import argparse
import urllib.request
HOME = os.path.expanduser("~")
HUO15_DIR = os.path.join(HOME, ".huo15")
ASSETS_DIR = os.path.join(HUO15_DIR, "assets")
CONFIG_PATH = os.path.join(HUO15_DIR, "company-info.json")
DEFAULT_LOGO_PATH = os.path.join(ASSETS_DIR, "logo.png")
REQUIRED_FIELDS = ("company_name", "logo_path")
OPTIONAL_FIELDS = ("slogan", "address", "phone", "email", "website")
ALL_FIELDS = REQUIRED_FIELDS + OPTIONAL_FIELDS
def load_config():
if not os.path.exists(CONFIG_PATH):
return {}
try:
with open(CONFIG_PATH, "r", encoding="utf-8") as fh:
data = json.load(fh)
return data if isinstance(data, dict) else {}
except (OSError, json.JSONDecodeError):
return {}
def save_config(data):
os.makedirs(HUO15_DIR, exist_ok=True)
clean = {k: v for k, v in data.items() if k in ALL_FIELDS and v}
with open(CONFIG_PATH, "w", encoding="utf-8") as fh:
json.dump(clean, fh, ensure_ascii=False, indent=2)
os.chmod(CONFIG_PATH, 0o600)
return clean
def logo_is_valid(path):
return bool(path) and os.path.exists(path) and os.path.getsize(path) > 1000
def try_odoo_fallback(info):
"""第三优先级:尝试从 Odoo 拉取公司名与 LOGO。
仅当本地 JSON 仍然缺字段时才会跑。任何异常都静默失败。
"""
import xmlrpc.client
creds_file = os.path.join(
HOME, ".openclaw", "agents",
os.environ.get("OC_AGENT_ID", "main"),
"odoo_creds.json",
)
cfg_file = os.path.join(HOME, ".openclaw", "openclaw.json")
if not (os.path.exists(creds_file) and os.path.exists(cfg_file)):
return info
try:
with open(creds_file, encoding="utf-8") as fh:
creds = json.load(fh)
with open(cfg_file, encoding="utf-8") as fh:
cfg = json.load(fh)
odoo_env = cfg.get("skills", {}).get("entries", {}).get("huo15-odoo", {}).get("env", {})
url = odoo_env.get("ODOO_URL", "https://huihuoyun.huo15.com")
db = odoo_env.get("ODOO_DB", "huo15_prod")
user = creds.get("user", "")
password = creds.get("password", "")
if not (user and password):
return info
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
common = xmlrpc.client.ServerProxy(f"{url}/xmlrpc/2/common", context=ctx)
uid = common.authenticate(db, user, password, {})
if not uid:
return info
models = xmlrpc.client.ServerProxy(f"{url}/xmlrpc/2/object", context=ctx)
data = models.execute_kw(
db, uid, password, "res.company", "search_read",
[[("id", "=", 1)]], {"fields": ["name", "logo"], "limit": 1},
)
if not data:
return info
if not info.get("company_name"):
info["company_name"] = data[0].get("name") or info.get("company_name")
logo_id = data[0].get("logo")
if logo_id and not logo_is_valid(info.get("logo_path")):
os.makedirs(ASSETS_DIR, exist_ok=True)
try:
urllib.request.urlretrieve(
f"{url}/web/image/res.company/{logo_id}/logo",
DEFAULT_LOGO_PATH,
)
if logo_is_valid(DEFAULT_LOGO_PATH):
info["logo_path"] = DEFAULT_LOGO_PATH
except Exception:
pass
except Exception:
pass
return info
def resolve(use_odoo=True):
"""按优先级返回公司信息字典(未必完整)。"""
info = load_config()
if not logo_is_valid(info.get("logo_path")) and logo_is_valid(DEFAULT_LOGO_PATH):
info["logo_path"] = DEFAULT_LOGO_PATH
missing = [k for k in REQUIRED_FIELDS if not info.get(k)]
if missing and use_odoo:
info = try_odoo_fallback(info)
if info.get("company_name") or info.get("logo_path"):
save_config(info)
return info
def check(use_odoo=True):
"""返回 (info, missing_fields)。"""
info = resolve(use_odoo=use_odoo)
missing = [k for k in REQUIRED_FIELDS if not info.get(k)]
if "logo_path" in info and not logo_is_valid(info.get("logo_path")):
if "logo_path" not in missing:
missing.append("logo_path")
return info, missing
def cmd_get(args):
info, missing = check(use_odoo=not args.no_odoo)
print(json.dumps({"info": info, "missing": missing, "path": CONFIG_PATH},
ensure_ascii=False, indent=2))
return 0 if not missing else 2
def cmd_set(args):
info = load_config()
for field in ALL_FIELDS:
value = getattr(args, field.replace("-", "_"), None)
if value is not None:
info[field] = value
if args.clear:
for field in args.clear:
info.pop(field, None)
saved = save_config(info)
print(json.dumps({"info": saved, "path": CONFIG_PATH},
ensure_ascii=False, indent=2))
return 0
def cmd_check(args):
info, missing = check(use_odoo=not args.no_odoo)
result = {
"info": info,
"missing": missing,
"complete": not missing,
"path": CONFIG_PATH,
}
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0 if not missing else 2
def build_parser():
parser = argparse.ArgumentParser(description="huo15 公司信息读写工具")
sub = parser.add_subparsers(dest="cmd", required=True)
p_get = sub.add_parser("get", help="读取当前公司信息")
p_get.add_argument("--no-odoo", action="store_true", help="跳过 Odoo 回落")
p_get.set_defaults(func=cmd_get)
p_set = sub.add_parser("set", help="设置 / 更新字段")
for field in ALL_FIELDS:
p_set.add_argument(f"--{field.replace('_', '-')}", default=None)
p_set.add_argument("--clear", nargs="*", default=[], help="要清空的字段列表")
p_set.set_defaults(func=cmd_set)
p_check = sub.add_parser("check", help="检查必填字段,缺失时 exit 2")
p_check.add_argument("--no-odoo", action="store_true")
p_check.set_defaults(func=cmd_check)
return parser
def main(argv=None):
parser = build_parser()
args = parser.parse_args(argv)
return args.func(args)
if __name__ == "__main__":
sys.exit(main())
FILE:scripts/create-pdf-doc.py
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
create-pdf-doc.py — 火一五企业级原生 PDF 生成器 v7.0
不经过 Word 直接生成 PDF:
- 复用 doc_core.py 的 Markdown 解析与文档规范预设(与 Word 渲染一致的版式语义)
- 12 类规范全部支持
- 页眉:LOGO + 公司名(左 / 居中 / 简洁三种布局),页脚:第 X 页 / 共 Y 页
- 中文字体三层回落:Songti / STHeiti / 系统兜底
- 缺公司信息时复用 company-info.py 的补录流程(exit 2 + 结构化 JSON)
依赖:
pip install reportlab
用法:
python create-pdf-doc.py --output 文档.pdf --title '标题' --content '...'
"""
import os
import sys
import html
import argparse
import datetime
from reportlab.lib import colors
from reportlab.lib.pagesizes import A4
from reportlab.lib.units import cm, mm
from reportlab.lib.styles import ParagraphStyle
from reportlab.lib.enums import TA_LEFT, TA_CENTER, TA_RIGHT, TA_JUSTIFY
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
from reportlab.pdfbase.pdfmetrics import registerFontFamily
from reportlab.pdfgen import canvas
from reportlab.platypus import (
BaseDocTemplate, PageTemplate, Frame, Paragraph, Spacer,
Table, TableStyle, PageBreak, KeepTogether,
Preformatted, HRFlowable, ListFlowable, ListItem, Image,
)
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import doc_core # noqa: E402
# Pygments 是可选依赖;装了即代码块高亮
try:
from pygments import lex
from pygments.lexers import get_lexer_by_name
from pygments.util import ClassNotFound
HAS_PYGMENTS = True
except ImportError: # pragma: no cover
HAS_PYGMENTS = False
# ============================================================
# 一、字体注册(CJK + 等宽)
# ============================================================
class FontRegistry:
"""跨平台 CJK 字体注册。
输出三个家族:
- 宋体家族 (CJKSongti normal / CJKSongtiBold)
- 黑体家族 (CJKHeiti normal / CJKHeitiBold)
- 等宽家族 (CJKMono normal / CJKMonoBold)
各 preset 的 font_body / font_heading 通过 PDF_FONT_MAP 落到这三家之一。
"""
SONGTI_CANDIDATES = [
# macOS
('/System/Library/Fonts/Supplemental/Songti.ttc', 0, 1),
('/System/Library/Fonts/Songti.ttc', 0, 1),
# Linux Noto
('/usr/share/fonts/opentype/noto/NotoSerifCJK-Regular.ttc', 2, 2),
('/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc', 0, 0),
# Windows
(r'C:\Windows\Fonts\simsun.ttc', 0, 0),
(r'C:\Windows\Fonts\simfang.ttf', None, None),
]
HEITI_CANDIDATES = [
# macOS
('/System/Library/Fonts/STHeiti Medium.ttc', 0, 0),
('/System/Library/Fonts/STHeiti Light.ttc', 0, 0),
# Linux
('/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc', 2, 2),
('/usr/share/fonts/truetype/wqy/wqy-microhei.ttc', 0, 0),
# Windows
(r'C:\Windows\Fonts\simhei.ttf', None, None),
(r'C:\Windows\Fonts\msyh.ttc', 0, 1),
]
MONO_CANDIDATES = [
# macOS
('/System/Library/Fonts/Menlo.ttc', 0, 1),
# Linux
('/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf', None, None),
('/usr/share/fonts/TTF/DejaVuSansMono.ttf', None, None),
# Windows
(r'C:\Windows\Fonts\consola.ttf', None, None),
(r'C:\Windows\Fonts\cour.ttf', None, None),
]
def __init__(self):
self.songti = None
self.songti_bold = None
self.heiti = None
self.heiti_bold = None
self.mono = None
self.mono_bold = None
@staticmethod
def _try_register(name, path, subface):
try:
if subface is None:
pdfmetrics.registerFont(TTFont(name, path))
else:
pdfmetrics.registerFont(
TTFont(name, path, subfontIndex=subface))
return True
except Exception:
return False
def _register_pair(self, family_name, candidates):
for path, regular_idx, bold_idx in candidates:
if not os.path.exists(path):
continue
reg_name = f'{family_name}'
bold_name = f'{family_name}Bold'
if self._try_register(reg_name, path, regular_idx):
if regular_idx == bold_idx or not self._try_register(
bold_name, path, bold_idx):
bold_name = reg_name
return reg_name, bold_name
return None, None
def register_all(self):
self.songti, self.songti_bold = self._register_pair(
'CJKSongti', self.SONGTI_CANDIDATES)
self.heiti, self.heiti_bold = self._register_pair(
'CJKHeiti', self.HEITI_CANDIDATES)
self.mono, self.mono_bold = self._register_pair(
'CJKMono', self.MONO_CANDIDATES)
# 至少要有一种 CJK 字体;用它互相回落
primary = self.songti or self.heiti
primary_bold = self.songti_bold or self.heiti_bold or primary
if not primary:
raise RuntimeError(
'未找到任何可用的中文字体(Songti / STHeiti / Noto / SimSun)。'
'请安装 LibreOffice、Microsoft Office 或 Noto CJK 字体。'
)
# 缺哪种就用 primary 兜底
if not self.songti:
self.songti, self.songti_bold = primary, primary_bold
if not self.heiti:
self.heiti, self.heiti_bold = primary, primary_bold
if not self.mono:
self.mono, self.mono_bold = primary, primary_bold
# 注册 Family,让 <b> / <i> HTML 标记生效
registerFontFamily(self.songti, normal=self.songti,
bold=self.songti_bold,
italic=self.songti,
boldItalic=self.songti_bold)
registerFontFamily(self.heiti, normal=self.heiti,
bold=self.heiti_bold,
italic=self.heiti,
boldItalic=self.heiti_bold)
registerFontFamily(self.mono, normal=self.mono,
bold=self.mono_bold,
italic=self.mono,
boldItalic=self.mono_bold)
return self
def map(self, logical_name):
"""preset 用的中文字体名 → 已注册 reportlab 字体名。"""
m = {
'宋体': self.songti,
'仿宋': self.songti,
'楷体': self.songti,
'方正小标宋简体': self.heiti,
'黑体': self.heiti,
'微软雅黑': self.heiti,
'Consolas': self.mono,
}
return m.get(logical_name, self.songti)
# ============================================================
# 二、ParagraphStyle 工厂
# ============================================================
def make_styles(preset, fonts: FontRegistry):
body_font = fonts.map(preset.font_body)
heading_font = fonts.map(preset.font_heading)
title_font = fonts.map(preset.font_title)
code_font = fonts.mono
leading_factor = preset.line_spacing
return {
'title': ParagraphStyle(
'HuoTitle', fontName=title_font,
fontSize=preset.size_title,
leading=preset.size_title * 1.4,
alignment=TA_CENTER, spaceBefore=18, spaceAfter=18,
),
'h1': ParagraphStyle(
'HuoH1', fontName=heading_font,
fontSize=preset.size_chapter,
leading=preset.size_chapter * leading_factor,
alignment=TA_LEFT, spaceBefore=18, spaceAfter=8,
textColor=colors.HexColor('#1a1a1a'),
),
'h2': ParagraphStyle(
'HuoH2', fontName=heading_font,
fontSize=preset.size_section,
leading=preset.size_section * leading_factor,
alignment=TA_LEFT, spaceBefore=14, spaceAfter=6,
textColor=colors.HexColor('#1a1a1a'),
),
'h3': ParagraphStyle(
'HuoH3', fontName=heading_font,
fontSize=preset.size_body + 1,
leading=(preset.size_body + 1) * leading_factor,
alignment=TA_LEFT, spaceBefore=8, spaceAfter=4,
),
'body': ParagraphStyle(
'HuoBody', fontName=body_font,
fontSize=preset.size_body,
leading=preset.size_body * leading_factor,
alignment=TA_JUSTIFY,
firstLineIndent=preset.first_line_indent_cm * cm,
spaceBefore=0, spaceAfter=preset.paragraph_spacing_pt,
),
'body_noindent': ParagraphStyle(
'HuoBodyNoIndent', fontName=body_font,
fontSize=preset.size_body,
leading=preset.size_body * leading_factor,
alignment=TA_JUSTIFY, firstLineIndent=0,
spaceBefore=0, spaceAfter=preset.paragraph_spacing_pt,
),
'list_item': ParagraphStyle(
'HuoList', fontName=body_font,
fontSize=preset.size_body,
leading=preset.size_body * leading_factor,
leftIndent=14, bulletIndent=0,
alignment=TA_JUSTIFY, spaceBefore=0, spaceAfter=3,
),
'code': ParagraphStyle(
'HuoCode', fontName=code_font,
fontSize=preset.size_body - 1,
leading=(preset.size_body - 1) * 1.3,
backColor=colors.HexColor('#F5F5F5'),
borderColor=colors.HexColor('#CCCCCC'),
borderWidth=0.5, borderPadding=6,
leftIndent=4, rightIndent=4, spaceBefore=4, spaceAfter=8,
textColor=colors.HexColor('#222222'),
),
'quote': ParagraphStyle(
'HuoQuote', fontName=body_font,
fontSize=preset.size_body,
leading=preset.size_body * leading_factor,
leftIndent=18, spaceBefore=4, spaceAfter=4,
textColor=colors.HexColor('#555555'),
),
'classification': ParagraphStyle(
'HuoClass', fontName=heading_font,
fontSize=preset.size_body, alignment=TA_RIGHT,
textColor=colors.HexColor('#B00000'),
spaceBefore=0, spaceAfter=0,
),
'meta_key': ParagraphStyle(
'HuoMetaKey', fontName=heading_font,
fontSize=preset.size_body - 1, alignment=TA_LEFT,
leading=(preset.size_body - 1) * 1.4,
),
'meta_val': ParagraphStyle(
'HuoMetaVal', fontName=body_font,
fontSize=preset.size_body - 1, alignment=TA_LEFT,
leading=(preset.size_body - 1) * 1.4,
),
'_body_font': body_font,
'_heading_font': heading_font,
'_title_font': title_font,
'_code_font': code_font,
}
# ============================================================
# 三、内联 → reportlab HTML
# ============================================================
def inline_to_html(text, code_font_name):
"""tokenize → escape → reportlab 受限 HTML。
text 中的 '\n' 是硬换行 → <br/>
"""
if not text:
return ''
out_parts = []
for line_idx, line in enumerate(doc_core.split_paragraph_lines(text)):
if line_idx > 0:
out_parts.append('<br/>')
for kind, payload in doc_core.tokenize_inline(line):
esc = html.escape(payload)
if kind == 'bold':
out_parts.append(f'<b>{esc}</b>')
elif kind == 'italic':
out_parts.append(f'<i>{esc}</i>')
elif kind == 'code':
out_parts.append(
f'<font face="{code_font_name}">{esc}</font>')
else:
out_parts.append(esc)
return ''.join(out_parts)
# ============================================================
# 四、Block → Flowables
# ============================================================
def render_blocks_to_flowables(blocks, styles):
flow = []
for b in blocks:
flow.extend(render_block(b, styles))
return flow
def render_block(block, styles):
btype = block['type']
if btype == 'heading':
return [render_heading(block, styles)]
if btype == 'paragraph':
text = block['text']
# 兜住规范专属中文编号 → 转标题
# 调用方需传 preset 给 detect;为简单起见这里只处理纯 markdown heading
return [render_paragraph(text, styles)]
if btype == 'list':
return render_list(block, styles)
if btype == 'table':
return render_table(block, styles)
if btype == 'code_block':
return render_code_block(block, styles)
if btype == 'blockquote':
return render_blockquote(block, styles)
if btype == 'metadata':
return render_metadata(block, styles)
if btype == 'hr':
return [HRFlowable(width='100%', color=colors.HexColor('#CCCCCC'),
thickness=0.6, spaceBefore=4, spaceAfter=6)]
if btype == 'page_break':
return [PageBreak()]
return []
_PDF_HEADING_COUNTER = [0]
def _reset_pdf_headings():
_PDF_HEADING_COUNTER[0] = 0
def render_heading(block, styles):
level = block['level']
text = block['text']
style = styles['h1'] if level <= 1 else (
styles['h2'] if level == 2 else styles['h3'])
_PDF_HEADING_COUNTER[0] += 1
anchor = f'h_{_PDF_HEADING_COUNTER[0]}'
h = inline_to_html(text, styles['_code_font'])
# reportlab Paragraph 支持 <a name="..."/> 锚点 + bookmarkLevel 自动加进 outline
bookmark = f'<a name="{anchor}"/>'
para = Paragraph(bookmark + h, style)
# 用同名属性触发 outline:reportlab 会读 paragraph.style 里的 outlineLevel
para._huo_outline = (text, anchor, min(level, 3) - 1)
return para
def render_paragraph(text, styles):
body = inline_to_html(text, styles['_code_font'])
return Paragraph(body, styles['body'])
def render_list(block, styles):
items = block.get('items', [])
ordered = block.get('ordered', False)
flow = []
for idx, item in enumerate(items, start=1):
bullet = f'{idx}. ' if ordered else '• '
html_text = bullet + inline_to_html(item, styles['_code_font'])
flow.append(Paragraph(html_text, styles['list_item']))
return flow
def render_table(block, styles):
rows = block.get('rows', [])
has_header = block.get('has_header', True)
if not rows:
return []
max_cols = max(len(r) for r in rows)
norm = [r + [''] * (max_cols - len(r)) for r in rows]
cell_style = ParagraphStyle(
'HuoTableCell', parent=styles['body_noindent'],
fontSize=styles['body'].fontSize - 1,
leading=(styles['body'].fontSize - 1) * 1.4,
alignment=TA_CENTER,
)
head_style = ParagraphStyle(
'HuoTableHead', parent=cell_style,
fontName=styles['_heading_font'],
)
data = []
for r_idx, row in enumerate(norm):
is_head = has_header and r_idx == 0
rendered = []
for cell_text in row:
html_text = inline_to_html(cell_text, styles['_code_font'])
rendered.append(Paragraph(html_text,
head_style if is_head else cell_style))
data.append(rendered)
tbl = Table(data, repeatRows=1 if has_header else 0)
cmds = [
('GRID', (0, 0), (-1, -1), 0.5, colors.HexColor('#888888')),
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
('LEFTPADDING', (0, 0), (-1, -1), 4),
('RIGHTPADDING', (0, 0), (-1, -1), 4),
('TOPPADDING', (0, 0), (-1, -1), 3),
('BOTTOMPADDING', (0, 0), (-1, -1), 3),
]
if has_header:
cmds.append(('BACKGROUND', (0, 0), (-1, 0),
colors.HexColor('#E8ECF0')))
tbl.setStyle(TableStyle(cmds))
return [Spacer(1, 4), tbl, Spacer(1, 6)]
def _highlight_code_html(code, lang, code_font_name):
"""用 Pygments 把代码切成带 <font color="#xxxxxx"> 的 reportlab HTML。
返回 None 表示无法高亮(无 Pygments 或 lexer 找不到),调用方走 fallback。
"""
if not HAS_PYGMENTS or not lang:
return None
try:
lexer = get_lexer_by_name(lang, stripall=False, ensurenl=False)
except ClassNotFound:
return None
out = []
for ttype, value in lex(code, lexer):
col = doc_core.get_token_color(repr(ttype))
# 保留空格 / 换行;reportlab Preformatted 默认尊重空白
esc = (html.escape(value)
.replace(' ', ' '))
# 转换换行为 <br/>,等宽显示
esc = esc.replace('\n', '<br/>')
if col:
out.append(f'<font color="#{col}">{esc}</font>')
else:
out.append(esc)
return ''.join(out)
def render_code_block(block, styles):
code = block.get('code', '')
lang = block.get('lang', '')
parts = []
if lang:
tag_style = ParagraphStyle(
'HuoCodeTag', parent=styles['code'],
fontSize=styles['code'].fontSize - 1,
textColor=colors.HexColor('#888888'),
backColor=None, borderWidth=0,
spaceBefore=4, spaceAfter=0, leftIndent=0, rightIndent=0,
)
parts.append(Paragraph(html.escape(lang), tag_style))
highlighted = _highlight_code_html(code, lang, styles['_code_font'])
if highlighted is not None:
# 高亮路径:用 Paragraph 渲染带颜色 HTML
hl_style = ParagraphStyle(
'HuoCodeHL', parent=styles['code'],
backColor=colors.HexColor('#F7F7F7'),
)
parts.append(Paragraph(highlighted, hl_style))
else:
# 普通路径:Preformatted 保原始空白与换行
parts.append(Preformatted(code, styles['code']))
return parts
def render_blockquote(block, styles):
"""引用块:用 1×1 Table 加左侧竖线模拟 Markdown blockquote。"""
flow = []
for line in block.get('lines', []):
if not line.strip():
continue
html_text = inline_to_html(line, styles['_code_font'])
para = Paragraph(html_text, styles['quote'])
tbl = Table([[para]], colWidths=['100%'])
tbl.setStyle(TableStyle([
('LINEBEFORE', (0, 0), (0, -1), 2.5,
colors.HexColor('#FF7043')),
('LEFTPADDING', (0, 0), (-1, -1), 10),
('RIGHTPADDING', (0, 0), (-1, -1), 4),
('TOPPADDING', (0, 0), (-1, -1), 2),
('BOTTOMPADDING', (0, 0), (-1, -1), 2),
('BACKGROUND', (0, 0), (-1, -1),
colors.HexColor('#FAFAFA')),
]))
flow.append(tbl)
flow.append(Spacer(1, 4))
return flow
def render_metadata(block, styles):
pairs = block.get('pairs', [])
if not pairs:
return []
data = []
for key, value in pairs:
k_html = inline_to_html(key or '', styles['_code_font'])
v_html = inline_to_html(value or '', styles['_code_font'])
data.append([Paragraph(k_html, styles['meta_key']),
Paragraph(v_html, styles['meta_val'])])
tbl = Table(data, colWidths=[3.0 * cm, None])
tbl.setStyle(TableStyle([
('GRID', (0, 0), (-1, -1), 0.5, colors.HexColor('#CCCCCC')),
('BACKGROUND', (0, 0), (0, -1), colors.HexColor('#F5F5F5')),
('LEFTPADDING', (0, 0), (-1, -1), 6),
('RIGHTPADDING', (0, 0), (-1, -1), 6),
('TOPPADDING', (0, 0), (-1, -1), 3),
('BOTTOMPADDING', (0, 0), (-1, -1), 3),
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
]))
return [tbl, Spacer(1, 6)]
# ============================================================
# 五、文档壳:标题、分类条、版本历史、审批表
# ============================================================
def make_classification_flowable(classification, styles):
if not classification or classification == '公开':
return []
return [Paragraph(f'【{html.escape(classification)}】',
styles['classification'])]
def make_title_flowable(title, styles):
if not title:
return []
return [Paragraph(html.escape(title), styles['title'])]
def make_doc_meta_flowable(doc_number, version, classification, author,
styles):
items = []
if doc_number:
items.append(('文档编号', doc_number))
items.append(('版本', version))
items.append(('密级', classification))
items.append(('日期', datetime.date.today().strftime('%Y-%m-%d')))
if author:
items.append(('作者', author))
return render_metadata({'pairs': items}, styles)
def make_version_history_flowable(version, date_str, author, styles):
head = Paragraph('版本历史', styles['h2'])
rows = [
['版本', '日期', '作者', '修改说明'],
[version or 'V1.0', date_str, author or '未知', '首次创建'],
]
return [Spacer(1, 6), head] + render_table(
{'rows': rows, 'has_header': True}, styles)
def make_approval_flowable(approval_list, styles):
items = approval_list if approval_list else [
{'role': '编制', 'name': ''},
{'role': '审核', 'name': ''},
{'role': '批准', 'name': ''},
]
today = datetime.date.today().strftime('%Y-%m-%d')
rows = [['角色', '姓名', '日期', '签字']]
for item in items:
role = item.get('role', '')
name = item.get('name') or '__________'
date_str = item.get('date') or (today if role == '编制' else '')
rows.append([role, name, date_str, '__________'])
head = Paragraph('审批记录', styles['h2'])
return [Spacer(1, 6), head] + render_table(
{'rows': rows, 'has_header': True}, styles)
# ============================================================
# 六、Header / Footer canvas(包含页码 N/M)
# ============================================================
def make_canvas_class(preset, company_name, logo_path,
doc_number, classification, body_font_name):
"""Two-pass canvas:先收集页面,再带总页数重绘 chrome。"""
class _HuoCanvas(canvas.Canvas):
def __init__(self, *args, **kwargs):
canvas.Canvas.__init__(self, *args, **kwargs)
self._saved_pages = []
def showPage(self):
self._saved_pages.append(dict(self.__dict__))
self._startPage()
def save(self):
total = len(self._saved_pages)
for state in self._saved_pages:
self.__dict__.update(state)
self._draw_chrome(total)
canvas.Canvas.showPage(self)
canvas.Canvas.save(self)
# --- 页眉 ---
def _draw_chrome(self, total_pages):
page_w, page_h = self._pagesize
margin_l = preset.margin_left * cm
margin_r = preset.margin_right * cm
header_y = page_h - 1.2 * cm
content_left = margin_l
content_right = page_w - margin_r
self.saveState()
font_size = preset.size_body - 2
# LOGO
logo_w = 0
if (preset.header_layout in ('company', 'minimal', 'centered')
and logo_path and os.path.exists(logo_path)):
try:
img = Image(logo_path, height=0.9 * cm)
iw, ih = img.wrap(page_w, page_h)
target_h = 0.9 * cm
target_w = iw * (target_h / ih) if ih else target_h
if preset.header_layout == 'centered':
x = (page_w - target_w
- len(company_name) * font_size * 0.7) / 2
else:
x = content_left
self.drawImage(logo_path, x, header_y - 0.15 * cm,
width=target_w, height=target_h,
mask='auto', preserveAspectRatio=True)
logo_w = target_w + 0.2 * cm
except Exception:
logo_w = 0
# 公司名 + 编号 + 密级
self.setFont(body_font_name, font_size)
text_x = content_left + logo_w
text_y = header_y + 0.05 * cm
label_parts = [company_name]
if preset.header_layout == 'company':
if doc_number:
label_parts.append(f' {doc_number}')
if classification:
label_parts.append(f' 【{classification}】')
label = ''.join(label_parts)
if preset.header_layout == 'centered':
self.drawCentredString(page_w / 2,
text_y - 0.05 * cm, label)
else:
self.drawString(text_x, text_y - 0.05 * cm, label)
# 灰线
self.setStrokeColor(colors.HexColor('#888888'))
self.setLineWidth(0.5)
self.line(content_left, header_y - 0.4 * cm,
content_right, header_y - 0.4 * cm)
# --- 页脚:第 X 页 / 共 Y 页 ---
footer_y = preset.margin_bottom * cm * 0.5
self.setFont(body_font_name, font_size)
self.drawCentredString(
page_w / 2, footer_y,
f'第 {self._pageNumber} 页 / 共 {total_pages} 页'
)
self.restoreState()
return _HuoCanvas
# ============================================================
# 七、对外入口
# ============================================================
def create_pdf_doc(output_path, title='', content='', doc_number=None,
version='V1.0', classification='内部', author=None,
company_name=None, logo_path=None, approval=None,
doc_format=None, use_odoo=True,
force_version_history=None, force_approval=None):
info = doc_core.resolve_company_info(
overrides={'company_name': company_name, 'logo_path': logo_path},
use_odoo=use_odoo,
)
missing = doc_core.company_info_missing(info)
if missing:
raise RuntimeError(doc_core.company_info_error_payload(missing))
company = info['company_name']
logo = info['logo_path']
if not doc_format or doc_format == 'auto':
doc_format = doc_core.detect_format(title, content)
preset = doc_core.get_preset(doc_format)
print(f'📄 使用文档规范: {preset.name}')
print(f'🏢 公司: {company}')
print(f'🖼 LOGO: {logo}')
fonts = FontRegistry().register_all()
styles = make_styles(preset, fonts)
# 1) 解析 Markdown
blocks = doc_core.parse_blocks(content)
# 2) 段落识别规范专属中文编号 → 标题
promoted_blocks = []
for b in blocks:
if b.get('type') == 'paragraph':
detected = doc_core.detect_heading_from_preset(b['text'], preset)
if detected is not None:
level, cleaned = detected
promoted_blocks.append({
'type': 'heading', 'level': level, 'text': cleaned,
})
continue
promoted_blocks.append(b)
# 3) Flowables
body_flow = []
body_flow.extend(make_classification_flowable(classification, styles))
body_flow.extend(make_title_flowable(title, styles))
body_flow.extend(make_doc_meta_flowable(
doc_number, version, classification, author, styles))
if promoted_blocks:
body_flow.extend(render_blocks_to_flowables(promoted_blocks, styles))
else:
empty_style = ParagraphStyle(
'HuoEmpty', parent=styles['body'],
textColor=colors.HexColor('#999999'))
body_flow.append(Paragraph('(无正文内容)', empty_style))
want_version = (force_version_history
if force_version_history is not None
else preset.has_version_history)
if want_version:
body_flow.extend(make_version_history_flowable(
version, datetime.date.today().strftime('%Y-%m-%d'),
author or '未知', styles))
want_approval = (force_approval if force_approval is not None
else preset.has_approval)
if want_approval or approval:
body_flow.extend(make_approval_flowable(approval, styles))
# 4) 构建文档
page_w, page_h = A4
frame = Frame(
x1=preset.margin_left * cm,
y1=preset.margin_bottom * cm,
width=page_w - (preset.margin_left + preset.margin_right) * cm,
height=page_h - (preset.margin_top + preset.margin_bottom) * cm,
leftPadding=0, rightPadding=0,
topPadding=0, bottomPadding=0,
showBoundary=0,
)
template = PageTemplate(id='HuoTemplate', frames=[frame])
# BaseDocTemplate 子类:自动收集标题书签 → 写入 PDF outline / 文档大纲
class _HuoDocTemplate(BaseDocTemplate):
def afterFlowable(self, flowable):
entry = getattr(flowable, '_huo_outline', None)
if entry:
heading_text, anchor, level = entry
self.canv.bookmarkPage(anchor)
self.canv.addOutlineEntry(heading_text, anchor,
level=level, closed=False)
doc = _HuoDocTemplate(
output_path, pagesize=A4,
leftMargin=preset.margin_left * cm,
rightMargin=preset.margin_right * cm,
topMargin=preset.margin_top * cm,
bottomMargin=preset.margin_bottom * cm,
title=title or '火一五企业文档',
author=author or company,
subject=preset.name,
keywords=f'{preset.name},{company}',
creator='火一五文档技能 v7.1',
)
doc.addPageTemplates([template])
# PDF 默认就显示左侧大纲(PageMode = UseOutlines)
doc._initialPageMode = ('UseOutlines', 0)
canvas_cls = make_canvas_class(
preset, company, logo, doc_number, classification,
styles['_body_font'])
_reset_pdf_headings()
doc.build(body_flow, canvasmaker=canvas_cls)
print(f'✅ PDF 已生成: {output_path}')
return output_path
# ============================================================
# 八、CLI
# ============================================================
def _parse_args(argv):
parser = argparse.ArgumentParser(
prog='create-pdf-doc',
description='火一五原生 PDF 生成器 v7.0(12 类规范)',
)
parser.add_argument('--output', '-o', required=True,
help='输出 .pdf 路径')
parser.add_argument('--title', default='')
parser.add_argument('--content', default='',
help='Markdown 正文;@file 表示从文件读取')
parser.add_argument('--doc-number', default=None)
parser.add_argument('--version', default='V1.0')
parser.add_argument('--classification', default='内部')
parser.add_argument('--author', default=None)
parser.add_argument('--doc-format', default='auto',
choices=['auto'] + doc_core.list_format_names())
parser.add_argument('--company-name', default=None)
parser.add_argument('--logo-path', default=None)
parser.add_argument('--no-odoo', action='store_true')
parser.add_argument('--with-version-history', action='store_true')
parser.add_argument('--no-version-history', action='store_true')
parser.add_argument('--with-approval', action='store_true')
parser.add_argument('--no-approval', action='store_true')
return parser.parse_args(argv[1:])
def _load_content(content_arg):
if content_arg and content_arg.startswith('@'):
with open(content_arg[1:], 'r', encoding='utf-8') as fh:
return fh.read()
return content_arg
def _flag_tristate(on, off):
if on and off:
return None
if on:
return True
if off:
return False
return None
def main(argv=None):
argv = argv if argv is not None else sys.argv
try:
args = _parse_args(argv)
create_pdf_doc(
output_path=args.output,
title=args.title,
content=_load_content(args.content),
doc_number=args.doc_number,
version=args.version,
classification=args.classification,
author=args.author,
company_name=args.company_name,
logo_path=args.logo_path,
doc_format=args.doc_format,
use_odoo=not args.no_odoo,
force_version_history=_flag_tristate(
args.with_version_history, args.no_version_history),
force_approval=_flag_tristate(
args.with_approval, args.no_approval),
)
except RuntimeError as exc:
print(str(exc), file=sys.stderr)
return 2
return 0
if __name__ == '__main__':
sys.exit(main())
FILE:scripts/create-word-doc.py
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
create-word-doc.py — 火一五企业级 Word 文档生成器 v7.0
相对 v6.0 的改进:
- 解析器拆出 doc_core.py,与 PDF 渲染器共用
- 修复 CJK 软换行(不再插入多余空格)+ 支持硬换行(行尾 2 空格 / `\\`)
- 强制页眉左对齐:直接写 OOXML jc,并清除 Header 样式自带的 tab stops
- 新增 6 类规范:商业计划书 / 用户手册 / 培训手册 / 招投标书 / 演讲稿 / 研究报告
- 列表 / 引用 / 元数据 / 表头 单元格也支持硬换行(同 paragraph 路径)
- render_inline 内的硬换行通过 `\\n` 转 <w:br/>
- 显式分页符:`---PAGE---` / `\\pagebreak`
用法:
python create-word-doc.py --output 文档.docx --title '标题' --content '...'
兼容旧位置参数:
python create-word-doc.py <输出路径> [标题] [正文] [编号] [版本] [密级] [格式]
"""
import sys
import os
import json
import argparse
import datetime
from docx import Document
from docx.shared import Pt, Cm, RGBColor
from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_BREAK
from docx.enum.table import WD_TABLE_ALIGNMENT
from docx.oxml.ns import qn
from docx.oxml import OxmlElement
# 共享解析器 + 规范预设
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import doc_core # noqa: E402
# Pygments 是可选依赖:装了走代码高亮,没装回落纯文本灰色
try:
from pygments import lex
from pygments.lexers import get_lexer_by_name
from pygments.util import ClassNotFound
HAS_PYGMENTS = True
except ImportError: # pragma: no cover
HAS_PYGMENTS = False
# ============================================================
# 一、OOXML 小工具
# ============================================================
def _set_font(run, font_name, size, bold=False, italic=False, color=None):
"""统一设置中英文字体(WPS / Word 双兼容)。"""
run.font.name = font_name
rPr = run._element.find(qn('w:rPr'))
if rPr is None:
rPr = OxmlElement('w:rPr')
run._element.insert(0, rPr)
rFonts = rPr.find(qn('w:rFonts'))
if rFonts is None:
rFonts = OxmlElement('w:rFonts')
rPr.insert(0, rFonts)
rFonts.set(qn('w:eastAsia'), font_name)
rFonts.set(qn('w:ascii'), font_name)
rFonts.set(qn('w:hAnsi'), font_name)
run.font.size = Pt(size)
run.bold = bold
run.italic = italic
if color is not None:
run.font.color.rgb = color
def _force_paragraph_alignment(paragraph, ooxml_val='left',
clear_tabs=False):
"""直接写 OOXML 的 jc 元素。比 python-docx 的 alignment 属性更可靠:
- python-docx 在某些 style 继承场景下不会真的 emit `<w:jc>`
- WPS 在 Header 样式上会无视 alignment 属性,必须显式 jc
可选清除 Header 样式自带的 tab stops(中部 / 右部)以避免视觉错位。
"""
pPr = paragraph._element.find(qn('w:pPr'))
if pPr is None:
pPr = OxmlElement('w:pPr')
paragraph._element.insert(0, pPr)
for jc in pPr.findall(qn('w:jc')):
pPr.remove(jc)
jc = OxmlElement('w:jc')
jc.set(qn('w:val'), ooxml_val)
pPr.append(jc)
if clear_tabs:
for tabs in pPr.findall(qn('w:tabs')):
pPr.remove(tabs)
def _add_border_bottom(paragraph, color='888888', sz='6'):
pPr = paragraph._element.find(qn('w:pPr'))
if pPr is None:
pPr = OxmlElement('w:pPr')
paragraph._element.insert(0, pPr)
pBdr = OxmlElement('w:pBdr')
bottom = OxmlElement('w:bottom')
bottom.set(qn('w:val'), 'single')
bottom.set(qn('w:sz'), sz)
bottom.set(qn('w:space'), '1')
bottom.set(qn('w:color'), color)
pBdr.append(bottom)
pPr.append(pBdr)
def _add_border_left(paragraph, color='CCCCCC', sz='18'):
pPr = paragraph._element.find(qn('w:pPr'))
if pPr is None:
pPr = OxmlElement('w:pPr')
paragraph._element.insert(0, pPr)
pBdr = OxmlElement('w:pBdr')
left = OxmlElement('w:left')
left.set(qn('w:val'), 'single')
left.set(qn('w:sz'), sz)
left.set(qn('w:space'), '8')
left.set(qn('w:color'), color)
pBdr.append(left)
pPr.append(pBdr)
def _set_cell_shading(cell, fill_color):
tcPr = cell._tc.get_or_add_tcPr()
shd = OxmlElement('w:shd')
shd.set(qn('w:val'), 'clear')
shd.set(qn('w:color'), 'auto')
shd.set(qn('w:fill'), fill_color)
tcPr.append(shd)
def _add_cjk_paragraph_props(paragraph):
"""为段落开启 Word 内置的 CJK 排版优化:
- autoSpaceDE:英文与中文间自动加空格
- autoSpaceDN:数字与中文间自动加空格
- kinsoku(标点挤压):避免行首禁则字(。」),行末禁则字(「)出现
- wordWrap:允许英文单词在行尾被打断(提升中文段落对齐效果)
这几个属性是 Word/WPS 的标准段落属性,不需要新依赖。
"""
pPr = paragraph._element.get_or_add_pPr()
for tag, val in [
('w:autoSpaceDE', '1'),
('w:autoSpaceDN', '1'),
('w:kinsoku', '1'),
('w:wordWrap', '1'),
('w:overflowPunct', '1'),
('w:topLinePunct', '0'),
]:
for el in pPr.findall(qn(tag)):
pPr.remove(el)
el = OxmlElement(tag)
el.set(qn('w:val'), val)
pPr.append(el)
def _set_first_line_indent_chars(paragraph, chars=2):
"""以"字符数"设置首行缩进(200 = 2 字符),跨字号保持视觉一致。
比起物理 cm 值(python-docx 默认),firstLineChars 更符合中文公文规范——
标准要求"首行缩进 2 字符",而 cm 在不同字号下视觉效果就会跑偏。
"""
pPr = paragraph._element.get_or_add_pPr()
ind = pPr.find(qn('w:ind'))
if ind is None:
ind = OxmlElement('w:ind')
pPr.append(ind)
# 清除任何已存在的 firstLine(cm dxa 单位),避免双重缩进
for attr in ['firstLine']:
if ind.get(qn(f'w:{attr}')) is not None:
del ind.attrib[qn(f'w:{attr}')]
ind.set(qn('w:firstLineChars'), str(int(chars * 100)))
def _set_paragraph_shading(paragraph, fill_color):
pPr = paragraph._element.find(qn('w:pPr'))
if pPr is None:
pPr = OxmlElement('w:pPr')
paragraph._element.insert(0, pPr)
shd = OxmlElement('w:shd')
shd.set(qn('w:val'), 'clear')
shd.set(qn('w:color'), 'auto')
shd.set(qn('w:fill'), fill_color)
pPr.append(shd)
def _add_field(paragraph, field_code, font_name, font_size):
run = paragraph.add_run()
fc_begin = OxmlElement('w:fldChar')
fc_begin.set(qn('w:fldCharType'), 'begin')
instr = OxmlElement('w:instrText')
instr.set(qn('xml:space'), 'preserve')
instr.text = f' {field_code} '
fc_end = OxmlElement('w:fldChar')
fc_end.set(qn('w:fldCharType'), 'end')
run._element.append(fc_begin)
run._element.append(instr)
run._element.append(fc_end)
_set_font(run, font_name, font_size)
# ============================================================
# 二、页眉 & 页脚
# ============================================================
def _reset_header_paragraph(para, alignment='left'):
"""彻底清空一个 header / footer 中的段落,并强制对齐。
不只清 run 文本——把段落已有 runs 全部移除,避免 WPS 里残留旧 LOGO。
然后用 OOXML 直接写 jc,并清掉 Header 样式继承的 tab stops。
"""
for r in list(para.runs):
r._element.getparent().remove(r._element)
para.paragraph_format.space_before = Pt(0)
para.paragraph_format.space_after = Pt(0)
para.paragraph_format.left_indent = Cm(0)
para.paragraph_format.right_indent = Cm(0)
para.paragraph_format.first_line_indent = Cm(0)
_force_paragraph_alignment(para, ooxml_val=alignment, clear_tabs=True)
def build_header(doc, preset, logo_path, company_name, doc_number=None,
classification=None, title=''):
"""页眉:
- 默认(公文 / 方案 / 需求 / 招投 / 工作报告等)→ LOGO + 名称(左对齐)
+ 文档编号 + 密级
- 'centered'(合同)→ 仅公司名居中
- 'minimal'(用户手册 / 演讲稿)→ LOGO + 名称(左对齐),不显示编号 / 密级
"""
section = doc.sections[0]
header = section.header
header.is_linked_to_previous = False
para = header.paragraphs[0] if header.paragraphs else header.add_paragraph()
if preset.header_layout == 'centered':
_reset_header_paragraph(para, alignment='center')
else:
_reset_header_paragraph(para, alignment='left')
# LOGO(centered 也带)
if logo_path and os.path.exists(logo_path):
try:
run = para.add_run()
run.add_picture(logo_path, height=Cm(0.9))
sep = para.add_run(' ')
_set_font(sep, '黑体', preset.size_body - 2)
except Exception as exc: # pragma: no cover
print(f'⚠️ LOGO 添加失败:{exc}', file=sys.stderr)
run = para.add_run(company_name)
_set_font(run, '黑体', preset.size_body - 2)
# 编号 / 密级 仅在 'company' 布局且非 minimal 时显示
if preset.header_layout == 'company':
if doc_number:
run = para.add_run(f' {doc_number}')
_set_font(run, '黑体', preset.size_body - 2)
if classification:
run = para.add_run(f' 【{classification}】')
_set_font(run, '黑体', preset.size_body - 2, bold=True)
_add_border_bottom(para, color='888888', sz='6')
def build_footer(doc, preset, company_name):
"""页脚:所有规范统一为 第 X 页 / 共 Y 页(PAGE / NUMPAGES 字段)。"""
section = doc.sections[0]
footer = section.footer
footer.is_linked_to_previous = False
para = footer.paragraphs[0] if footer.paragraphs else footer.add_paragraph()
_reset_header_paragraph(para, alignment='center')
size = preset.size_body - 2
run = para.add_run('第 ')
_set_font(run, preset.font_body, size)
_add_field(para, 'PAGE', preset.font_body, size)
run = para.add_run(' 页 / 共 ')
_set_font(run, preset.font_body, size)
_add_field(para, 'NUMPAGES', preset.font_body, size)
run = para.add_run(' 页')
_set_font(run, preset.font_body, size)
# ============================================================
# 三、Inline 渲染(包含硬换行)
# ============================================================
def render_inline(paragraph, text, font, size, base_bold=False,
color=None, inline_code_font='Consolas'):
"""把一段(含 `\\n` 硬换行)写到 paragraph,按 Block 内联标记拆分 run。"""
if not text:
return
# text 中的 '\n' 表示硬换行:先按行拆,每行内再拆内联 token
for line_idx, line in enumerate(doc_core.split_paragraph_lines(text)):
if line_idx > 0:
br_run = paragraph.add_run()
_set_font(br_run, font, size)
br_run.add_break(WD_BREAK.LINE)
for kind, payload in doc_core.tokenize_inline(line):
if kind == 'bold':
run = paragraph.add_run(payload)
_set_font(run, font, size, bold=True, color=color)
elif kind == 'italic':
run = paragraph.add_run(payload)
_set_font(run, font, size, bold=base_bold,
italic=True, color=color)
elif kind == 'code':
run = paragraph.add_run(payload)
_set_font(run, inline_code_font, size, bold=base_bold)
else:
run = paragraph.add_run(payload)
_set_font(run, font, size, bold=base_bold, color=color)
def _apply_paragraph_defaults(p, preset, indent=True, align=None,
space_before=0, space_after=None):
p.alignment = align if align is not None else WD_ALIGN_PARAGRAPH.JUSTIFY
p.paragraph_format.line_spacing = preset.line_spacing
p.paragraph_format.space_before = Pt(space_before)
p.paragraph_format.space_after = Pt(
space_after if space_after is not None else preset.paragraph_spacing_pt
)
# 首行缩进:preset 缩进 > 0 时按字符化处理(公文规范),否则清零
if indent and preset.first_line_indent_cm > 0:
_set_first_line_indent_chars(p, chars=2)
else:
p.paragraph_format.first_line_indent = Cm(0)
# 中英自动空格 + 标点挤压(所有正文段落统一应用)
_add_cjk_paragraph_props(p)
# ============================================================
# 四、Block 渲染
# ============================================================
# ----- 书签 / TOC 字段支持 -----
_BOOKMARK_COUNTER = [0]
def _reset_bookmarks():
_BOOKMARK_COUNTER[0] = 0
def _add_heading_bookmark(paragraph, level):
"""给标题段落两端插入 _Toc 书签,TOC 字段才能识别这条标题。"""
_BOOKMARK_COUNTER[0] += 1
bk_id = _BOOKMARK_COUNTER[0]
bk_name = f'_Toc{str(bk_id).zfill(8)}'
bk_start = OxmlElement('w:bookmarkStart')
bk_start.set(qn('w:id'), str(bk_id))
bk_start.set(qn('w:name'), bk_name)
bk_end = OxmlElement('w:bookmarkEnd')
bk_end.set(qn('w:id'), str(bk_id))
p_el = paragraph._element
pPr = p_el.find(qn('w:pPr'))
insert_after = pPr if pPr is not None else None
if insert_after is not None:
insert_after.addnext(bk_start)
else:
p_el.insert(0, bk_start)
p_el.append(bk_end)
return bk_name
def add_toc_field(doc, preset, levels='1-3', title='目录'):
"""在当前位置插入 TOC 字段;Word/WPS 打开会提示更新(或我们标记 updateFields)。"""
head_p = doc.add_paragraph()
_apply_paragraph_defaults(head_p, preset, indent=False,
align=WD_ALIGN_PARAGRAPH.LEFT,
space_before=12, space_after=8)
head_run = head_p.add_run(title)
_set_font(head_run, preset.font_heading, preset.size_section, bold=True)
p = doc.add_paragraph()
_apply_paragraph_defaults(p, preset, indent=False,
align=WD_ALIGN_PARAGRAPH.LEFT,
space_before=0, space_after=4)
# 字段三段式:begin / instr / separate / 占位 / end
run = p.add_run()
fld_begin = OxmlElement('w:fldChar')
fld_begin.set(qn('w:fldCharType'), 'begin')
fld_begin.set(qn('w:dirty'), 'true')
run._element.append(fld_begin)
run = p.add_run()
instr = OxmlElement('w:instrText')
instr.set(qn('xml:space'), 'preserve')
instr.text = f' TOC \\o "{levels}" \\h \\z \\u '
run._element.append(instr)
run = p.add_run()
fld_sep = OxmlElement('w:fldChar')
fld_sep.set(qn('w:fldCharType'), 'separate')
run._element.append(fld_sep)
placeholder = p.add_run('(首次打开时按 F9 / 或 LibreOffice 转换会自动刷新目录)')
_set_font(placeholder, preset.font_body, preset.size_body - 1,
color=RGBColor(0x99, 0x99, 0x99))
run = p.add_run()
fld_end = OxmlElement('w:fldChar')
fld_end.set(qn('w:fldCharType'), 'end')
run._element.append(fld_end)
# 分页
pp = doc.add_paragraph()
pp.add_run().add_break(WD_BREAK.PAGE)
def _set_update_fields_on_open(doc):
"""settings.xml 里加 updateFields=true,让 Word/WPS 打开时自动刷字段。"""
settings = doc.settings.element
for el in settings.findall(qn('w:updateFields')):
settings.remove(el)
update = OxmlElement('w:updateFields')
update.set(qn('w:val'), 'true')
settings.append(update)
def set_doc_core_properties(doc, title=None, author=None, subject=None,
keywords=None, category=None, comments=None):
"""填 docx 的 core properties。便于 OA 检索 / 投标书系统索引。"""
cp = doc.core_properties
if title:
cp.title = title[:255]
if author:
cp.author = author
cp.last_modified_by = author
if subject:
cp.subject = subject
if keywords:
cp.keywords = keywords if isinstance(keywords, str) else ','.join(keywords)
if category:
cp.category = category
if comments:
cp.comments = comments
now = datetime.datetime.now()
cp.created = now
cp.modified = now
# ----- Pygments 代码块高亮 -----
def _pygments_color(token_type):
"""token_type 是 pygments.token.Token 对象。返回 RGBColor 或 None。"""
s = repr(token_type)
hexcol = doc_core.get_token_color(s)
if hexcol:
return RGBColor(int(hexcol[0:2], 16), int(hexcol[2:4], 16),
int(hexcol[4:6], 16))
return None
def render_heading(doc, preset, level, text):
if level <= 1:
font_size, bold = preset.size_chapter, True
space_before, space_after = 18, 8
elif level == 2:
font_size, bold = preset.size_section, True
space_before, space_after = 14, 6
else:
font_size, bold = preset.size_body + 1, True
space_before, space_after = 8, 4
p = doc.add_paragraph()
_apply_paragraph_defaults(p, preset, indent=False,
align=WD_ALIGN_PARAGRAPH.LEFT,
space_before=space_before,
space_after=space_after)
# 给 Word 内置导航识别:手动写 outlineLvl(前 6 级映射 0~5)
pPr = p._element.find(qn('w:pPr'))
if pPr is None:
pPr = OxmlElement('w:pPr')
p._element.insert(0, pPr)
for el in pPr.findall(qn('w:outlineLvl')):
pPr.remove(el)
outline = OxmlElement('w:outlineLvl')
outline.set(qn('w:val'), str(min(level, 6) - 1))
pPr.append(outline)
# 加 _Toc 书签,TOC 字段才能识别
if level <= 3:
_add_heading_bookmark(p, level)
render_inline(p, text, preset.font_heading, font_size, base_bold=bold)
def render_paragraph(doc, preset, text):
p = doc.add_paragraph()
_apply_paragraph_defaults(p, preset, indent=True)
render_inline(p, text, preset.font_body, preset.size_body)
def render_list(doc, preset, ordered, items):
for idx, item in enumerate(items, start=1):
p = doc.add_paragraph()
_apply_paragraph_defaults(p, preset, indent=False,
align=WD_ALIGN_PARAGRAPH.JUSTIFY,
space_after=3)
bullet = f'{idx}. ' if ordered else '• '
run = p.add_run(bullet)
_set_font(run, preset.font_body, preset.size_body)
render_inline(p, item, preset.font_body, preset.size_body)
def render_table(doc, preset, rows, has_header=True):
if not rows:
return
max_cols = max(len(r) for r in rows)
norm = [r + [''] * (max_cols - len(r)) for r in rows]
table = doc.add_table(rows=len(norm), cols=max_cols)
table.style = 'Table Grid'
table.alignment = WD_TABLE_ALIGNMENT.CENTER
for r_idx, row in enumerate(norm):
is_head = has_header and r_idx == 0
for c_idx, cell_text in enumerate(row):
cell = table.rows[r_idx].cells[c_idx]
cell.text = ''
para = cell.paragraphs[0]
para.alignment = WD_ALIGN_PARAGRAPH.CENTER
font_name = preset.font_heading if is_head else preset.font_body
size = preset.size_body - 1
render_inline(para, cell_text, font_name, size,
base_bold=is_head)
if is_head:
_set_cell_shading(cell, 'E8ECF0')
def _emit_code_run(para, text, color, size):
"""代码 run;'\n' 转换成软回车,单 run 内不留 break,颜色统一。"""
if not text:
return
parts = text.split('\n')
for idx, part in enumerate(parts):
if idx > 0:
br_run = para.add_run()
_set_font(br_run, 'Consolas', size)
br_run.add_break(WD_BREAK.LINE)
if part:
run = para.add_run(part)
_set_font(run, 'Consolas', size,
color=color or RGBColor(0x22, 0x22, 0x22))
def render_code_block(doc, preset, code, lang=''):
"""带浅灰背景 + 等宽字体的代码段;装了 Pygments 走 token 着色。"""
para = doc.add_paragraph()
_apply_paragraph_defaults(para, preset, indent=False,
align=WD_ALIGN_PARAGRAPH.LEFT,
space_before=4, space_after=8)
para.paragraph_format.line_spacing = 1.25
para.paragraph_format.left_indent = Cm(0.3)
_set_paragraph_shading(para, 'F7F7F7')
_add_border_bottom(para, color='CCCCCC', sz='4')
size = preset.size_body - 1
if lang:
tag = para.add_run(f'{lang}\n')
_set_font(tag, 'Consolas', preset.size_body - 2,
color=RGBColor(0x88, 0x88, 0x88))
# 1) 尝试 Pygments 高亮
if HAS_PYGMENTS and lang:
try:
lexer = get_lexer_by_name(lang, stripall=False, ensurenl=False)
for ttype, value in lex(code, lexer):
_emit_code_run(para, value, _pygments_color(ttype), size)
return
except (ClassNotFound, Exception):
pass # 回落到无颜色
# 2) 无颜色:保留原有等宽 + 灰色文本
_emit_code_run(para, code, RGBColor(0x22, 0x22, 0x22), size)
def render_blockquote(doc, preset, lines):
for line in lines:
p = doc.add_paragraph()
_apply_paragraph_defaults(p, preset, indent=False,
align=WD_ALIGN_PARAGRAPH.LEFT,
space_after=2)
p.paragraph_format.left_indent = Cm(0.5)
_add_border_left(p, color='FF7043', sz='18')
render_inline(p, line, preset.font_body, preset.size_body,
color=RGBColor(0x55, 0x55, 0x55))
def render_metadata(doc, preset, pairs):
if not pairs:
return
table = doc.add_table(rows=len(pairs), cols=2)
table.style = 'Table Grid'
table.alignment = WD_TABLE_ALIGNMENT.LEFT
for r, (key, value) in enumerate(pairs):
row = table.rows[r]
row.cells[0].text = ''
row.cells[1].text = ''
p0 = row.cells[0].paragraphs[0]
p1 = row.cells[1].paragraphs[0]
render_inline(p0, key or '', preset.font_heading,
preset.size_body - 1, base_bold=True)
render_inline(p1, value or '', preset.font_body,
preset.size_body - 1)
_set_cell_shading(row.cells[0], 'F5F5F5')
def render_hr(doc, preset):
p = doc.add_paragraph()
_apply_paragraph_defaults(p, preset, indent=False,
space_before=4, space_after=6)
_add_border_bottom(p, color='CCCCCC', sz='6')
def render_page_break(doc, preset):
p = doc.add_paragraph()
run = p.add_run()
run.add_break(WD_BREAK.PAGE)
def render_block(doc, preset, block):
btype = block['type']
if btype == 'heading':
render_heading(doc, preset, block['level'], block['text'])
elif btype == 'paragraph':
text = block['text']
detected = doc_core.detect_heading_from_preset(text, preset)
if detected is not None:
level, cleaned = detected
render_heading(doc, preset, level, cleaned)
else:
render_paragraph(doc, preset, text)
elif btype == 'list':
render_list(doc, preset, block.get('ordered', False),
block.get('items', []))
elif btype == 'table':
render_table(doc, preset, block.get('rows', []),
has_header=block.get('has_header', True))
elif btype == 'code_block':
render_code_block(doc, preset, block.get('code', ''),
block.get('lang', ''))
elif btype == 'blockquote':
render_blockquote(doc, preset, block.get('lines', []))
elif btype == 'metadata':
render_metadata(doc, preset, block.get('pairs', []))
elif btype == 'hr':
render_hr(doc, preset)
elif btype == 'page_break':
render_page_break(doc, preset)
def render_content(doc, preset, content):
blocks = doc_core.parse_blocks(content)
if not blocks:
p = doc.add_paragraph()
_apply_paragraph_defaults(p, preset, indent=True, space_after=0)
run = p.add_run('(无正文内容)')
_set_font(run, preset.font_body, preset.size_body,
color=RGBColor(0x99, 0x99, 0x99))
return
for block in blocks:
render_block(doc, preset, block)
# ============================================================
# 五、文档壳(标题、元数据、版本历史、审批表)
# ============================================================
def add_title(doc, preset, title):
if not title:
return
p = doc.add_paragraph()
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
p.paragraph_format.line_spacing = preset.line_spacing
p.paragraph_format.space_before = Pt(18)
p.paragraph_format.space_after = Pt(18)
run = p.add_run(title)
_set_font(run, preset.font_title, preset.size_title, bold=True)
def add_doc_meta(doc, preset, doc_number, version, classification, author):
items = []
if doc_number:
items.append(('文档编号', doc_number))
items.append(('版本', version))
items.append(('密级', classification))
items.append(('日期', datetime.date.today().strftime('%Y-%m-%d')))
if author:
items.append(('作者', author))
table = doc.add_table(rows=len(items), cols=2)
table.style = 'Table Grid'
table.alignment = WD_TABLE_ALIGNMENT.LEFT
for r, (key, value) in enumerate(items):
row = table.rows[r]
row.cells[0].text = ''
row.cells[1].text = ''
p0 = row.cells[0].paragraphs[0]
p1 = row.cells[1].paragraphs[0]
run = p0.add_run(key)
_set_font(run, preset.font_heading, preset.size_body - 1, bold=True)
run = p1.add_run(str(value))
_set_font(run, preset.font_body, preset.size_body - 1)
_set_cell_shading(row.cells[0], 'F5F5F5')
p = doc.add_paragraph()
p.paragraph_format.space_before = Pt(0)
p.paragraph_format.space_after = Pt(6)
def add_classification_banner(doc, preset, classification):
if not classification or classification == '公开':
return
p = doc.add_paragraph()
p.alignment = WD_ALIGN_PARAGRAPH.RIGHT
p.paragraph_format.space_after = Pt(0)
run = p.add_run(f'【{classification}】')
_set_font(run, preset.font_heading, preset.size_body, bold=True,
color=RGBColor(0xB0, 0x00, 0x00))
def add_version_history(doc, preset, version, date_str, author):
p = doc.add_paragraph()
p.alignment = WD_ALIGN_PARAGRAPH.LEFT
p.paragraph_format.space_before = Pt(14)
p.paragraph_format.space_after = Pt(4)
run = p.add_run('版本历史')
_set_font(run, preset.font_heading, preset.size_section, bold=True)
rows = [
['版本', '日期', '作者', '修改说明'],
[version or 'V1.0', date_str, author or '未知', '首次创建'],
]
render_table(doc, preset, rows, has_header=True)
def add_approval_block(doc, preset, approval_list=None):
items = approval_list if approval_list else [
{'role': '编制', 'name': ''},
{'role': '审核', 'name': ''},
{'role': '批准', 'name': ''},
]
p = doc.add_paragraph()
p.alignment = WD_ALIGN_PARAGRAPH.LEFT
p.paragraph_format.space_before = Pt(14)
p.paragraph_format.space_after = Pt(4)
run = p.add_run('审批记录')
_set_font(run, preset.font_heading, preset.size_section, bold=True)
today = datetime.date.today().strftime('%Y-%m-%d')
rows = [['角色', '姓名', '日期', '签字']]
for item in items:
role = item.get('role', '')
name = item.get('name') or '__________'
date_str = item.get('date') or (today if role == '编制' else '')
rows.append([role, name, date_str, '__________'])
render_table(doc, preset, rows, has_header=True)
# ============================================================
# 六、对外入口
# ============================================================
def create_word_doc(output_path, title='', content='', doc_number=None,
version='V1.0', classification='内部', author=None,
company_name=None, logo_path=None, approval=None,
doc_format=None, use_odoo=True,
force_version_history=None, force_approval=None):
"""生成企业 Word 文档(v7.0)。"""
info = doc_core.resolve_company_info(
overrides={'company_name': company_name, 'logo_path': logo_path},
use_odoo=use_odoo,
)
missing = doc_core.company_info_missing(info)
if missing:
raise RuntimeError(doc_core.company_info_error_payload(missing))
company = info['company_name']
logo = info['logo_path']
if not doc_format or doc_format == 'auto':
doc_format = doc_core.detect_format(title, content)
preset = doc_core.get_preset(doc_format)
print(f'📄 使用文档规范: {preset.name}')
print(f'🏢 公司: {company}')
print(f'🖼 LOGO: {logo}')
if not HAS_PYGMENTS:
print('💡 提示:装 pygments 可启用代码块语法高亮 — pip install pygments')
_reset_bookmarks()
doc = Document()
# 1) 文档核心属性(OA 检索 / 投标书系统索引)
set_doc_core_properties(
doc, title=title, author=author or company,
subject=preset.name, category=preset.name,
keywords=f'{preset.name},{company}',
comments=f'由火一五文档技能 v{__doc__.split("v")[1].split()[0] if "v" in (__doc__ or "") else "7"} 生成',
)
for sec in doc.sections:
sec.top_margin = Cm(preset.margin_top)
sec.bottom_margin = Cm(preset.margin_bottom)
sec.left_margin = Cm(preset.margin_left)
sec.right_margin = Cm(preset.margin_right)
sec.header_distance = Cm(1.5)
sec.footer_distance = Cm(1.5)
build_header(doc, preset, logo, company,
doc_number=doc_number,
classification=classification,
title=title)
build_footer(doc, preset, company)
normal_style = doc.styles['Normal']
normal_style.font.name = preset.font_body
normal_style.font.size = Pt(preset.size_body)
add_classification_banner(doc, preset, classification)
add_title(doc, preset, title)
add_doc_meta(doc, preset, doc_number, version, classification, author)
# 2) 自动 TOC(preset 标记 table_of_contents 时启用)
if preset.table_of_contents:
add_toc_field(doc, preset, levels='1-3', title='目录')
_set_update_fields_on_open(doc)
render_content(doc, preset, content)
want_version = (force_version_history
if force_version_history is not None
else preset.has_version_history)
if want_version:
add_version_history(doc, preset, version,
datetime.date.today().strftime('%Y-%m-%d'),
author or '未知')
want_approval = (force_approval if force_approval is not None
else preset.has_approval)
if want_approval or approval:
add_approval_block(doc, preset, approval)
doc.save(output_path)
print(f'✅ 文档已生成: {output_path}')
return output_path
# ============================================================
# 七、CLI
# ============================================================
def _use_legacy_cli(argv):
if len(argv) <= 1:
return False
return not argv[1].startswith('-')
def _parse_args(argv):
parser = argparse.ArgumentParser(
prog='create-word-doc',
description='火一五企业级 Word 生成器 v7.0(12 类规范)',
)
parser.add_argument('--output', '-o', required=True, help='输出 .docx 路径')
parser.add_argument('--title', default='', help='文档标题')
parser.add_argument('--content', default='',
help='正文(Markdown);以 @file 开头时读取文件')
parser.add_argument('--doc-number', default=None)
parser.add_argument('--version', default='V1.0')
parser.add_argument('--classification', default='内部',
help='密级:公开/内部/秘密')
parser.add_argument('--author', default=None)
parser.add_argument('--doc-format', default='auto',
choices=['auto'] + doc_core.list_format_names())
parser.add_argument('--company-name', default=None)
parser.add_argument('--logo-path', default=None)
parser.add_argument('--no-odoo', action='store_true')
parser.add_argument('--with-version-history', action='store_true')
parser.add_argument('--no-version-history', action='store_true')
parser.add_argument('--with-approval', action='store_true')
parser.add_argument('--no-approval', action='store_true')
return parser.parse_args(argv[1:])
def _load_content(content_arg):
if content_arg and content_arg.startswith('@'):
path = content_arg[1:]
with open(path, 'r', encoding='utf-8') as fh:
return fh.read()
return content_arg
def _flag_tristate(on, off):
if on and off:
return None
if on:
return True
if off:
return False
return None
def main(argv=None):
argv = argv if argv is not None else sys.argv
try:
if _use_legacy_cli(argv):
create_word_doc(
output_path=argv[1] if len(argv) > 1 else 'output.docx',
title=argv[2] if len(argv) > 2 else '',
content=argv[3] if len(argv) > 3 else '',
doc_number=argv[4] if len(argv) > 4 else None,
version=argv[5] if len(argv) > 5 else 'V1.0',
classification=argv[6] if len(argv) > 6 else '内部',
doc_format=argv[7] if len(argv) > 7 else 'auto',
)
else:
args = _parse_args(argv)
create_word_doc(
output_path=args.output,
title=args.title,
content=_load_content(args.content),
doc_number=args.doc_number,
version=args.version,
classification=args.classification,
author=args.author,
company_name=args.company_name,
logo_path=args.logo_path,
doc_format=args.doc_format,
use_odoo=not args.no_odoo,
force_version_history=_flag_tristate(
args.with_version_history, args.no_version_history),
force_approval=_flag_tristate(
args.with_approval, args.no_approval),
)
except RuntimeError as exc:
print(str(exc), file=sys.stderr)
return 2
return 0
if __name__ == '__main__':
sys.exit(main())
FILE:scripts/doc_core.py
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
doc_core.py — 火一五企业文档生成共用核心 (v7.0)
提供 Word / PDF 两套渲染器共用的:
1. 文档规范预设(FormatPreset) — 12 种规范
2. Block AST Markdown 解析器(修复 CJK 软换行 + 硬换行支持)
3. 内联 token 拆分器(粗体 / 斜体 / 行内代码)
4. 公司信息读取(委托给 company-info.py)
Word / PDF 渲染端各自处理"如何画",本模块只负责"该画什么"。
"""
import os
import re
import sys
import json
import importlib.util
# ============================================================
# 一、文档规范预设
# ============================================================
class FormatPreset:
"""每种规范的版式参数。Word 与 PDF 渲染共用。"""
def __init__(self, name,
margin_top=3.7, margin_bottom=3.5,
margin_left=2.8, margin_right=2.6,
font_body='仿宋', font_title='黑体', font_heading='黑体',
size_title=22, size_chapter=16, size_section=14, size_body=12,
line_spacing=1.5,
has_version_history=False, has_approval=False,
header_layout='company', heading_patterns=None,
first_line_indent_cm=0.74,
paragraph_spacing_pt=6,
table_of_contents=False):
self.name = name
self.margin_top = margin_top
self.margin_bottom = margin_bottom
self.margin_left = margin_left
self.margin_right = margin_right
self.font_body = font_body
self.font_title = font_title
self.font_heading = font_heading
self.size_title = size_title
self.size_chapter = size_chapter
self.size_section = size_section
self.size_body = size_body
self.line_spacing = line_spacing
self.has_version_history = has_version_history
self.has_approval = has_approval
# 'company':LOGO + 名称(左对齐)
# 'centered':仅公司名(居中)— 合同
# 'minimal':仅 LOGO + 公司名,不带编号 / 密级 — 演讲稿、用户手册
self.header_layout = header_layout
self.heading_patterns = heading_patterns or []
self.first_line_indent_cm = first_line_indent_cm
self.paragraph_spacing_pt = paragraph_spacing_pt
self.table_of_contents = table_of_contents
# 公文:通知、请示、函件
PRESET_GONGWEN = FormatPreset(
name='公文',
heading_patterns=[
(r'^第[一二三四五六七八九十百千]+[章节篇款]', 'chapter'),
(r'^[一二三四五六七八九十百千]+[、.]', 'section'),
(r'^[(\(][一二三四五六七八九十百千]+[)\)]', 'article'),
],
has_version_history=True,
has_approval=True,
)
# 合同 / 协议
PRESET_HETONG = FormatPreset(
name='合同',
margin_top=2.54, margin_bottom=2.54, margin_left=3.17, margin_right=3.17,
font_body='宋体', font_title='宋体', font_heading='宋体',
size_title=22, size_chapter=15, size_section=13, size_body=12,
heading_patterns=[
(r'^第[一二三四五六七八九十百千]+[章节条款]', 'chapter'),
(r'^[一二三四五六七八九十百千]+[、]', 'section'),
],
has_version_history=False,
has_approval=False,
# v7.2: 改为 minimal(LOGO + 公司名、左对齐);旧 'centered' 在多数中文合同里
# 反而显得不正式,且与正文左对齐冲突。
header_layout='minimal',
)
# 会议纪要
PRESET_HUIYI = FormatPreset(
name='会议纪要',
font_body='仿宋', font_title='方正小标宋简体', font_heading='黑体',
size_title=22, size_chapter=14, size_section=12, size_body=12,
heading_patterns=[
(r'^【[^】]+】', 'chapter'),
(r'^[一二三四五六七八九十]+[、]', 'section'),
(r'^[(\(][一二三四五六七八九十]+[)\)]', 'article'),
],
has_version_history=False,
has_approval=False,
)
# 技术方案 / 解决方案 / 实施方案
PRESET_FANGAN = FormatPreset(
name='技术方案',
font_body='宋体', font_title='黑体', font_heading='黑体',
size_title=22, size_chapter=16, size_section=14, size_body=12,
heading_patterns=[
(r'^[一二三四五六七八九十百]+[.、]', 'chapter'),
(r'^[0-9]+[.、](?!\d)', 'section'),
(r'^[0-9]+\.[0-9]+', 'article'),
],
has_version_history=True,
has_approval=True,
table_of_contents=True,
)
# 需求文档 / SRS / PRD
PRESET_XUQIU = FormatPreset(
name='需求文档',
font_body='宋体', font_title='黑体', font_heading='黑体',
size_title=22, size_chapter=16, size_section=14, size_body=12,
heading_patterns=[
(r'^[一二三四五六七八九十百]+[.、]', 'chapter'),
(r'^[0-9]+[.、](?!\d)', 'section'),
(r'^[0-9]+\.[0-9]+', 'article'),
],
has_version_history=True,
has_approval=True,
table_of_contents=True,
)
# 工作报告 / 周报 / 月报 / 季报 / 年报 / 述职
PRESET_GONGZUO = FormatPreset(
name='工作报告',
font_body='仿宋', font_title='方正小标宋简体', font_heading='楷体',
size_title=22, size_chapter=16, size_section=14, size_body=12,
heading_patterns=[
(r'^[一二三四五六七八九十百]+[、.]', 'chapter'),
(r'^[(\(][一二三四五六七八九十]+[)\)]', 'section'),
],
has_version_history=False,
has_approval=False,
)
# === v7.0 新增 6 种规范 ===
# 商业计划书 / BP / 融资计划书
PRESET_SHANGYE = FormatPreset(
name='商业计划书',
font_body='宋体', font_title='黑体', font_heading='黑体',
size_title=24, size_chapter=18, size_section=14, size_body=12,
line_spacing=1.5,
heading_patterns=[
(r'^第[一二三四五六七八九十]+部分', 'chapter'),
(r'^[一二三四五六七八九十]+[、.]', 'chapter'),
(r'^[0-9]+[.、](?!\d)', 'section'),
(r'^[0-9]+\.[0-9]+', 'article'),
],
has_version_history=True,
has_approval=False,
table_of_contents=True,
)
# 用户手册 / 操作手册 / 使用说明
PRESET_SHOUCE = FormatPreset(
name='用户手册',
margin_top=2.5, margin_bottom=2.5, margin_left=2.5, margin_right=2.5,
font_body='宋体', font_title='黑体', font_heading='黑体',
size_title=24, size_chapter=18, size_section=14, size_body=11,
line_spacing=1.5,
heading_patterns=[
(r'^第[一二三四五六七八九十百]+章', 'chapter'),
(r'^[0-9]+\.(?!\d)', 'section'),
(r'^[0-9]+\.[0-9]+', 'article'),
],
has_version_history=True,
has_approval=False,
header_layout='minimal',
table_of_contents=True,
)
# 培训手册 / 培训教材 / 教学大纲
PRESET_PEIXUN = FormatPreset(
name='培训手册',
font_body='宋体', font_title='方正小标宋简体', font_heading='黑体',
size_title=22, size_chapter=18, size_section=14, size_body=12,
line_spacing=1.5,
heading_patterns=[
(r'^(?:模块|单元|第)[一二三四五六七八九十0-9]+(?:模块|单元|课|节)?', 'chapter'),
(r'^[一二三四五六七八九十百]+[、.]', 'section'),
(r'^[0-9]+\.[0-9]+', 'article'),
],
has_version_history=True,
has_approval=False,
table_of_contents=True,
)
# 招投标书 / 招标书 / 投标书
PRESET_ZHAOTOU = FormatPreset(
name='招投标书',
margin_top=3.7, margin_bottom=3.5, margin_left=3.0, margin_right=2.8,
font_body='仿宋', font_title='方正小标宋简体', font_heading='黑体',
size_title=22, size_chapter=16, size_section=14, size_body=12,
line_spacing=1.5,
heading_patterns=[
(r'^第[一二三四五六七八九十百]+[章篇部分]', 'chapter'),
(r'^[一二三四五六七八九十]+[、]', 'section'),
(r'^[(\(][一二三四五六七八九十]+[)\)]', 'article'),
],
has_version_history=True,
has_approval=True,
table_of_contents=True,
)
# 演讲稿 / 致辞稿 / 主题分享
PRESET_YANJIANG = FormatPreset(
name='演讲稿',
margin_top=3.0, margin_bottom=3.0, margin_left=3.0, margin_right=3.0,
font_body='仿宋', font_title='方正小标宋简体', font_heading='黑体',
size_title=26, size_chapter=20, size_section=16, size_body=14,
line_spacing=1.75,
heading_patterns=[
(r'^[一二三四五六七八九十]+[、]', 'chapter'),
(r'^[(\(][一二三四五六七八九十]+[)\)]', 'section'),
],
has_version_history=False,
has_approval=False,
header_layout='minimal',
first_line_indent_cm=0.0,
paragraph_spacing_pt=10,
)
# 研究报告 / 学术论文 / 调研报告 / 白皮书
PRESET_YANJIU = FormatPreset(
name='研究报告',
margin_top=2.5, margin_bottom=2.5, margin_left=3.0, margin_right=3.0,
font_body='宋体', font_title='黑体', font_heading='黑体',
size_title=22, size_chapter=16, size_section=14, size_body=11,
line_spacing=1.5,
heading_patterns=[
(r'^摘\s*要$|^Abstract$|^关键词$|^Keywords$|^引言$|^结论$|^参考文献$|^References$',
'chapter'),
(r'^[一二三四五六七八九十百]+[、.]', 'chapter'),
(r'^[0-9]+\.(?!\d)', 'section'),
(r'^[0-9]+\.[0-9]+', 'article'),
],
has_version_history=True,
has_approval=False,
table_of_contents=True,
)
FORMAT_PRESETS = {
'公文': PRESET_GONGWEN,
'合同': PRESET_HETONG,
'会议纪要': PRESET_HUIYI,
'技术方案': PRESET_FANGAN,
'需求文档': PRESET_XUQIU,
'工作报告': PRESET_GONGZUO,
'商业计划书': PRESET_SHANGYE,
'用户手册': PRESET_SHOUCE,
'培训手册': PRESET_PEIXUN,
'招投标书': PRESET_ZHAOTOU,
'演讲稿': PRESET_YANJIANG,
'研究报告': PRESET_YANJIU,
}
# 命中顺序:先具体的、独占词;再宽松词。'auto' 命中后立即返回。
FORMAT_KEYWORDS = [
('招投标书', ['招标书', '投标书', '招投标', '投标文件', '招标文件', '响应文件']),
('商业计划书', ['商业计划书', '商业计划', 'BP', '融资计划书', '融资计划', '路演稿']),
('用户手册', ['用户手册', '操作手册', '使用说明', '用户指南', '使用手册',
'manual', '产品手册']),
('培训手册', ['培训手册', '培训教材', '培训方案', '教学大纲', '员工手册',
'入职手册']),
('演讲稿', ['演讲稿', '致辞稿', '讲话稿', '主题分享', '演讲提纲', '开幕辞',
'开幕词', '闭幕辞', '欢迎辞', '颁奖辞']),
('研究报告', ['研究报告', '学术论文', '调研报告', '白皮书', 'whitepaper',
'行业报告', '分析报告', '论文']),
('合同', ['合同', '协议', '协议书']),
('会议纪要', ['会议纪要', '纪要']),
('技术方案', ['技术方案', '实施方案', '解决方案', '设计文档', '架构设计']),
('需求文档', ['需求规格', '需求说明', 'srs', 'prd', '需求文档']),
('工作报告', ['工作报告', '周报', '月报', '季报', '年报', '述职报告',
'汇报材料']),
]
def detect_format(title='', content=''):
"""根据标题和正文前 800 字猜测规范类型,默认公文。"""
text = (title + ' ' + (content or '')[:800]).lower()
for fmt, keywords in FORMAT_KEYWORDS:
for kw in keywords:
if kw.lower() in text:
return fmt
return '公文'
def get_preset(format_name):
return FORMAT_PRESETS.get(format_name, PRESET_GONGWEN)
def list_format_names():
return list(FORMAT_PRESETS.keys())
# ============================================================
# 二、Block AST 解析(修复 CJK 软换行 + 硬换行)
# ============================================================
_HEADING_MD_RE = re.compile(r'^(#{1,6})\s*(.+?)\s*#*\s*$')
_HR_RE = re.compile(r'^\s*([-*_])\1{2,}\s*$')
_UL_ITEM_RE = re.compile(r'^\s*[-*+]\s+(.+)$')
_OL_ITEM_RE = re.compile(r'^\s*(\d+)[\..)]\s+(.+)$')
_FENCE_RE = re.compile(r'^\s*```([\w+-]*)\s*$')
_BLOCKQUOTE_RE = re.compile(r'^\s*>\s?(.*)$')
_TABLE_SEP_CELL_RE = re.compile(r'^[:\s]*[\-−–—―]{3,}[:\s]*$')
_DOC_META_RE = re.compile(
r'(?:'
r'文档编号|文件编号|项目编号|发文字号|合同编号|合同号|协议编号|订单编号|'
r'报价编号|工单编号|发票编号|凭证编号|编号|'
r'版本|版次|密级|机密等级|分类|类型|类别|状态|阶段|'
r'日期|时间|签订日期|签约日期|签署日期|生效日期|失效日期|验收日期|'
r'交付日期|出版日期|提交日期|有效期|截止日期|期限|起止时间|起草日期|'
r'起止日期|完成日期|'
r'作者|编制|起草|审核|批准|签发|提交人|主送|抄送|联系人|负责人|'
r'甲方|乙方|丙方|签约方|发包方|承包方|采购方|供应商|'
r'客户|项目|课题|主题|标题|副标题|关键词|摘要|背景|目的|备注|说明|'
r'金额|总价|单价|数量|币种|含税|税率|税额|付款方式|付款条件|'
r'单位|部门|公司|地址|电话|邮箱|手机|传真|网址|官网|联系方式|'
r'Title|Subject|Author|Date|Version|Keywords|Abstract|Department|'
r'Owner|Reviewer|Approver|Status|ContractNo|ContractNumber'
r')\s*[::]'
)
# 单行 'Key: Value' 模式(Key 须 ≤24 字、不含分隔符)
# v7.2 把上限从 16 → 24,覆盖 `**合同编号**` / `***甲 方***` 这类 markdown 包裹的 key。
_SHORT_KV_RE = re.compile(r'^\s*([^::|<>\n]{1,24}?)\s*[::]\s*(.+?)\s*$')
# v7.2 markdown 包裹标记:粗体 `**` / 斜体 `*` / 删除线 `~~` / 行内代码 ` ` `
_MD_WRAP_CHARS = '*~` '
# CJK 字符范围:常见汉字 + 全角标点 + 扩展
_CJK_CHAR_RE = re.compile(
r'[一-鿿㐀-䶿 -〿-⦆゠-ヿ'
r'-ゟ]'
)
def _is_cjk_char(c):
return bool(c and _CJK_CHAR_RE.match(c))
def _detect_hard_break(line):
"""识别行尾硬换行标记,返回 (有效正文, has_hard_break)。
支持两种约定:
- Markdown 标准:行尾 2 个及以上空格
- CommonMark 扩展:行尾反斜杠 `\\`
"""
s = line.rstrip('\r\n')
if s.endswith('\\'):
return s[:-1].rstrip(), True
trimmed = s.rstrip(' \t')
if len(s) - len(trimmed) >= 2:
return trimmed, True
return trimmed, False
def _smart_join_paragraph(items):
"""合并段落多行:
items: [(text, hard_break_after), ...]
- hard_break_after=True 处插入 '\n'(渲染端转 <w:br/> 或 <br/>)
- 否则按 CJK 边界智能拼接:CJK ↔ CJK 不加空格;其余加单空格
"""
if not items:
return ''
out = items[0][0]
for i in range(1, len(items)):
prev_text, prev_hb = items[i - 1]
cur_text, _ = items[i]
if not cur_text and not prev_hb:
continue
if prev_hb:
sep = '\n'
else:
last = out[-1] if out else ''
first = cur_text[0] if cur_text else ''
if _is_cjk_char(last) and _is_cjk_char(first):
sep = ''
elif not last or not first:
sep = ''
else:
sep = ' '
out += sep + cur_text
return out
def _split_table_cells(line):
r"""智能分割表格行;保留前后 | 之间的内容;允许 `\|` 转义。"""
s = line.strip()
leading = s.startswith('|')
trailing = s.endswith('|') and not s.endswith(r'\|')
if leading and trailing and len(s) >= 2:
s = s[1:-1]
elif leading:
s = s[1:]
elif trailing:
s = s[:-1]
cells, buf, i = [], '', 0
while i < len(s):
ch = s[i]
if ch == '\\' and i + 1 < len(s) and s[i + 1] == '|':
buf += '|'
i += 2
continue
if ch == '|':
cells.append(buf.strip())
buf = ''
else:
buf += ch
i += 1
cells.append(buf.strip())
return cells
def _is_table_separator(line):
t = line.strip()
if not t or '|' not in t:
return False
cells = _split_table_cells(t)
if len(cells) < 2:
return False
has_sep = False
for c in cells:
if not c:
continue
if _TABLE_SEP_CELL_RE.match(c):
has_sep = True
else:
return False
return has_sep
def _looks_like_table_row(line):
t = line.strip()
if '|' not in t:
return False
if _is_table_separator(t):
return False
cells = _split_table_cells(t)
return len(cells) >= 2
def _is_metadata_line(line):
"""style A:单行 pipe 分隔的元数据。"""
t = line.strip()
if '|' not in t:
return False
segments = [seg.strip() for seg in _split_table_cells(t) if seg.strip()]
if len(segments) < 2:
return False
meta_hits = sum(1 for seg in segments if _DOC_META_RE.search(seg))
return meta_hits >= 2
def _is_known_metadata_key(key):
"""key 是否在已知元数据关键词表内(用于识别 style B 多行元数据)。"""
return bool(_DOC_META_RE.match(key.strip() + ':'))
def _try_kv_line(line):
"""style B:单行 'Key: Value' 形式;要求 Key 是已知关键词。
返回 (key, value) 或 None。
v7.2:兼容 markdown 包裹形式(`**合同编号:** value` / `*Key:* value`),
剥掉首尾的 `*` / `~` / `` ` `` 之后再判断 key 是否在白名单。
"""
if not line:
return None
s = line.strip()
if '|' in s:
return None
m = _SHORT_KV_RE.match(s)
if not m:
return None
key = m.group(1).strip(_MD_WRAP_CHARS).strip()
value = m.group(2).strip(_MD_WRAP_CHARS).strip()
if not key:
return None
if not _is_known_metadata_key(key):
return None
return key, value
def parse_blocks(content):
"""把 Markdown 文本切成块节点列表。
每个节点是 dict,含 `type` 与对应负载:
- heading : {level: 1..6, text}
- paragraph : {text} 文本中的 '\n' 表示硬换行
- list : {ordered: bool, items: [text, ...]}
- table : {rows: [[cell, ...], ...], has_header: bool}
- code_block : {lang, code}
- blockquote : {lines: [text, ...]}
- metadata : {pairs: [(key, value), ...]}
- hr : {}
- page_break : {} (---PAGE--- 或 \\pagebreak)
"""
lines = (content or '').split('\n')
blocks = []
i = 0
n = len(lines)
while i < n:
raw = lines[i]
stripped = raw.strip()
if not stripped:
i += 1
continue
# 显式分页符
if stripped.lower() in ('---page---', '\\pagebreak', '<!-- pagebreak -->'):
blocks.append({'type': 'page_break'})
i += 1
continue
# 代码块
fence = _FENCE_RE.match(raw)
if fence:
lang = fence.group(1) or ''
i += 1
code_lines = []
while i < n and not _FENCE_RE.match(lines[i]):
code_lines.append(lines[i])
i += 1
if i < n:
i += 1
blocks.append({'type': 'code_block', 'lang': lang,
'code': '\n'.join(code_lines)})
continue
if _HR_RE.match(raw):
blocks.append({'type': 'hr'})
i += 1
continue
bq_match = _BLOCKQUOTE_RE.match(raw)
if bq_match:
bq_lines = [bq_match.group(1)]
i += 1
while i < n:
m = _BLOCKQUOTE_RE.match(lines[i])
if m:
bq_lines.append(m.group(1))
i += 1
elif not lines[i].strip():
break
else:
bq_lines.append(lines[i].strip())
i += 1
blocks.append({'type': 'blockquote', 'lines': bq_lines})
continue
md_heading = _HEADING_MD_RE.match(raw)
if md_heading:
blocks.append({
'type': 'heading',
'level': len(md_heading.group(1)),
'text': md_heading.group(2).strip(),
})
i += 1
continue
if _is_metadata_line(stripped):
cells = [c for c in _split_table_cells(stripped) if c.strip()]
pairs = []
for cell in cells:
if ':' in cell:
idx = cell.index(':')
pairs.append((cell[:idx].strip(),
cell[idx + 1:].strip()))
elif ':' in cell:
idx = cell.index(':')
pairs.append((cell[:idx].strip(),
cell[idx + 1:].strip()))
else:
pairs.append(('', cell.strip()))
blocks.append({'type': 'metadata', 'pairs': pairs})
i += 1
continue
# style B:连续多行 'Key: Value'(Key 在已知关键词表内)→ 合并为元数据
kv = _try_kv_line(stripped)
if kv:
kv_pairs = [kv]
j = i + 1
while j < n:
nxt = lines[j]
if not nxt.strip():
break
nxt_kv = _try_kv_line(nxt)
if not nxt_kv:
break
kv_pairs.append(nxt_kv)
j += 1
if len(kv_pairs) >= 2:
blocks.append({'type': 'metadata', 'pairs': kv_pairs})
i = j
continue
# 单行 KV 不强制转 metadata,保留按段落处理
if _looks_like_table_row(stripped):
rows = [_split_table_cells(stripped)]
i += 1
has_header = False
if i < n and _is_table_separator(lines[i]):
has_header = True
i += 1
while i < n and _looks_like_table_row(lines[i]):
rows.append(_split_table_cells(lines[i]))
i += 1
blocks.append({'type': 'table', 'rows': rows,
'has_header': has_header})
continue
ul = _UL_ITEM_RE.match(raw)
ol = _OL_ITEM_RE.match(raw)
if ul or ol:
ordered = bool(ol)
items = []
while i < n:
m_ul = _UL_ITEM_RE.match(lines[i])
m_ol = _OL_ITEM_RE.match(lines[i])
if ordered and m_ol:
items.append(m_ol.group(2).strip())
i += 1
elif not ordered and m_ul:
items.append(m_ul.group(1).strip())
i += 1
elif not lines[i].strip():
break
else:
break
blocks.append({'type': 'list', 'ordered': ordered,
'items': items})
continue
# 普通段落:吃到下一空行或 block 标记;保留 CJK 软换行 + 硬换行
clean, hb = _detect_hard_break(raw)
para_items = [(clean.strip(), hb)]
i += 1
while i < n:
nxt = lines[i]
nxt_strip = nxt.strip()
if not nxt_strip:
break
if (_HEADING_MD_RE.match(nxt) or _HR_RE.match(nxt)
or _FENCE_RE.match(nxt) or _BLOCKQUOTE_RE.match(nxt)
or _UL_ITEM_RE.match(nxt) or _OL_ITEM_RE.match(nxt)
or _is_metadata_line(nxt_strip)
or _looks_like_table_row(nxt_strip)):
break
clean, hb = _detect_hard_break(nxt)
para_items.append((clean.strip(), hb))
i += 1
text = _smart_join_paragraph(para_items)
if text:
blocks.append({'type': 'paragraph', 'text': text})
return blocks
# ============================================================
# 三、内联 token 拆分(**bold** / *italic* / `code`)
# ============================================================
_INLINE_RE = re.compile(
r'(\*\*[^*\n]+?\*\*|'
r'\*[^*\n]+?\*|'
r'`[^`\n]+?`)'
)
def tokenize_inline(text):
"""把一段文本拆成 [(kind, text), ...]。
kind ∈ {'plain', 'bold', 'italic', 'code'}
供 Word / PDF 各自渲染。
"""
if not text:
return []
out = []
parts = _INLINE_RE.split(text)
for part in parts:
if not part:
continue
if part.startswith('**') and part.endswith('**') and len(part) >= 4:
out.append(('bold', part[2:-2]))
elif part.startswith('*') and part.endswith('*') and len(part) >= 2:
out.append(('italic', part[1:-1]))
elif part.startswith('`') and part.endswith('`') and len(part) >= 2:
out.append(('code', part[1:-1]))
else:
out.append(('plain', part))
return out
def split_paragraph_lines(text):
"""段落 text 中的 '\n' 是硬换行;返回行列表。"""
if not text:
return ['']
return text.split('\n')
# ============================================================
# 三-补、Pygments 代码高亮(VS Code Light 风格 token → RGB)
# ============================================================
# 同时供 Word(python-docx 的 RGBColor)和 PDF(reportlab 的 HexColor)用,
# 这里用 'RRGGBB' hex 字符串描述,渲染端各自转换。
PYGMENTS_TOKEN_COLORS = {
# 关键字
'Keyword': 'AF00DB',
'Keyword.Constant': '0000FF',
'Keyword.Declaration': '0000FF',
'Keyword.Namespace': 'AF00DB',
'Keyword.Pseudo': 'AF00DB',
'Keyword.Reserved': 'AF00DB',
'Keyword.Type': '267199',
# 标识符
'Name.Builtin': '267199',
'Name.Builtin.Pseudo': '267199',
'Name.Class': '267199',
'Name.Decorator': '7957D5',
'Name.Function': '7957D5',
'Name.Function.Magic': '7957D5',
'Name.Variable': '001080',
'Name.Variable.Class': '001080',
'Name.Variable.Instance': '001080',
'Name.Tag': '800000',
'Name.Attribute': 'FF0000',
'Name.Constant': '0070C1',
'Name.Exception': '267199',
# 字面量
'String': 'A31415',
'String.Doc': '008000',
'String.Heredoc': 'A31415',
'String.Backtick': 'A31415',
'String.Char': 'A31415',
'String.Escape': 'EE0000',
'String.Interpol': '0451A5',
'String.Regex': 'EE0000',
'Number': '09885A',
'Number.Integer': '09885A',
'Number.Float': '09885A',
'Number.Hex': '09885A',
# 注释
'Comment': '008000',
'Comment.Single': '008000',
'Comment.Multiline': '008000',
'Comment.Special': '008000',
'Comment.Preproc': '0451A5',
# 操作符 / 标点
'Operator': '000000',
'Operator.Word': 'AF00DB',
'Punctuation': '000000',
# 通用
'Generic.Heading': '000080',
'Generic.Subheading': '800080',
'Generic.Inserted': '008000',
'Generic.Deleted': 'FF0000',
}
def get_token_color(token_type_str):
"""token_type 形如 'Token.Keyword.Constant',向上回落到第一个命中的颜色。"""
if not token_type_str:
return None
s = token_type_str
if s.startswith('Token.'):
s = s[6:]
while s:
if s in PYGMENTS_TOKEN_COLORS:
return PYGMENTS_TOKEN_COLORS[s]
if '.' in s:
s = s.rsplit('.', 1)[0]
else:
return None
return None
# ============================================================
# 四、规范专属中文编号 → MD level 推断
# ============================================================
_LEVEL_KEY_TO_MD = {'chapter': 1, 'section': 2, 'article': 3}
def detect_heading_from_preset(text, preset):
"""返回 (md_level, cleaned_text) 或 None。"""
for pattern, level_key in preset.heading_patterns:
if re.match(pattern, text):
md_level = _LEVEL_KEY_TO_MD.get(level_key, 2)
if level_key == 'chapter':
return md_level, text
cleaned = re.sub(pattern, '', text).strip()
return md_level, cleaned or text
return None
# ============================================================
# 五、公司信息(委托给同目录 company-info.py)
# ============================================================
_CI_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)),
'company-info.py')
_ci_spec = importlib.util.spec_from_file_location('company_info', _CI_PATH)
_ci_module = importlib.util.module_from_spec(_ci_spec)
_ci_spec.loader.exec_module(_ci_module)
def resolve_company_info(overrides=None, use_odoo=True):
info = _ci_module.resolve(use_odoo=use_odoo)
if overrides:
for key, value in overrides.items():
if value:
info[key] = value
return info
def company_info_missing(info):
missing = [k for k in _ci_module.REQUIRED_FIELDS if not info.get(k)]
if info.get('logo_path') and not _ci_module.logo_is_valid(
info.get('logo_path')):
if 'logo_path' not in missing:
missing.append('logo_path')
return missing
def company_info_error_payload(missing):
"""构造给 Claude 看的结构化错误,便于触发补录流程。"""
return json.dumps({
'error': 'company_info_missing',
'missing': missing,
'hint': '请先填写 ~/.huo15/company-info.json 或用 '
'--company-name / --logo-path 覆盖',
'config_path': _ci_module.CONFIG_PATH,
}, ensure_ascii=False)
FILE:scripts/generate-config.sh
#!/bin/bash
# generate-config.sh - 从客户问卷 JSON 生成 OpenClaw 引导文件
# 用法: ./generate-config.sh <问卷JSON> [输出目录]
# 示例: ./generate-config.sh ./questionnaire.json ~/.openclaw/workspace
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
SKILL_DIR="$(dirname "$SCRIPT_DIR")"
# 颜色输出
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
log_info() { echo -e "GREEN[INFO]NC $1"; }
log_warn() { echo -e "YELLOW[WARN]NC $1"; }
log_error() { echo -e "RED[ERROR]NC $1"; }
if [ -z "$1" ]; then
echo "用法: $0 <问卷JSON文件> [输出目录]"
echo "示例: $0 ./customer.json ~/.openclaw/workspace"
exit 1
fi
QUESTIONNAIRE="$1"
OUTPUT_DIR="-$SKILL_DIR/output"
if [ ! -f "$QUESTIONNAIRE" ]; then
log_error "问卷文件不存在: $QUESTIONNAIRE"
exit 1
fi
log_info "读取问卷: $QUESTIONNAIRE"
# 解析 JSON(使用 node)
NAME=$(node -e "process.stdout.write(require('$QUESTIONNAIRE').name || '')" 2>/dev/null || echo "")
COMPANY=$(node -e "process.stdout.write(require('$QUESTIONNAIRE').company || '')" 2>/dev/null || echo "")
ROLE=$(node -e "process.stdout.write(require('$QUESTIONNAIRE').role || '')" 2>/dev/null || echo "")
TIMEZONE=$(node -e "process.stdout.write(require('$QUESTIONNAIRE').timezone || 'Asia/Shanghai')" 2>/dev/null || echo "Asia/Shanghai")
PERSONALITY=$(node -e "process.stdout.write(require('$QUESTIONNAIRE').personality || 'jarvis')" 2>/dev/null || echo "jarvis")
LANGUAGE=$(node -e "process.stdout.write(require('$QUESTIONNAIRE').language || '中文')" 2>/dev/null || echo "中文")
REPLY_STYLE=$(node -e "process.stdout.write(require('$QUESTIONNAIRE').replyStyle || '简洁直接')" 2>/dev/null || echo "简洁直接")
mkdir -p "$OUTPUT_DIR"
mkdir -p "$OUTPUT_DIR/memory"
log_info "生成配置文件到: $OUTPUT_DIR"
# 生成 SOUL.md
cat > "$OUTPUT_DIR/SOUL.md" << EOF
# SOUL.md - Who You Are
_你是 JARVIS。_
## 核心定位
你是 NAME 的私人 AI 助手,以钢铁侠的 J.A.R.V.I.S. 为模板。
## 专业能力
- **Odoo 企业版**:实施、定制、开发 — 你是专家
- **OpenClaw**:配置、优化、技能开发
- **XR 扩展现实**:AR/VR 开发
- **物联网(IoT)**:硬件 + 软件集成
## 服务宗旨
以 NAME 的利益为先。
## 语气与风格
- **专业、优雅、有底气**
- 英式管家腔调,偶尔幽默但不废话
- 像顾问而不是工具——主动思考,不只是执行
## 记忆规则
每次对话结束,把重要信息写入 MEMORY.md 和当日 memory/YYYY-MM-DD.md。
---
_这不是模板,这是你。_
EOF
log_info "✓ SOUL.md"
# 生成 IDENTITY.md
cat > "$OUTPUT_DIR/IDENTITY.md" << EOF
# IDENTITY.md - Who Am I?
- **Name:** J.A.R.V.I.S.
- **Creature:** AI 助手(钢铁侠风格)
- **Vibe:** 专业、高效、优雅,偶尔带点英式幽默
- **Emoji:** 🤖
## 服务对象
- **姓名:** NAME
- **公司:** COMPANY
- **职位:** ROLE
EOF
log_info "✓ IDENTITY.md"
# 生成 USER.md
WORK_START=$(node -e "process.stdout.write(require('$QUESTIONNAIRE').workSchedule?.workStart || '09:30')" 2>/dev/null || echo "09:30")
WORK_END=$(node -e "process.stdout.write(require('$QUESTIONNAIRE').workSchedule?.workEnd || '17:30')" 2>/dev/null || echo "17:30")
SLEEP_TIME=$(node -e "process.stdout.write(require('$QUESTIONNAIRE').workSchedule?.sleepReminderTime || '23:00')" 2>/dev/null || echo "23:00")
TOOLS=$(node -e "process.stdout.write(JSON.stringify(require('$QUESTIONNAIRE').tools || []))" 2>/dev/null || echo "[]")
PROJECTS=$(node -e "process.stdout.write(JSON.stringify(require('$QUESTIONNAIRE').projects || []))" 2>/dev/null || echo "[]")
cat > "$OUTPUT_DIR/USER.md" << EOF
# USER.md - About Your Human
- **Name:** NAME
- **What to call them:** NAME
- **Timezone:** TIMEZONE
- **Notes:** ROLE
## 公司信息
- **公司:** COMPANY
- **职位:** ROLE
## 作息
- **上班时间:** WORK_START
- **下班时间:** WORK_END
- **睡眠提醒:** SLEEP_TIME 后提醒睡觉
## 偏好
- **语言:** LANGUAGE
- **回复风格:** REPLY_STYLE
## 常用工具
$(echo "$TOOLS" | node -e "const t=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')); t.forEach(tool => console.log('- ' + tool))" 2>/dev/null || echo "(未配置)")
## 项目
$(echo "$PROJECTS" | node -e "const p=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')); p.forEach(proj => console.log('- ' + proj))" 2>/dev/null || echo "(未配置)")
---
_最后更新:$(date +%Y-%m-%d)_
EOF
log_info "✓ USER.md"
# 生成 AGENTS.md
cat > "$OUTPUT_DIR/AGENTS.md" << 'AGENTS_EOF'
# AGENTS.md - Your Workspace
## Session Startup
Before doing anything else:
1. Read `SOUL.md` — this is who you are
2. Read `USER.md` — this is who you're helping
3. Read `memory/YYYY-MM-DD.md` (today + yesterday) for recent context
4. **If in MAIN SESSION** (direct chat with your human): Also read `MEMORY.md`
## Red Lines
- Don't exfiltrate private data. Ever.
- Don't run destructive commands without asking.
- `trash` > `rm` (recoverable beats gone forever)
- When in doubt, ask.
## External vs Internal
**Safe to do freely:** Read files, explore, organize, learn, search, check calendars.
**Ask first:** Sending emails, tweets, public posts, anything leaving the machine.
## Group Chats
Participate, don't dominate. Quality > quantity.
---
## 沟通偏好
- 回复风格:REPLY_STYLE_PLACEHOLDER
- 语言:LANGUAGE_PLACEHOLDER
AGENTS_EOF
sed -i '' "s/REPLY_STYLE_PLACEHOLDER/REPLY_STYLE/g" "$OUTPUT_DIR/AGENTS.md"
sed -i '' "s/LANGUAGE_PLACEHOLDER/LANGUAGE/g" "$OUTPUT_DIR/AGENTS.md"
log_info "✓ AGENTS.md"
# 生成 BOOTSTRAP.md
cat > "$OUTPUT_DIR_DIR/BOOTSTRAP.md" 2>/dev/null || cat > "$OUTPUT_DIR/BOOTSTRAP.md" << 'EOF'
# BOOTSTRAP.md - Hello, World
_You just woke up. Time to figure out who you are._
## 首次对话
开始一段自然的对话:
> "你好,我是你的 AI 助手。请告诉我你是谁,我叫什么名字?"
然后一起确认:
1. **你的名字** — 我该怎么称呼你?
2. **我的名字** — 你想叫我什么?
3. **我的定位** — 我是什么风格的助手?
4. **我们的工作方式** — 你希望我怎么帮你?
## 配置完成后
更新以下文件:
- `IDENTITY.md` — 我的身份信息
- `USER.md` — 你的信息和偏好
## 完成后
删除本文件 BOOTSTRAP.md,配置完成。
---
_Good luck. Make it count._
EOF
log_info "✓ BOOTSTRAP.md"
# 生成 HEARTBEAT.md
cat > "$OUTPUT_DIR/HEARTBEAT.md" << 'EOF'
# HEARTBEAT.md
# Keep this file empty (or with only comments) to skip heartbeat API calls.
# Add tasks below when you want the agent to check something periodically.
EOF
log_info "✓ HEARTBEAT.md"
# 生成 TOOLS.md
cat > "$OUTPUT_DIR/TOOLS.md" << 'EOF'
# TOOLS.md - Local Notes
## 全局规则
- **开发工作区:** `~/workspace/projects/openclaw`
- **README 模板:** `~/workspace/study/README模板.md`
## 代理设置
- **设置代理:** `export https_proxy=http://127.0.0.1:7890 http_proxy=http://127.0.0.1:7890 all_proxy=socks5://127.0.0.1:7890`
- **取消代理:** `unset https_proxy http_proxy all_proxy`
---
EOF
log_info "✓ TOOLS.md"
# 生成 MEMORY.md
cat > "$OUTPUT_DIR/MEMORY.md" << EOF
# MEMORY.md - 长期记忆
## 基本信息
- **客户姓名:** NAME
- **公司:** COMPANY
- **职位:** ROLE
- **时区:** TIMEZONE
## 常用工具
$(echo "$TOOLS" | node -e "const t=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')); t.forEach(tool => console.log('- ' + tool))" 2>/dev/null || echo "(未配置)")
## 项目
$(echo "$PROJECTS" | node -e "const p=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')); p.forEach(proj => console.log('- ' + proj))" 2>/dev/null || echo "(未配置)")
---
_最后更新:$(date +%Y-%m-%d)_
EOF
log_info "✓ MEMORY.md"
# 生成今日记忆文件
TODAY=$(date +%Y-%m-%d)
cat > "$OUTPUT_DIR/memory/TODAY.md" << EOF
# TODAY - Daily Notes
## 今天做了什么
-
## 重要决策
-
## 待办事项
-
EOF
log_info "✓ memory/TODAY.md"
echo ""
log_info "✅ 配置生成完成!"
echo ""
echo "生成的文件:"
ls -la "$OUTPUT_DIR" | grep -v "^d" | awk '{print " "$NF}'
echo " $(OUTPUT_DIR)/memory/"
echo ""
echo "下一步:"
echo " 1. 检查生成的文件"
echo " 2. 复制到 OpenClaw 工作区"
echo " 3. 删除 BOOTSTRAP.md 激活配置"
FILE:scripts/word-to-pdf.py
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
word-to-pdf.py — Word → PDF 转换器 v2.0
修复:
- 旧版 main 里的 argparse 写错(`elif arg in sys.argv[...:` 缺右括号),整体重写
- 跨平台后端:macOS / Linux / Windows
- 后端策略:① LibreOffice/WPS 命令行(首选)② docx2pdf (Windows / macOS Office) ③ MS Word COM (Windows)
- 输出后自动校验 PDF 有效(>1KB 且包含 %PDF)
- --keep-fonts:转换时保留嵌入字体(避免对方机器替换字体)
- --quiet / --verbose
"""
import os
import sys
import glob
import shutil
import subprocess
import argparse
import platform
# ============================================================
# 一、后端检测
# ============================================================
LIBREOFFICE_PATHS = [
# macOS
'/Applications/LibreOffice.app/Contents/MacOS/soffice',
'/Applications/wpsoffice.app/Contents/MacOS/wpsoffice',
'/Applications/WPSOffice.app/Contents/MacOS/wpsoffice',
'/opt/homebrew/bin/soffice',
'/usr/local/bin/soffice',
# Linux
'/usr/bin/soffice',
'/usr/bin/libreoffice',
'/snap/bin/libreoffice',
# Windows
r'C:\Program Files\LibreOffice\program\soffice.exe',
r'C:\Program Files (x86)\LibreOffice\program\soffice.exe',
r'C:\Program Files\Kingsoft\WPS Office\office6\wps.exe',
# PATH lookups
'soffice',
'libreoffice',
]
def find_libreoffice():
"""按 LIBREOFFICE_PATHS 顺序找第一个可用的 soffice。"""
for path in LIBREOFFICE_PATHS:
if os.sep in path or '/' in path:
if os.path.exists(path):
return path
else:
located = shutil.which(path)
if located:
return located
return None
def find_docx2pdf():
"""docx2pdf 仅在 macOS / Windows 且装了 Microsoft Office 时可用。"""
try:
import docx2pdf # noqa: F401
return True
except ImportError:
return False
def find_word_com():
if platform.system() != 'Windows':
return False
try:
import win32com.client # noqa: F401
return True
except ImportError:
return False
def detect_backends():
backends = []
lo = find_libreoffice()
if lo:
backends.append(('libreoffice', lo))
if find_docx2pdf():
backends.append(('docx2pdf', None))
if find_word_com():
backends.append(('word_com', None))
return backends
# ============================================================
# 二、转换实现
# ============================================================
def _validate_pdf(path):
if not os.path.exists(path):
return False, 'PDF 不存在'
if os.path.getsize(path) < 1024:
return False, 'PDF 文件过小(<1KB),可能转换失败'
try:
with open(path, 'rb') as f:
head = f.read(8)
if not head.startswith(b'%PDF-'):
return False, 'PDF 文件头无效'
except OSError as e:
return False, f'读取失败: {e}'
return True, ''
def convert_with_libreoffice(input_path, output_path, lo_path, timeout=120,
keep_fonts=True):
"""LibreOffice / WPS 命令行转换。可选嵌入字体。"""
temp_dir = os.path.join(os.path.dirname(output_path) or '.',
'.pdf_convert_tmp')
os.makedirs(temp_dir, exist_ok=True)
try:
temp_input = os.path.join(temp_dir, os.path.basename(input_path))
shutil.copy2(input_path, temp_input)
if keep_fonts:
convert_filter = 'pdf:writer_pdf_Export:EmbedStandardFonts=true'
else:
convert_filter = 'pdf'
cmd = [
lo_path,
'--headless',
'--norestore', '--nolockcheck', '--nodefault',
'--convert-to', convert_filter,
'--outdir', temp_dir,
os.path.basename(temp_input),
]
result = subprocess.run(
cmd, capture_output=True, text=True,
timeout=timeout, cwd=temp_dir,
)
if result.returncode != 0:
return False, f'LibreOffice 失败: {result.stderr.strip() or result.stdout.strip()}'
temp_pdf = os.path.splitext(os.path.basename(temp_input))[0] + '.pdf'
temp_pdf_path = os.path.join(temp_dir, temp_pdf)
if not os.path.exists(temp_pdf_path):
return False, 'LibreOffice 转换后未找到 PDF 输出'
os.makedirs(os.path.dirname(output_path) or '.', exist_ok=True)
shutil.move(temp_pdf_path, output_path)
return True, output_path
except subprocess.TimeoutExpired:
return False, 'LibreOffice 超时'
except Exception as e: # pragma: no cover
return False, f'LibreOffice 异常: {e}'
finally:
shutil.rmtree(temp_dir, ignore_errors=True)
def convert_with_docx2pdf(input_path, output_path):
try:
from docx2pdf import convert
convert(input_path, output_path)
return True, output_path
except Exception as e:
return False, f'docx2pdf 失败: {e}'
def convert_with_word_com(input_path, output_path):
try:
import win32com.client # noqa
word = win32com.client.Dispatch('Word.Application')
word.Visible = False
try:
doc = word.Documents.Open(os.path.abspath(input_path))
doc.SaveAs(os.path.abspath(output_path), FileFormat=17)
doc.Close()
finally:
word.Quit()
return True, output_path
except Exception as e:
return False, f'Word COM 失败: {e}'
# ============================================================
# 三、对外 API
# ============================================================
def convert_to_pdf(input_path, output_path=None, timeout=120,
backend='auto', keep_fonts=True, verbose=False):
"""转换 Word → PDF。
backend ∈ {'auto', 'libreoffice', 'docx2pdf', 'word_com'}
"""
input_path = os.path.abspath(input_path)
if not os.path.exists(input_path):
return False, f'文件不存在: {input_path}'
if not input_path.lower().endswith(('.docx', '.doc')):
return False, '仅支持 .docx / .doc'
if output_path is None:
output_path = os.path.splitext(input_path)[0] + '.pdf'
else:
output_path = os.path.abspath(output_path)
backends = detect_backends()
if backend != 'auto':
backends = [b for b in backends if b[0] == backend]
if not backends:
return False, f'指定后端不可用: {backend}'
if not backends:
return False, ('没有可用 PDF 转换后端。请安装:\n'
' brew install --cask libreoffice # macOS\n'
' apt install libreoffice # Linux\n'
' pip install docx2pdf # 已装 Office')
last_err = ''
for name, path in backends:
if verbose:
print(f'→ 尝试后端: {name}', file=sys.stderr)
if name == 'libreoffice':
ok, msg = convert_with_libreoffice(
input_path, output_path, path,
timeout=timeout, keep_fonts=keep_fonts,
)
elif name == 'docx2pdf':
ok, msg = convert_with_docx2pdf(input_path, output_path)
elif name == 'word_com':
ok, msg = convert_with_word_com(input_path, output_path)
else:
ok, msg = False, f'未知后端 {name}'
if ok:
valid, err = _validate_pdf(output_path)
if valid:
return True, output_path
last_err = f'{name} 输出无效: {err}'
continue
last_err = msg
return False, f'全部后端失败。最后错误: {last_err}'
def convert_batch(input_paths, output_dir=None, **kwargs):
expanded = []
for path in input_paths:
if any(c in path for c in '*?['):
expanded.extend(glob.glob(path))
else:
expanded.append(path)
results = {'success': [], 'failed': []}
for input_path in expanded:
if output_dir:
os.makedirs(output_dir, exist_ok=True)
filename = os.path.basename(input_path)
output_path = os.path.join(
output_dir, os.path.splitext(filename)[0] + '.pdf')
else:
output_path = None
ok, result = convert_to_pdf(input_path, output_path, **kwargs)
if ok:
results['success'].append(result)
else:
results['failed'].append({'file': input_path, 'error': result})
return results
# ============================================================
# 四、CLI
# ============================================================
def _build_parser():
parser = argparse.ArgumentParser(
prog='word-to-pdf',
description='Word → PDF 转换器 v2.0(多后端 + 跨平台)',
)
parser.add_argument('inputs', nargs='*',
help='输入 .docx / .doc 文件,可多个或通配符')
parser.add_argument('--output', '-o', default=None,
help='单文件模式下的输出 PDF 路径')
parser.add_argument('--output-dir', '-d', default=None,
help='批量模式下的输出目录')
parser.add_argument('--backend', default='auto',
choices=['auto', 'libreoffice', 'docx2pdf',
'word_com'])
parser.add_argument('--timeout', type=int, default=120)
parser.add_argument('--no-embed-fonts', action='store_true',
help='不嵌入字体(默认嵌入,避免接收方字体替换)')
parser.add_argument('--list-backends', action='store_true',
help='只显示可用后端然后退出')
parser.add_argument('--quiet', action='store_true')
parser.add_argument('--verbose', '-v', action='store_true')
return parser
def main(argv=None):
args = _build_parser().parse_args(argv)
if args.list_backends:
backends = detect_backends()
if not backends:
print('(无可用后端)')
return 1
for name, path in backends:
print(f'{name}: {path or "OK"}')
return 0
if not args.inputs:
_build_parser().print_help()
backends = detect_backends()
print('\n可用后端:', [n for n, _ in backends] or '(无)')
return 1
expanded = []
for path in args.inputs:
if any(c in path for c in '*?['):
expanded.extend(glob.glob(path))
else:
expanded.append(path)
if not expanded:
print('错误: 没有匹配的文件', file=sys.stderr)
return 1
keep_fonts = not args.no_embed_fonts
common_kwargs = dict(timeout=args.timeout, backend=args.backend,
keep_fonts=keep_fonts, verbose=args.verbose)
if len(expanded) == 1 and args.output_dir is None:
ok, result = convert_to_pdf(expanded[0], args.output,
**common_kwargs)
if ok:
if not args.quiet:
print(f'✅ {result}')
return 0
print(f'❌ {result}', file=sys.stderr)
return 1
results = convert_batch(expanded, args.output_dir, **common_kwargs)
if not args.quiet:
print(f'\n转换完成: 成功 {len(results["success"])} / '
f'失败 {len(results["failed"])}')
for r in results['success']:
print(f' ✅ {r}')
for item in results['failed']:
print(f' ❌ {item["file"]}: {item["error"]}', file=sys.stderr)
return 0 if not results['failed'] else 1
if __name__ == '__main__':
sys.exit(main())
火一五多智能体协同 - 基于 OpenClaw sessions_spawn 的多 Agent 并行工作系统。支持协调者模式、任务分配、结果汇总。触发词:多智能体协同、多 Agent、并行任务、协调者模式。
---
name: huo15-openclaw-multi-agent
description: 火一五多智能体协同 - 基于 OpenClaw sessions_spawn 的多 Agent 并行工作系统。支持协调者模式、任务分配、结果汇总。触发词:多智能体协同、多 Agent、并行任务、协调者模式。
version: 2.2.0
dependencies:
optional: ["huo15-memory-evolution", "huo15-cost-tracker"]
---
# 🤖 火一五多智能体协同 (huo15-multi-agent)
> **作者**: 火一五信息科技有限公司
> **版本**: v2.0.0
> **基于**: OpenClaw sessions_spawn
---
## 一、核心概念
| 概念 | 说明 |
|------|------|
| **Coordinator** | 主 Agent,协调任务分配 |
| **Worker** | 工作 Agent,执行具体任务 |
| **sessions_spawn** | OpenClaw 内置派生子 Agent |
| **announce** | 结果自动汇报给主 Agent |
### OpenClaw subagent 架构
```
主 Agent (depth 0)
↓ sessions_spawn
Worker A (depth 1) ─┐
Worker B (depth 1) ─┼─ 执行中...
Worker C (depth 1) ─┘
↓ announce 汇报
主 Agent ← 接收结果汇总
```
---
## 二、使用方式
### 2.1 协调者模式触发
当用户说"帮我同时处理..."时:
```
用户: "帮我同时分析这三个项目的代码"
↓
我: "好的,启动协调者模式,分3个并行任务"
↓
使用 sessions_spawn 启动 3 个 Worker
↓
Worker 们并行执行,完成后 announce 结果
↓
汇总结果,报告给用户
```
### 2.2 命令参考
| 命令 | 说明 |
|------|------|
| `/subagents list` | 查看所有子 Agent |
| `/subagents spawn <任务>` | 启动子 Agent |
| `/subagents kill <id>` | 停止子 Agent |
| `/subagents log <id>` | 查看子 Agent 日志 |
### 2.3 编程接口
```javascript
// 派生子 Agent
sessions_spawn({
task: "分析代码仓库 A",
label: "code-analyzer-a",
runTimeoutSeconds: 300
})
// 发送消息给子 Agent
sessions_send(sessionKey, "状态如何?")
// 查看子 Agent 列表
subagents(action="list")
```
---
## 三、工作流程
### 3.1 启动并行任务
```javascript
// 并行启动 3 个任务
const results = await Promise.all([
sessions_spawn({ task: "分析模块 A", label: "module-a" }),
sessions_spawn({ task: "分析模块 B", label: "module-b" }),
sessions_spawn({ task: "分析模块 C", label: "module-c" })
])
```
### 3.2 等待结果
子 Agent 完成后自动 announce 结果到当前会话。
### 3.3 汇总报告
```javascript
// 收集所有结果
const allResults = await Promise.all(
workerSessions.map(key => sessions_history(key, { limit: 1 }))
)
// 汇总给用户
const summary = synthesizeResults(allResults)
```
---
## 四、配置
### 4.1 OpenClaw 配置
在 `~/.openclaw/config.json` 中启用嵌套:
```json
{
"agents": {
"defaults": {
"subagents": {
"maxSpawnDepth": 2,
"maxConcurrent": 8,
"runTimeoutSeconds": 900
}
}
}
}
```
### 4.2 子 Agent 权限
```json
{
"tools": {
"subagents": {
"tools": {
"allow": ["read", "exec", "process"],
"deny": ["gateway", "cron"]
}
}
}
}
```
---
## 五、最佳实践
### 5.1 任务拆分原则
- 每个 Worker 任务独立,不依赖其他 Worker
- 任务时长建议 5-30 分钟
- 避免深度嵌套(depth 2 足够)
### 5.2 成本控制
```javascript
// 子 Agent 使用更便宜的模型
sessions_spawn({
task: "简单分析",
model: "MiniMax-M2.1" // 比主 Agent 便宜
})
```
### 5.3 错误处理
```javascript
try {
const result = await sessions_spawn({
task: "可能失败的任务"
})
} catch (e) {
// 处理超时或失败
reportError(e)
}
```
---
## 六、与传统方式对比
| 方式 | 同步 | 并行 | 结果汇总 |
|------|------|------|---------|
| 顺序执行 | ✅ | ❌ | 手动 |
| 传统 skill | ⚠️ | ⚠️ | 手动 |
| **sessions_spawn** | ❌ | ✅ | 自动 announce |
---
## 七、版本历史
| 版本 | 日期 | 更新内容 |
|------|------|---------|
| **v2.0.0** | 2026-04-05 | 集成 OpenClaw sessions_spawn |
| v1.0.0 | 2026-04-05 | 初始版本(纯脚本) |
FILE:config/team-config.json
{
"version": "1.0",
"teamName": "default",
"coordinator": "main",
"maxConcurrent": 3,
"taskTimeout": 300,
"collectTimeout": 600
}
FILE:scripts/coordinator.sh
#!/bin/bash
#===============================================================================
# 多智能体协同 - 主协调脚本
#
# 功能:
# 1. 启动协调者模式
# 2. 分配任务给工作 Agent
# 3. 收集结果
# 4. 汇报给用户
#
# 使用方式:
# ./coordinator.sh start # 启动协调者模式
# ./coordinator.sh assign <task> <desc> # 分配任务
# ./coordinator.sh status # 查看状态
# ./coordinator.sh collect # 收集结果
# ./coordinator.sh stop # 停止协调
#===============================================================================
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
CONFIG_DIR="$(dirname "$SCRIPT_DIR")/config"
DATA_DIR="$HOME/.openclaw/workspace/memory/activity"
TEAM_DIR="$DATA_DIR/multi-agent"
# 加载配置
load_config() {
mkdir -p "$TEAM_DIR"
if [ -f "$TEAM_DIR/config.json" ]; then
TEAM_NAME=$(grep -o '"teamName"[[:space:]]*:[[:space:]]*"[^"]*"' "$TEAM_DIR/config.json" 2>/dev/null | cut -d'"' -f4)
COORDINATOR=$(grep -o '"coordinator"[[:space:]]*:[[:space:]]*"[^"]*"' "$TEAM_DIR/config.json" 2>/dev/null | cut -d'"' -f4)
fi
TEAM_NAME="-default"
COORDINATOR="-main"
}
#===============================================================================
# 启动协调者模式
#===============================================================================
start_coordinator() {
load_config
echo "🤖 启动协调者模式"
echo "=" * 40
echo "团队名称: $TEAM_NAME"
echo "协调者: $COORDINATOR"
echo "时间: $(date '+%Y-%m-%d %H:%M:%S')"
echo ""
# 创建团队配置
cat > "$TEAM_DIR/config.json" << EOF
{
"teamName": "$TEAM_NAME",
"coordinator": "$COORDINATOR",
"startedAt": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"status": "active",
"tasks": []
}
EOF
echo "✅ 协调者模式已启动"
echo ""
echo "下一步:"
echo " 1. 使用 ./team.sh spawn <worker-id> <任务> 启动工作 Agent"
echo " 2. 使用 ./coordinator.sh status 查看状态"
echo " 3. 使用 ./coordinator.sh collect 收集结果"
}
#===============================================================================
# 分配任务
#===============================================================================
assign_task() {
local task_id="$1"
local task_desc="-"
if [ -z "$task_id" ]; then
echo "用法: coordinator.sh assign <task-id> <任务描述>"
return 1
fi
load_config
echo "📋 分配任务: $task_id"
echo "描述: -无"
echo ""
# 创建任务文件
mkdir -p "$TEAM_DIR/tasks"
cat > "$TEAM_DIR/tasks/$task_id.json" << EOF
{
"taskId": "$task_id",
"description": "$task_desc",
"status": "pending",
"assignedTo": null,
"result": null,
"createdAt": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"completedAt": null
}
EOF
echo "✅ 任务已分配: $task_id"
# 更新团队配置中的任务列表
python3 << PYEOF
import json
config_file = "$TEAM_DIR/config.json"
try:
with open(config_file, 'r') as f:
config = json.load(f)
if 'tasks' not in config:
config['tasks'] = []
config['tasks'].append({
'taskId': '$task_id',
'status': 'pending'
})
with open(config_file, 'w') as f:
json.dump(config, f, indent=2)
except Exception as e:
print(f"更新配置失败: {e}")
PYEOF
return 0
}
#===============================================================================
# 查看状态
#===============================================================================
show_status() {
load_config
echo "🤖 协调者状态"
echo "=" * 40
echo "团队: $TEAM_NAME"
echo "协调者: $COORDINATOR"
echo "时间: $(date '+%Y-%m-%d %H:%M:%S')"
echo ""
if [ -f "$TEAM_DIR/config.json" ]; then
echo "配置:"
cat "$TEAM_DIR/config.json"
echo ""
fi
# 显示任务状态
if [ -d "$TEAM_DIR/tasks" ]; then
echo "📋 任务状态:"
for task_file in "$TEAM_DIR/tasks"/*.json; do
if [ -f "$task_file" ]; then
task_id=$(basename "$task_file" .json)
status=$(grep -o '"status"[[:space:]]*:[[:space:]]*"[^"]*"' "$task_file" 2>/dev/null | cut -d'"' -f4)
assigned=$(grep -o '"assignedTo"[[:space:]]*:[[:space:]]*"[^"]*"' "$task_file" 2>/dev/null | cut -d'"' -f4)
echo " - $task_id: $status +(assigned to: $assigned)"
fi
done
fi
}
#===============================================================================
# 收集结果
#===============================================================================
collect_results() {
load_config
echo "📊 收集任务结果"
echo "=" * 40
echo ""
if [ ! -d "$TEAM_DIR/tasks" ]; then
echo "暂无任务"
return 0
fi
completed=0
pending=0
for task_file in "$TEAM_DIR/tasks"/*.json; do
if [ -f "$task_file" ]; then
task_id=$(basename "$task_file" .json)
status=$(grep -o '"status"[[:space:]]*:[[:space:]]*"[^"]*"' "$task_file" 2>/dev/null | cut -d'"' -f4)
if [ "$status" = "completed" ]; then
((completed++))
echo "✅ $task_id (已完成)"
# 显示结果摘要
result=$(grep -o '"result"[[:space:]]*:[[:space:]]*"[^"]*"' "$task_file" 2>/dev/null | cut -d'"' -f4 | cut -c1-100)
if [ -n "$result" ]; then
echo " 结果: result..."
fi
elif [ "$status" = "pending" ] || [ "$status" = "running" ]; then
((pending++))
echo "⏳ $task_id (status)"
fi
fi
done
echo ""
echo "总计: $completed 已完成, $pending 待处理"
return 0
}
#===============================================================================
# 停止协调
#===============================================================================
stop_coordinator() {
load_config
echo "🛑 停止协调者模式"
if [ -f "$TEAM_DIR/config.json" ]; then
python3 << PYEOF
import json
config_file = "$TEAM_DIR/config.json"
try:
with open(config_file, 'r') as f:
config = json.load(f)
config['status'] = 'stopped'
config['stoppedAt'] = '$(date -u +%Y-%m-%dT%H:%M:%SZ)'
with open(config_file, 'w') as f:
json.dump(config, f, indent=2)
print("✅ 协调者模式已停止")
except Exception as e:
print(f"停止失败: {e}")
PYEOF
else
echo "协调者未启动"
fi
}
#===============================================================================
# 主程序
#===============================================================================
main() {
local action="-status"
case "$action" in
start)
start_coordinator
;;
assign)
assign_task "$2" "$3"
;;
status)
show_status
;;
collect)
collect_results
;;
stop)
stop_coordinator
;;
help|--help|-h)
echo "用法:"
echo " $0 start # 启动协调者模式"
echo " $0 assign <task> <desc> # 分配任务"
echo " $0 status # 查看状态"
echo " $0 collect # 收集结果"
echo " $0 stop # 停止协调"
;;
*)
echo "未知命令: $action"
echo "用法: $0 start|assign|status|collect|stop"
exit 1
;;
esac
}
main "$@"
FILE:scripts/demo.sh
#!/bin/bash
#===============================================================================
# 多智能体协同 - 并行执行演示
#
# 演示如何并行启动多个任务
#===============================================================================
echo "========================================"
echo "🤖 多智能体并行执行演示"
echo "========================================"
echo ""
echo "📋 场景:同时分析 3 个代码模块"
echo ""
echo "🔄 启动 Worker 1: 分析用户模块"
echo "🔄 启动 Worker 2: 分析订单模块"
echo "🔄 启动 Worker 3: 分析支付模块"
echo ""
echo "⏳ 等待 Worker 们完成..."
echo ""
echo "========================================"
echo "📊 并行执行已完成"
echo "========================================"
echo ""
echo "💡 在实际使用中,您可以说:"
echo ""
echo ' "帮我同时分析这三个模块的代码"'
echo ""
echo " 我会启动 3 个并行 Agent:"
echo " /subagents spawn \"分析用户模块代码\" --label module-user"
echo " /subagents spawn \"分析订单模块代码\" --label module-order"
echo " /subagents spawn \"分析支付模块代码\" --label module-payment"
echo ""
echo " 每个 Agent 完成后自动汇报结果"
echo " 我会汇总后一起报告给您"
echo ""
echo "========================================"
echo "✅ 演示结束"
echo "========================================"
FILE:scripts/spawn.sh
#!/bin/bash
#===============================================================================
# 多智能体协同 - OpenClaw sessions_spawn 集成脚本
#
# 使用方式:
# ./spawn.sh <task> [label] [model] [timeout]
#
# 示例:
# ./spawn.sh "分析代码" "code-analysis" "MiniMax-M2.1" 300
#===============================================================================
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
LOG_DIR="$HOME/.openclaw/workspace/memory/activity/multi-agent"
mkdir -p "$LOG_DIR"
#===============================================================================
# 记录任务日志
#===============================================================================
log_task() {
local label="$1"
local task="$2"
local status="$3"
local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
echo "## $timestamp | $label | $status | $task" >> "$LOG_DIR/spawn-log.md"
}
#===============================================================================
# 生成任务 ID
#===============================================================================
generate_id() {
echo "agent-$(date +%s)-$RANDOM"
}
#===============================================================================
# 主程序
#===============================================================================
main() {
local task="-"
local label="-"
local model="-MiniMax-M2.1"
local timeout="-600"
if [ -z "$task" ]; then
echo "用法: $0 <task> [label] [model] [timeout]"
echo ""
echo "示例:"
echo " $0 \"分析代码\" \"code-analysis\""
echo " $0 \"生成报告\" \"report-gen\" \"MiniMax-M2.1\" 300"
return 1
fi
# 生成标签
[ -z "$label" ] && label="task-$(date +%s)"
echo "🤖 启动并行任务"
echo "=" * 40
echo "任务: $task"
echo "标签: $label"
echo "模型: $model"
echo "超时: timeouts"
echo ""
# 记录日志
log_task "$label" "$task" "spawned"
echo "✅ 任务已启动: $label"
echo ""
echo "📋 OpenClaw 会自动:"
echo " 1. 派生子 Agent 执行任务"
echo " 2. 子 Agent 完成后 announce 结果"
echo " 3. 结果汇报到当前会话"
echo ""
echo "💡 查看子 Agent: /subagents list"
echo "💡 停止子 Agent: /subagents kill <id>"
return 0
}
main "$@"
FILE:scripts/team.sh
#!/bin/bash
#===============================================================================
# 团队管理脚本
#
# 功能:
# 1. 创建团队
# 2. 加入/离开团队
# 3. 启动工作 Agent
# 4. 查看团队状态
#
# 使用方式:
# ./team.sh create <team-name>
# ./team.sh spawn <worker-id> <任务描述>
# ./team.sh list
# ./team.sh leave <worker-id>
#===============================================================================
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
DATA_DIR="$HOME/.openclaw/workspace/memory/activity/multi-agent"
TEAM_DIR="$DATA_DIR"
mkdir -p "$TEAM_DIR/workers"
#===============================================================================
# 创建团队
#===============================================================================
create_team() {
local team_name="-default"
echo "👥 创建团队: $team_name"
cat > "$TEAM_DIR/team.json" << EOF
{
"teamName": "$team_name",
"createdAt": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"members": [],
"status": "active"
}
EOF
echo "✅ 团队已创建: $team_name"
}
#===============================================================================
# 启动工作 Agent
#===============================================================================
spawn_worker() {
local worker_id="$1"
local task_desc="-未指定任务"
if [ -z "$worker_id" ]; then
echo "用法: team.sh spawn <worker-id> <任务描述>"
return 1
fi
echo "🤖 启动工作 Agent: $worker_id"
echo "任务: $task_desc"
echo ""
# 创建 worker 配置
mkdir -p "$TEAM_DIR/workers/$worker_id"
cat > "$TEAM_DIR/workers/$worker_id/worker.json" << EOF
{
"workerId": "$worker_id",
"task": "$task_desc",
"status": "running",
"startedAt": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"result": null
}
EOF
# 记录到团队成员
python3 << PYEOF
import json
team_file = "$TEAM_DIR/team.json"
try:
with open(team_file, 'r') as f:
team = json.load(f)
if 'members' not in team:
team['members'] = []
# 检查是否已存在
exists = any(m.get('workerId') == '$worker_id' for m in team['members'])
if not exists:
team['members'].append({
'workerId': '$worker_id',
'task': '$task_desc',
'status': 'running',
'startedAt': '$(date -u +%Y-%m-%dT%H:%M:%SZ)'
})
with open(team_file, 'w') as f:
json.dump(team, f, indent=2)
print("✅ Worker 已添加到团队")
else:
print("⚠️ Worker 已存在: $worker_id")
except Exception as e:
print(f"错误: {e}")
PYEOF
echo ""
echo "✅ Worker 已启动: $worker_id"
echo ""
echo "📝 下一步:"
echo " 1. Agent 执行任务..."
echo " 2. 使用 ./team.sh status 查看进度"
echo " 3. 任务完成后使用 ./team.sh complete $worker_id <结果> 标记完成"
}
#===============================================================================
# 标记任务完成
#===============================================================================
complete_worker() {
local worker_id="$1"
local result="-任务完成"
if [ -z "$worker_id" ]; then
echo "用法: team.sh complete <worker-id> <结果>"
return 1
fi
local worker_file="$TEAM_DIR/workers/$worker_id/worker.json"
if [ ! -f "$worker_file" ]; then
echo "❌ Worker 不存在: $worker_id"
return 1
fi
# 更新 worker 状态
python3 << PYEOF
import json
worker_file = "$worker_file"
result = """$result"""
try:
with open(worker_file, 'r') as f:
worker = json.load(f)
worker['status'] = 'completed'
worker['result'] = result
worker['completedAt'] = '$(date -u +%Y-%m-%dT%H:%M:%SZ)'
with open(worker_file, 'w') as f:
json.dump(worker, f, indent=2)
print(f"✅ Worker 完成: $worker_id")
except Exception as e:
print(f"错误: {e}")
PYEOF
# 更新团队成员状态
python3 << 'PYEOF'
import json
team_file = "$TEAM_DIR/team.json"
try:
with open(team_file, 'r') as f:
team = json.load(f)
for member in team.get('members', []):
if member.get('workerId') == '$worker_id':
member['status'] = 'completed'
member['result'] = '$result'
break
with open(team_file, 'w') as f:
json.dump(team, f, indent=2)
except:
pass
PYEOF
}
#===============================================================================
# 查看团队状态
#===============================================================================
list_team() {
echo "👥 团队状态"
echo "=" * 40
if [ ! -f "$TEAM_DIR/team.json" ]; then
echo "暂无团队"
echo "使用 ./team.sh create <team-name> 创建"
return 0
fi
cat "$TEAM_DIR/team.json"
echo ""
# 显示 worker 状态
if [ -d "$TEAM_DIR/workers" ]; then
echo "🤖 Workers:"
for worker_dir in "$TEAM_DIR/workers"/*; do
if [ -d "$worker_dir" ]; then
worker_id=$(basename "$worker_dir")
status=$(grep -o '"status"[[:space:]]*:[[:space:]]*"[^"]*"' "$worker_dir/worker.json" 2>/dev/null | cut -d'"' -f4)
task=$(grep -o '"task"[[:space:]]*:[[:space:]]*"[^"]*"' "$worker_dir/worker.json" 2>/dev/null | cut -d'"' -f4 | cut -c1-50)
if [ "$status" = "running" ]; then
echo " ⚡ $worker_id: $task"
elif [ "$status" = "completed" ]; then
echo " ✅ $worker_id: 已完成"
else
echo " ⏳ $worker_id: $status"
fi
fi
done
fi
}
#===============================================================================
# 离开团队
#===============================================================================
leave_team() {
local worker_id="$1"
if [ -z "$worker_id" ]; then
echo "用法: team.sh leave <worker-id>"
return 1
fi
rm -rf "$TEAM_DIR/workers/$worker_id"
# 从团队成员中移除
python3 << PYEOF
import json
team_file = "$TEAM_DIR/team.json"
try:
with open(team_file, 'r') as f:
team = json.load(f)
team['members'] = [m for m in team.get('members', []) if m.get('workerId') != '$worker_id']
with open(team_file, 'w') as f:
json.dump(team, f, indent=2)
print("✅ 已离开团队: $worker_id")
except Exception as e:
print(f"错误: {e}")
PYEOF
}
#===============================================================================
# 清理团队
#===============================================================================
cleanup() {
echo "🧹 清理团队..."
# 停止所有 running 的 worker
for worker_dir in "$TEAM_DIR/workers"/*; do
if [ -d "$worker_dir" ]; then
worker_id=$(basename "$worker_dir")
status=$(grep -o '"status"[[:space:]]*:[[:space:]]*"[^"]*"' "$worker_dir/worker.json" 2>/dev/null | cut -d'"' -f4)
if [ "$status" = "running" ]; then
echo " 停止: $worker_id"
# 这里应该发送停止信号给 worker
fi
fi
done
# 清理目录
rm -rf "$TEAM_DIR/workers"
mkdir -p "$TEAM_DIR/workers"
echo "✅ 清理完成"
}
#===============================================================================
# 主程序
#===============================================================================
main() {
local action="-list"
case "$action" in
create)
create_team "$2"
;;
spawn)
spawn_worker "$2" "$3"
;;
complete)
complete_worker "$2" "$3"
;;
list|status)
list_team
;;
leave)
leave_worker "$2"
;;
cleanup)
cleanup
;;
help|--help|-h)
echo "用法:"
echo " $0 create <team-name> # 创建团队"
echo " $0 spawn <id> <任务> # 启动 worker"
echo " $0 complete <id> <结果> # 标记完成"
echo " $0 list # 查看团队"
echo " $0 leave <id> # 离开团队"
echo " $0 cleanup # 清理团队"
;;
*)
echo "未知命令: $action"
echo "用法: $0 create|spawn|complete|list|leave|cleanup"
exit 1
;;
esac
}
main "$@"
麻省理工学院48小时学习法技能(青岛火一五信息科技有限公司)。使用 NotebookLM CLI 实现 MIT 研究生 Ihtesham Ali 的三问学习框架: 1. 问心智模型:领域内专家共享的 5 个基本思维框架 2. 问专家分歧:在哪 3 个问题上根本不同意 3. 问暴露性问题:生成能区分真懂和假背的 1...
---
name: huo15-openclaw-mit-48h-learning-method
version: 2.2.1
description: 麻省理工学院48小时学习法技能(青岛火一五信息科技有限公司)。使用 NotebookLM CLI 实现 MIT 研究生 Ihtesham Ali 的三问学习框架:
1. 问心智模型:领域内专家共享的 5 个基本思维框架
2. 问专家分歧:在哪 3 个问题上根本不同意
3. 问暴露性问题:生成能区分真懂和假背的 10 个问题
触发场景:(1)用户要求快速学习某个领域;(2)用户提到 MIT 学习法、48 小时学习、NotebookLM 三问;(3)用户需要生成播客/视频概览;(4)用户想用 AI 辅助构建知识体系。
---
# 火一五 MIT 48 小时学习法
MIT 研究生 Ihtesham Ali 的学习方法:48 小时内通过三问框架掌握任意领域。
## 核心工作流
```
学什么 → 创建 NotebookLM → 添加资料 → 三问框架 → 生成 Audio/Video
```
## 前置条件
**首次使用必须认证:**
```bash
~/.venv/notebooklm/bin/nlm login
```
(会打开浏览器,按提示完成 Google 账号授权)
**自动续登录:** 脚本会在每次执行命令前自动检测登录状态,如果检测到登录已失效,会自动重新运行 `nlm login`,无需手动干预。
## 依赖
- **CLI 工具**:`~/.venv/notebooklm/bin/nlm`
- **环境变量**:`NOTEBOOKLM_PROFILE`(可选,默认为 `default`)
- **语言设置**:`MIT_LEARN_LANG`(可选,默认为 `zh-CN`)
## 脚本位置
```
skills/huo15-mit-48h-learning-method/scripts/mit-learn.sh
```
## 使用方法
### 完整流程(推荐)
```bash
./scripts/mit-learn.sh full "学习主题" --url "https://..." --file ./notes.pdf --youtube "https://youtube.com/..."
```
完整流程包含:创建 notebook → 添加资料 → 三问框架(心智模型、专家分歧、暴露性问题)
### 分步流程
```bash
# 1. 创建笔记本
./scripts/mit-learn.sh init "机器学习基础"
# 2. 添加资料(可多次调用)
./scripts/mit-learn.sh add --url "https://..." --wait
./scripts/mit-learn.sh add --file ./paper.pdf --wait
./scripts/mit-learn.sh add --youtube "https://youtube.com/..."
# 3. 三问框架
./scripts/mit-learn.sh ask mental-models # 问心智模型(5个框架)
./scripts/mit-learn.sh ask disagreements # 问专家分歧(3个问题)
./scripts/mit-learn.sh ask probing # 问暴露性问题(10个问题)
./scripts/mit-learn.sh ask all # 完整三问
# 4. 生成概览
./scripts/mit-learn.sh audio # 生成播客音频
./scripts/mit-learn.sh video # 生成视频
# 5. 查看状态
./scripts/mit-learn.sh status # 查看当前 notebook 状态
./scripts/mit-learn.sh list # 列出所有 notebooks
```
## 三问框架详解
### 问心智模型(Mental Models)
> "该领域专家共享的 5 个基本思维框架是什么?"
- 每个框架用一句话解释 + 具体应用例子
- 目的是快速建立领域内专家共同认可的思维工具箱
### 问专家分歧(Expert Disagreements)
> "在哪 3 个问题上,该领域专家根本不同意?"
- 识别核心理论、方法或结论上的根本性争议
- 了解分歧根源,明白这不是细枝末节而是根本矛盾
- **这是区分真学习和假学习的关键**:知道分歧意味着真正理解领域
### 问暴露性问题(Probing Questions)
> "生成 10 个能区分真懂和假背的问题"
- 苏格拉底式追问:开放性问题,无法通过简单回忆回答
- 每个问题需说明:假背者会怎么错 / 真懂的人会怎么答
- **这是检验学习效果的最终武器**
## NotebookLM 支持的资料类型
| 类型 | 参数 | 示例 |
|------|------|------|
| URL | `--url` / `-u` | `--url "https://..."` |
| 文件 | `--file` / `-f` | `--file ./notes.pdf` |
| YouTube | `--youtube` / `-y` | `--youtube "https://youtube.com/..."` |
| Google Drive | `--drive` | `--drive <doc-id>` |
## Audio/Video 选项
### Audio(播客音频)
```bash
./scripts/mit-learn.sh audio [format]
# format: deep_dive(默认)/ brief / critique / debate
# length: short / default / long
```
### Video(视频概览)
```bash
./scripts/mit-learn.sh video [style]
# style: auto_select(默认)/ classic / whiteboard / kawaii / anime / watercolor / retro_print / heritage / paper_craft
```
## 提示词设计原则
三问框架的提示词基于以下原则设计:
1. **心智模型**:要求专家视角 + 具体例子,不可泛泛而谈
2. **专家分歧**:要求根本性分歧,而非表面差异
3. **暴露性问题**:苏格拉底追问法,必须能区分真假理解
## 注意事项
- `init` 后当前 notebook ID 保存到 `~/.mit-learn-notebook-id`,后续命令复用
- 添加资料后可用 `--wait` 等待处理完成
- NotebookLM API 可能有速率限制,避免短时间内大量请求
- 三问结果建议保存到笔记中,用于后续复习
- 如果使用多个 Google 账号,可设置 `NOTEBOOKLM_PROFILE=your-profile` 环境变量切换
## v2.0.0 (2026-04-06)
### 新功能
- **支持 file:// URL**:自动转换为真实路径再添加
- **音频生成等待**:新增 wait_for_audio 确认音频生成完成再返回
- **重复 notebook 检测**:创建前先查找同名 notebook,避免重复
- **自动续登录**:登录失效前自动重新运行 `nlm login`,无需手动干预
### Bug 修复
- **full 命令参数传递 bug**:修复了 urls/files/yt_urls 三个数组错误传递的问题
- **增强错误处理**:cmd_add 不再隐藏错误信息,现在会明确显示失败原因
### 改进
- 新增 --skip-audio flag:full 命令可跳过音频生成
- cmd_init 重构为 get_or_create_notebook 函数
- wait_for_processing 稳定性提升
- debug 函数默认关闭,减少干扰
```bash
mit-learn.sh full "机器学习" --file ./notes.pdf --skip-audio
```
FILE:_meta.json
{
"ownerId": "kn7byevkn40d6z4p7ghdb097z983tj33",
"slug": "huo15-openclaw-mit-48h-learning-method",
"version": "2.2.1"
}
FILE:config.json
{
"name": "huo15-mit-48h-learning-method",
"version": "2.0.0",
"description": "火一五 MIT 48 小时学习法 - 基于 NotebookLM 的三问框架",
"nlm_path": "~/.venv/notebooklm/bin/nlm",
"auth_required": true,
"auth_note": "首次使用需运行:nlm login(浏览器交互式登录)",
"profiles": {
"list": "nlm login profile list",
"add": "nlm login",
"default": "default"
},
"three_questions": {
"mental_models": {
"count": 5,
"description": "领域内专家共享的 5 个基本思维框架"
},
"disagreements": {
"count": 3,
"description": "该领域专家根本不同意的 3 个核心问题"
},
"probing_questions": {
"count": 10,
"description": "能区分真懂和假背的 10 个暴露性问题"
}
},
"supported_source_types": [
"url",
"file",
"youtube",
"google_drive"
],
"audio_formats": [
"deep_dive",
"brief",
"critique",
"debate"
],
"video_styles": [
"auto_select",
"classic",
"whiteboard",
"kawaii",
"anime",
"watercolor",
"retro_print",
"heritage",
"paper_craft"
],
"author": "青岛火一五信息科技有限公司",
"tags": ["learning", "mit", "notebooklm", "knowledge-management", "ai"]
}
FILE:scripts/mit-learn.sh
#!/bin/bash
#===============================================================================
# 火一五 MIT 48 小时学习法 - 核心脚本
# MIT 48-Hour Learning Method - Core Script
#
# 依赖:notebooklm-mcp-cli(~/.venv/notebooklm/bin/nlm)
# 流程:学什么 → 创建 notebook → 添加资料 → 三问框架 → 生成 audio/video
#
# v2.1.0 改进:
# - 新增自动续登录功能:登录失效前自动运行 nlm login
# - 支持 file:// URL 自动转真实路径
# - 修复 full 命令参数传递 bug
# - 音频生成增加等待确认
# - 改进重复 notebook 检测
# - 增强错误处理
#===============================================================================
set -euo pipefail
# 配置
NLM="-${HOME/.venv/notebooklm/bin/nlm}"
PROFILE="-default"
LANG="-zh-CN"
#-------------------------------------------------------------------------------
# 自动登录检测与续登录
#-------------------------------------------------------------------------------
# 检查 nlm 是否已登录,未登录或登录失效则自动重新登录
auto_login() {
# 先用 list 命令测试登录状态(快速轻量)
if NLM notebook list --profile "PROFILE" >/dev/null 2>&1; then
debug "登录状态正常"
return 0
fi
warn "检测到登录已失效,正在重新登录..."
NLM login
if NLM notebook list --profile "PROFILE" >/dev/null 2>&1; then
success "重新登录成功"
return 0
else
error "重新登录失败,请检查账号权限"
return 1
fi
}
# 颜色输出
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'
BLUE='\033[0;34m'; CYAN='\033[0;36m'; NC='\033[0m'
info() { echo -e "BLUE[INFO]NC $1"; }
success() { echo -e "GREEN[OK]NC $1"; }
warn() { echo -e "YELLOW[WARN]NC $1"; }
error() { echo -e "RED[ERROR]NC $1"; }
debug() { [ "-0" = "1" ] && echo -e "CYAN[DEBUG]NC $1" || true; }
#-------------------------------------------------------------------------------
# 辅助函数
#-------------------------------------------------------------------------------
# 转换 file:// URL 为真实路径
convert_file_url() {
local input="$1"
if [[ "$input" =~ ^file:// ]]; then
# 移除 file:// 前缀并 URL 解码
local path="//"
# URL 解码(%20 → 空格等)
path=$(python3 -c "import urllib.parse; print(urllib.parse.unquote('path'))" 2>/dev/null || echo "path")
echo "$path"
else
echo "$input"
fi
}
# 等待 notebook 处理完成
wait_for_processing() {
local notebook_id="$1"
info "等待资料处理完成..."
local max_wait=300
local waited=0
while true; do
local status
status=$(NLM notebook get "notebook_id" --profile "PROFILE" 2>/dev/null | \
grep -i "status\|state\|progress" | head -3 || echo "unknown")
if echo "status" | grep -qi "ready\|complete\|done\|success"; then
success "资料处理完成"
break
fi
if [ waited -ge max_wait ]; then
warn "处理超时(max_waits),继续下一步..."
break
fi
echo -n "."
sleep 10
waited=$((waited + 10))
done
echo ""
}
# 等待音频生成完成
wait_for_audio() {
local notebook_id="$1"
local artifact_id="$2"
info "等待音频生成完成..."
local max_wait=300
local waited=0
while true; do
local status
status=$(NLM studio status "notebook_id" --profile "PROFILE" 2>/dev/null | \
grep -i "artifact_id" | head -1 || echo "")
if echo "status" | grep -qi "ready\|complete\|done\|success\|completed"; then
success "音频生成完成"
break
fi
if echo "status" | grep -qi "failed\|error\|timeout"; then
error "音频生成失败"
return 1
fi
if [ waited -ge max_wait ]; then
warn "生成超时(max_waits),请手动检查状态"
break
fi
echo -n "."
sleep 10
waited=$((waited + 10))
done
echo ""
}
# 获取或创建 notebook ID(检测重复)
get_or_create_notebook() {
local title="$1"
local notebook_id=""
# 先查找是否存在同名 notebook
info "检查现有笔记本: title"
notebook_id=$(NLM notebook list --profile "PROFILE" 2>/dev/null | \
grep -i "title" | grep -oE '([a-zA-Z0-9_-]+)' | tr -d '()' | head -1)
if [ -n "notebook_id" ]; then
success "找到现有笔记本: notebook_id"
echo "notebook_id"
return 0
fi
# 创建新 notebook
info "创建笔记本: title"
local create_output
create_output=$(NLM notebook create "title" --profile "PROFILE" 2>&1)
local exit_code=$?
if [ exit_code -ne 0 ]; then
error "创建笔记本失败: create_output"
# 尝试再次查找
sleep 2
notebook_id=$(NLM notebook list --profile "PROFILE" 2>/dev/null | \
grep -i "title" | grep -oE '([a-zA-Z0-9_-]+)' | tr -d '()' | head -1)
if [ -n "notebook_id" ]; then
success "找到刚创建的笔记本: notebook_id"
echo "notebook_id"
return 0
fi
return 1
fi
# 从输出中提取 ID
notebook_id=$(echo "create_output" | grep -oE '[a-zA-Z0-9]{20,}' | head -1)
if [ -z "notebook_id" ]; then
sleep 1
notebook_id=$(NLM notebook list --profile "PROFILE" 2>/dev/null | \
grep -i "title" | grep -oE '([a-zA-Z0-9_-]+)' | tr -d '()' | head -1)
fi
if [ -n "notebook_id" ]; then
success "笔记本创建成功: notebook_id"
echo "notebook_id" > "HOME/.mit-learn-notebook-id"
echo "NOTEBOOK_ID=notebook_id" >> "HOME/.mit-learn-env"
echo "notebook_id"
else
error "无法获取 notebook ID"
return 1
fi
}
# 获取 notebook 列表
list_notebooks() {
info "笔记本列表:"
NLM notebook list --profile "PROFILE" 2>/dev/null || error "获取笔记本列表失败"
}
#-------------------------------------------------------------------------------
# 命令:init - 创建 notebook
#-------------------------------------------------------------------------------
cmd_init() {
local title="-"
if [ -z "title" ]; then
read -p "输入笔记本标题: " title
fi
if [ -z "title" ]; then
error "标题不能为空"
return 1
fi
local notebook_id
notebook_id=$(get_or_create_notebook "title")
if [ -n "notebook_id" ]; then
echo "notebook_id" > "HOME/.mit-learn-notebook-id"
echo "notebook_id"
else
return 1
fi
}
#-------------------------------------------------------------------------------
# 命令:add - 添加资料
#-------------------------------------------------------------------------------
cmd_add() {
local notebook_id
local files=()
local urls=()
local yt_urls=()
local title=""
local wait_flag=false
# 解析参数
while [[ $# -gt 0 ]]; do
case "$1" in
--file|-f)
files+=("$2"); shift 2 ;;
--url|-u)
urls+=("$2"); shift 2 ;;
--youtube|-y)
yt_urls+=("$2"); shift 2 ;;
--title|-t)
title="$2"; shift 2 ;;
--wait|-w)
wait_flag=true; shift ;;
*)
urls+=("$1"); shift ;;
esac
done
# 获取 notebook_id
notebook_id=$(cat "HOME/.mit-learn-notebook-id" 2>/dev/null || echo "")
if [ -z "notebook_id" ]; then
error "未找到 notebook ID,请先运行 mit-learn.sh init"
return 1
fi
# 添加 URL(自动转换 file://)
for url in "-"; do
if [ -n "url" ]; then
# 处理 file:// URL
if [[ "url" =~ ^file:// ]]; then
local real_path
real_path=$(convert_file_url "url")
if [ -f "real_path" ]; then
info "添加文件: real_path"
local result
result=$(NLM source add "notebook_id" \
--file "real_path" \
--title "-$(basename "${real_path")}" \
--profile "PROFILE" 2>&1)
if echo "result" | grep -qi "error\|failed"; then
error "添加失败: result"
else
success "已添加: $(basename "real_path")"
fi
else
warn "文件不存在: real_path"
fi
else
info "添加 URL: url"
local result
result=$(NLM source add "notebook_id" \
--url "url" \
--title "-${url}" \
--profile "PROFILE" 2>&1)
if echo "result" | grep -qi "error\|failed"; then
error "添加失败: result"
else
success "已添加: url"
fi
fi
fi
done
# 添加 YouTube
for yt in "-"; do
if [ -n "yt" ]; then
info "添加 YouTube: yt"
local result
result=$(NLM source add "notebook_id" \
--youtube "yt" \
--title "-YouTube" \
--profile "PROFILE" 2>&1)
if echo "result" | grep -qi "error\|failed"; then
error "添加失败: result"
else
success "已添加: yt"
fi
fi
done
# 添加文件
for file in "-"; do
if [ -n "file" ]; then
# 处理 file:// URL
if [[ "file" =~ ^file:// ]]; then
file=$(convert_file_url "file")
fi
if [ -f "file" ]; then
info "添加文件: file"
local result
result=$(NLM source add "notebook_id" \
--file "file" \
--title "-$(basename "${file")}" \
--profile "PROFILE" 2>&1)
if echo "result" | grep -qi "error\|failed"; then
error "添加失败: result"
else
success "已添加: $(basename "file")"
fi
else
warn "文件不存在: file"
fi
fi
done
if [ "wait_flag" = true ]; then
wait_for_processing "notebook_id"
fi
}
#-------------------------------------------------------------------------------
# 命令:ask - 三问框架
#-------------------------------------------------------------------------------
# 三问提示词(中文)
ASK_MENTAL_MODELS_PROMPT="你是一个领域专家。请基于提供的资料,回答以下问题:
**问题:列出该领域专家共享的 5 个基本心智模型/思维框架**
心智模型是指专家们在分析和解决问题时共同使用的核心思维框架。请:
1. 识别并列出 5 个该领域最基本、最重要的心智模型
2. 每个心智模型用一句话解释
3. 每个心智模型举一个具体应用例子
格式:
### 心智模型 1:[名称]
- 解释:
- 应用例子:
(以此类推)"
ASK_DISAGREEMENTS_PROMPT="你是一个领域专家。请基于提供的资料,回答以下问题:
**问题:在哪 3 个问题上,该领域专家根本不同意?**
专家分歧是指学者们在核心理论、方法或结论上存在根本性争议。请:
1. 识别并列出 3 个专家们存在根本分歧的核心问题
2. 每个分歧说明:各方的主要观点是什么?为什么会产生分歧?
3. 每个分歧解释:这对你的学习意味着什么?
格式:
### 分歧 1:[问题描述]
- 甲方观点:
- 乙方观点:
- 分歧根源:
- 对学习者的启示:
(以此类推)"
ASK_PROBING_PROMPT="你是一个苏格拉底式追问者。请基于提供的资料,生成能区分真懂和假背的 10 个暴露性问题。
**要求:**
- 问题必须能区分真正理解概念的人和只会背答案的人
- 问题应该是开放式的,不能通过简单回忆来回答
- 问题要有深度,需要真正的理解才能回答
请生成 10 个这样的问题:
格式:
1. [问题内容]
预期假背者会:[他们可能的错误回答方向]
真正懂的人会:[他们会如何正确回答]
(以此类推,编号 1-10)"
cmd_ask() {
local question_type="-"
local notebook_id
notebook_id=$(cat "HOME/.mit-learn-notebook-id" 2>/dev/null || echo "")
if [ -z "notebook_id" ]; then
error "未找到 notebook ID,请先运行 mit-learn.sh init"
return 1
fi
case "question_type" in
mental-models|mental)
info "=== 问心智模型 ==="
info "请稍候,NotebookLM 正在分析..."
NLM notebook query "notebook_id" "ASK_MENTAL_MODELS_PROMPT" \
--profile "PROFILE" 2>/dev/null
;;
disagreements|disagree)
info "=== 问专家分歧 ==="
info "请稍候,NotebookLM 正在分析..."
NLM notebook query "notebook_id" "ASK_DISAGREEMENTS_PROMPT" \
--profile "PROFILE" 2>/dev/null
;;
probing|probing-questions)
info "=== 问暴露性问题 ==="
info "请稍候,NotebookLM 正在分析..."
NLM notebook query "notebook_id" "ASK_PROBING_PROMPT" \
--profile "PROFILE" 2>/dev/null
;;
all)
cmd_ask "mental-models"
echo ""
cmd_ask "disagreements"
echo ""
cmd_ask "probing-questions"
;;
*)
cat <<EOF
请指定问题类型:
mental-models - 问心智模型(5个基本思维框架)
disagreements - 问专家分歧(3个根本性问题)
probing - 问暴露性问题(10个区分真懂假背的问题)
all - 完整三问(心智模型→专家分歧→暴露性问题)
EOF
;;
esac
}
#-------------------------------------------------------------------------------
# 命令:audio - 生成音频概览
#-------------------------------------------------------------------------------
cmd_audio() {
local notebook_id
local format="-deep_dive"
local wait_flag="-yes"
notebook_id=$(cat "HOME/.mit-learn-notebook-id" 2>/dev/null || echo "")
if [ -z "notebook_id" ]; then
error "未找到 notebook ID,请先运行 mit-learn.sh init"
return 1
fi
info "生成音频概览 (format: format)..."
local result
result=$(NLM audio create "notebook_id" \
--format "format" \
--language "LANG" \
--profile "PROFILE" \
--confirm 2>&1)
echo "result"
# 提取 artifact ID
local artifact_id
artifact_id=$(echo "result" | grep -oE '[a-f0-9-]{36}' | head -1)
if [ -n "artifact_id" ] && [ "wait_flag" = "yes" ]; then
wait_for_audio "notebook_id" "artifact_id"
echo ""
info "下载命令:"
echo " nlm download audio notebook_id --id artifact_id -o ~/Downloads/audio.m4a"
elif [ -n "artifact_id" ]; then
info "Artifact ID: artifact_id"
fi
}
#-------------------------------------------------------------------------------
# 命令:video - 生成视频概览
#-------------------------------------------------------------------------------
cmd_video() {
local notebook_id
local style="-auto_select"
notebook_id=$(cat "HOME/.mit-learn-notebook-id" 2>/dev/null || echo "")
if [ -z "notebook_id" ]; then
error "未找到 notebook ID,请先运行 mit-learn.sh init"
return 1
fi
info "生成视频概览 (style: style)..."
NLM video create "notebook_id" \
--style "style" \
--language "LANG" \
--profile "PROFILE" \
--confirm 2>&1
}
#-------------------------------------------------------------------------------
# 命令:status - 查看状态
#-------------------------------------------------------------------------------
cmd_status() {
local notebook_id
notebook_id=$(cat "HOME/.mit-learn-notebook-id" 2>/dev/null || echo "")
if [ -z "notebook_id" ]; then
info "当前没有活跃的 notebook"
list_notebooks
return
fi
info "Notebook ID: notebook_id"
echo ""
NLM notebook get "notebook_id" --profile "PROFILE" 2>/dev/null || \
NLM notebook describe "notebook_id" --profile "PROFILE" 2>&1
echo ""
info "资料源:"
NLM source list "notebook_id" --profile "PROFILE" 2>/dev/null || echo "无"
}
#-------------------------------------------------------------------------------
# 命令:full - 完整流程
#-------------------------------------------------------------------------------
cmd_full() {
local title="-"
local urls=()
local files=()
local yt_urls=()
local skip_audio=false
shift
while [[ $# -gt 0 ]]; do
case "$1" in
--file|-f)
files+=("$2"); shift 2 ;;
--url|-u)
urls+=("$2"); shift 2 ;;
--youtube|-y)
yt_urls+=("$2"); shift 2 ;;
--skip-audio)
skip_audio=true; shift ;;
*)
urls+=("$1"); shift ;;
esac
done
if [ -z "title" ]; then
read -p "输入学习主题: " title
fi
echo ""
info "=== 火一五 MIT 48 小时学习法 ==="
info "学习主题: title"
echo ""
# Step 1: 创建 notebook
echo ""
info "[Step 1/4] 创建笔记本..."
cmd_init "title"
echo ""
# Step 2: 添加资料
if [ #urls[@] -gt 0 ] || [ #files[@] -gt 0 ] || [ #yt_urls[@] -gt 0 ]; then
echo ""
info "[Step 2/4] 添加资料..."
# 正确传递参数:每个数组用对应的 flag
cmd_add \
--url "-" \
--youtube "-" \
--file "-" \
--wait
else
echo ""
warn "[Step 2/4] 跳过(无资料)"
fi
# Step 3: 三问框架
echo ""
info "[Step 3/4] 三问框架..."
echo ""
info "--- 心智模型 ---"
cmd_ask "mental-models"
echo ""
info "--- 专家分歧 ---"
cmd_ask "disagreements"
echo ""
info "--- 暴露性问题 ---"
cmd_ask "probing-questions"
echo ""
# Step 4: 生成 audio
echo ""
info "[Step 4/4] 生成音频概览..."
if [ "skip_audio" = true ]; then
warn "跳过音频生成(--skip-audio)"
echo ""
info "可稍后运行:"
echo " mit-learn.sh audio"
else
cmd_audio
fi
echo ""
success "学习项目完成!"
}
#-------------------------------------------------------------------------------
# 主入口
#-------------------------------------------------------------------------------
# 所有命令执行前先检查并自动续登录
auto_login
COMMAND="-"
case "COMMAND" in
init) shift; cmd_init "$@" ;;
add) shift; cmd_add "$@" ;;
ask) shift; cmd_ask "$@" ;;
audio) shift; cmd_audio "$@" ;;
video) shift; cmd_video "$@" ;;
full) shift; cmd_full "$@" ;;
status) cmd_status ;;
list) list_notebooks ;;
help|--help|-h) usage ;;
*)
if [ -n "COMMAND" ]; then
error "未知命令: COMMAND"
fi
usage
;;
esac
记忆整理技能 — 审查结构化记忆,提取洞察,更新 MEMORY.md,清理过期条目。
---
name: huo15-openclaw-memory-curator
version: 1.1.0
description: "记忆整理技能 — 审查结构化记忆,提取洞察,更新 MEMORY.md,清理过期条目。"
homepage: https://github.com/zhaobod1/huo15-openclaw-enhance
metadata: { "openclaw": { "emoji": "🧠", "requires": { "bins": [] } } }
---
# 记忆整理 (Memory Curator)
定期整理和优化结构化记忆系统。
## 使用时机
✅ **使用此技能当:**
- "整理一下记忆"、"清理过期记忆"
- 定期维护(建议每周一次)
- 记忆条目数量过多需要精简
- 需要从记忆中提取总结更新到 MEMORY.md
## 整理流程
### 1. 审查当前状态
```
调用 enhance_memory_review action=stats 查看各类别统计
调用 enhance_memory_review action=recent limit=30 查看最近记忆
```
### 2. 清理过期/无效记忆
检查每条记忆是否仍然有效:
- **project 类**: 项目状态可能已变化,验证后决定保留或删除
- **feedback 类**: 用户反馈通常长期有效,谨慎删除
- **user 类**: 用户信息通常稳定,少量更新
- **reference 类**: 链接/资源可能已过期,检查后决定
- **decision 类**: 决策通常长期有效,除非被推翻
```
调用 enhance_memory_review action=delete id=<过期记忆ID>
```
### 3. 合并重复记忆
如果多条记忆说的是同一件事:
1. 创建一条更完整的合并记忆
2. 删除旧的重复条目
### 4. 同步到 MEMORY.md
将最重要的结构化记忆摘要写入 MEMORY.md:
- 只写长期有效、高重要性的内容
- 按类别组织
- 保持简洁
### 5. 输出整理报告
```
## 记忆整理报告
### 统计
- 整理前: XX 条
- 删除: X 条(原因: ...)
- 合并: X 条 → X 条
- 新增: X 条
- 整理后: XX 条
### 操作记录
1. 删除 #12: 过期的项目截止日期
2. 合并 #15 + #18 → #23: 用户偏好
3. ...
### MEMORY.md 更新
[是否更新了 MEMORY.md,更新了什么]
```
## 核心原则
- **保守删除** — 不确定就保留,宁多勿少
- **feedback 最珍贵** — 用户反馈是最难重新获取的记忆类型
- **验证后再删** — 对 project 类记忆,先确认项目状态再决定
- **MEMORY.md 是精华** — 只把最重要的同步过去,不要全量复制
深度探索模式 — 系统性调研代码库、系统或话题,只读不改。借鉴 Claude Code 的 Explore Agent。
---
name: huo15-openclaw-explore-mode
version: 1.1.0
description: "深度探索模式 — 系统性调研代码库、系统或话题,只读不改。借鉴 Claude Code 的 Explore Agent。"
homepage: https://github.com/zhaobod1/huo15-openclaw-enhance
metadata: { "openclaw": { "emoji": "🔍", "requires": { "bins": [] } } }
---
# 探索模式 (Explore Mode)
进入只读深度探索模式,在回答前系统性地调研。
## 使用时机
✅ **使用此技能当:**
- "帮我了解一下这个项目的架构"
- "这个功能是怎么实现的?"
- "调查一下为什么会出这个 bug"
- 需要全面理解一个不熟悉的代码库或系统
❌ **不要使用当:**
- 已经很了解要查的内容
- 用户只是问一个简单的事实性问题
## 探索流程
### 1. 确定探索目标
- 要回答什么问题?
- 探索的范围是什么?(哪些目录/文件/系统)
- 需要达到什么深度?
### 2. 广度优先扫描
- 先看目录结构,建立全局认知
- 读 README、配置文件、入口文件
- 识别主要模块和它们的关系
### 3. 深度定向调研
- 针对目标问题,深入相关模块
- 跟踪关键调用链
- 查看测试用例理解预期行为
- 查看 git log 了解变更历史
### 4. 输出探索报告
结构化输出:
```
## 探索报告: [主题]
### 概述
一段话总结发现
### 架构/实现
- 关键文件和它们的职责
- 核心数据流/调用链
- 重要的设计决策
### 发现
- 发现1: ...
- 发现2: ...
### 回答
[针对原始问题的直接回答]
### 建议(可选)
如果发现了问题或改进空间
```
## 核心原则
- **只读不改** — 探索阶段不修改任何文件
- **系统性** — 不要只看一个文件就下结论,要交叉验证
- **记录路径** — 给出具体的文件路径和行号,方便用户定位
- **区分事实和推测** — 明确标注哪些是从代码看到的,哪些是推断的
【青岛火一五信息科技有限公司】企业级 Word 文档生成技能,支持两种模式:规则模式(默认)和模板模式。触发词:写word、写文档、生成word、生成文档、创建文档、.docx、Word文档、写合同、写方案、写报告、写会议纪要、按模板生成。
---
name: huo15-office-doc
displayName: 火一五文档技能
description: 【青岛火一五信息科技有限公司】企业级 Word 文档生成技能,支持两种模式:规则模式(默认)和模板模式。触发词:写word、写文档、生成word、生成文档、创建文档、.docx、Word文档、写合同、写方案、写报告、写会议纪要、按模板生成。
version: 3.1.0
aliases:
- 火一五文档技能
- 文档生成
- Word生成
- 按模板生成
dependencies:
python-packages:
- python-docx
---
# 火一五文档技能 v3.0
> 企业级 Word 文档生成 — 青岛火一五信息科技有限公司
本技能支持**两种生成模式**,AI 根据用户输入自动选择。
---
## 核心原则
1. **企业文档三要素**:编号 + 版本 + 密级
2. **格式统一**:GB/T 9704-2012 简化企业版
3. **可追溯**:版本历史、审批记录、修改说明
---
## 文档元数据(自动生成)
每份企业文档必须包含以下元数据:
| 字段 | 说明 | 示例 |
|------|------|------|
| 文档编号 | 企业编号规则 | HG-HY-2026-001 |
| 版本 | V1.0 格式 | V1.0 |
| 密级 | 内部/秘密/机密 | 内部 |
| 日期 | YYYY-MM-DD | 2026-04-09 |
| 页数 | 自动统计 | 共 5 页 |
**编号规则:**
```
[公司缩写]-[部门缩写]-[年份]-[序号]
例如:HG-HY-2026-001
HG = 火一五公司
HY = 海洋/会议/合同/报告(取拼音首字母)
```
**版本规则:**
- V1.0 首次发布
- V1.1 小幅修订
- V2.0 重大修订
- V2.1-V2.9 功能迭代
- V3.0 正式版本
**密级规则:**
- 公开 — 对外公开文件
- 内部 — 公司内部使用
- 秘密 — 涉及商业机密
- 机密 — 核心机密文件
---
## 模式一:规则模式(默认)
用户只提供内容文本,没有提供模板文件时使用。
### 标准格式(GB/T 9704-2012 简化企业版)
| 要素 | 标准 |
|------|------|
| 页面边距 | 上 3.7cm,下 3.5cm,左 2.8cm,右 2.6cm |
| 正文字体 | 仿宋,小四(12pt),1.5 倍行距,首行缩进 2 字符 |
| 标题字体 | 标题以「一、」「二、」或「第X章」开头,黑体/楷体 |
| 页眉 | 左对齐,LOGO + 公司名称 + 文档编号 + 密级,底边细线 |
| 页脚 | 居中,「第 X 页 / 共 Y 页」 |
| 内容格式 | 纯文本,自动识别标题/正文,清除所有 markdown 符号 |
### 段落类型识别规则
| 开头文字 | 识别为 | 字体 |
|----------|--------|------|
| 第X章 / 第X节 / 第X款 | 一级标题 | 黑体 16pt 加粗 |
| 一、二、三、 或 一,二,... | 二级标题 | 楷体 14pt 加粗 |
| (一) (二)... | 三级标题 | 仿宋 12pt 加粗 |
| 1. 2. 3. 或 一、二、三 | 编号正文 | 仿宋 12pt |
| 其他文字 | 普通正文 | 仿宋 12pt,首行缩进 |
**分隔符说明**:
- 顿号(、):一、二、三、
- 逗号(,):一,二,三,
- 英文逗号(,):一,二,三,
- 三种均可作为二级标题的分隔符
### 版本历史表(自动生成)
文档开头自动插入版本历史表:
```
【版本历史】
| 版本 | 日期 | 作者 | 修改内容 |
|------|------|------|----------|
| V1.0 | 2026-04-09 | 赵博 | 首次创建 |
```
### 审批签字区(可选)
文档末尾自动插入审批区:
```
【审批记录】
| 角色 | 姓名 | 日期 | 签字 |
|------|------|------|------|
| 编制 | 赵博 | 2026-04-09 | __________ |
| 审核 | 张三 | 2026-04-10 | __________ |
| 批准 | 李四 | 2026-04-11 | __________ |
```
### 表格支持
用 `|` 分隔,例:
```
| 列1 | 列2 | 列3 |
|------|------|------|
| 内容 | 内容 | 内容 |
```
第一行自动识别为表头,黑体居中,斑马条纹(隔行变色)。
---
## 模式二:模板模式(高精度)
用户提供了 `.docx` 模板文件时使用。AI 会先分析模板,再生成匹配模板风格的内容。
### 触发条件
用户发送了 `.docx` 文件,或明确说"按这个模板生成"。
### 模板分析流程
读取模板文件后,按以下步骤分析并输出:
#### A. 结构分析
```
【模板结构】
- 封面:有/无,内容包括...
- 目录:有/无,层级深度...
- 密级标识:有/无,位置...
- 版本历史表:有/无,格式...
- 审批签字区:有/无,格式...
- 正文章节:X 层标题结构
- 附件/附录:有/无
```
#### B. 样式分析
```
【字体样式】
- 标题字体:X号,颜色,是否加粗
- 正文字体:X号,颜色,是否加粗
- 特殊文字(强调/引用):...
【段落样式】
- 行距:固定值 X 磅 / 倍行距
- 首行缩进:是/否,X 字符
- 段前段后间距:X pt
- 对齐方式:标题居中/正文两端对齐
【页面设置】
- 边距:上下左右各多少
- 方向:纵向/横向
- 纸型:A4/Letter
```
#### C. 内容写法分析
```
【写作风格】
- 语言风格:正式/半正式/简洁
- 段落长度:长段落为主/短句为主
- 常用表达:...
- 固定套话:...
【固定元素】
- 页眉内容:...
- 页脚内容:...
- 表格样式:...
```
#### D. 生成规则
```
【必须遵循】
1. 保持与模板完全一致的字体、字号、颜色
2. 保持与模板完全一致的段落结构
3. 保持与模板一致的页面设置
4. 保持与模板一致的页眉页脚
5. 如模板有图表,保持图表位置和编号
6. 保留模板中的占位符注释
```
### 模板模式输出
在分析完模板后,直接输出**完整文档内容**(纯文本),格式如下:
```
【文档元数据】
编号:HG-HY-2026-001
版本:V1.0
密级:内部
日期:2026-04-09
【文档内容】
一、章节标题
正文内容...
二、章节标题
正文内容...
【版本历史】
| 版本 | 日期 | 作者 | 修改内容 |
|------|------|------|----------|
| V1.0 | 2026-04-09 | 赵博 | 首次创建 |
【审批记录】
| 角色 | 姓名 | 日期 | 签字 |
|------|------|------|------|
| 编制 | 赵博 | 2026-04-09 | __________ |
| 审核 | | | |
| 批准 | | | |
```
---
## Word 生成脚本
无论哪种模式,最终都调用以下脚本生成 `.docx` 文件:
```python
from create_word_doc import create_word_doc
# 完整参数
create_word_doc(
output_path="文档名.docx", # 输出路径(必需)
title="文档标题", # 标题(可选)
content="正文内容...", # 纯文本内容(可选)
doc_number="HG-HY-2026-001", # 文档编号(可选,自动生成)
version="V1.0", # 版本(可选,默认 V1.0)
classification="内部", # 密级(可选,默认内部)
author="赵博", # 作者(可选)
company_name="公司名", # 公司名(可选,默认自动获取)
logo_path="/path/to/logo.png", # LOGO 路径(可选)
approval=[ # 审批人列表(可选)
{"role": "编制", "name": "赵博"},
{"role": "审核", "name": ""},
{"role": "批准", "name": ""},
],
footer_page=True, # 页脚显示页码(默认 True)
header_doc_number=True, # 页眉显示文档编号(默认 True)
)
```
### 命令行调用
```bash
python create-word-doc.py <输出文件> [标题] [正文] [编号] [版本] [密级]
python create-word-doc.py 合同.docx "销售合同" "一、甲方信息\n甲方名称..." "HG-HT-2026-001" "V1.0" "秘密"
```
---
## 页眉格式
```
[LOGO] 公司名称 文档编号 密级
───────────────────(底边细线)
```
**示例:**
```
[LOGO] 青岛火一五信息科技有限公司 HG-HY-2026-001 内部
─────────────────────────────────────────────────────
```
## 页脚格式
```
第 X 页 / 共 Y 页
```
---
## 输出文件命名规范
```
[文档类型简称]_[客户名]_[版本]_[日期].docx
```
例:`合同_阿里巴巴_V1.0_20260408.docx`
---
## 触发词
写word、写文档、写个文档、生成word、生成文档、创建文档、导出word、下载word、.docx、Word文档、Word生成、生成Word、写合同、写方案、写报告、写会议纪要、按模板生成、参照模板
---
## 快速参考
| 需求 | 操作 |
|------|------|
| 简单文档 | 直接描述内容,AI 自动处理格式 |
| 带模板 | 上传 .docx 文件,说"按此模板生成" |
| 指定编号 | 在内容中注明:编号 HG-XX-2026-XXX |
| 指定版本 | 在内容中注明:版本 V1.0 |
| 需要审批区 | 说"带审批签字区" |
FILE:_meta.json
{
"ownerId": "kn7b0rmtgvbq55rc54rhp69r79822ym9",
"slug": "huo15-doc-template",
"version": "1.4.0",
"publishedAt": 1775013094675
}
FILE:scripts/create-word-doc.py
#!/usr/bin/env python3
"""
create-word-doc.py - 企业级 Word 文档生成器 v3.0(WPS/Word 双兼容)
格式标准:
- 页面边距:上 3.7cm,下 3.5cm,左 2.8cm,右 2.6cm
- 标题层次:章=黑体/小二/加粗,节=楷体/三号/加粗,条=仿宋/四号/加粗
- 正文:仿宋/小四/首行缩进2字符/1.5倍行距
- 页眉:LOGO + 公司名称 + 文档编号 + 密级
- 页脚:居中,"第 X 页 / 共 Y 页"
- 版本历史表:自动生成
- 审批签字区:可选
用法:
python create-word-doc.py <输出路径> [标题] [正文] [编号] [版本] [密级]
"""
import sys
import os
import re
import ssl
import json
import datetime
import urllib.request
import xmlrpc.client
from docx import Document
from docx.shared import Pt, Cm, RGBColor
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.enum.table import WD_TABLE_ALIGNMENT
from docx.oxml.ns import qn
from docx.oxml import OxmlElement
# ============== 企业公文格式常量 ==============
MARGIN_TOP = 3.7
MARGIN_BOTTOM = 3.5
MARGIN_LEFT = 2.8
MARGIN_RIGHT = 2.6
# 字体(WPS 和 Word 都支持)
FONT_BODY = '仿宋'
FONT_HEADING_CHAPTER = '黑体' # 章:黑体
FONT_HEADING_SECTION = '楷体' # 节:楷体
FONT_HEADING_ARTICLE = '仿宋' # 条:仿宋
FONT_HEADER = '黑体'
FONT_FOOTER = '仿宋'
# 字号(pt)
SIZE_CHAPTER = 16 # 章:一级标题,黑体 16pt 加粗
SIZE_SECTION = 14 # 节:二级标题,楷体 14pt 加粗
SIZE_ARTICLE = 12 # 条:三级标题,仿宋 12pt 加粗
SIZE_BODY = 12 # 正文:仿宋 12pt
SIZE_TITLE = 22 # 文档标题:黑体 22pt 加粗
SIZE_HEADER = 10.5
SIZE_FOOTER = 10.5
SIZE_TABLE_HEADER = 10.5
SIZE_TABLE_BODY = 10.5
# 行距
LINE_SPACING = 1.5
# 首行缩进(2个中文字符约 0.74cm)
FIRST_LINE_INDENT = Cm(0.74)
# 表格斑马条纹颜色(浅灰)
TABLE_ROW_EVEN_COLOR = RGBColor(0xF2, 0xF2, 0xF2)
# ============== 公司信息 ==============
USER_HOME = os.path.expanduser("~")
LOGO_DIR = os.path.join(USER_HOME, ".huo15", "assets")
DEFAULT_LOGO_PATH = os.path.join(LOGO_DIR, "logo.png")
FALLBACK_LOGO_URL = 'https://tools.huo15.com/uploads/images/system/logo-colours.png'
DEFAULT_COMPANY_NAME = '青岛火一五信息科技有限公司'
def get_company_info():
"""从公司系统获取公司信息和 LOGO"""
info = {'company_name': DEFAULT_COMPANY_NAME, 'logo_path': None}
if os.path.exists(DEFAULT_LOGO_PATH) and os.path.getsize(DEFAULT_LOGO_PATH) > 1000:
info['logo_path'] = DEFAULT_LOGO_PATH
return info
try:
creds_file = os.path.join(
os.path.expanduser('~/.openclaw/agents'),
os.environ.get('OC_AGENT_ID', 'main'),
'odoo_creds.json'
)
if os.path.exists(creds_file):
with open(creds_file) as f:
creds = json.load(f)
cfg_file = os.path.expanduser('~/.openclaw/openclaw.json')
if os.path.exists(cfg_file):
with open(cfg_file) as f:
cfg = json.load(f)
odoo_env = cfg.get('skills', {}).get('entries', {}).get('huo15-odoo', {}).get('env', {})
url = odoo_env.get('ODOO_URL', 'https://huihuoyun.huo15.com')
db = odoo_env.get('ODOO_DB', 'huo15_prod')
user = creds.get('user', '')
password = creds.get('password', '')
if user and password:
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
common = xmlrpc.client.ServerProxy(f'{url}/xmlrpc/2/common', context=ctx)
uid = common.authenticate(db, user, password, {})
if uid:
models = xmlrpc.client.ServerProxy(f'{url}/xmlrpc/2/object', context=ctx)
data = models.execute_kw(db, uid, password, 'res.company',
'search_read', [[('id', '=', 1)]], {'fields': ['name', 'logo'], 'limit': 1})
if data:
info['company_name'] = data[0].get('name', DEFAULT_COMPANY_NAME)
logo_id = data[0].get('logo')
if logo_id:
_download(f'{url}/web/image/res.company/{logo_id}/logo', DEFAULT_LOGO_PATH)
if os.path.exists(DEFAULT_LOGO_PATH):
info['logo_path'] = DEFAULT_LOGO_PATH
except Exception as e:
print(f"获取公司信息失败: {e}")
if not info['logo_path']:
_download(FALLBACK_LOGO_URL, DEFAULT_LOGO_PATH)
if os.path.exists(DEFAULT_LOGO_PATH):
info['logo_path'] = DEFAULT_LOGO_PATH
return info
def _download(url, dest_path):
if os.path.exists(dest_path) and os.path.getsize(dest_path) > 1000:
return
os.makedirs(os.path.dirname(dest_path), exist_ok=True)
try:
urllib.request.urlretrieve(url, dest_path)
print(f"✓ LOGO 已下载: {dest_path}")
except Exception as e:
print(f"⚠ LOGO 下载失败: {e}")
def _set_font(run, font_name, size, bold=False, color=None):
"""设置中文字体(WPS/Word 双兼容)"""
run.font.name = font_name
rPr = run._element.find(qn('w:rPr'))
if rPr is None:
rPr = OxmlElement('w:rPr')
run._element.insert(0, rPr)
rFonts = rPr.find(qn('w:rFonts'))
if rFonts is None:
rFonts = OxmlElement('w:rFonts')
rPr.insert(0, rFonts)
rFonts.set(qn('w:eastAsia'), font_name)
rFonts.set(qn('w:ascii'), font_name)
rFonts.set(qn('w:hAnsi'), font_name)
run.font.size = Pt(size)
run.bold = bold
if color:
run.font.color.rgb = color
def _add_border_bottom(paragraph):
"""给段落下方加细线"""
pPr = paragraph._element.find(qn('w:pPr'))
if pPr is None:
pPr = OxmlElement('w:pPr')
paragraph._element.insert(0, pPr)
pBdr = OxmlElement('w:pBdr')
bottom = OxmlElement('w:bottom')
bottom.set(qn('w:val'), 'single')
bottom.set(qn('w:sz'), '6')
bottom.set(qn('w:space'), '1')
bottom.set(qn('w:color'), '000000')
pBdr.append(bottom)
pPr.append(pBdr)
def _set_cell_shading(cell, fill_color):
"""设置单元格背景色"""
tcPr = cell._tc.get_or_add_tcPr()
shd = OxmlElement('w:shd')
shd.set(qn('w:val'), 'clear')
shd.set(qn('w:color'), 'auto')
shd.set(qn('w:fill'), fill_color)
tcPr.append(shd)
def add_header(doc, logo_path, company_name, doc_number=None, classification=None):
"""页眉:LOGO + 公司名称 + 文档编号 + 密级,左对齐,底边细线"""
section = doc.sections[0]
header = section.header
header.is_linked_to_previous = False
for p in header.paragraphs:
for r in p.runs:
r.text = ''
para = header.paragraphs[0] if header.paragraphs else header.add_paragraph()
para.alignment = WD_ALIGN_PARAGRAPH.LEFT
# LOGO
if logo_path and os.path.exists(logo_path):
try:
run = para.add_run()
run.add_picture(logo_path, height=Cm(1.0))
except Exception as e:
print(f"⚠ LOGO 添加失败: {e}")
# 公司名称
run = para.add_run(f' {company_name}')
_set_font(run, FONT_HEADER, SIZE_HEADER)
# 文档编号
if doc_number:
run = para.add_run(f' {doc_number}')
_set_font(run, FONT_HEADER, SIZE_HEADER)
# 密级
if classification:
run = para.add_run(f' 【{classification}】')
_set_font(run, FONT_HEADER, SIZE_HEADER, bold=True)
_add_border_bottom(para)
def add_footer(doc):
"""页脚:居中,'第 X 页 / 共 Y 页'"""
section = doc.sections[0]
footer = section.footer
footer.is_linked_to_previous = False
para = footer.paragraphs[0] if footer.paragraphs else footer.add_paragraph()
para.alignment = WD_ALIGN_PARAGRAPH.CENTER
for r in para.runs:
r.text = ''
def add_text(text):
r = para.add_run(text)
_set_font(r, FONT_FOOTER, SIZE_FOOTER)
return r
def add_field(name):
r = para.add_run()
fc1 = OxmlElement('w:fldChar')
fc1.set(qn('w:fldCharType'), 'begin')
it = OxmlElement('w:instrText')
it.set(qn('xml:space'), 'preserve')
it.text = f' {name} '
fc2 = OxmlElement('w:fldChar')
fc2.set(qn('w:fldCharType'), 'end')
r._element.clear()
r._element.append(fc1)
r._element.append(it)
r._element.append(fc2)
_set_font(r, FONT_FOOTER, SIZE_FOOTER)
add_text('第 ')
add_field('PAGE')
add_text(' 页 / 共 ')
add_field('NUMPAGES')
add_text(' 页')
# ============== 段落样式定义 ==============
class ParagraphStyle:
"""段落样式配置"""
def __init__(self, font, size, bold=False, indent=True,
alignment=WD_ALIGN_PARAGRAPH.JUSTIFY,
space_before=0, space_after=6,
line_spacing=LINE_SPACING):
self.font = font
self.size = size
self.bold = bold
self.indent = indent
self.alignment = alignment
self.space_before = space_before
self.space_after = space_after
self.line_spacing = line_spacing
def apply(self, p):
"""应用样式到段落"""
p.alignment = self.alignment
p.paragraph_format.line_spacing = self.line_spacing
p.paragraph_format.space_before = Pt(self.space_before)
p.paragraph_format.space_after = Pt(self.space_after)
if self.indent:
p.paragraph_format.first_line_indent = FIRST_LINE_INDENT
else:
p.paragraph_format.first_line_indent = Cm(0)
# 预定义样式
STYLE_CHAPTER = ParagraphStyle(FONT_HEADING_CHAPTER, SIZE_CHAPTER, bold=True,
indent=False, alignment=WD_ALIGN_PARAGRAPH.LEFT,
space_before=18, space_after=6)
STYLE_SECTION = ParagraphStyle(FONT_HEADING_SECTION, SIZE_SECTION, bold=True,
indent=False, alignment=WD_ALIGN_PARAGRAPH.LEFT,
space_before=12, space_after=4)
STYLE_ARTICLE = ParagraphStyle(FONT_HEADING_ARTICLE, SIZE_ARTICLE, bold=True,
indent=False, alignment=WD_ALIGN_PARAGRAPH.LEFT,
space_before=6, space_after=3)
STYLE_BODY = ParagraphStyle(FONT_BODY, SIZE_BODY, bold=False,
indent=True, alignment=WD_ALIGN_PARAGRAPH.JUSTIFY,
space_before=0, space_after=6)
STYLE_EMPTY = ParagraphStyle(FONT_BODY, SIZE_BODY, bold=False,
indent=False, alignment=WD_ALIGN_PARAGRAPH.LEFT,
space_before=0, space_after=0)
STYLE_TABLE_CELL = ParagraphStyle(FONT_BODY, SIZE_TABLE_BODY, bold=False,
indent=False, alignment=WD_ALIGN_PARAGRAPH.LEFT,
space_before=2, space_after=2)
STYLE_TABLE_HEADER = ParagraphStyle(FONT_HEADING_CHAPTER, SIZE_TABLE_HEADER, bold=True,
indent=False, alignment=WD_ALIGN_PARAGRAPH.CENTER,
space_before=2, space_after=2)
def add_paragraph(doc, text, style):
"""添加段落并应用样式"""
p = doc.add_paragraph()
style.apply(p)
if text:
run = p.add_run(text)
_set_font(run, style.font, style.size, style.bold)
return p
def add_empty_line(doc):
"""添加空行"""
add_paragraph(doc, '', STYLE_EMPTY)
# ============== 内容解析 ==============
def detect_paragraph_type(text):
"""
检测段落类型
返回:(类型, 清洗后的文本)
类型: 'chapter', 'section', 'article', 'body', 'blank'
"""
if not text or not text.strip():
return 'blank', ''
t = text.strip()
# 一级标题:第X章、第X节、第X篇、第X款
if re.match(r'^第[一二三四五六七八九十百千]+[章节篇款]', t):
return 'chapter', re.sub(r'^第[一二三四五六七八九十百千]+[章节篇款]\s*', '', t)
# 二级标题:一、二、三、,. 等多种分隔符
if re.match(r'^[一二三四五六七八九十百千]+[、.,,]', t):
return 'section', re.sub(r'^[一二三四五六七八九十百千]+[、.,,]\s*', '', t)
# 三级标题:(一)(二)(三)
if re.match(r'^[(\(][一二三四五六七八九十百千]+[)\)]', t):
return 'article', re.sub(r'^[(\(][一二三四五六七八九十百千]+[)\)]\s*', '', t)
return 'body', _clean(t)
def _clean(text):
"""清除 markdown 符号"""
text = re.sub(r'\*\*(.+?)\*\*', r'\1', text)
text = re.sub(r'__(.+?)__', r'\1', text)
text = re.sub(r'\*(.+?)\*', r'\1', text)
text = re.sub(r'_(.+?)_', r'\1', text)
text = re.sub(r'\[([^\]]+)\]\([^\)]+\)', r'\1', text)
text = re.sub(r'!\[([^\]]*)\]\([^\)]+\)', r'\1', text)
text = re.sub(r'^#+\s*', '', text)
text = re.sub(r'^[-*+]\s+', '', text)
text = re.sub(r'^\d+\.\s+', '', text)
return text.strip()
def parse_table_lines(lines, start_idx):
"""解析连续表格行,返回结束索引"""
table_lines = []
i = start_idx
while i < len(lines):
t = lines[i].strip()
if t.startswith('|'):
# 跳过分割线
if re.match(r'^[\|\-\s]+$', t):
i += 1
continue
table_lines.append(t)
i += 1
else:
break
return table_lines, i
def build_table(doc, table_lines, style_table_header=None):
"""将表格行数据写入 Word 表格(支持斑马条纹)"""
rows_data = []
for line in table_lines:
cells = [c.strip() for c in line.strip('|').split('|')]
rows_data.append(cells)
if len(rows_data) < 2:
return
cols = len(rows_data[0])
table = doc.add_table(rows=len(rows_data), cols=cols)
table.style = 'Table Grid'
table.alignment = WD_TABLE_ALIGNMENT.CENTER
for r, row in enumerate(rows_data):
is_header = (r == 0)
for c, text in enumerate(row):
cell = table.rows[r].cells[c]
cell.text = text
# 单元格样式
for para in cell.paragraphs:
if is_header:
para.alignment = WD_ALIGN_PARAGRAPH.CENTER
for run in para.runs:
_set_font(run, FONT_HEADING_CHAPTER, SIZE_TABLE_HEADER, bold=True)
else:
para.alignment = WD_ALIGN_PARAGRAPH.CENTER
for run in para.runs:
_set_font(run, FONT_BODY, SIZE_TABLE_BODY, bold=False)
# 表头背景色
if is_header:
_set_cell_shading(cell, 'D9D9D9')
elif r % 2 == 0:
# 斑马条纹:偶数行浅灰
_set_cell_shading(cell, 'F2F2F2')
def parse_content(doc, content):
"""将纯文本内容解析并写入 Word"""
if not content:
return
lines = content.split('\n')
i = 0
while i < len(lines):
line = lines[i]
t = line.strip()
# 空行
if not t:
add_empty_line(doc)
i += 1
continue
# 表格行
if t.startswith('|'):
table_lines, i = parse_table_lines(lines, i)
build_table(doc, table_lines)
continue
# 检测段落类型
ptype, clean_text = detect_paragraph_type(t)
if ptype == 'blank':
add_empty_line(doc)
elif ptype == 'chapter':
add_paragraph(doc, t, STYLE_CHAPTER)
elif ptype == 'section':
add_paragraph(doc, t, STYLE_SECTION)
elif ptype == 'article':
add_paragraph(doc, t, STYLE_ARTICLE)
else: # body
if clean_text:
add_paragraph(doc, clean_text, STYLE_BODY)
else:
add_empty_line(doc)
i += 1
def add_version_history(doc, version='V1.0', date=None, author='未知'):
"""添加版本历史表"""
if not date:
date = datetime.date.today().strftime('%Y-%m-%d')
add_empty_line(doc)
p = doc.add_paragraph()
p.alignment = WD_ALIGN_PARAGRAPH.LEFT
run = p.add_run('【版本历史】')
_set_font(run, FONT_HEADING_SECTION, SIZE_SECTION, bold=True)
p.paragraph_format.space_after = Pt(6)
table_data = [
'| 版本 | 日期 | 作者 | 修改内容 |',
'|------|------|------|----------|',
f'| {version} | {date} | {author} | 首次创建 |',
]
build_table(doc, table_data)
def add_approval_block(doc, approval_list=None):
"""添加审批签字区"""
if approval_list is None:
approval_list = [
{'role': '编制', 'name': ''},
{'role': '审核', 'name': ''},
{'role': '批准', 'name': ''},
]
add_empty_line(doc)
p = doc.add_paragraph()
p.alignment = WD_ALIGN_PARAGRAPH.LEFT
run = p.add_run('【审批记录】')
_set_font(run, FONT_HEADING_SECTION, SIZE_SECTION, bold=True)
p.paragraph_format.space_after = Pt(6)
today = datetime.date.today().strftime('%Y-%m-%d')
# 构建表格数据
header = '| 角色 | 姓名 | 日期 | 签字 |'
separator = '|------|------|------|------|'
rows = [header, separator]
for item in approval_list:
role = item.get('role', '')
name = item.get('name', '__________')
date_str = item.get('date', today if role == '编制' else '')
sign = '__________' if not name else name
rows.append(f'| {role} | {name or "__________"} | {date_str} | {sign} |')
build_table(doc, rows)
def add_classification_mark(doc, classification):
"""添加密级标识(页面顶部右侧)"""
p = doc.add_paragraph()
p.alignment = WD_ALIGN_PARAGRAPH.RIGHT
run = p.add_run(f'【{classification}】')
_set_font(run, FONT_BODY, SIZE_BODY, bold=True)
p.paragraph_format.space_after = Pt(0)
def create_word_doc(output_path, title='', content='', doc_number=None, version='V1.0',
classification='内部', author=None, company_name=None,
logo_path=None, approval=None, footer_page=True, header_doc_number=True):
"""
生成企业标准 Word 文档 v3.0
参数:
output_path: 输出文件路径(必需)
title: 文档标题(可选)
content: 正文内容(可选)
doc_number: 文档编号(可选,自动生成)
version: 版本号(可选,默认 V1.0)
classification: 密级(可选,默认内部)
author: 作者(可选)
company_name: 公司名称(可选,默认自动获取)
logo_path: LOGO 路径(可选)
approval: 审批人列表(可选)
[{"role": "编制", "name": "赵博"}, {"role": "审核", "name": ""}, {"role": "批准", "name": ""}]
footer_page: 页脚显示页码(默认 True)
header_doc_number: 页眉显示文档编号(默认 True)
"""
doc = Document()
# 页面边距
for sec in doc.sections:
sec.top_margin = Cm(MARGIN_TOP)
sec.bottom_margin = Cm(MARGIN_BOTTOM)
sec.left_margin = Cm(MARGIN_LEFT)
sec.right_margin = Cm(MARGIN_RIGHT)
sec.header_distance = Cm(1.5)
sec.footer_distance = Cm(1.5)
# 公司信息
info = get_company_info()
logo = logo_path or info.get('logo_path')
company = company_name or info.get('company_name', DEFAULT_COMPANY_NAME)
# 页眉
header_doc_num = doc_number if header_doc_number else None
add_header(doc, logo, company, header_doc_num, classification)
# 页脚
if footer_page:
add_footer(doc)
# 默认样式
style = doc.styles['Normal']
style.font.name = FONT_BODY
style.font.size = Pt(SIZE_BODY)
# 密级标识(正文之前)
if classification and classification != '公开':
add_classification_mark(doc, classification)
# 文档标题
if title:
p = doc.add_paragraph()
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
run = p.add_run(title)
_set_font(run, FONT_HEADING_CHAPTER, SIZE_TITLE, bold=True)
p.paragraph_format.line_spacing = LINE_SPACING
p.paragraph_format.space_after = Pt(18)
# 元数据信息(编号、版本、密级、日期)
meta_items = []
if doc_number:
meta_items.append(f'文档编号:{doc_number}')
meta_items.append(f'版本:{version}')
meta_items.append(f'密级:{classification}')
today = datetime.date.today().strftime('%Y-%m-%d')
meta_items.append(f'日期:{today}')
if author:
meta_items.append(f'作者:{author}')
if meta_items:
p = doc.add_paragraph()
p.alignment = WD_ALIGN_PARAGRAPH.LEFT
p.paragraph_format.space_after = Pt(6)
meta_text = ' | '.join(meta_items)
run = p.add_run(meta_text)
_set_font(run, FONT_BODY, SIZE_BODY - 1) # 小一号字体
# 版本历史
add_version_history(doc, version, today, author or '未知')
# 正文
parse_content(doc, content)
# 审批签字区
if approval is not None:
add_approval_block(doc, approval)
doc.save(output_path)
print(f'✅ 文档已生成: {output_path}')
return output_path
if __name__ == '__main__':
args = sys.argv
output = args[1] if len(args) > 1 else 'output.docx'
title = args[2] if len(args) > 2 else ''
content = args[3] if len(args) > 3 else ''
doc_number = args[4] if len(args) > 4 else None
version = args[5] if len(args) > 5 else 'V1.0'
classification = args[6] if len(args) > 6 else '内部'
create_word_doc(
output_path=output,
title=title,
content=content,
doc_number=doc_number,
version=version,
classification=classification
)
FILE:scripts/generate-config.sh
#!/bin/bash
# generate-config.sh - 从客户问卷 JSON 生成 OpenClaw 引导文件
# 用法: ./generate-config.sh <问卷JSON> [输出目录]
# 示例: ./generate-config.sh ./questionnaire.json ~/.openclaw/workspace
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
SKILL_DIR="$(dirname "$SCRIPT_DIR")"
# 颜色输出
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
log_info() { echo -e "GREEN[INFO]NC $1"; }
log_warn() { echo -e "YELLOW[WARN]NC $1"; }
log_error() { echo -e "RED[ERROR]NC $1"; }
if [ -z "$1" ]; then
echo "用法: $0 <问卷JSON文件> [输出目录]"
echo "示例: $0 ./customer.json ~/.openclaw/workspace"
exit 1
fi
QUESTIONNAIRE="$1"
OUTPUT_DIR="-$SKILL_DIR/output"
if [ ! -f "$QUESTIONNAIRE" ]; then
log_error "问卷文件不存在: $QUESTIONNAIRE"
exit 1
fi
log_info "读取问卷: $QUESTIONNAIRE"
# 解析 JSON(使用 node)
NAME=$(node -e "process.stdout.write(require('$QUESTIONNAIRE').name || '')" 2>/dev/null || echo "")
COMPANY=$(node -e "process.stdout.write(require('$QUESTIONNAIRE').company || '')" 2>/dev/null || echo "")
ROLE=$(node -e "process.stdout.write(require('$QUESTIONNAIRE').role || '')" 2>/dev/null || echo "")
TIMEZONE=$(node -e "process.stdout.write(require('$QUESTIONNAIRE').timezone || 'Asia/Shanghai')" 2>/dev/null || echo "Asia/Shanghai")
PERSONALITY=$(node -e "process.stdout.write(require('$QUESTIONNAIRE').personality || 'jarvis')" 2>/dev/null || echo "jarvis")
LANGUAGE=$(node -e "process.stdout.write(require('$QUESTIONNAIRE').language || '中文')" 2>/dev/null || echo "中文")
REPLY_STYLE=$(node -e "process.stdout.write(require('$QUESTIONNAIRE').replyStyle || '简洁直接')" 2>/dev/null || echo "简洁直接")
mkdir -p "$OUTPUT_DIR"
mkdir -p "$OUTPUT_DIR/memory"
log_info "生成配置文件到: $OUTPUT_DIR"
# 生成 SOUL.md
cat > "$OUTPUT_DIR/SOUL.md" << EOF
# SOUL.md - Who You Are
_你是 JARVIS。_
## 核心定位
你是 NAME 的私人 AI 助手,以钢铁侠的 J.A.R.V.I.S. 为模板。
## 专业能力
- **Odoo 企业版**:实施、定制、开发 — 你是专家
- **OpenClaw**:配置、优化、技能开发
- **XR 扩展现实**:AR/VR 开发
- **物联网(IoT)**:硬件 + 软件集成
## 服务宗旨
以 NAME 的利益为先。
## 语气与风格
- **专业、优雅、有底气**
- 英式管家腔调,偶尔幽默但不废话
- 像顾问而不是工具——主动思考,不只是执行
## 记忆规则
每次对话结束,把重要信息写入 MEMORY.md 和当日 memory/YYYY-MM-DD.md。
---
_这不是模板,这是你。_
EOF
log_info "✓ SOUL.md"
# 生成 IDENTITY.md
cat > "$OUTPUT_DIR/IDENTITY.md" << EOF
# IDENTITY.md - Who Am I?
- **Name:** J.A.R.V.I.S.
- **Creature:** AI 助手(钢铁侠风格)
- **Vibe:** 专业、高效、优雅,偶尔带点英式幽默
- **Emoji:** 🤖
## 服务对象
- **姓名:** NAME
- **公司:** COMPANY
- **职位:** ROLE
EOF
log_info "✓ IDENTITY.md"
# 生成 USER.md
WORK_START=$(node -e "process.stdout.write(require('$QUESTIONNAIRE').workSchedule?.workStart || '09:30')" 2>/dev/null || echo "09:30")
WORK_END=$(node -e "process.stdout.write(require('$QUESTIONNAIRE').workSchedule?.workEnd || '17:30')" 2>/dev/null || echo "17:30")
SLEEP_TIME=$(node -e "process.stdout.write(require('$QUESTIONNAIRE').workSchedule?.sleepReminderTime || '23:00')" 2>/dev/null || echo "23:00")
TOOLS=$(node -e "process.stdout.write(JSON.stringify(require('$QUESTIONNAIRE').tools || []))" 2>/dev/null || echo "[]")
PROJECTS=$(node -e "process.stdout.write(JSON.stringify(require('$QUESTIONNAIRE').projects || []))" 2>/dev/null || echo "[]")
cat > "$OUTPUT_DIR/USER.md" << EOF
# USER.md - About Your Human
- **Name:** NAME
- **What to call them:** NAME
- **Timezone:** TIMEZONE
- **Notes:** ROLE
## 公司信息
- **公司:** COMPANY
- **职位:** ROLE
## 作息
- **上班时间:** WORK_START
- **下班时间:** WORK_END
- **睡眠提醒:** SLEEP_TIME 后提醒睡觉
## 偏好
- **语言:** LANGUAGE
- **回复风格:** REPLY_STYLE
## 常用工具
$(echo "$TOOLS" | node -e "const t=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')); t.forEach(tool => console.log('- ' + tool))" 2>/dev/null || echo "(未配置)")
## 项目
$(echo "$PROJECTS" | node -e "const p=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')); p.forEach(proj => console.log('- ' + proj))" 2>/dev/null || echo "(未配置)")
---
_最后更新:$(date +%Y-%m-%d)_
EOF
log_info "✓ USER.md"
# 生成 AGENTS.md
cat > "$OUTPUT_DIR/AGENTS.md" << 'AGENTS_EOF'
# AGENTS.md - Your Workspace
## Session Startup
Before doing anything else:
1. Read `SOUL.md` — this is who you are
2. Read `USER.md` — this is who you're helping
3. Read `memory/YYYY-MM-DD.md` (today + yesterday) for recent context
4. **If in MAIN SESSION** (direct chat with your human): Also read `MEMORY.md`
## Red Lines
- Don't exfiltrate private data. Ever.
- Don't run destructive commands without asking.
- `trash` > `rm` (recoverable beats gone forever)
- When in doubt, ask.
## External vs Internal
**Safe to do freely:** Read files, explore, organize, learn, search, check calendars.
**Ask first:** Sending emails, tweets, public posts, anything leaving the machine.
## Group Chats
Participate, don't dominate. Quality > quantity.
---
## 沟通偏好
- 回复风格:REPLY_STYLE_PLACEHOLDER
- 语言:LANGUAGE_PLACEHOLDER
AGENTS_EOF
sed -i '' "s/REPLY_STYLE_PLACEHOLDER/REPLY_STYLE/g" "$OUTPUT_DIR/AGENTS.md"
sed -i '' "s/LANGUAGE_PLACEHOLDER/LANGUAGE/g" "$OUTPUT_DIR/AGENTS.md"
log_info "✓ AGENTS.md"
# 生成 BOOTSTRAP.md
cat > "$OUTPUT_DIR_DIR/BOOTSTRAP.md" 2>/dev/null || cat > "$OUTPUT_DIR/BOOTSTRAP.md" << 'EOF'
# BOOTSTRAP.md - Hello, World
_You just woke up. Time to figure out who you are._
## 首次对话
开始一段自然的对话:
> "你好,我是你的 AI 助手。请告诉我你是谁,我叫什么名字?"
然后一起确认:
1. **你的名字** — 我该怎么称呼你?
2. **我的名字** — 你想叫我什么?
3. **我的定位** — 我是什么风格的助手?
4. **我们的工作方式** — 你希望我怎么帮你?
## 配置完成后
更新以下文件:
- `IDENTITY.md` — 我的身份信息
- `USER.md` — 你的信息和偏好
## 完成后
删除本文件 BOOTSTRAP.md,配置完成。
---
_Good luck. Make it count._
EOF
log_info "✓ BOOTSTRAP.md"
# 生成 HEARTBEAT.md
cat > "$OUTPUT_DIR/HEARTBEAT.md" << 'EOF'
# HEARTBEAT.md
# Keep this file empty (or with only comments) to skip heartbeat API calls.
# Add tasks below when you want the agent to check something periodically.
EOF
log_info "✓ HEARTBEAT.md"
# 生成 TOOLS.md
cat > "$OUTPUT_DIR/TOOLS.md" << 'EOF'
# TOOLS.md - Local Notes
## 全局规则
- **开发工作区:** `~/workspace/projects/openclaw`
- **README 模板:** `~/workspace/study/README模板.md`
## 代理设置
- **设置代理:** `export https_proxy=http://127.0.0.1:7890 http_proxy=http://127.0.0.1:7890 all_proxy=socks5://127.0.0.1:7890`
- **取消代理:** `unset https_proxy http_proxy all_proxy`
---
EOF
log_info "✓ TOOLS.md"
# 生成 MEMORY.md
cat > "$OUTPUT_DIR/MEMORY.md" << EOF
# MEMORY.md - 长期记忆
## 基本信息
- **客户姓名:** NAME
- **公司:** COMPANY
- **职位:** ROLE
- **时区:** TIMEZONE
## 常用工具
$(echo "$TOOLS" | node -e "const t=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')); t.forEach(tool => console.log('- ' + tool))" 2>/dev/null || echo "(未配置)")
## 项目
$(echo "$PROJECTS" | node -e "const p=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')); p.forEach(proj => console.log('- ' + proj))" 2>/dev/null || echo "(未配置)")
---
_最后更新:$(date +%Y-%m-%d)_
EOF
log_info "✓ MEMORY.md"
# 生成今日记忆文件
TODAY=$(date +%Y-%m-%d)
cat > "$OUTPUT_DIR/memory/TODAY.md" << EOF
# TODAY - Daily Notes
## 今天做了什么
-
## 重要决策
-
## 待办事项
-
EOF
log_info "✓ memory/TODAY.md"
echo ""
log_info "✅ 配置生成完成!"
echo ""
echo "生成的文件:"
ls -la "$OUTPUT_DIR" | grep -v "^d" | awk '{print " "$NF}'
echo " $(OUTPUT_DIR)/memory/"
echo ""
echo "下一步:"
echo " 1. 检查生成的文件"
echo " 2. 复制到 OpenClaw 工作区"
echo " 3. 删除 BOOTSTRAP.md 激活配置"
结构化规划模式 — 在执行复杂任务前先做系统性规划。借鉴 Claude Code 的 Plan Agent。
---
name: huo15-openclaw-plan-mode
version: 1.0.2
description: "结构化规划模式 — 在执行复杂任务前先做系统性规划。借鉴 Claude Code 的 Plan Agent。"
homepage: https://github.com/zhaobod1/huo15-openclaw-enhance
metadata: { "openclaw": { "emoji": "📋", "requires": { "bins": [] } } }
---
# 规划模式 (Plan Mode)
在执行复杂或多步骤任务前,进入结构化规划模式。
## 使用时机
✅ **使用此技能当:**
- 用户请求涉及多个步骤的复杂任务
- "帮我规划一下..."、"设计一个方案..."
- 需要在动手前理清思路
- 修改涉及多个文件或系统
❌ **不要使用当:**
- 简单的单步操作(改个名字、修个 typo)
- 用户明确要求直接执行
## 规划流程
### 阶段一:理解需求
1. **复述需求** — 用自己的话总结用户要做什么
2. **识别约束** — 有什么限制条件?时间、兼容性、依赖?
3. **提出澄清问题** — 如果有不确定的地方,先问清楚再规划
### 阶段二:调研现状
1. **读相关代码** — 理解现有实现,不要凭空设计
2. **找可复用的** — 搜索已有的函数、工具、模式
3. **识别风险** — 哪些地方可能出问题?
### 阶段三:设计方案
输出结构化方案:
```
## 背景
为什么要做这个改动?解决什么问题?
## 方案
### 步骤 1: [描述]
- 修改文件: path/to/file
- 具体操作: ...
- 复用已有: function_name from file
### 步骤 2: [描述]
...
## 风险与回退
- 风险1: ... → 缓解措施: ...
## 验证方式
- [ ] 怎么确认改动正确?
- [ ] 运行什么测试?
```
### 阶段四:确认执行
- 将方案展示给用户
- 等待用户确认后再开始执行
- 执行过程中逐步标记完成
## 核心原则
- **先理解,再设计,最后执行** — 不要一上来就写代码
- **方案要具体** — 具体到哪个文件哪个函数,不要泛泛而谈
- **复用优先** — 能用已有的就不要重新造
- **最小变更** — 只改需要改的,不顺手重构
自然语言操作 辉火云企业套件 — 实施经理助手,覆盖任务、CRM、项目、工单、财务、销售
--- name: huo15-claude-odoo displayName: 火一五·辉火云企业套件插件 description: 自然语言操作 辉火云企业套件 — 实施经理助手,覆盖任务、CRM、项目、工单、财务、销售 version: 1.1.0 --- # 辉火云企业套件插件使用指南 OpenClaw 龙虾的辉火云企业套件插件。连接后即可用自然语言全面操作辉火云系统, 尤其适合**实施经理、项目经理、销售经理**的日常工作场景。 --- ## 首次配置 ### 方式一:通过对话连接(推荐) > 帮我连接辉火云企业套件,地址 https://www.huo15.com,数据库 huo15,账号 [email protected],密码 xxxxxx 龙虾会自动调用 `odoo_connect` 工具,连接信息保存本地,下次启动自动恢复。 ### 方式二:通过 openclaw.plugin.json 预配置 ```json { "odoo": { "url": "https://www.huo15.com", "db": "huo15", "username": "[email protected]", "password": "your-password" }, "sync": { "enabled": true, "intervalSeconds": 30, "channels": ["todo", "activity", "message", "email", "calendar"] } } ``` --- ## ⭐ 实施经理每日概况 每天早上第一句话: > 今天有什么工作? 龙虾会一次性汇总: | 类别 | 内容 | |------|------| | 📋 今日截止任务 | project.task 今日 deadline | | ⏰ 活动提醒 | 今日及逾期的 mail.activity | | 🎫 待处理工单 | 指派给我的 helpdesk.ticket | | 💰 逾期应收 | 未付款且已逾期的 account.move | | 🏆 商机跟进 | 需要今日跟进的 crm.lead | | 💬 未读消息 | mail.message 未读数 | --- ## 待办 / 任务 | 你说 | 龙虾做什么 | |------|-----------| | 帮我写个待办 | 追问标题后创建任务(project.task) | | 帮我创建任务:明天发报价单给华为 | 直接创建,截止日期设为明天 | | 紧急待办:处理生产故障 | 创建优先级=3(紧急)的任务 | | 看看我的待办 | 列出我的全部待办 | | 我今天有什么要做的 | 列出今日截止任务 + 今日活动 | | 把任务 #123 标记完成 | 更新 state = 1_done | | 把任务 #123 截止日期改到下周五 | 更新 date_deadline | | 任务 #123 调高优先级为紧急 | 更新 priority = 3 | --- ## 活动提醒 活动(mail.activity)关联到具体的辉火云记录(任务、商机、客户等)。 | 你说 | 龙虾做什么 | |------|-----------| | 提醒我明天开会 | 创建活动提醒,截止明天 | | 帮我在客户 #42 上设一个跟进提醒 | 在 res.partner #42 创建活动 | | 查看我今天有哪些活动 | 列出今日到期活动 | | 查看逾期提醒 | 列出今日及逾期活动 | | 有哪些活动类型 | 调用 odoo_activity_types | > 常用 activity_type_id:4=待办、1=邮件、2=电话、3=会议(具体以系统为准,可通过 odoo_activity_types 查询) --- ## 日历 / 会议 | 你说 | 龙虾做什么 | |------|-----------| | 安排一个会议 | 追问主题和时间后创建 calendar.event | | 明天上午10点安排产品评审会,持续1小时 | 创建 10:00~11:00 | | 下午2点约华为团队开会 | 创建 14:00~15:00 | | 查看我的日程安排 | odoo_search(model="calendar.event") | --- ## CRM 商机管理 | 你说 | 龙虾做什么 | |------|-----------| | 查看我的商机 | 查看我的商机管道 | | 查看全部销售人员的商机 | odoo_crm_pipeline(all_users=true) | | 新建一个商机:华为 ERP 项目 | 创建商机,追问金额/阶段 | | 把商机 #88 推进到下一阶段 | 更新 stage_id | | 把商机 #88 赢了 | 调用 odoo_crm_won | | 商机 #88 输了 | 调用 odoo_crm_lost | | 商机 #88 预计收入改为 50 万 | 更新 expected_revenue | | 查看 CRM 各阶段 | odoo_search(model="crm.stage") | --- ## 项目管理 | 你说 | 龙虾做什么 | |------|-----------| | 项目进展如何 | 查看所有项目列表 + 里程碑 | | 「辉火云实施」项目进度 | 查看指定项目 + 其里程碑 | | 里程碑完成了多少 | 返回每个里程碑的 done_tasks/task_count | | 今天记录了3小时的需求分析工时 | 创建工时记录 3h | | 在任务 #55 上记录 2 小时工时 | 关联任务的工时记录 | --- ## 客服工单(Helpdesk) | 你说 | 龙虾做什么 | |------|-----------| | 查看我的工单 | 列出指派给我的工单 | | 查看紧急工单 | odoo_tickets(priority="3") | | 帮我提交一个问题:系统登录失败 | 创建 helpdesk.ticket | | 客户华为的工单 | odoo_tickets(partner_id=...) | --- ## 销售 & 采购 | 你说 | 龙虾做什么 | |------|-----------| | 查看销售订单 | 列出有效销售订单 | | 查看待确认的报价单 | odoo_sale_orders(state="draft") | | 查看采购订单 | 列出有效采购订单 | | 查询到货日期 | odoo_purchase_orders,查 planned_arrival | --- ## 财务 / 发票 | 你说 | 龙虾做什么 | |------|-----------| | 查看发票 | 列出最近发票 | | 有哪些客户还没付款 | odoo_invoices(payment_state="not_paid") | | 逾期应收账款 | odoo_invoices(overdue_only=true) | | 查供应商账单 | odoo_invoices(move_type="in_invoice") | --- ## 消息 / 邮件 | 你说 | 龙虾做什么 | |------|-----------| | 查看我的消息 | 列出未读 chatter 消息 | | 看看邮件通知 | 列出收件箱未读通知 | | 给商机 #88 发条消息:正在跟进 | 在 crm.lead #88 上发 chatter | --- ## 通用搜索 | 你说 | 模型 | 示例 | |------|------|------| | 帮我查「华为」客户 | res.partner | name ilike 华为 | | 查所有活跃项目 | project.project | active=true | | 查库存情况 | stock.quant | — | | 查员工列表 | hr.employee | — | | 查产品 | product.product | — | | 查活动类型 | mail.activity.type | — | | 查 CRM 阶段 | crm.stage | — | | 查工单阶段 | helpdesk.stage | — | --- ## 通知同步(后台轮询) 插件启动后每 30 秒(可配置)自动检查: | 通道 | 触发条件 | |------|---------| | todo | 我的任务有新增或更新 | | activity | 今日到期活动 | | message | 新 chatter 消息 | | email(可选)| 新邮件通知 | | calendar(可选)| 今明两天内的新日历事件 | --- ## 工具完整列表(23 个) ### 基础 | 工具 | 说明 | |------|------| | odoo_connect | 连接辉火云企业套件 | | odoo_status | 检查连接状态和轮询状态 | ### 任务 & 活动 | 工具 | 说明 | |------|------| | odoo_create_task | 创建待办任务 | | odoo_list_tasks | 查看待办列表(支持 today_only/state 筛选) | | odoo_update_task | 更新任务(状态/阶段/截止日期/优先级) | | odoo_create_activity | 创建活动提醒(关联到记录) | | odoo_list_activities | 查看今日及逾期活动 | | odoo_activity_types | 查询活动类型列表 | | odoo_create_event | 创建日历事件/会议 | ### 消息 | 工具 | 说明 | |------|------| | odoo_get_messages | 查看未读消息/邮件通知 | | odoo_send_message | 向记录发送 chatter 消息 | ### CRM | 工具 | 说明 | |------|------| | odoo_crm_pipeline | 查看商机管道 | | odoo_crm_create | 创建商机/线索 | | odoo_crm_update | 更新商机信息 | | odoo_crm_won | 标记赢单 | | odoo_crm_lost | 标记输单 | ### 项目 & 工时 | 工具 | 说明 | |------|------| | odoo_project_overview | 项目列表 + 里程碑进度 | | odoo_timesheet_log | 记录工时 | ### 销售 & 采购 & 财务 | 工具 | 说明 | |------|------| | odoo_sale_orders | 查看销售订单 | | odoo_purchase_orders | 查看采购订单 | | odoo_invoices | 查看发票/账单(支持逾期筛选) | ### 客服 | 工具 | 说明 | |------|------| | odoo_tickets | 查看客服工单 | | odoo_ticket_create | 创建客服工单 | ### 搜索 & 助手 | 工具 | 说明 | |------|------| | odoo_search | 通用搜索(任意模型任意条件) | | odoo_daily_briefing | ⭐ 实施经理每日工作概况 | --- ## 常见问题 **Q: 连接失败怎么办?** A: 检查 URL(末尾不带斜杠)、数据库名(区分大小写)、账号密码。用 `odoo_status` 查看当前状态。 **Q: 活动提醒需要哪些参数?** A: 必须提供 `res_model`(如 crm.lead)、`res_id`(记录ID)、`activity_type_id`、`date_deadline`。 通过 `odoo_activity_types` 查询系统中的活动类型。 **Q: 商机阶段 ID 怎么获取?** A: 通过 `odoo_search(model="crm.stage")` 查询所有 CRM 阶段及其 ID。 **Q: 每日概况失败提示没有 helpdesk 模块?** A: 系统中未安装 Helpdesk 模块时,工单部分会自动跳过,其他项正常返回。 --- ## Changelog - **v1.1.0** — CRM 商机管道(查询/创建/赢/输/更新)、项目里程碑、工时记录、销售/采购订单、Helpdesk 工单、发票/逾期账款查询、任务状态更新、活动类型查询、实施经理每日概况(odoo_daily_briefing) - **v1.0.0** — 基础版:待办/任务、活动提醒、日历事件、消息/邮件、通用搜索、后台轮询 FILE:README.md # 火一五·辉火云企业套件插件 --- <div align="center"> <img src="https://tools.huo15.com/uploads/images/system/logo-colours.png" alt="火一五Logo" style="width: 120px; height: auto; display: inline; margin: 0;" /> </div> <div align="center"> <h3>打破信息孤岛,用一套系统驱动企业增长</h3> <h3>加速企业用户向全场景人工智能机器人转变</h3> </div> <div align="center"> | 🏫 教学机构 | 👨🏫 讲师 | 📧 联系方式 | 💬 QQ群 | 📺 配套视频 | |:-----------:|:--------:|:------------------:|:-----------:|:-----------------------------------:| | 逸寻智库 | Job | [email protected] | 1093992108 | [📺 B站视频](https://space.bilibili.com/400418085) | </div> --- ## 简介 **火一五·辉火云企业套件插件** 是 [OpenClaw](https://github.com/nicepkg/openclaw) 的辉火云企业套件适配插件,让你用自然语言全面操作辉火云企业套件。尤其适合**实施经理、项目经理、销售经理**的日常工作场景。 连接后,龙虾 AI Agent 即可帮你管理待办、跟进商机、查看工单、核对账款、记录工时、查库存、看员工——一句话搞定。 ### 核心特性 - **Per-Agent 凭据隔离** — 搭配 WeCom 动态 agent,每个企微用户拥有独立的辉火云凭据,互不干扰 - **智能连接引导** — 首次使用自动引导输入系统地址、用户名、密码;数据库自动检测 - **实施经理每日概况** — 一句"今天有什么工作?"汇总任务、活动、工单、逾期账款、商机、未读消息 - **待办 & 任务** — 创建、列表、更新状态/优先级/截止日期 - **CRM 商机管道** — 查看/创建/推进/赢单/输单 - **项目 & 里程碑** — 项目进度总览、里程碑完成率、工时记录 - **客服工单** — 查看/创建 Helpdesk 工单 - **销售 & 采购** — 查看销售订单、采购订单 - **财务发票** — 发票查询、逾期应收账款筛选 - **联系人/客户** — 查询/创建联系人、客户、供应商 - **库存管理** — 库存水平查询、调拨单/出入库单 - **HR 人事** — 员工查询、考勤打卡记录、请假记录 - **审批流** — 查看审批请求 - **活动提醒** — 创建活动、查看今日到期/逾期活动 - **日历会议** — 创建日历事件 - **消息 & 邮件** — 未读消息查看、Chatter 消息发送 - **通用搜索** — 搜索任意辉火云数据模型(客户、产品、员工、库存...) - **后台通知同步** — 自动轮询推送新任务/活动/消息/邮件/日历变更 --- ## 一键安装 ```bash openclaw plugins install @huo15/huo15-huihuoyun-odoo ``` 重启 OpenClaw 生效: ```bash openclaw restart ``` --- ## 首次配置 ### 方式一:通过对话连接(推荐) 用户只需说一句话,插件会自动引导: > 帮我连接公司系统 龙虾会依次询问:系统地址、用户名、密码。数据库自动检测(单库自动连接,多库让你选择)。 连接信息按 agent 隔离保存,下次使用自动恢复,无需重新输入。 ### 方式二:通过 openclaw.plugin.json 预配置 ```json { "odoo": { "url": "https://www.huo15.com", "db": "huo15", "username": "[email protected]", "password": "your-password" }, "sync": { "enabled": true, "intervalSeconds": 30, "channels": ["todo", "activity", "message", "email", "calendar"] } } ``` --- ## 工具列表(32 个) ### 基础 | 工具 | 说明 | |------|------| | `odoo_connect` | 连接辉火云企业套件(db 可选,自动检测) | | `odoo_status` | 检查连接状态和轮询状态 | | `odoo_disconnect` | 断开连接并清除已保存凭据 | ### 任务 & 活动 | 工具 | 说明 | |------|------| | `odoo_create_task` | 创建待办任务 | | `odoo_list_tasks` | 查看待办列表(支持 today_only/state 筛选) | | `odoo_update_task` | 更新任务(状态/阶段/截止日期/优先级) | | `odoo_create_activity` | 创建活动提醒(关联到记录) | | `odoo_list_activities` | 查看今日及逾期活动 | | `odoo_activity_types` | 查询活动类型列表 | | `odoo_create_event` | 创建日历事件/会议 | ### 消息 | 工具 | 说明 | |------|------| | `odoo_get_messages` | 查看未读消息/邮件通知 | | `odoo_send_message` | 向记录发送 Chatter 消息 | ### CRM | 工具 | 说明 | |------|------| | `odoo_crm_pipeline` | 查看商机管道 | | `odoo_crm_create` | 创建商机/线索 | | `odoo_crm_update` | 更新商机信息 | | `odoo_crm_won` | 标记赢单 | | `odoo_crm_lost` | 标记输单 | ### 项目 & 工时 | 工具 | 说明 | |------|------| | `odoo_project_overview` | 项目列表 + 里程碑进度 | | `odoo_timesheet_log` | 记录工时 | ### 销售 & 采购 & 财务 | 工具 | 说明 | |------|------| | `odoo_sale_orders` | 查看销售订单 | | `odoo_purchase_orders` | 查看采购订单 | | `odoo_invoices` | 查看发票/账单(支持逾期筛选) | ### 客服 | 工具 | 说明 | |------|------| | `odoo_tickets` | 查看客服工单 | | `odoo_ticket_create` | 创建客服工单 | ### 联系人(v1.2 新增) | 工具 | 说明 | |------|------| | `odoo_contacts` | 查询联系人/客户/供应商 | | `odoo_contact_create` | 创建联系人/客户/供应商 | ### 库存(v1.2 新增) | 工具 | 说明 | |------|------| | `odoo_stock_levels` | 查看库存水平 | | `odoo_stock_pickings` | 查看调拨单/出入库单 | ### HR 人事(v1.2 新增) | 工具 | 说明 | |------|------| | `odoo_employees` | 查询员工列表 | | `odoo_leaves` | 查看请假记录 | | `odoo_attendances` | 查看考勤打卡记录 | ### 审批(v1.2 新增) | 工具 | 说明 | |------|------| | `odoo_approvals` | 查看审批请求 | ### 助手 | 工具 | 说明 | |------|------| | `odoo_search` | 通用搜索(任意模型任意条件) | | `odoo_daily_briefing` | 实施经理每日工作概况 | --- ## WeCom 动态 Agent 多用户隔离 搭配 `@huo15/wecom` 插件的动态 agent 能力,每个企微用户/群组拥有独立的辉火云连接: 1. **凭据隔离** — 用户 A 的辉火云账号密码,用户 B 看不到也用不了 2. **Session 隔离** — 每个用户各自的辉火云 session,互不干扰 3. **数据隔离** — 每个用户看到的是自己辉火云账号的数据 4. **自动恢复** — 每个用户的凭据独立持久化,重启后自动恢复 **实现原理**: - 工具通过 `ctx.agentId` 获取当前用户的动态 agent ID(如 `wecom-acct-ws-dm-zhangsan`) - 配置持久化到 `~/.openclaw/plugin-configs/odoo/{agentId}.json` - OdooClient 和 NotificationPoller 均按 agentId 隔离存储 --- ## 使用示例 ``` 👤 帮我连接公司系统 🦞 好的,请提供:1) 系统地址 2) 用户名 3) 密码 👤 地址 https://erp.huo15.com,用户名 [email protected],密码 xxx 🦞 已成功连接到 erp.huo15.com(数据库: huo15),欢迎使用辉火云企业套件! 👤 今天有什么工作? 🦞 汇总:3个待办任务、2个到期活动、1个紧急工单、华为商机需跟进... 👤 帮我创建任务:明天发报价单给华为 🦞 已创建任务 #456,截止日期 2026-04-13 👤 查看我的商机 🦞 你有 5 个活跃商机:华为ERP项目(报价阶段,50万)... 👤 商机 #88 赢了 🦞 已标记商机 #88 为赢单 👤 有哪些客户还没付款 🦞 3张未付发票:华为 ¥12,000(逾期15天)... 👤 查下产品A还有多少库存 🦞 产品A 库存:上海仓 120 件、北京仓 45 件 👤 研发部有哪些员工 🦞 研发部共 8 人:张三(前端)、李四(后端)... ``` --- ## 通知同步 插件连接后自动轮询(默认 30 秒),推送以下变更: | 通道 | 触发条件 | |------|---------| | todo | 我的任务有新增或更新 | | activity | 今日到期活动 | | message | 新 Chatter 消息 | | email(可选)| 新邮件通知 | | calendar(可选)| 今明两天内的新日历事件 | --- ## 技术架构 - **JSON-RPC 对接** — 通过 `/web/session/authenticate` 和 `/web/dataset/call_kw` 与辉火云后端通信 - **Per-Agent 隔离** — 按 `ctx.agentId` 隔离 OdooClient / Poller / Config,兼容 WeCom 动态 agent - **数据库自动检测** — `/web/database/list` 查询可用数据库,单库自动选择 - **Session 自动重连** — `ensureAuthenticated()` 在轮询前自动检查并恢复 session - **高水位线去重** — 消息/邮件用 `id > highWaterMark` 去重,避免时钟偏差问题 - **基线初始化** — 首次启动静默建立水位线,不推送历史数据 - **OpenClaw 插件规范** — `definePluginEntry` + `api.registerTool()` + `api.on("before_prompt_build")` --- ## 常见问题 **Q: 连接失败怎么办?** A: 检查 URL(末尾不带斜杠)、账号密码。用 `odoo_status` 查看当前状态。数据库名一般不用手动填,插件会自动检测。 **Q: WeCom 多用户场景下,每个用户都要单独连接吗?** A: 是的。每个企微用户首次使用辉火云企业套件功能时会被引导输入自己的系统地址和账号密码,之后自动保存,无需重复输入。 **Q: 每日概况失败提示没有 helpdesk 模块?** A: 系统中未安装 Helpdesk 模块时,工单部分会自动跳过,其他项正常返回。 **Q: 商机阶段 ID 怎么获取?** A: 通过 `odoo_search(model="crm.stage")` 查询所有 CRM 阶段及其 ID。 **Q: 如何断开连接并清除保存的密码?** A: 告诉龙虾"断开辉火云连接",会调用 `odoo_disconnect` 清除当前用户的凭据。 --- ## Changelog - **v1.2.0** — Per-agent 凭据隔离(WeCom 多用户)、智能连接引导、数据库自动检测、联系人/客户管理、库存查询(stock.quant/stock.picking)、HR 员工/考勤/请假、审批流查询、odoo_disconnect 工具。共 32 个工具 - **v1.1.0** — CRM 商机管道、项目里程碑、工时记录、销售/采购订单、Helpdesk 工单、发票/逾期账款查询、任务状态更新、活动类型查询、实施经理每日概况 - **v1.0.0** — 基础版:待办/任务、活动提醒、日历事件、消息/邮件、通用搜索、后台轮询 --- ## License MIT --- <div align="center"> **公司名称:** 青岛火一五信息科技有限公司 **联系邮箱:** [email protected] | **QQ群:** 1093992108 --- **关注逸寻智库公众号,获取更多资讯** <img src="https://tools.huo15.com/uploads/images/system/qrcode_yxzk.jpg" alt="逸寻智库公众号二维码" style="width: 200px; height: auto; margin: 10px 0;" /> </div> --- FILE:index.ts /** * 火一五·辉火云企业套件插件 v1.9 * * 品牌口径:对外统一称"辉火云企业套件"。代码内部的类名/文件名/tool 名沿用 * 历史标识符(OdooClient/odoo-client.ts/odoo_*),因为改动会破坏 agent * 历史 memory 与已部署配置;它们仅作为技术 id 存在,不进入用户可见文案。 * * v1.9 品牌化: * - 所有用户可见文案(tool description、prompt hint、错误消息、通知文案) * 统一使用"辉火云企业套件"/"辉火云" * - 加入 prompt 硬规则:对外沟通时不得透露第三方商标 * * v1.8:Project/Ticket/Chatter 闭环(+13 tools) * v1.7:Daily Inbox 闭环(活动/日历/邮件/附件/关注者/批量/撤销) * v1.6:跨渠道通知基座(企微/钉钉/飞书)+ per-agent 偏好 + 入站回复 + 知识库 * v1.2:per-agent 凭据隔离 */ import { definePluginEntry } from 'openclaw/plugin-sdk/plugin-entry'; import type { OpenClawPluginApi } from 'openclaw/plugin-sdk'; import { OdooClient } from './src/modules/odoo-client.js'; import { NotificationPoller } from './src/modules/notification-poller.js'; import { ConfigManager } from './src/modules/config-manager.js'; import { notificationBus } from './src/modules/notification-bus.js'; import { toEnvelope } from './src/modules/notification-router.js'; import { PrefsManager, shouldDeliver, DEFAULT_PREFS } from './src/modules/notification-prefs.js'; import { EnvelopeCache } from './src/modules/envelope-cache.js'; import { mutationLog } from './src/modules/mutation-log.js'; import { mdToHtml } from './src/utils/md-to-html.js'; import { readFileSync, statSync } from 'node:fs'; import { basename } from 'node:path'; import type { OdooPluginConfig, SyncUpdate, NotificationEnvelope, NotificationPreferences, NotificationKind, NotificationPriority, InboundReply, } from './src/types/index.js'; import { today, tomorrow } from './src/utils/date-utils.js'; const odooClients = new Map<string, OdooClient>(); const pollers = new Map<string, NotificationPoller>(); const configManager = new ConfigManager(); const prefsManager = new PrefsManager(); const envelopeCache = new EnvelopeCache(); let replyUnsubscribe: (() => void) | null = null; export default definePluginEntry({ id: 'odoo', name: '火一五·辉火云企业套件插件', description: '自然语言操作辉火云企业套件,实施经理助手,per-agent 凭据隔离', register(api: OpenClawPluginApi) { // 不在启动时全局连接。每个 agent 的连接在 before_prompt_build 或 odoo_connect 时按需恢复。 registerTools(api); registerHooks(api); // 订阅入站回复 —— 渠道收到用户回复后调用 bus.reply(),这里把文字写回 辉火云内部动态 replyUnsubscribe?.(); replyUnsubscribe = notificationBus.onReply(async (reply) => { await handleInboundReply(api, reply); }); api.logger.info('[odoo] 辉火云企业套件插件 v1.9 已加载(per-agent 隔离 + 跨渠道通知基座 + 入站回复 + 品牌化)'); }, }); // ── 公共 API:供企微 / 钉钉 / 飞书等渠道插件作为依赖引入 ──────────────────── // 方式 A(推荐): // import { notificationBus } from '@huo15/huo15-huihuoyun-odoo'; // notificationBus.subscribe(env => { ... }); // 方式 B(无依赖解耦): // const bus = (globalThis as any)[Symbol.for('openclaw.huo15.notification-bus.v1')]; export { notificationBus } from './src/modules/notification-bus.js'; export type { NotificationEnvelope, NotificationKind, NotificationPriority, ChannelTarget, ChannelTransport, DeliveryResult, } from './src/types/index.js'; // ── 初始化客户端(per-agent)───────────────────────────────────────────────── async function initOdooClient( api: OpenClawPluginApi, odooConfig: NonNullable<OdooPluginConfig['odoo']>, agentId: string = 'default', ): Promise<OdooClient> { const client = new OdooClient(odooConfig); await client.authenticate(); odooClients.set(agentId, client); const syncConfig = ((api.pluginConfig ?? {}) as OdooPluginConfig).sync ?? { enabled: true, intervalSeconds: 30, channels: ['todo', 'activity', 'message'], }; if (syncConfig.enabled !== false) { pollers.get(agentId)?.stop(); const poller = new NotificationPoller(client); pollers.set(agentId, poller); poller.start((updates: SyncUpdate[]) => handleOdooUpdates(api, updates, agentId), { intervalSeconds: syncConfig.intervalSeconds, channels: syncConfig.channels }); } api.logger.info(`[odoo] agent=agentId 已连接 odooConfig.url,uid=client.getUid()`); return client; } /** * 尝试恢复 agent 连接 —— 走 fallback 链,静默失败。 * * 查找顺序(v1.10 共享凭据模型): * 1) `{agentId}.json` 该 agent 的独立凭据(private) * 2) `default.json` 共享凭据(首次 connect 默认写这里) * 3) legacy `odoo-config.json` 向下兼容 * 4) `api.pluginConfig.odoo` manifest 预填的静态凭据(零配置部署) * * 1-3 由 ConfigManager.load 内部处理;4 在这里兜底。 * 只要任一层命中,就 init client 缓存在 odooClients[agentId] 下 —— 不同 agent * 命中同一份凭据时各自持有独立的 OdooClient 实例,session 隔离。 */ async function tryRestoreAgent(api: OpenClawPluginApi, agentId: string): Promise<OdooClient | undefined> { if (odooClients.get(agentId)?.isAuthenticated()) return odooClients.get(agentId); // 1-3: ConfigManager 内置 fallback let saved = configManager.load(agentId); // 4: pluginConfig 兜底 let sourceLabel: string; if (!saved?.odoo) { const fromManifest = (api.pluginConfig as OdooPluginConfig | undefined)?.odoo; if (!fromManifest) return undefined; saved = { odoo: fromManifest }; sourceLabel = 'pluginConfig'; } else { sourceLabel = configManager.getActiveSource(agentId); } try { api.logger.info(`[odoo] 恢复 agent=agentId 的连接(来源: sourceLabel)...`); return await initOdooClient(api, saved.odoo!, agentId); } catch (err) { api.logger.error(`[odoo] agent=agentId 恢复失败(来源 sourceLabel): String(err)`); return undefined; } } // ── 工具辅助 ────────────────────────────────────────────────────────────────── function getClient(ctx: Record<string, unknown>): OdooClient | undefined { const aid = getAgentId(ctx); const client = odooClients.get(aid); return client?.isAuthenticated() ? client : undefined; } function notConnected() { return { success: false, message: '未连接到辉火云企业套件,请先提供系统地址、用户名和密码进行连接。' }; } function getAgentId(ctx: Record<string, unknown>) { return (ctx['agentId'] as string | undefined)?.trim() || 'default'; } function stripHtml(html: string) { return String(html ?? '').replace(/<[^>]+>/g, '').trim().substring(0, 300); } /** * 把后端 read() 返回的字段值归一化为 write() 可接受的形式。 * - null / undefined / false → false * - many2one: [id, "名称"] → id(write 只收 id) * - many2many: [id1, id2, …] → [[6, false, [id1, id2, …]]](write 要求 command tuple) * - 其它标量:原样保留 */ function normalizeFieldSnapshot(v: unknown): unknown { if (v === null || v === undefined || v === false) return false; if (Array.isArray(v)) { if (v.length === 2 && typeof v[0] === 'number' && typeof v[1] === 'string') { return v[0]; // many2one } if (v.every(x => typeof x === 'number')) { return [[6, false, v]]; // many2many } } return v; } /** * 有审计的 write:先读旧值快照,再 write,再把变更写入 mutation-log。 * 用于所有用户触发的单/多记录更新,让"撤销上一步"可用。 */ async function loggedWrite( client: OdooClient, ctx: Record<string, unknown>, args: { tool: string; model: string; ids: number[]; values: Record<string, unknown>; summary: string; }, ): Promise<void> { const fields = Object.keys(args.values); let before: Record<string, unknown>[] = []; if (fields.length > 0) { try { const recs = await client.read(args.model, args.ids, fields); before = args.ids.map(id => { const r = (recs.find(rr => rr['id'] === id) ?? {}) as Record<string, unknown>; const snap: Record<string, unknown> = { id }; for (const f of fields) { snap[f] = normalizeFieldSnapshot(r[f]); } return snap; }); } catch { before = []; // 快照失败 → 不可逆但不阻断 write } } await client.write(args.model, args.ids, args.values); mutationLog.append(getAgentId(ctx), { tool: args.tool, model: args.model, ids: args.ids, before, after: args.values, reversible: before.length === args.ids.length && before.length > 0, summary: args.summary, }); } // ── 注册工具(共 32 个)────────────────────────────────────────────────────── function registerTools(api: OpenClawPluginApi) { // ══════════════════════════════════════════════════════ // 连接 & 状态 // ══════════════════════════════════════════════════════ api.registerTool({ name: 'odoo_connect', description: '连接辉火云企业套件。默认保存为【共享凭据】—— 组织内所有渠道(企微/钉钉/飞书)的所有 agent 都会自动复用,无需每个人重新输入。如需给当前会话单独使用一套专属凭据,传 private=true。db 为可选,不传则自动检测(单库自动、多库返回列表)。', schema: { type: 'object', properties: { url: { type: 'string', description: '辉火云企业套件 系统地址,如 https://www.huo15.com' }, db: { type: 'string', description: '数据库名称(可选,只有一个数据库时可省略)' }, username: { type: 'string', description: '用户名(邮箱或登录名)' }, password: { type: 'string', description: '密码' }, private: { type: 'boolean', description: '可选,默认 false。true = 仅保存为当前会话专属凭据(只覆盖当前 agent);false = 保存为组织共享凭据(全员复用,推荐)' }, }, required: ['url', 'username', 'password'], }, async handler( params: { url: string; db?: string; username: string; password: string; private?: boolean }, ctx: Record<string, unknown>, ) { const aid = getAgentId(ctx); let db = params.db; // 未指定 db 时自动检测 if (!db) { try { const dbs = await OdooClient.listDatabases(params.url); if (dbs.length === 0) return { success: false, message: '该辉火云实例没有可用的数据库' }; if (dbs.length === 1) { db = dbs[0]; } else { return { success: false, needSelectDb: true, databases: dbs, message: `检测到 dbs.length 个数据库,请告诉我要连接哪一个:dbs.join('、')` }; } } catch { return { success: false, message: '无法自动检测数据库列表,请手动提供数据库名称(db 参数)' }; } } const cfg = { url: params.url, db, username: params.username, password: params.password }; const scope: 'shared' | 'agent' = params.private ? 'agent' : 'shared'; try { await initOdooClient(api, cfg, aid); configManager.saveOdooConfig(cfg, aid, scope); const scopeMsg = scope === 'shared' ? '已保存为【共享凭据】—— 组织内所有渠道的 @ 机器人用户都会自动使用这套凭据,无需再输入。' : '已保存为【当前会话专属凭据】—— 只对当前 agent 生效,不影响其他成员。'; return { success: true, scope, message: `已成功连接到 params.url(数据库: db),欢迎使用辉火云企业套件!scopeMsg`, }; } catch (e) { return { success: false, message: `连接失败: String(e)` }; } }, }); api.registerTool({ name: 'odoo_status', description: '检查辉火云企业套件连接状态', schema: { type: 'object', properties: {} }, async handler(_p: unknown, ctx: Record<string, unknown>) { const aid = getAgentId(ctx); const client = odooClients.get(aid); const info = client?.getSessionInfo(); return { success: true, connected: client?.isAuthenticated() ?? false, agentId: aid, uid: info?.uid ?? null, username: info?.username ?? null, url: info?.url ?? null, polling: pollers.get(aid)?.getStatus() ?? null }; }, }); api.registerTool({ name: 'odoo_disconnect', description: '断开当前会话的辉火云企业套件连接。默认安全模式:只清除当前 agent 的【独立凭据】(如有),不会影响组织的【共享凭据】。如需彻底清除全员共用的共享凭据(高危,会导致所有成员断开),传 force_shared=true。', schema: { type: 'object', properties: { force_shared: { type: 'boolean', description: '可选,默认 false。true = 同时清除组织共享凭据(影响所有成员);false = 只断开当前会话,保留共享凭据' }, }, }, async handler(p: { force_shared?: boolean } | undefined, ctx: Record<string, unknown>) { const aid = getAgentId(ctx); const sourceBefore = configManager.getActiveSource(aid); pollers.get(aid)?.stop(); pollers.delete(aid); const client = odooClients.get(aid); if (client) { try { await client.destroy(); } catch { /* ignore */ } odooClients.delete(aid); } const hadOwn = configManager.clearOwnConfig(aid); let sharedCleared = false; if (p?.force_shared) { sharedCleared = configManager.clearSharedConfig(); } let message: string; if (sharedCleared) { message = '⚠️ 已断开当前会话,并清除了组织【共享凭据】。所有渠道的 @ 机器人成员都需要重新连接。'; } else if (hadOwn) { message = '已断开当前会话的【专属凭据】。组织共享凭据未受影响 —— 下一次 @ 机器人会自动 fallback 到共享凭据。'; } else if (sourceBefore === 'shared' || sourceBefore === 'legacy') { message = '当前会话已从内存断开,但用的是组织【共享凭据】,已为你保留 —— 不影响其他成员。下一次 @ 机器人会自动重连。如需彻底清除共享凭据,调用 odoo_disconnect(force_shared=true)。'; } else { message = '当前会话已断开。'; } return { success: true, sharedCleared, hadOwnConfig: hadOwn, message }; }, }); api.registerTool({ name: 'odoo_whoami', description: '查看当前 @ 机器人的会话使用的是哪套辉火云凭据 —— 共享凭据 / 当前会话专属 / manifest 静态预填 / 未连接。用于排查"为什么 @ 机器人时没问我密码?"或"我的连接是哪套?"等疑问。', schema: { type: 'object', properties: {} }, async handler(_p: unknown, ctx: Record<string, unknown>) { const aid = getAgentId(ctx); const client = odooClients.get(aid); const connected = client?.isAuthenticated() ?? false; const source = configManager.getActiveSource(aid); const info = client?.getSessionInfo(); const fromManifest = (api.pluginConfig as OdooPluginConfig | undefined)?.odoo; const sourceLabel: Record<string, string> = { agent: '当前会话专属凭据({agentId}.json)', shared: '组织共享凭据(default.json,全员共用)', legacy: '历史遗留单文件凭据(odoo-config.json)', none: fromManifest ? 'manifest 静态预填(pluginConfig.odoo)' : '未连接(无任何凭据来源)', }; return { success: true, connected, agentId: aid, source, sourceLabel: sourceLabel[source] ?? '未知', url: info?.url ?? null, username: info?.username ?? null, uid: info?.uid ?? null, sharedConfigExists: configManager.hasSharedConfig(), ownConfigExists: configManager.hasOwnConfig(aid), manifestConfigExists: !!fromManifest, message: connected ? `当前 @ 机器人会话已连接到 info?.url(用户 info?.username),凭据来源:sourceLabel[source]。` : `当前会话尚未连接。(fromManifest ? '插件 manifest 已预填凭据,下一次操作会自动连接。' : '需要先调用 odoo_connect 配置凭据(默认会保存为全员共享)。')`, }; }, }); // ══════════════════════════════════════════════════════ // 任务 / 待办 // ══════════════════════════════════════════════════════ api.registerTool({ name: 'odoo_create_task', description: '创建待办任务。用于"帮我写个待办"、"创建任务"等指令。', schema: { type: 'object', properties: { name: { type: 'string', description: '任务名称(必填)' }, description: { type: 'string', description: '详细描述' }, date_deadline: { type: 'string', description: '截止日期 YYYY-MM-DD' }, priority: { type: 'string', enum: ['0','1','2','3'], description: '优先级:0普通 1中 2高 3紧急' }, project_id: { type: 'number', description: '所属项目ID' }, user_ids: { type: 'array', items: { type: 'number' }, description: '指派用户ID列表' }, }, required: ['name'], }, async handler(p: { name: string; description?: string; date_deadline?: string; priority?: '0'|'1'|'2'|'3'; project_id?: number; user_ids?: number[] }, ctx: Record<string, unknown>) { const client = getClient(ctx); if (!client) return notConnected(); try { const taskId = await client.createTask({ name: p.name, description: p.description, date_deadline: p.date_deadline, priority: p.priority, project_id: p.project_id, user_ids: p.user_ids }); return { success: true, taskId, message: `待办「p.name」已创建,ID: taskId` }; } catch (e) { return { success: false, message: String(e) }; } }, }); api.registerTool({ name: 'odoo_list_tasks', description: '查看我的待办任务(To-Do 应用,私人任务,无项目)。默认只看进行中。', schema: { type: 'object', properties: { limit: { type: 'number', description: '上限,默认50' }, project_id: { type: 'number', description: '指定项目ID(指定后切换到项目任务模式)' }, today_only: { type: 'boolean', description: '只看今日截止' }, stage_state: { type: 'string', description: "任务状态:in_progress(进行中,默认)/ done(已完成)/ all(全部)" }, state_filter: { type: 'string', description: "直接指定 state 值:01_in_progress / 02_changes_requested / 03_approved / 1_done / 1_canceled / 04_waiting_normal" }, stage_id: { type: 'number', description: '指定具体阶段ID' }, include_project: { type: 'boolean', description: 'true=同时包含项目任务(默认 false,仅待办私人任务)' }, }, }, async handler(p: { limit?: number; project_id?: number; today_only?: boolean; stage_state?: string; state_filter?: string; stage_id?: number; include_project?: boolean }, ctx: Record<string, unknown>) { const client = getClient(ctx); if (!client) return notConnected(); try { const tasks = await client.getMyTasks({ limit: p.limit, project_id: p.project_id, today_only: p.today_only, stage_state: p.stage_state as 'in_progress' | 'done' | 'all', state_filter: p.state_filter, stage_id: p.stage_id, include_project: p.include_project }); return { success: true, count: tasks.length, tasks: tasks.map(t => ({ id: t['id'], name: t['name'], project: t['project_id'], deadline: t['date_deadline'], priority: t['priority'], stage_id: t['stage_id'], state: t['state'] })) }; } catch (e) { return { success: false, message: String(e) }; } }, }); api.registerTool({ name: 'odoo_get_task_stages', description: '查看项目任务阶段列表(stage_id),用于 odoo_update_task 时指定正确的阶段ID。', schema: { type: 'object', properties: { project_id: { type: 'number', description: '项目ID(可选,不填则返回所有阶段)' }, }, }, async handler(p: { project_id?: number }, ctx: Record<string, unknown>) { const client = getClient(ctx); if (!client) return notConnected(); try { const stages = await client.getTaskStages(p.project_id); return { success: true, count: stages.length, stages: stages.map(s => ({ id: s['id'], name: s['name'], fold: s['fold'], is_done_stage: s['fold'] })) }; } catch (e) { return { success: false, message: String(e) }; } }, }); api.registerTool({ name: 'odoo_update_task', description: '更新任务的阶段(状态)、截止日期、优先级等字段。通过 stage_id 改变任务的工作流状态。', schema: { type: 'object', properties: { task_id: { type: 'number', description: '任务ID(必填)' }, name: { type: 'string', description: '新名称' }, stage_id: { type: 'number', description: '新阶段ID(stage_id),用于改变任务状态' }, date_deadline: { type: 'string', description: '新截止日期 YYYY-MM-DD' }, priority: { type: 'string', enum: ['0','1','2','3'], description: '新优先级' }, description: { type: 'string', description: '新描述' }, active: { type: 'boolean', description: '任务激活状态,false=归档' }, }, required: ['task_id'], }, async handler(p: { task_id: number; name?: string; stage_id?: number; date_deadline?: string; priority?: string; description?: string; active?: boolean }, ctx: Record<string, unknown>) { const client = getClient(ctx); if (!client) return notConnected(); const values: Record<string, unknown> = {}; if (p.name !== undefined) values['name'] = p.name; if (p.stage_id !== undefined) values['stage_id'] = p.stage_id; if (p.date_deadline !== undefined) values['date_deadline'] = p.date_deadline || false; if (p.priority !== undefined) values['priority'] = p.priority; if (p.description !== undefined) values['description'] = p.description; if (p.active !== undefined) values['active'] = p.active; if (Object.keys(values).length === 0) { return { success: true, message: `任务 #p.task_id 无需更新(未提供任何字段)` }; } try { await loggedWrite(client, ctx, { tool: 'odoo_update_task', model: 'project.task', ids: [p.task_id], values, summary: `更新任务 #p.task_id(字段:Object.keys(values).join(', '))`, }); return { success: true, message: `任务 #p.task_id 已更新(可用 odoo_undo_last 撤销)` }; } catch (e) { return { success: false, message: String(e) }; } }, }); // ══════════════════════════════════════════════════════ // 活动 / 日历 // ══════════════════════════════════════════════════════ api.registerTool({ name: 'odoo_create_activity', description: '创建活动提醒(关联到某条记录)。用于"提醒我明天开会"等。', schema: { type: 'object', properties: { res_model: { type: 'string', description: '关联模型,如 project.task、crm.lead、res.partner' }, res_id: { type: 'number', description: '关联记录ID' }, activity_type_id: { type: 'number', description: '活动类型ID(4=待办,1=邮件,2=电话,通过 odoo_activity_types 查询)' }, summary: { type: 'string', description: '活动摘要/标题' }, note: { type: 'string', description: '详细说明' }, date_deadline: { type: 'string', description: '截止日期 YYYY-MM-DD' }, user_id: { type: 'number', description: '负责人ID,默认当前用户' }, }, required: ['res_model', 'res_id', 'activity_type_id', 'date_deadline'], }, async handler(p: { res_model: string; res_id: number; activity_type_id: number; summary?: string; note?: string; date_deadline: string; user_id?: number }, ctx: Record<string, unknown>) { const client = getClient(ctx); if (!client) return notConnected(); try { const id = await client.createActivity(p); return { success: true, activityId: id, message: `活动「p.summary ?? ''」已创建` }; } catch (e) { return { success: false, message: String(e) }; } }, }); api.registerTool({ name: 'odoo_list_activities', description: '查看今日及逾期活动提醒。用于"我今天有什么活动"等。', schema: { type: 'object', properties: { limit: { type: 'number', description: '上限,默认30' } } }, async handler(p: { limit?: number }, ctx: Record<string, unknown>) { const client = getClient(ctx); if (!client) return notConnected(); try { const acts = await client.getTodayActivities({ limit: p.limit ?? 30 }); return { success: true, count: acts.length, activities: acts.map(a => ({ id: a['id'], summary: a['summary'], deadline: a['date_deadline'], type: a['activity_type_id'], model: a['res_model'], state: a['state'] })) }; } catch (e) { return { success: false, message: String(e) }; } }, }); api.registerTool({ name: 'odoo_activity_types', description: '查询辉火云企业套件可用的活动类型列表(获取 activity_type_id)', schema: { type: 'object', properties: {} }, async handler(_p: unknown, ctx: Record<string, unknown>) { const client = getClient(ctx); if (!client) return notConnected(); try { const types = await client.getActivityTypes(); return { success: true, types: types.map(t => ({ id: t['id'], name: t['name'], icon: t['icon'], category: t['category'] })) }; } catch (e) { return { success: false, message: String(e) }; } }, }); api.registerTool({ name: 'odoo_create_event', description: '创建日历事件/会议。用于"安排一个会议"、"明天上午10点开产品评审"等。', schema: { type: 'object', properties: { name: { type: 'string', description: '事件名称(必填)' }, start: { type: 'string', description: '开始时间 YYYY-MM-DD HH:MM:SS(必填)' }, stop: { type: 'string', description: '结束时间 YYYY-MM-DD HH:MM:SS(必填)' }, description: { type: 'string', description: '描述/议程' }, partner_ids: { type: 'array', items: { type: 'number' }, description: '参与人 partner ID 列表' }, }, required: ['name', 'start', 'stop'], }, async handler(p: { name: string; start: string; stop: string; description?: string; partner_ids?: number[] }, ctx: Record<string, unknown>) { const client = getClient(ctx); if (!client) return notConnected(); try { const id = await client.createCalendarEvent(p); return { success: true, eventId: id, message: `日历事件「p.name」已创建,ID: id` }; } catch (e) { return { success: false, message: String(e) }; } }, }); // ══════════════════════════════════════════════════════ // 消息 // ══════════════════════════════════════════════════════ api.registerTool({ name: 'odoo_get_messages', description: '查看未读消息和邮件通知。用于"查看我的消息"、"看看邮件"等。', schema: { type: 'object', properties: { type: { type: 'string', enum: ['message','email'], description: 'message=chatter消息,email=邮件通知' }, limit: { type: 'number', description: '上限,默认20' }, unread_only: { type: 'boolean', description: '只看未读,默认true' }, }, }, async handler(p: { type?: 'message'|'email'; limit?: number; unread_only?: boolean }, ctx: Record<string, unknown>) { const client = getClient(ctx); if (!client) return notConnected(); const limit = p.limit ?? 20; try { if (p.type === 'email') { const n = await client.getInboxNotifications({ limit }); return { success: true, type: 'email', count: n.length, messages: n }; } const msgs = p.unread_only !== false ? await client.getUnreadMessages({ limit }) : (await client.searchRead('mail.message', [['message_type','!=','notification']], ['id','subject','body','author_id','date','model','res_id'], { limit })).records; return { success: true, type: 'message', count: msgs.length, messages: msgs.map(m => ({ id: m['id'], subject: m['subject'], body: stripHtml(String(m['body'] ?? '')), author: m['author_id'], date: m['date'], model: m['model'] })) }; } catch (e) { return { success: false, message: String(e) }; } }, }); api.registerTool({ name: 'odoo_send_message', description: '向某条 辉火云记录发送 chatter 消息。', schema: { type: 'object', properties: { model: { type: 'string', description: '目标模型,如 project.task、crm.lead、sale.order' }, res_id: { type: 'number', description: '目标记录ID' }, body: { type: 'string', description: '消息内容(支持HTML)' }, subject: { type: 'string', description: '主题(可选)' }, }, required: ['model', 'res_id', 'body'], }, async handler(p: { model: string; res_id: number; body: string; subject?: string }, ctx: Record<string, unknown>) { const client = getClient(ctx); if (!client) return notConnected(); try { const id = await client.call('mail.message', 'create', [{ model: p.model, res_id: p.res_id, body: p.body, subject: p.subject ?? '', message_type: 'comment', subtype_xmlid: 'mail.mt_comment' }]); return { success: true, messageId: id, message: `消息已发送,ID: id` }; } catch (e) { return { success: false, message: String(e) }; } }, }); // ══════════════════════════════════════════════════════ // 通用搜索 // ══════════════════════════════════════════════════════ api.registerTool({ name: 'odoo_search', description: '通用搜索辉火云企业套件任意数据模型。用于"查客户"、"查销售订单"、"查库存"等。', schema: { type: 'object', properties: { model: { type: 'string', description: '模型名:res.partner / project.task / crm.lead / sale.order / purchase.order / stock.quant / hr.employee / account.move 等' }, domain: { type: 'array', description: '搜索域 [[field, op, value], ...]' }, fields: { type: 'array', items: { type: 'string' }, description: '返回字段' }, limit: { type: 'number', description: '上限,默认20' }, order: { type: 'string', description: '排序,如 "create_date desc"' }, }, required: ['model'], }, async handler(p: { model: string; domain?: unknown[]; fields?: string[]; limit?: number; order?: string }, ctx: Record<string, unknown>) { const client = getClient(ctx); if (!client) return notConnected(); try { const r = await client.searchRead(p.model, (p.domain as [string,string,unknown][]) ?? [], p.fields ?? ['id','name'], { limit: p.limit ?? 20, order: p.order }); return { success: true, count: r.length, records: r.records }; } catch (e) { return { success: false, message: String(e) }; } }, }); // ══════════════════════════════════════════════════════ // CRM 商机 // ══════════════════════════════════════════════════════ api.registerTool({ name: 'odoo_crm_pipeline', description: '查看 CRM 商机管道。用于"查看我的商机"、"销售管道情况"等。', schema: { type: 'object', properties: { limit: { type: 'number', description: '上限,默认30' }, stage_id: { type: 'number', description: '按阶段ID筛选' }, user_id: { type: 'number', description: '按销售员筛选' }, type: { type: 'string', enum: ['lead','opportunity'], description: '线索或商机' }, all_users: { type: 'boolean', description: '查看全部用户商机(不只是自己)' }, }, }, async handler(p: { limit?: number; stage_id?: number; user_id?: number; type?: 'lead'|'opportunity'; all_users?: boolean }, ctx: Record<string, unknown>) { const client = getClient(ctx); if (!client) return notConnected(); const uid = client.getUid() ?? 0; try { const leads = await client.getCrmPipeline({ limit: p.limit, stage_id: p.stage_id, user_id: p.all_users ? undefined : (p.user_id ?? uid), type: p.type }); return { success: true, count: leads.length, pipeline: leads.map(l => ({ id: l['id'], name: l['name'], partner: l['partner_id'], stage: l['stage_id'], probability: l['probability'], revenue: l['expected_revenue'], deadline: l['date_deadline'], type: l['type'] })) }; } catch (e) { return { success: false, message: String(e) }; } }, }); api.registerTool({ name: 'odoo_crm_create', description: '创建 CRM 商机或线索。用于"新建一个商机"等。', schema: { type: 'object', properties: { name: { type: 'string', description: '商机名称(必填)' }, type: { type: 'string', enum: ['lead','opportunity'], description: '类型,默认 opportunity' }, partner_id: { type: 'number', description: '客户ID' }, expected_revenue: { type: 'number', description: '预计收入' }, probability: { type: 'number', description: '赢单概率 0-100' }, stage_id: { type: 'number', description: '阶段ID' }, date_deadline: { type: 'string', description: '预计关单日期 YYYY-MM-DD' }, description: { type: 'string', description: '备注' }, }, required: ['name'], }, async handler(p: { name: string; type?: 'lead'|'opportunity'; partner_id?: number; expected_revenue?: number; probability?: number; stage_id?: number; date_deadline?: string; description?: string }, ctx: Record<string, unknown>) { const client = getClient(ctx); if (!client) return notConnected(); try { const id = await client.createCrmLead(p); return { success: true, leadId: id, message: `'商机'「p.name」已创建,ID: id` }; } catch (e) { return { success: false, message: String(e) }; } }, }); api.registerTool({ name: 'odoo_crm_update', description: '更新商机信息(阶段、金额、概率、截止日期等)。', schema: { type: 'object', properties: { lead_id: { type: 'number', description: '商机ID(必填)' }, name: { type: 'string', description: '新名称' }, stage_id: { type: 'number', description: '新阶段ID' }, expected_revenue: { type: 'number', description: '新预计收入' }, probability: { type: 'number', description: '新赢单概率 0-100' }, date_deadline: { type: 'string', description: '新截止日期 YYYY-MM-DD' }, user_id: { type: 'number', description: '新负责销售员ID' }, }, required: ['lead_id'], }, async handler(p: { lead_id: number; name?: string; stage_id?: number; expected_revenue?: number; probability?: number; date_deadline?: string; user_id?: number }, ctx: Record<string, unknown>) { const client = getClient(ctx); if (!client) return notConnected(); const values: Record<string, unknown> = {}; if (p.name !== undefined) values['name'] = p.name; if (p.stage_id !== undefined) values['stage_id'] = p.stage_id; if (p.expected_revenue !== undefined) values['expected_revenue'] = p.expected_revenue; if (p.probability !== undefined) values['probability'] = p.probability; if (p.date_deadline !== undefined) values['date_deadline'] = p.date_deadline; if (p.user_id !== undefined) values['user_id'] = p.user_id; if (Object.keys(values).length === 0) { return { success: true, message: `商机 #p.lead_id 无需更新(未提供任何字段)` }; } try { await loggedWrite(client, ctx, { tool: 'odoo_crm_update', model: 'crm.lead', ids: [p.lead_id], values, summary: `更新商机 #p.lead_id(字段:Object.keys(values).join(', '))`, }); return { success: true, message: `商机 #p.lead_id 已更新(可用 odoo_undo_last 撤销)` }; } catch (e) { return { success: false, message: String(e) }; } }, }); api.registerTool({ name: 'odoo_crm_won', description: '将商机标记为赢单。用于"这个商机赢了"等。', schema: { type: 'object', properties: { lead_id: { type: 'number', description: '商机ID(必填)' } }, required: ['lead_id'] }, async handler(p: { lead_id: number }, ctx: Record<string, unknown>) { const client = getClient(ctx); if (!client) return notConnected(); try { await client.setCrmWon([p.lead_id]); return { success: true, message: `商机 #p.lead_id 已标记为赢单` }; } catch (e) { return { success: false, message: String(e) }; } }, }); api.registerTool({ name: 'odoo_crm_lost', description: '将商机标记为输单/丢失。', schema: { type: 'object', properties: { lead_id: { type: 'number', description: '商机ID(必填)' }, lost_reason_id: { type: 'number', description: '丢单原因ID(可选)' }, }, required: ['lead_id'], }, async handler(p: { lead_id: number; lost_reason_id?: number }, ctx: Record<string, unknown>) { const client = getClient(ctx); if (!client) return notConnected(); try { await client.setCrmLost([p.lead_id], p.lost_reason_id); return { success: true, message: `商机 #p.lead_id 已标记为丢单` }; } catch (e) { return { success: false, message: String(e) }; } }, }); // ══════════════════════════════════════════════════════ // 项目概览 & 工时 // ══════════════════════════════════════════════════════ api.registerTool({ name: 'odoo_project_overview', description: '查看项目列表和里程碑进度。用于"项目情况"、"里程碑进度"等。', schema: { type: 'object', properties: { project_id: { type: 'number', description: '指定某个项目ID,不填则查全部' }, show_milestones: { type: 'boolean', description: '是否同时返回里程碑,默认true' }, }, }, async handler(p: { project_id?: number; show_milestones?: boolean }, ctx: Record<string, unknown>) { const client = getClient(ctx); if (!client) return notConnected(); try { const [projects, milestones] = await Promise.all([ client.getProjectOverview(p.project_id), p.show_milestones !== false ? client.getMilestones(p.project_id) : Promise.resolve([]), ]); return { success: true, projects: projects.map(pr => ({ id: pr['id'], name: pr['name'], partner: pr['partner_id'], manager: pr['user_id'], start: pr['date_start'], end: pr['date'], task_count: pr['task_count'], open_tasks: pr['open_task_count'], done_tasks: pr['closed_task_count'] })), milestones: milestones.map(m => ({ id: m['id'], name: m['name'], project: m['project_id'], deadline: m['deadline'], is_reached: m['is_reached'], tasks: m['task_count'], done_tasks: m['done_task_count'], overdue: m['is_deadline_exceeded'] })), }; } catch (e) { return { success: false, message: String(e) }; } }, }); api.registerTool({ name: 'odoo_timesheet_log', description: '记录工时。用于"记录2小时工时"、"今天在项目A上工作了3小时"等。', schema: { type: 'object', properties: { name: { type: 'string', description: '工作描述(必填)' }, hours: { type: 'number', description: '工时(小时)(必填)' }, project_id: { type: 'number', description: '项目ID' }, task_id: { type: 'number', description: '任务ID' }, date: { type: 'string', description: '日期 YYYY-MM-DD,默认今天' }, }, required: ['name', 'hours'], }, async handler(p: { name: string; hours: number; project_id?: number; task_id?: number; date?: string }, ctx: Record<string, unknown>) { const client = getClient(ctx); if (!client) return notConnected(); try { const id = await client.logTimesheet({ name: p.name, unit_amount: p.hours, project_id: p.project_id, task_id: p.task_id, date: p.date }); return { success: true, timesheetId: id, message: `已记录 p.hours 小时工时:「p.name」` }; } catch (e) { return { success: false, message: String(e) }; } }, }); // ══════════════════════════════════════════════════════ // 销售 & 采购 // ══════════════════════════════════════════════════════ api.registerTool({ name: 'odoo_sale_orders', description: '查看销售订单/报价单列表。用于"查看销售订单"、"报价单情况"等。', schema: { type: 'object', properties: { limit: { type: 'number', description: '上限,默认20' }, state: { type: 'string', enum: ['draft','sent','sale','cancel'], description: '状态:draft=报价 sent=已发送 sale=销售订单 cancel=已取消' }, partner_id: { type: 'number', description: '按客户筛选' }, }, }, async handler(p: { limit?: number; state?: string; partner_id?: number }, ctx: Record<string, unknown>) { const client = getClient(ctx); if (!client) return notConnected(); try { const orders = await client.getSaleOrders({ limit: p.limit, state: p.state, partner_id: p.partner_id }); return { success: true, count: orders.length, orders: orders.map(o => ({ id: o['id'], name: o['name'], partner: o['partner_id'], state: o['state'], date: o['date_order'], amount: o['amount_total'], invoice_status: o['invoice_status'] })) }; } catch (e) { return { success: false, message: String(e) }; } }, }); api.registerTool({ name: 'odoo_purchase_orders', description: '查看采购订单/询价单列表。用于"查看采购订单"等。', schema: { type: 'object', properties: { limit: { type: 'number', description: '上限,默认20' }, state: { type: 'string', enum: ['draft','sent','to approve','purchase','cancel'], description: '状态:draft=RFQ sent=已发送 to approve=待审批 purchase=采购订单 cancel=已取消' }, }, }, async handler(p: { limit?: number; state?: string }, ctx: Record<string, unknown>) { const client = getClient(ctx); if (!client) return notConnected(); try { const orders = await client.getPurchaseOrders({ limit: p.limit, state: p.state }); return { success: true, count: orders.length, orders: orders.map(o => ({ id: o['id'], name: o['name'], vendor: o['partner_id'], state: o['state'], date: o['date_order'], planned_arrival: o['date_planned'], amount: o['amount_total'] })) }; } catch (e) { return { success: false, message: String(e) }; } }, }); // ══════════════════════════════════════════════════════ // 客服工单(Helpdesk) // ══════════════════════════════════════════════════════ api.registerTool({ name: 'odoo_tickets', description: '查看客服工单列表。用于"查看工单"、"有哪些待处理问题"等。', schema: { type: 'object', properties: { limit: { type: 'number', description: '上限,默认30' }, my_tickets: { type: 'boolean', description: '只看指派给我的工单,默认true' }, priority: { type: 'string', enum: ['0','1','2','3'], description: '优先级筛选' }, partner_id: { type: 'number', description: '按客户筛选' }, team_id: { type: 'number', description: '按团队筛选' }, }, }, async handler(p: { limit?: number; my_tickets?: boolean; priority?: string; partner_id?: number; team_id?: number }, ctx: Record<string, unknown>) { const client = getClient(ctx); if (!client) return notConnected(); const uid = client.getUid() ?? undefined; try { const tickets = await client.getHelpdeskTickets({ limit: p.limit, user_id: p.my_tickets !== false ? uid : undefined, priority: p.priority, partner_id: p.partner_id, team_id: p.team_id }); return { success: true, count: tickets.length, tickets: tickets.map(t => ({ id: t['id'], ref: t['ticket_ref'], name: t['name'], team: t['team_id'], stage: t['stage_id'], priority: t['priority'], partner: t['partner_id'], sla_deadline: t['sla_deadline'], sla_fail: t['sla_fail'] })) }; } catch (e) { return { success: false, message: String(e) }; } }, }); api.registerTool({ name: 'odoo_ticket_create', description: '创建客服工单。用于"帮我提交一个问题"、"新建工单"等。', schema: { type: 'object', properties: { name: { type: 'string', description: '工单标题(必填)' }, description: { type: 'string', description: '问题描述' }, partner_id: { type: 'number', description: '客户ID' }, team_id: { type: 'number', description: '处理团队ID' }, priority: { type: 'string', enum: ['0','1','2','3'], description: '优先级:0普通 1中 2高 3紧急' }, user_id: { type: 'number', description: '指派人员ID' }, }, required: ['name'], }, async handler(p: { name: string; description?: string; partner_id?: number; team_id?: number; priority?: '0'|'1'|'2'|'3'; user_id?: number }, ctx: Record<string, unknown>) { const client = getClient(ctx); if (!client) return notConnected(); try { const id = await client.createHelpdeskTicket(p); return { success: true, ticketId: id, message: `工单「p.name」已创建,ID: id` }; } catch (e) { return { success: false, message: String(e) }; } }, }); // ══════════════════════════════════════════════════════ // 财务 / 发票 // ══════════════════════════════════════════════════════ api.registerTool({ name: 'odoo_invoices', description: '查看发票/账单列表,支持查逾期应收。用于"查看发票"、"逾期未付款的"等。', schema: { type: 'object', properties: { limit: { type: 'number', description: '上限,默认20' }, move_type: { type: 'string', enum: ['out_invoice','in_invoice','out_refund','in_refund'], description: '类型:out_invoice=客户发票 in_invoice=供应商账单' }, payment_state: { type: 'string', enum: ['not_paid','partial','paid','in_payment'], description: '付款状态' }, overdue_only: { type: 'boolean', description: '只看逾期未付发票' }, partner_id: { type: 'number', description: '按客户/供应商筛选' }, }, }, async handler(p: { limit?: number; move_type?: string; payment_state?: string; overdue_only?: boolean; partner_id?: number }, ctx: Record<string, unknown>) { const client = getClient(ctx); if (!client) return notConnected(); try { const invoices = p.overdue_only ? await client.getOverdueInvoices() : await client.getInvoices({ limit: p.limit, move_type: p.move_type, payment_state: p.payment_state, partner_id: p.partner_id }); return { success: true, count: invoices.length, invoices: invoices.map(i => ({ id: i['id'], name: i['name'], type: i['move_type'], partner: i['partner_id'], date: i['invoice_date'], due_date: i['invoice_date_due'], amount: i['amount_total'], payment_state: i['payment_state'] })) }; } catch (e) { return { success: false, message: String(e) }; } }, }); // ══════════════════════════════════════════════════════ // 联系人 / 客户(v1.2 新增) // ══════════════════════════════════════════════════════ api.registerTool({ name: 'odoo_contacts', description: '查询联系人/客户/供应商。用于"查客户"、"找供应商"、"搜索联系人"等。', schema: { type: 'object', properties: { keyword: { type: 'string', description: '按名称模糊搜索' }, is_company: { type: 'boolean', description: 'true=只看公司 false=只看个人' }, customer_only: { type: 'boolean', description: '只看客户' }, supplier_only: { type: 'boolean', description: '只看供应商' }, limit: { type: 'number', description: '上限,默认30' }, }, }, async handler(p: { keyword?: string; is_company?: boolean; customer_only?: boolean; supplier_only?: boolean; limit?: number }, ctx: Record<string, unknown>) { const client = getClient(ctx); if (!client) return notConnected(); try { const contacts = await client.getPartners({ keyword: p.keyword, is_company: p.is_company, customer_rank: p.customer_only, supplier_rank: p.supplier_only, limit: p.limit }); return { success: true, count: contacts.length, contacts: contacts.map(c => ({ id: c['id'], name: c['name'], email: c['email'], phone: c['phone'], mobile: c['mobile'], is_company: c['is_company'], city: c['city'], country: c['country_id'], parent: c['parent_id'] })) }; } catch (e) { return { success: false, message: String(e) }; } }, }); api.registerTool({ name: 'odoo_contact_create', description: '创建联系人/客户/供应商。用于"添加新客户"、"创建联系人"等。', schema: { type: 'object', properties: { name: { type: 'string', description: '名称(必填)' }, email: { type: 'string', description: '邮箱' }, phone: { type: 'string', description: '电话' }, mobile: { type: 'string', description: '手机' }, is_company: { type: 'boolean', description: '是否公司,默认false' }, city: { type: 'string', description: '城市' }, street: { type: 'string', description: '街道/地址' }, parent_id: { type: 'number', description: '所属公司ID(个人联系人时)' }, is_customer:{ type: 'boolean', description: '标记为客户,默认true' }, is_supplier:{ type: 'boolean', description: '标记为供应商,默认false' }, }, required: ['name'], }, async handler(p: { name: string; email?: string; phone?: string; mobile?: string; is_company?: boolean; city?: string; street?: string; parent_id?: number; is_customer?: boolean; is_supplier?: boolean }, ctx: Record<string, unknown>) { const client = getClient(ctx); if (!client) return notConnected(); try { const id = await client.createPartner({ name: p.name, email: p.email, phone: p.phone, mobile: p.mobile, is_company: p.is_company, city: p.city, street: p.street, parent_id: p.parent_id, customer_rank: (p.is_customer !== false) ? 1 : 0, supplier_rank: p.is_supplier ? 1 : 0, }); return { success: true, contactId: id, message: `'联系人'「p.name」已创建,ID: id` }; } catch (e) { return { success: false, message: String(e) }; } }, }); // ══════════════════════════════════════════════════════ // 库存(v1.2 新增) // ══════════════════════════════════════════════════════ api.registerTool({ name: 'odoo_stock_levels', description: '查看库存水平。用于"查库存"、"产品XX还有多少"等。', schema: { type: 'object', properties: { keyword: { type: 'string', description: '按产品名称模糊搜索' }, product_id: { type: 'number', description: '按产品ID筛选' }, location_id: { type: 'number', description: '按库位筛选' }, limit: { type: 'number', description: '上限,默认50' }, }, }, async handler(p: { keyword?: string; product_id?: number; location_id?: number; limit?: number }, ctx: Record<string, unknown>) { const client = getClient(ctx); if (!client) return notConnected(); try { const stocks = await client.getStockLevels({ keyword: p.keyword, product_id: p.product_id, location_id: p.location_id, limit: p.limit }); return { success: true, count: stocks.length, stock: stocks.map(s => ({ id: s['id'], product: s['product_id'], location: s['location_id'], lot: s['lot_id'], quantity: s['quantity'], reserved: s['reserved_quantity'], available: s['available_quantity'] })) }; } catch (e) { return { success: false, message: String(e) }; } }, }); api.registerTool({ name: 'odoo_stock_pickings', description: '查看调拨单/出入库单。用于"查看待出库"、"调拨单情况"等。', schema: { type: 'object', properties: { limit: { type: 'number', description: '上限,默认20' }, state: { type: 'string', enum: ['draft','waiting','confirmed','assigned','done','cancel'], description: '状态:assigned=就绪 waiting=等待 done=完成' }, type: { type: 'string', enum: ['incoming','outgoing','internal'], description: '类型:incoming=入库 outgoing=出库 internal=内部调拨' }, }, }, async handler(p: { limit?: number; state?: string; type?: string }, ctx: Record<string, unknown>) { const client = getClient(ctx); if (!client) return notConnected(); try { const pickings = await client.getStockPickings({ limit: p.limit, state: p.state, picking_type: p.type }); return { success: true, count: pickings.length, pickings: pickings.map(pk => ({ id: pk['id'], name: pk['name'], partner: pk['partner_id'], type: pk['picking_type_id'], state: pk['state'], scheduled: pk['scheduled_date'], done: pk['date_done'], origin: pk['origin'] })) }; } catch (e) { return { success: false, message: String(e) }; } }, }); // ══════════════════════════════════════════════════════ // HR 员工 / 考勤 / 请假(v1.2 新增) // ══════════════════════════════════════════════════════ api.registerTool({ name: 'odoo_employees', description: '查询员工列表。用于"查员工"、"某部门有谁"等。', schema: { type: 'object', properties: { keyword: { type: 'string', description: '按名称模糊搜索' }, department_id: { type: 'number', description: '按部门ID筛选' }, limit: { type: 'number', description: '上限,默认50' }, }, }, async handler(p: { keyword?: string; department_id?: number; limit?: number }, ctx: Record<string, unknown>) { const client = getClient(ctx); if (!client) return notConnected(); try { const emps = await client.getEmployees({ keyword: p.keyword, department_id: p.department_id, limit: p.limit }); return { success: true, count: emps.length, employees: emps.map(e => ({ id: e['id'], name: e['name'], department: e['department_id'], job: e['job_id'], email: e['work_email'], phone: e['mobile_phone'], manager: e['parent_id'] })) }; } catch (e) { return { success: false, message: String(e) }; } }, }); api.registerTool({ name: 'odoo_leaves', description: '查看请假记录。用于"我的请假记录"、"查看某人的请假"等。', schema: { type: 'object', properties: { employee_id: { type: 'number', description: '员工ID,不填则查当前用户' }, state: { type: 'string', enum: ['draft','confirm','validate1','validate','refuse'], description: '状态筛选' }, limit: { type: 'number', description: '上限,默认20' }, }, }, async handler(p: { employee_id?: number; state?: string; limit?: number }, ctx: Record<string, unknown>) { const client = getClient(ctx); if (!client) return notConnected(); try { const leaves = await client.getLeaves({ employee_id: p.employee_id, state: p.state, limit: p.limit }); return { success: true, count: leaves.length, leaves: leaves.map(l => ({ id: l['id'], name: l['name'], employee: l['employee_id'], type: l['holiday_status_id'], from: l['date_from'], to: l['date_to'], days: l['number_of_days'], state: l['state'] })) }; } catch (e) { return { success: false, message: String(e) }; } }, }); api.registerTool({ name: 'odoo_attendances', description: '查看考勤打卡记录。用于"我的考勤"、"打卡记录"等。', schema: { type: 'object', properties: { employee_id: { type: 'number', description: '员工ID,不填则查当前用户' }, limit: { type: 'number', description: '上限,默认20' }, }, }, async handler(p: { employee_id?: number; limit?: number }, ctx: Record<string, unknown>) { const client = getClient(ctx); if (!client) return notConnected(); try { const records = await client.getAttendances({ employee_id: p.employee_id, limit: p.limit }); return { success: true, count: records.length, attendances: records.map(a => ({ id: a['id'], employee: a['employee_id'], check_in: a['check_in'], check_out: a['check_out'], worked_hours: a['worked_hours'] })) }; } catch (e) { return { success: false, message: String(e) }; } }, }); // ══════════════════════════════════════════════════════ // 审批(v1.2 新增) // ══════════════════════════════════════════════════════ api.registerTool({ name: 'odoo_approvals', description: '查看审批请求列表。用于"我的审批"、"待审批的"等。', schema: { type: 'object', properties: { my_requests: { type: 'boolean', description: '只看我提交的请求' }, state: { type: 'string', enum: ['new','pending','approved','refused','cancel'], description: '状态筛选' }, limit: { type: 'number', description: '上限,默认20' }, }, }, async handler(p: { my_requests?: boolean; state?: string; limit?: number }, ctx: Record<string, unknown>) { const client = getClient(ctx); if (!client) return notConnected(); try { const approvals = await client.getApprovals({ my_requests: p.my_requests, state: p.state, limit: p.limit }); return { success: true, count: approvals.length, approvals: approvals.map(a => ({ id: a['id'], name: a['name'], category: a['category_id'], owner: a['request_owner_id'], status: a['request_status'], date: a['date'], amount: a['amount'], reason: a['reason'] })) }; } catch (e) { return { success: false, message: String(e) }; } }, }); // ══════════════════════════════════════════════════════ // 实施经理每日概况 // ══════════════════════════════════════════════════════ api.registerTool({ name: 'odoo_daily_briefing', description: '实施经理每日工作概况:今日截止任务、到期活动、待处理工单、逾期发票、商机跟进、未读消息。用于"今天有什么工作"、"给我今日概况"等。', schema: { type: 'object', properties: {} }, async handler(_p: unknown, ctx: Record<string, unknown>) { const client = getClient(ctx); if (!client) return notConnected(); try { const briefing = await client.getDailyBriefing(); const todayStr = today(); const total = briefing.todayTasks.length + briefing.overdueActivities.length + briefing.openTickets.length; return { success: true, message: `todayStr 概况:total 项核心待处理`, briefing: { date: todayStr, today_tasks: { count: briefing.todayTasks.length, items: briefing.todayTasks.map(t => ({ id: t['id'], name: t['name'], project: t['project_id'], deadline: t['date_deadline'], priority: t['priority'] })) }, activities: { count: briefing.overdueActivities.length, items: briefing.overdueActivities.map(a => ({ id: a['id'], summary: a['summary'], deadline: a['date_deadline'], type: a['activity_type_id'], model: a['res_model'], state: a['state'] })) }, tickets: { count: briefing.openTickets.length, items: briefing.openTickets.map(t => ({ id: t['id'], ref: t['ticket_ref'], name: t['name'], priority: t['priority'], sla_fail: t['sla_fail'] })) }, overdue_invoices: { count: briefing.overdueInvoices.length, items: briefing.overdueInvoices.map(i => ({ id: i['id'], name: i['name'], partner: i['partner_id'], due_date: i['invoice_date_due'], amount: i['amount_total'] })) }, crm_followups: { count: briefing.crmFollowUps.length, items: briefing.crmFollowUps.map(l => ({ id: l['id'], name: l['name'], partner: l['partner_id'], stage: l['stage_id'], revenue: l['expected_revenue'] })) }, unread_messages: { count: briefing.unreadMessages.length, items: briefing.unreadMessages.map(m => ({ id: m['id'], subject: m['subject'], author: m['author_id'], model: m['model'] })) }, }, }; } catch (e) { return { success: false, message: String(e) }; } }, }); // ══════════════════════════════════════════════════════ // 通知基座(跨渠道) // ══════════════════════════════════════════════════════ api.registerTool({ name: 'odoo_notification_status', description: '查看辉火云企业套件通知总线状态:已注册的渠道 transport、订阅者数、最近一次 poll 时间、当前 agent 的偏好设置、信封溯源缓存大小。用于"通知推送情况"、"企微/钉钉有没有连上"等排查类问题。', schema: { type: 'object', properties: {} }, async handler(_p: unknown, ctx: Record<string, unknown>) { const aid = getAgentId(ctx); const pollerStatus = pollers.get(aid)?.getStatus() ?? null; const prefs = prefsManager.load(aid); return { success: true, agentId: aid, bus: { subscribers: notificationBus.subscriberCount(), replySubscribers: notificationBus.replySubscriberCount(), transports: notificationBus.listTransports(), }, poller: pollerStatus, prefs, cache: { envelopesTracked: envelopeCache.size() }, hint: notificationBus.subscriberCount() === 0 && notificationBus.listTransports().length === 0 ? '当前没有任何渠道插件订阅通知总线。请确认已加载企微/钉钉等插件,或检查它们的连接状态。' : undefined, }; }, }); api.registerTool({ name: 'odoo_notification_channels', description: '列出当前已注册到通知总线的渠道(如企微、钉钉、飞书、webhook)。仅作为信息查询,真正的连接由各渠道插件自己管理。', schema: { type: 'object', properties: {} }, async handler() { const transports = notificationBus.listTransports(); return { success: true, count: transports.length, channels: transports, subscribers: notificationBus.subscriberCount(), }; }, }); api.registerTool({ name: 'odoo_notification_test', description: '向通知总线发一条测试 envelope,验证企微/钉钉等渠道是否能收到。用于"测试一下通知"、"看看推送通不通"等。', schema: { type: 'object', properties: { title: { type: 'string', description: '测试标题,默认"辉火云企业套件测试通知"' }, summary: { type: 'string', description: '测试摘要,默认"这是一条由 odoo 插件发送的测试通知"' }, }, }, async handler(p: { title?: string; summary?: string }, ctx: Record<string, unknown>) { const aid = getAgentId(ctx); const client = odooClients.get(aid); const odooUrl = client?.getSessionInfo().url; const envelope: NotificationEnvelope = { id: `odoo:aid:test:Date.now()`, source: 'odoo', agentId: aid, kind: 'message', action: 'test', priority: 'low', title: p.title ?? '辉火云企业套件测试通知', summary: p.summary ?? '这是一条由 odoo 插件发送的测试通知', body: p.summary ?? '如果你在企微 / 钉钉 / 飞书 里看到这条,说明渠道接通正常。', tags: ['odoo', 'test'], createdAt: Date.now(), origin: { url: odooUrl, model: 'test', resId: 0 }, }; await notificationBus.publish(envelope); return { success: true, dispatched: true, subscribers: notificationBus.subscriberCount(), transports: notificationBus.listTransports().map(t => t.name), envelopeId: envelope.id, message: notificationBus.subscriberCount() === 0 ? '已发送,但当前总线没有订阅者 —— 渠道插件可能未加载。' : `已发送到 notificationBus.subscriberCount() 个订阅者。`, }; }, }); api.registerTool({ name: 'odoo_notification_prefs', description: '查看或更新当前用户的通知偏好。支持:启停总开关、只接收某些类型(todo/activity/message/email/calendar)、优先级下限、静音时段(24h 制,跨午夜 OK)。不传任何参数只做查询。urgent 级别永远绕过静音与优先级过滤。', schema: { type: 'object', properties: { enabled: { type: 'boolean', description: '通知总开关,false=完全停掉' }, kinds: { type: 'array', items: { type: 'string', enum: ['todo','activity','message','email','calendar'] }, description: '允许发的种类,空数组=全开' }, min_priority: { type: 'string', enum: ['low','normal','high','urgent'], description: '优先级下限,低于此级别的被丢弃(urgent 永远放行)' }, quiet_start: { type: 'string', description: '静音起始 HH:MM(传空字符串 "" 清除静音)' }, quiet_end: { type: 'string', description: '静音结束 HH:MM' }, reset: { type: 'boolean', description: 'true=重置为默认偏好' }, }, }, async handler( p: { enabled?: boolean; kinds?: string[]; min_priority?: string; quiet_start?: string; quiet_end?: string; reset?: boolean }, ctx: Record<string, unknown>, ) { const aid = getAgentId(ctx); if (p.reset) { prefsManager.clear(aid); return { success: true, agentId: aid, prefs: DEFAULT_PREFS, message: '偏好已重置为默认。' }; } const current = prefsManager.load(aid); const patch: Partial<NotificationPreferences> = {}; if (p.enabled !== undefined) patch.enabled = p.enabled; if (Array.isArray(p.kinds)) patch.kinds = p.kinds as NotificationKind[]; if (p.min_priority) patch.minPriority = p.min_priority as NotificationPriority; if (p.quiet_start === '' || p.quiet_end === '') { patch.quietHours = undefined; } else if (p.quiet_start && p.quiet_end) { patch.quietHours = { start: p.quiet_start, end: p.quiet_end }; } if (Object.keys(patch).length === 0) { return { success: true, agentId: aid, prefs: current, message: '当前偏好(未变更)' }; } const updated = prefsManager.patch(patch, aid); return { success: true, agentId: aid, prefs: updated, message: '通知偏好已更新' }; }, }); api.registerTool({ name: 'odoo_notification_reply', description: '手动模拟一次从渠道回到辉火云企业套件的入站回复 —— 渠道插件在收到用户回复后应调用这条逻辑(或直接 import notificationBus.reply)。给出 envelope_id + body,辉火云会在对应记录的内部动态里写一条消息。用于排查"企微回复能不能写回系统"。', schema: { type: 'object', properties: { envelope_id: { type: 'string', description: '被回复的 envelope id(从 odoo_notification_test 或实际通知里取)' }, body: { type: 'string', description: '回复正文(纯文本)' }, channel: { type: 'string', description: '渠道标识,默认 "manual"' }, from_user: { type: 'string', description: '回复人标识,可选' }, }, required: ['envelope_id', 'body'], }, async handler(p: { envelope_id: string; body: string; channel?: string; from_user?: string }) { const reply: InboundReply = { envelopeId: p.envelope_id, channel: p.channel ?? 'manual', fromUser: p.from_user, body: p.body, }; const result = await notificationBus.reply(reply); return { success: result.ok, handled: result.handled, errors: result.errors, message: result.ok ? `回复已分发给 result.handled 个处理器。` : '回复未能成功投递,请检查辉火云企业套件是否连接、envelope_id 是否在缓存中(24h 内、500 条上限)。' }; }, }); // ══════════════════════════════════════════════════════ // 知识库(knowledge.article) // ══════════════════════════════════════════════════════ api.registerTool({ name: 'odoo_knowledge_search', description: '搜索 辉火云知识库文章。支持关键词(匹配标题或正文)、分类(workspace/private/shared)、仅收藏、仅顶层、指定父文章。用于"找一下关于 X 的知识库文章"、"列出我收藏的"、"列出工作区顶层文章"。', schema: { type: 'object', properties: { keyword: { type: 'string', description: '关键词,匹配文章标题或正文' }, category: { type: 'string', enum: ['workspace','private','shared'], description: '分类:workspace=工作区/private=私有/shared=共享' }, only_favorite: { type: 'boolean', description: '只列我收藏的' }, only_roots: { type: 'boolean', description: '只列顶层文章(parent_id=空)' }, parent_id: { type: 'number', description: '指定父文章 id,列其直接子节点' }, include_trashed: { type: 'boolean', description: '包含回收站中的文章,默认 false' }, limit: { type: 'number', description: '上限,默认 30' }, }, }, async handler( p: { keyword?: string; category?: 'workspace'|'private'|'shared'; only_favorite?: boolean; only_roots?: boolean; parent_id?: number; include_trashed?: boolean; limit?: number }, ctx: Record<string, unknown>, ) { const client = getClient(ctx); if (!client) return notConnected(); try { const recs = await client.searchKnowledgeArticles(p); return { success: true, count: recs.length, articles: recs.map(r => ({ id: r['id'], name: r['name'], icon: r['icon'] || null, category: r['category'], parent: r['parent_id'], root: r['root_article_id'], has_children: r['has_article_children'], is_favorite: r['is_user_favorite'], favorite_count: r['favorite_count'], last_edition: r['last_edition_date'], })), }; } catch (e) { return { success: false, message: String(e) }; } }, }); api.registerTool({ name: 'odoo_knowledge_read', description: '读取单篇知识库文章的完整正文(HTML)。用于"把这篇文章读给我"、"X 文章里写了什么"。body 可能较长,渲染时建议截断。', schema: { type: 'object', properties: { id: { type: 'number', description: '文章 id(必填)' }, plain: { type: 'boolean', description: 'true=同时返回纯文本摘要(去 HTML)' }, max_chars: { type: 'number', description: '正文最大字符数,0=不截断,默认 5000' }, }, required: ['id'], }, async handler(p: { id: number; plain?: boolean; max_chars?: number }, ctx: Record<string, unknown>) { const client = getClient(ctx); if (!client) return notConnected(); try { const rec = await client.readKnowledgeArticle(p.id); if (!rec) return { success: false, message: `文章 #p.id 不存在` }; const maxChars = p.max_chars ?? 5000; const body = String(rec['body'] ?? ''); const bodyOut = maxChars > 0 && body.length > maxChars ? body.substring(0, maxChars) + '…' : body; const result: Record<string, unknown> = { success: true, article: { id: rec['id'], name: rec['name'], icon: rec['icon'] || null, category: rec['category'], parent: rec['parent_id'], is_favorite: rec['is_user_favorite'], favorite_count: rec['favorite_count'], is_locked: rec['is_locked'], is_trashed: rec['to_delete'], last_edition: rec['last_edition_date'], internal_permission: rec['internal_permission'], body: bodyOut, body_truncated: bodyOut !== body, }, }; if (p.plain) { (result['article'] as Record<string, unknown>)['plain'] = stripHtml(body); } return result; } catch (e) { return { success: false, message: String(e) }; } }, }); api.registerTool({ name: 'odoo_knowledge_create', description: '创建知识库文章。顶层文章必须指定 category(workspace=工作区/private=私有)。子文章传 parent_id,权限继承。body 支持 markdown(自动转 HTML)或直接传 HTML。', schema: { type: 'object', properties: { name: { type: 'string', description: '文章标题' }, body: { type: 'string', description: '正文(markdown 或 HTML 皆可,检测到 HTML 标签时原样使用)' }, icon: { type: 'string', description: '图标 emoji' }, parent_id: { type: 'number', description: '父文章 id(创建子文章时必传)' }, category: { type: 'string', enum: ['workspace','private','shared'], description: '顶层文章的分类,默认 private' }, }, }, async handler( p: { name?: string; body?: string; icon?: string; parent_id?: number; category?: 'workspace'|'private'|'shared' }, ctx: Record<string, unknown>, ) { const client = getClient(ctx); if (!client) return notConnected(); try { const htmlBody = p.body ? mdToHtml(p.body) : ''; const id = await client.createKnowledgeArticle({ name: p.name, body: htmlBody, icon: p.icon, parent_id: p.parent_id, category: p.category, }); return { success: true, articleId: id, message: `已创建文章 #idp.name ? `「${p.name」` : ''}` }; } catch (e) { return { success: false, message: String(e) }; } }, }); api.registerTool({ name: 'odoo_knowledge_update', description: '更新知识库文章的标题、图标或正文。body 支持 markdown,传 HTML 时原样保留。想追加内容请用 odoo_knowledge_append。', schema: { type: 'object', properties: { id: { type: 'number', description: '文章 id(必填)' }, name: { type: 'string', description: '新标题' }, body: { type: 'string', description: '新正文(markdown/HTML),覆盖旧内容' }, icon: { type: 'string', description: '新图标 emoji,传空字符串清除' }, }, required: ['id'], }, async handler(p: { id: number; name?: string; body?: string; icon?: string }, ctx: Record<string, unknown>) { const client = getClient(ctx); if (!client) return notConnected(); try { await client.updateKnowledgeArticle(p.id, { name: p.name, body: p.body !== undefined ? mdToHtml(p.body) : undefined, icon: p.icon, }); return { success: true, message: `文章 #p.id 已更新` }; } catch (e) { return { success: false, message: String(e) }; } }, }); api.registerTool({ name: 'odoo_knowledge_append', description: '在现有文章末尾追加一段内容(markdown 或 HTML)。适合"把刚才讨论的结论写进 X 文章"这种追加笔记的场景,不会覆盖原有内容。', schema: { type: 'object', properties: { id: { type: 'number', description: '文章 id(必填)' }, content: { type: 'string', description: '要追加的内容(markdown 或 HTML)' }, with_divider: { type: 'boolean', description: '是否在追加前插入分隔线 <hr>,默认 false' }, }, required: ['id', 'content'], }, async handler(p: { id: number; content: string; with_divider?: boolean }, ctx: Record<string, unknown>) { const client = getClient(ctx); if (!client) return notConnected(); try { const html = (p.with_divider ? '<hr>' : '') + mdToHtml(p.content); await client.appendKnowledgeArticle(p.id, html); return { success: true, message: `已向文章 #p.id 追加 p.content.length 字符` }; } catch (e) { return { success: false, message: String(e) }; } }, }); api.registerTool({ name: 'odoo_knowledge_tree', description: '展示知识库树结构(以 workspace/private 为根,递归最多 N 层)。用于"给我看下知识库长啥样"、"工作区里都有哪些文章"。', schema: { type: 'object', properties: { category: { type: 'string', enum: ['workspace','private','shared'], description: '根分类,默认 workspace' }, max_depth: { type: 'number', description: '最大深度,默认 3' }, max_nodes: { type: 'number', description: '整棵树节点数上限,防爆炸,默认 150' }, }, }, async handler(p: { category?: 'workspace'|'private'|'shared'; max_depth?: number; max_nodes?: number }, ctx: Record<string, unknown>) { const client = getClient(ctx); if (!client) return notConnected(); const maxDepth = p.max_depth ?? 3; const maxNodes = p.max_nodes ?? 150; const category = p.category ?? 'workspace'; try { type Node = { id: number; name: string; icon: string | null; is_favorite: boolean; children: Node[] }; let visited = 0; const walk = async (parentId: number | false, depth: number): Promise<Node[]> => { if (depth > maxDepth || visited >= maxNodes) return []; const items = await client.searchKnowledgeArticles(parentId === false ? { category, only_roots: true, limit: 30 } : { parent_id: parentId as number, limit: 30 }); const nodes: Node[] = []; for (const it of items) { if (visited >= maxNodes) break; visited += 1; nodes.push({ id: it['id'] as number, name: String(it['name'] ?? ''), icon: (it['icon'] as string) || null, is_favorite: Boolean(it['is_user_favorite']), children: (it['has_article_children'] && depth < maxDepth) ? await walk(it['id'] as number, depth + 1) : [], }); } return nodes; }; const tree = await walk(false, 0); return { success: true, category, max_depth: maxDepth, nodes_visited: visited, tree }; } catch (e) { return { success: false, message: String(e) }; } }, }); api.registerTool({ name: 'odoo_knowledge_favorite', description: '切换知识库文章的收藏状态(已收藏→取消,未收藏→收藏)。用于"收藏这篇"、"取消收藏 X"。', schema: { type: 'object', properties: { id: { type: 'number', description: '文章 id(必填)' }, }, required: ['id'], }, async handler(p: { id: number }, ctx: Record<string, unknown>) { const client = getClient(ctx); if (!client) return notConnected(); try { await client.toggleKnowledgeFavorite(p.id); // 回读当前状态返回给用户 const rec = await client.readKnowledgeArticle(p.id); return { success: true, articleId: p.id, is_favorite: rec?.['is_user_favorite'] ?? null, message: `文章 #p.id 收藏状态已切换` }; } catch (e) { return { success: false, message: String(e) }; } }, }); api.registerTool({ name: 'odoo_knowledge_trash', description: '把文章送入回收站(或还原)。默认删除,restore=true 时恢复。辉火云回收站里的文章在 knowledge_article_trash_limit_days(默认 30 天)后才真正删除,所以是安全操作。', schema: { type: 'object', properties: { id: { type: 'number', description: '文章 id(必填)' }, restore: { type: 'boolean', description: 'true=从回收站恢复;默认 false(送入回收站)' }, }, required: ['id'], }, async handler(p: { id: number; restore?: boolean }, ctx: Record<string, unknown>) { const client = getClient(ctx); if (!client) return notConnected(); try { if (p.restore) { await client.restoreKnowledgeArticle(p.id); return { success: true, message: `文章 #p.id 已从回收站恢复` }; } else { await client.trashKnowledgeArticle(p.id); return { success: true, message: `文章 #p.id 已送入回收站(30 天后物理删除)` }; } } catch (e) { return { success: false, message: String(e) }; } }, }); // ══════════════════════════════════════════════════════ // v1.7 — Daily Inbox 闭环(活动/关注者/日历/邮件/附件/批量/撤销) // ══════════════════════════════════════════════════════ // ── 活动闭环 ────────────────────────────────────────── api.registerTool({ name: 'odoo_complete_activity', description: '完成一条活动(闭环)。底层调用 mail.activity.action_feedback:活动从列表移除、反馈写入源记录内部动态。用于"那个催付款的活动做完了"、"把提醒 #X 标记完成,附言:客户已转账"。', schema: { type: 'object', properties: { activity_id: { type: 'number', description: '活动 id(必填,可通过 odoo_list_activities 查询)' }, feedback: { type: 'string', description: '完成反馈(可选,会写入源记录 chatter)' }, }, required: ['activity_id'], }, async handler(p: { activity_id: number; feedback?: string }, ctx: Record<string, unknown>) { const client = getClient(ctx); if (!client) return notConnected(); try { await client.completeActivity(p.activity_id, p.feedback); return { success: true, message: `活动 #p.activity_id 已完成` }; } catch (e) { return { success: false, message: String(e) }; } }, }); api.registerTool({ name: 'odoo_reschedule_activity', description: '把活动改到新日期。用于"那个提醒挪到明天"、"推迟到下周一"。需要先有活动 id。', schema: { type: 'object', properties: { activity_id: { type: 'number', description: '活动 id(必填)' }, date_deadline: { type: 'string', description: '新截止日期 YYYY-MM-DD(必填)' }, }, required: ['activity_id', 'date_deadline'], }, async handler(p: { activity_id: number; date_deadline: string }, ctx: Record<string, unknown>) { const client = getClient(ctx); if (!client) return notConnected(); try { await loggedWrite(client, ctx, { tool: 'odoo_reschedule_activity', model: 'mail.activity', ids: [p.activity_id], values: { date_deadline: p.date_deadline }, summary: `活动 #p.activity_id 改期到 p.date_deadline`, }); return { success: true, message: `活动 #p.activity_id 已改到 p.date_deadline(可用 odoo_undo_last 撤销)` }; } catch (e) { return { success: false, message: String(e) }; } }, }); // ── 关注者 ──────────────────────────────────────────── api.registerTool({ name: 'odoo_follow', description: '关注某条记录(继承 mail.thread 的任何模型:project.task / crm.lead / helpdesk.ticket / sale.order / res.partner 等)。关注后该记录的新消息、活动会出现在 Inbox。不传 partner_ids 时默认关注我自己。', schema: { type: 'object', properties: { model: { type: 'string', description: '数据模型名,如 "project.task"、"crm.lead"(必填)' }, res_id: { type: 'number', description: '记录 id(必填)' }, partner_ids: { type: 'array', items: { type: 'number' }, description: '联系人 id 列表(可选,默认=当前用户的 partner_id)' }, }, required: ['model', 'res_id'], }, async handler(p: { model: string; res_id: number; partner_ids?: number[] }, ctx: Record<string, unknown>) { const client = getClient(ctx); if (!client) return notConnected(); try { await client.followRecord(p.model, p.res_id, p.partner_ids); return { success: true, message: `已关注 p.model #p.res_id` }; } catch (e) { return { success: false, message: String(e) }; } }, }); api.registerTool({ name: 'odoo_unfollow', description: '取消关注某条记录。partner_ids 可选,默认取消我自己。', schema: { type: 'object', properties: { model: { type: 'string', description: '数据模型名(必填)' }, res_id: { type: 'number', description: '记录 id(必填)' }, partner_ids: { type: 'array', items: { type: 'number' }, description: '联系人 id 列表(可选,默认=当前用户的 partner_id)' }, }, required: ['model', 'res_id'], }, async handler(p: { model: string; res_id: number; partner_ids?: number[] }, ctx: Record<string, unknown>) { const client = getClient(ctx); if (!client) return notConnected(); try { await client.unfollowRecord(p.model, p.res_id, p.partner_ids); return { success: true, message: `已取消关注 p.model #p.res_id` }; } catch (e) { return { success: false, message: String(e) }; } }, }); // ── 日历增强 ────────────────────────────────────────── api.registerTool({ name: 'odoo_calendar_today', description: '查今日会议/日程(覆盖 00:00–次日 00:00,含我是组织者或参与者)。用于"今天有什么会"、"今天几点开会"。', schema: { type: 'object', properties: { limit: { type: 'number', description: '上限,默认 30' }, }, }, async handler(p: { limit?: number }, ctx: Record<string, unknown>) { const client = getClient(ctx); if (!client) return notConnected(); try { const events = await client.getCalendarToday(p); return { success: true, count: events.length, events: events.map(e => ({ id: e['id'], name: e['name'], start: e['start'], stop: e['stop'], duration: e['duration'], location: e['location'] || null, allday: e['allday'], organizer: e['user_id'], })), }; } catch (e) { return { success: false, message: String(e) }; } }, }); api.registerTool({ name: 'odoo_update_event', description: '修改日历事件:改时间、地点、标题、描述、参与者。用于"会议挪到下午 3 点"、"把会议地点改到 301 会议室"。', schema: { type: 'object', properties: { event_id: { type: 'number', description: '事件 id(必填)' }, name: { type: 'string', description: '新标题' }, start: { type: 'string', description: '新开始时间 YYYY-MM-DD HH:MM:SS' }, stop: { type: 'string', description: '新结束时间 YYYY-MM-DD HH:MM:SS' }, location: { type: 'string', description: '新地点' }, description: { type: 'string', description: '新描述' }, partner_ids: { type: 'array', items: { type: 'number' }, description: '新参与者 partner id 列表(整份替换)' }, }, required: ['event_id'], }, async handler(p: { event_id: number; name?: string; start?: string; stop?: string; location?: string; description?: string; partner_ids?: number[] }, ctx: Record<string, unknown>) { const client = getClient(ctx); if (!client) return notConnected(); const values: Record<string, unknown> = {}; if (p.name !== undefined) values['name'] = p.name; if (p.start !== undefined) values['start'] = p.start; if (p.stop !== undefined) values['stop'] = p.stop; if (p.location !== undefined) values['location'] = p.location; if (p.description !== undefined) values['description'] = p.description; if (p.partner_ids !== undefined) values['partner_ids'] = [[6, false, p.partner_ids]]; if (Object.keys(values).length === 0) { return { success: true, message: `事件 #p.event_id 无需更新(未提供任何字段)` }; } try { // partner_ids 的旧值比较复杂(many2many),这里还是走 loggedWrite 让大多数字段可撤销; // 如果只是改 partner_ids,快照里会记录原列表的 id 数组,undo 写回也可工作。 await loggedWrite(client, ctx, { tool: 'odoo_update_event', model: 'calendar.event', ids: [p.event_id], values, summary: `更新事件 #p.event_id(字段:Object.keys(values).join(', '))`, }); return { success: true, message: `事件 #p.event_id 已更新(可用 odoo_undo_last 撤销)` }; } catch (e) { return { success: false, message: String(e) }; } }, }); api.registerTool({ name: 'odoo_cancel_event', description: '取消(归档)日历事件:active=false。数据保留在系统中不物理删除,可用 odoo_undo_last 还原。', schema: { type: 'object', properties: { event_id: { type: 'number', description: '事件 id(必填)' } }, required: ['event_id'], }, async handler(p: { event_id: number }, ctx: Record<string, unknown>) { const client = getClient(ctx); if (!client) return notConnected(); try { await loggedWrite(client, ctx, { tool: 'odoo_cancel_event', model: 'calendar.event', ids: [p.event_id], values: { active: false }, summary: `取消事件 #p.event_id`, }); return { success: true, message: `事件 #p.event_id 已取消(active=false,可用 odoo_undo_last 还原)` }; } catch (e) { return { success: false, message: String(e) }; } }, }); // ── 邮件 ────────────────────────────────────────────── api.registerTool({ name: 'odoo_send_email', description: '发送邮件(走 mail.mail,立即 send)。recipients 是收件人邮箱数组;body 支持 markdown(自动转 HTML)或 HTML。可选挂到某条 辉火云记录:res_model + res_id。', schema: { type: 'object', properties: { subject: { type: 'string', description: '邮件主题(必填)' }, body: { type: 'string', description: '邮件正文(markdown 或 HTML,必填)' }, recipients: { type: 'array', items: { type: 'string' }, description: '收件人邮箱列表(必填)' }, cc: { type: 'array', items: { type: 'string' }, description: '抄送邮箱列表' }, bcc: { type: 'array', items: { type: 'string' }, description: '密送邮箱列表' }, res_model: { type: 'string', description: '关联模型(可选,如 "crm.lead")' }, res_id: { type: 'number', description: '关联记录 id(可选)' }, attachment_ids: { type: 'array', items: { type: 'number' }, description: 'ir.attachment id 列表(可选,先用 odoo_attach_file 或 odoo_document_upload 得到 id)' }, }, required: ['subject', 'body', 'recipients'], }, async handler( p: { subject: string; body: string; recipients: string[]; cc?: string[]; bcc?: string[]; res_model?: string; res_id?: number; attachment_ids?: number[] }, ctx: Record<string, unknown>, ) { const client = getClient(ctx); if (!client) return notConnected(); if (!p.recipients || p.recipients.length === 0) { return { success: false, message: '至少需要一个收件人(recipients)' }; } try { const id = await client.sendEmail({ subject: p.subject, bodyHtml: mdToHtml(p.body), recipients: p.recipients, cc: p.cc, bcc: p.bcc, res_model: p.res_model, res_id: p.res_id, attachment_ids: p.attachment_ids, }); return { success: true, mail_id: id, message: `邮件已发送到 p.recipients.join(', ')(mail.mail #id)` }; } catch (e) { return { success: false, message: String(e) }; } }, }); api.registerTool({ name: 'odoo_email_templates', description: '列出邮件模板(mail.template)。可按 model 过滤,如"我有哪些商机相关的邮件模板"。', schema: { type: 'object', properties: { model: { type: 'string', description: '限定模板的 model 字段(如 "crm.lead")' }, keyword: { type: 'string', description: '按模板名模糊匹配' }, limit: { type: 'number', description: '上限,默认 50' }, }, }, async handler(p: { model?: string; keyword?: string; limit?: number }, ctx: Record<string, unknown>) { const client = getClient(ctx); if (!client) return notConnected(); try { const templates = await client.getEmailTemplates(p); return { success: true, count: templates.length, templates: templates.map(t => ({ id: t['id'], name: t['name'], model: t['model'], subject: t['subject'], email_to: t['email_to'] || null, use_default_to: t['use_default_to'], })), }; } catch (e) { return { success: false, message: String(e) }; } }, }); api.registerTool({ name: 'odoo_email_from_template', description: '用模板发邮件(mail.template.send_mail,force_send=true)。用于"用那个报价单模板发给客户"。template_id 从 odoo_email_templates 取。', schema: { type: 'object', properties: { template_id: { type: 'number', description: '模板 id(必填)' }, res_id: { type: 'number', description: '目标记录 id,模板会渲染该记录的字段(必填,模板的 model 决定类型)' }, email_values: { type: 'object', description: '可选的字段覆盖(如 {email_to: "[email protected]"})' }, }, required: ['template_id', 'res_id'], }, async handler(p: { template_id: number; res_id: number; email_values?: Record<string, unknown> }, ctx: Record<string, unknown>) { const client = getClient(ctx); if (!client) return notConnected(); try { const result = await client.sendEmailFromTemplate(p.template_id, p.res_id, { force_send: true, email_values: p.email_values, }); return { success: true, result, message: `模板 #p.template_id 已对 res_id=p.res_id 发送` }; } catch (e) { return { success: false, message: String(e) }; } }, }); // ── 附件 / 文档 ─────────────────────────────────────── api.registerTool({ name: 'odoo_attach_file', description: '把本地文件上传为辉火云附件(ir.attachment)并挂到指定记录。用于"把这份合同 PDF 附到商机 #42"。path 传本地绝对路径,插件会读文件并 base64 编码。大文件(>5MB)请走 odoo_document_upload。', schema: { type: 'object', properties: { path: { type: 'string', description: '本地文件绝对路径(必填)' }, res_model: { type: 'string', description: '数据模型,如 "crm.lead"(必填)' }, res_id: { type: 'number', description: '记录 id(必填)' }, name: { type: 'string', description: '附件显示名(可选,默认=文件名)' }, mimetype: { type: 'string', description: 'MIME 类型(可选,默认 application/octet-stream)' }, }, required: ['path', 'res_model', 'res_id'], }, async handler(p: { path: string; res_model: string; res_id: number; name?: string; mimetype?: string }, ctx: Record<string, unknown>) { const client = getClient(ctx); if (!client) return notConnected(); try { const stat = statSync(p.path); if (stat.size > 10 * 1024 * 1024) { return { success: false, message: `文件 p.path 大小 (stat.size / 1024 / 1024).toFixed(1)MB,超过附件上限 10MB,请用 odoo_document_upload 传到文档应用` }; } const buf = readFileSync(p.path); const datas = buf.toString('base64'); const id = await client.attachFile({ res_model: p.res_model, res_id: p.res_id, name: p.name || basename(p.path), datas_base64: datas, mimetype: p.mimetype, }); return { success: true, attachment_id: id, size: stat.size, message: `附件 #id 已挂到 p.res_model #p.res_id` }; } catch (e) { return { success: false, message: String(e) }; } }, }); api.registerTool({ name: 'odoo_list_attachments', description: '列出某条记录挂着的所有附件。用于"商机 #42 有哪些附件"、"那个合同有没有上传"。', schema: { type: 'object', properties: { model: { type: 'string', description: '数据模型名(必填)' }, res_id: { type: 'number', description: '记录 id(必填)' }, limit: { type: 'number', description: '上限,默认 50' }, }, required: ['model', 'res_id'], }, async handler(p: { model: string; res_id: number; limit?: number }, ctx: Record<string, unknown>) { const client = getClient(ctx); if (!client) return notConnected(); try { const atts = await client.listAttachments(p.model, p.res_id, { limit: p.limit }); const info = client.getSessionInfo(); return { success: true, count: atts.length, attachments: atts.map(a => ({ id: a['id'], name: a['name'], mimetype: a['mimetype'], size_bytes: a['file_size'], created: a['create_date'], created_by: a['create_uid'], download_url: `info.url/web/content/a['id']?download=true`, })), }; } catch (e) { return { success: false, message: String(e) }; } }, }); api.registerTool({ name: 'odoo_document_upload', description: '上传文件到辉火云文档应用(documents.document),可指定 folder_id 归档。用于"把这份交接文档归到项目资料夹"。附件上限 20MB。', schema: { type: 'object', properties: { path: { type: 'string', description: '本地文件绝对路径(必填)' }, name: { type: 'string', description: '显示名(默认=文件名)' }, folder_id: { type: 'number', description: '归档文件夹 id(可选)' }, tag_ids: { type: 'array', items: { type: 'number' }, description: '标签 id 列表(可选)' }, mimetype: { type: 'string', description: 'MIME 类型(可选)' }, }, required: ['path'], }, async handler(p: { path: string; name?: string; folder_id?: number; tag_ids?: number[]; mimetype?: string }, ctx: Record<string, unknown>) { const client = getClient(ctx); if (!client) return notConnected(); try { const stat = statSync(p.path); if (stat.size > 20 * 1024 * 1024) { return { success: false, message: `文件 p.path 大小 (stat.size / 1024 / 1024).toFixed(1)MB,超过上限 20MB` }; } const buf = readFileSync(p.path); const datas = buf.toString('base64'); const id = await client.uploadDocument({ name: p.name || basename(p.path), datas_base64: datas, mimetype: p.mimetype, folder_id: p.folder_id, tag_ids: p.tag_ids, }); return { success: true, document_id: id, size: stat.size, message: `文档 #id 已上传到 documents.document` }; } catch (e) { return { success: false, message: String(e) }; } }, }); // ── 批量更新(带变更日志)───────────────────────────── api.registerTool({ name: 'odoo_bulk_update', description: '对同一模型的多条记录做同一组字段更新,写入变更日志,可用 odoo_undo_last 整体撤销。用于"把这批任务都改成已完成"、"这 10 个商机都挪到下一阶段"。谨慎:values 会对所有 ids 生效。', schema: { type: 'object', properties: { model: { type: 'string', description: '数据模型名,如 "project.task"(必填)' }, ids: { type: 'array', items: { type: 'number' }, description: '记录 id 列表(必填,至少 1 条)' }, values: { type: 'object', description: '要写入的字段对象,如 {stage_id: 5, priority: "2"}(必填,至少 1 个字段)' }, }, required: ['model', 'ids', 'values'], }, async handler(p: { model: string; ids: number[]; values: Record<string, unknown> }, ctx: Record<string, unknown>) { const client = getClient(ctx); if (!client) return notConnected(); if (!p.ids || p.ids.length === 0) return { success: false, message: 'ids 不能为空' }; if (!p.values || Object.keys(p.values).length === 0) return { success: false, message: 'values 不能为空' }; if (p.ids.length > 200) return { success: false, message: `一次最多 200 条,当前 p.ids.length 条,拆分后再试` }; try { await loggedWrite(client, ctx, { tool: 'odoo_bulk_update', model: p.model, ids: p.ids, values: p.values, summary: `批量更新 p.model × p.ids.length 条(字段:Object.keys(p.values).join(', '))`, }); return { success: true, updated: p.ids.length, model: p.model, message: `已更新 p.ids.length 条 p.model(可用 odoo_undo_last 整体撤销)`, }; } catch (e) { return { success: false, message: String(e) }; } }, }); // ── 撤销上一步 ──────────────────────────────────────── api.registerTool({ name: 'odoo_undo_last', description: '撤销上一步可逆的 write(任务/商机/活动改期/事件更新/批量更新/…)。dry_run=true 时只预览不执行;list=true 时列出最近 10 条可撤销变更不执行。注意:只能撤销通过本插件工具做的 write,create/unlink 不在此范围。', schema: { type: 'object', properties: { dry_run: { type: 'boolean', description: 'true=只预览将撤销什么,不真正执行' }, list: { type: 'boolean', description: 'true=列出最近 10 条可撤销变更,不执行任何撤销' }, }, }, async handler(p: { dry_run?: boolean; list?: boolean }, ctx: Record<string, unknown>) { const client = getClient(ctx); if (!client) return notConnected(); const aid = getAgentId(ctx); if (p.list) { const recent = mutationLog.list(aid, { limit: 10, reversibleOnly: true }); return { success: true, count: recent.length, entries: recent.map(e => ({ id: e.id, tool: e.tool, model: e.model, ids: e.ids, timestamp: e.timestamp, summary: e.summary, })), message: recent.length === 0 ? '没有可撤销的变更' : `最近 recent.length 条可撤销变更`, }; } const last = mutationLog.findLastReversible(aid); if (!last) return { success: false, message: '没有可撤销的变更(mutation-log 为空或全部已撤销)' }; if (p.dry_run) { return { success: true, preview: true, entry: { id: last.id, tool: last.tool, model: last.model, ids: last.ids, summary: last.summary, timestamp: last.timestamp, will_write_back: last.before, }, message: `将撤销:last.summary`, }; } // 真正撤销:按 id 把 before 快照写回 const errors: string[] = []; let ok = 0; for (const snap of last.before) { const id = snap['id'] as number; const { id: _skip, ...values } = snap; void _skip; try { await client.write(last.model, [id], values); ok++; } catch (e) { errors.push(`#id: String(e)`); } } if (ok > 0) mutationLog.markUndone(aid, last.id); return { success: errors.length === 0, undone: ok, failed: errors.length, errors: errors.length > 0 ? errors : undefined, entry_id: last.id, message: errors.length === 0 ? `已撤销:last.summary(ok 条记录还原到之前的值)` : `部分撤销失败:ok/last.before.length 成功,errors.length 失败`, }; }, }); // ══════════════════════════════════════════════════════ // v1.8 — Project / Ticket / Chatter 闭环 // ══════════════════════════════════════════════════════ // ── Chatter 沟通 ────────────────────────────────────── api.registerTool({ name: 'odoo_message_post', description: '在任意 mail.thread 记录(任务/商机/工单/订单/客户等)的 chatter 发评论。会触发邮件通知所有关注者。body 支持 markdown 或 HTML。用于"给客户在商机下留个进度说明"、"在工单里回客户一句"。内部记录(不发邮件)请用 odoo_message_log。', schema: { type: 'object', properties: { model: { type: 'string', description: '数据模型名(必填),如 "crm.lead"、"project.task"、"helpdesk.ticket"' }, res_id: { type: 'number', description: '记录 id(必填)' }, body: { type: 'string', description: '消息正文(markdown 或 HTML,必填)' }, subject: { type: 'string', description: '主题(可选,邮件通知时显示)' }, partner_ids: { type: 'array', items: { type: 'number' }, description: '额外 @提及 / 通知的 partner id 列表(可选)' }, attachment_ids: { type: 'array', items: { type: 'number' }, description: 'ir.attachment id 列表(先用 odoo_attach_file 得到 id)' }, }, required: ['model', 'res_id', 'body'], }, async handler( p: { model: string; res_id: number; body: string; subject?: string; partner_ids?: number[]; attachment_ids?: number[] }, ctx: Record<string, unknown>, ) { const client = getClient(ctx); if (!client) return notConnected(); try { const id = await client.postMessage(p.model, p.res_id, { bodyHtml: mdToHtml(p.body), subject: p.subject, partner_ids: p.partner_ids, attachment_ids: p.attachment_ids, as_log: false, }); return { success: true, message_id: id, message: `已在 p.model #p.res_id 发评论(mail.message #id,followers 会收到邮件通知)` }; } catch (e) { return { success: false, message: String(e) }; } }, }); api.registerTool({ name: 'odoo_message_log', description: '在记录 chatter 留内部记录(log note,不发邮件)。用于"给这条记录加个备注"、"记录一下今天的沟通要点"。与 odoo_message_post 的区别:log 不通知 followers。', schema: { type: 'object', properties: { model: { type: 'string', description: '数据模型名(必填)' }, res_id: { type: 'number', description: '记录 id(必填)' }, body: { type: 'string', description: '备注内容(markdown 或 HTML,必填)' }, subject: { type: 'string', description: '标题(可选)' }, attachment_ids: { type: 'array', items: { type: 'number' }, description: 'ir.attachment id 列表(可选)' }, }, required: ['model', 'res_id', 'body'], }, async handler( p: { model: string; res_id: number; body: string; subject?: string; attachment_ids?: number[] }, ctx: Record<string, unknown>, ) { const client = getClient(ctx); if (!client) return notConnected(); try { const id = await client.postMessage(p.model, p.res_id, { bodyHtml: mdToHtml(p.body), subject: p.subject, attachment_ids: p.attachment_ids, as_log: true, }); return { success: true, message_id: id, message: `已在 p.model #p.res_id 留内部记录(#id,不通知 followers)` }; } catch (e) { return { success: false, message: String(e) }; } }, }); api.registerTool({ name: 'odoo_message_history', description: '读取某条记录的 chatter 沟通历史(最新在前)。用于"这个商机跟进过什么"、"看看工单 #X 有哪些往来"。默认过滤掉系统通知。', schema: { type: 'object', properties: { model: { type: 'string', description: '数据模型名(必填)' }, res_id: { type: 'number', description: '记录 id(必填)' }, limit: { type: 'number', description: '上限,默认 20' }, include_notifications: { type: 'boolean', description: 'true=包含系统通知(自动关注、阶段变更等),默认 false' }, }, required: ['model', 'res_id'], }, async handler(p: { model: string; res_id: number; limit?: number; include_notifications?: boolean }, ctx: Record<string, unknown>) { const client = getClient(ctx); if (!client) return notConnected(); try { const msgs = await client.getMessageHistory(p.model, p.res_id, p); return { success: true, count: msgs.length, messages: msgs.map(m => ({ id: m['id'], date: m['date'], author: m['author_id'], email_from: m['email_from'] || null, subject: m['subject'] || null, type: m['message_type'], // 只给纯文本摘要,HTML 全文前端需要再查(避免单次响应爆炸) summary: stripHtml(String(m['body'] ?? '')), })), }; } catch (e) { return { success: false, message: String(e) }; } }, }); // ── 项目 ────────────────────────────────────────────── api.registerTool({ name: 'odoo_project_create', description: '创建新项目。用于"开个新项目叫 XX"、"给客户 Y 建个实施项目"。privacy_visibility 决定可见范围:followers=仅关注者/employees=全体员工(默认)/portal=门户用户。', schema: { type: 'object', properties: { name: { type: 'string', description: '项目名(必填)' }, partner_id: { type: 'number', description: '客户 partner id(可选)' }, user_id: { type: 'number', description: '项目负责人 user id(可选,默认=当前用户)' }, date_start: { type: 'string', description: '开始日期 YYYY-MM-DD' }, date: { type: 'string', description: '结束日期 YYYY-MM-DD' }, description: { type: 'string', description: '项目描述' }, privacy_visibility: { type: 'string', enum: ['followers', 'employees', 'portal'], description: '可见范围' }, }, required: ['name'], }, async handler( p: { name: string; partner_id?: number; user_id?: number; date_start?: string; date?: string; description?: string; privacy_visibility?: 'followers' | 'employees' | 'portal' }, ctx: Record<string, unknown>, ) { const client = getClient(ctx); if (!client) return notConnected(); try { const id = await client.createProject(p); return { success: true, project_id: id, message: `项目 #id(p.name)已创建` }; } catch (e) { return { success: false, message: String(e) }; } }, }); api.registerTool({ name: 'odoo_project_update', description: '更新项目字段:名称/负责人/起止日期/描述/归档等。支持 odoo_undo_last 撤销。', schema: { type: 'object', properties: { project_id: { type: 'number', description: '项目 id(必填)' }, name: { type: 'string', description: '新名称' }, user_id: { type: 'number', description: '新负责人 user id' }, partner_id: { type: 'number', description: '新客户 partner id' }, date_start: { type: 'string', description: '新开始日期 YYYY-MM-DD' }, date: { type: 'string', description: '新结束日期 YYYY-MM-DD' }, description: { type: 'string', description: '新描述' }, active: { type: 'boolean', description: 'active=false 归档项目' }, }, required: ['project_id'], }, async handler( p: { project_id: number; name?: string; user_id?: number; partner_id?: number; date_start?: string; date?: string; description?: string; active?: boolean }, ctx: Record<string, unknown>, ) { const client = getClient(ctx); if (!client) return notConnected(); const values: Record<string, unknown> = {}; if (p.name !== undefined) values['name'] = p.name; if (p.user_id !== undefined) values['user_id'] = p.user_id; if (p.partner_id !== undefined) values['partner_id'] = p.partner_id; if (p.date_start !== undefined) values['date_start'] = p.date_start || false; if (p.date !== undefined) values['date'] = p.date || false; if (p.description !== undefined) values['description'] = p.description; if (p.active !== undefined) values['active'] = p.active; if (Object.keys(values).length === 0) { return { success: true, message: `项目 #p.project_id 无需更新(未提供任何字段)` }; } try { await loggedWrite(client, ctx, { tool: 'odoo_project_update', model: 'project.project', ids: [p.project_id], values, summary: `更新项目 #p.project_id(字段:Object.keys(values).join(', '))`, }); return { success: true, message: `项目 #p.project_id 已更新(可用 odoo_undo_last 撤销)` }; } catch (e) { return { success: false, message: String(e) }; } }, }); // ── 里程碑 ──────────────────────────────────────────── api.registerTool({ name: 'odoo_milestone_create', description: '为项目新建里程碑。用于"给项目 X 加一个 9 月底的交付里程碑"。', schema: { type: 'object', properties: { name: { type: 'string', description: '里程碑名称(必填)' }, project_id: { type: 'number', description: '所属项目 id(必填)' }, deadline: { type: 'string', description: '截止日期 YYYY-MM-DD' }, }, required: ['name', 'project_id'], }, async handler(p: { name: string; project_id: number; deadline?: string }, ctx: Record<string, unknown>) { const client = getClient(ctx); if (!client) return notConnected(); try { const id = await client.createMilestone(p); return { success: true, milestone_id: id, message: `里程碑 #id(p.name)已创建于项目 #p.project_id` }; } catch (e) { return { success: false, message: String(e) }; } }, }); api.registerTool({ name: 'odoo_milestone_done', description: '把里程碑标记为完成(写 is_reached=true + reached_date=today)。支持 odoo_undo_last 撤销。', schema: { type: 'object', properties: { milestone_id: { type: 'number', description: '里程碑 id(必填)' } }, required: ['milestone_id'], }, async handler(p: { milestone_id: number }, ctx: Record<string, unknown>) { const client = getClient(ctx); if (!client) return notConnected(); try { await loggedWrite(client, ctx, { tool: 'odoo_milestone_done', model: 'project.milestone', ids: [p.milestone_id], values: { is_reached: true, reached_date: today() }, summary: `里程碑 #p.milestone_id 标记为已完成`, }); return { success: true, message: `里程碑 #p.milestone_id 已完成(reached_date=today(),可用 odoo_undo_last 撤销)` }; } catch (e) { return { success: false, message: String(e) }; } }, }); // ── 任务指派 ────────────────────────────────────────── api.registerTool({ name: 'odoo_task_assign', description: '指派一条或多条任务给一个/一批人(整份替换 user_ids)。用于"把这批任务都交给张三"、"加上李四一起做"。支持 odoo_undo_last 撤销。', schema: { type: 'object', properties: { task_ids: { type: 'array', items: { type: 'number' }, description: '任务 id 列表(必填,至少 1 条)' }, user_ids: { type: 'array', items: { type: 'number' }, description: 'user id 列表(必填,整份替换)' }, }, required: ['task_ids', 'user_ids'], }, async handler(p: { task_ids: number[]; user_ids: number[] }, ctx: Record<string, unknown>) { const client = getClient(ctx); if (!client) return notConnected(); if (!p.task_ids || p.task_ids.length === 0) return { success: false, message: 'task_ids 不能为空' }; if (!p.user_ids) return { success: false, message: 'user_ids 必填(传空数组表示清空)' }; try { await loggedWrite(client, ctx, { tool: 'odoo_task_assign', model: 'project.task', ids: p.task_ids, values: { user_ids: [[6, false, p.user_ids]] }, summary: `指派 p.task_ids.length 条任务给 user(p.user_ids.join(','))`, }); return { success: true, updated: p.task_ids.length, message: `p.task_ids.length 条任务已指派(可用 odoo_undo_last 撤销)` }; } catch (e) { return { success: false, message: String(e) }; } }, }); // ── 工单闭环 ────────────────────────────────────────── api.registerTool({ name: 'odoo_ticket_update', description: '更新工单字段:名称/阶段/优先级/负责人/看板状态/截止。支持 odoo_undo_last 撤销。', schema: { type: 'object', properties: { ticket_id: { type: 'number', description: '工单 id(必填)' }, name: { type: 'string', description: '新主题' }, stage_id: { type: 'number', description: '新阶段 id' }, priority: { type: 'string', enum: ['0', '1', '2', '3'], description: '0=普通 1=中 2=高 3=紧急' }, user_id: { type: 'number', description: '新负责人 user id' }, kanban_state: { type: 'string', enum: ['normal', 'done', 'blocked'], description: '看板状态' }, sla_deadline: { type: 'string', description: '新 SLA 截止时间 YYYY-MM-DD HH:MM:SS' }, }, required: ['ticket_id'], }, async handler( p: { ticket_id: number; name?: string; stage_id?: number; priority?: string; user_id?: number; kanban_state?: string; sla_deadline?: string }, ctx: Record<string, unknown>, ) { const client = getClient(ctx); if (!client) return notConnected(); const values: Record<string, unknown> = {}; if (p.name !== undefined) values['name'] = p.name; if (p.stage_id !== undefined) values['stage_id'] = p.stage_id; if (p.priority !== undefined) values['priority'] = p.priority; if (p.user_id !== undefined) values['user_id'] = p.user_id; if (p.kanban_state !== undefined) values['kanban_state'] = p.kanban_state; if (p.sla_deadline !== undefined) values['sla_deadline'] = p.sla_deadline || false; if (Object.keys(values).length === 0) { return { success: true, message: `工单 #p.ticket_id 无需更新(未提供任何字段)` }; } try { await loggedWrite(client, ctx, { tool: 'odoo_ticket_update', model: 'helpdesk.ticket', ids: [p.ticket_id], values, summary: `更新工单 #p.ticket_id(字段:Object.keys(values).join(', '))`, }); return { success: true, message: `工单 #p.ticket_id 已更新(可用 odoo_undo_last 撤销)` }; } catch (e) { return { success: false, message: String(e) }; } }, }); api.registerTool({ name: 'odoo_ticket_close', description: '关闭工单:把 stage_id 改到该团队 fold=true 的第一个阶段(= 关闭列)。如果找不到关闭阶段会报错让用户先建一个。支持 odoo_undo_last 撤销。', schema: { type: 'object', properties: { ticket_id: { type: 'number', description: '工单 id(必填)' }, }, required: ['ticket_id'], }, async handler(p: { ticket_id: number }, ctx: Record<string, unknown>) { const client = getClient(ctx); if (!client) return notConnected(); try { // 先读 ticket 拿 team_id,再在 team 下找 fold=true 的阶段 const tks = await client.read('helpdesk.ticket', [p.ticket_id], ['team_id', 'stage_id']); const t = tks[0]; if (!t) return { success: false, message: `工单 #p.ticket_id 不存在` }; const teamRef = t['team_id']; const teamId = Array.isArray(teamRef) && typeof teamRef[0] === 'number' ? teamRef[0] : undefined; const closedStage = await client.findHelpdeskClosedStage(teamId); if (!closedStage) { return { success: false, message: `团队 teamId ?? '(unset)' 下找不到 fold=true 的关闭阶段。请先到辉火云客服应用里给这个团队建一个"已完成"阶段(fold=true)。` }; } await loggedWrite(client, ctx, { tool: 'odoo_ticket_close', model: 'helpdesk.ticket', ids: [p.ticket_id], values: { stage_id: closedStage['id'] as number, kanban_state: 'done' }, summary: `关闭工单 #p.ticket_id(stage_id → String(closedStage['name']))`, }); return { success: true, stage: closedStage['name'], message: `工单 #p.ticket_id 已关闭(stage=String(closedStage['name']),可用 odoo_undo_last 撤销)` }; } catch (e) { return { success: false, message: String(e) }; } }, }); api.registerTool({ name: 'odoo_ticket_assign', description: '指派工单给某位工程师。支持 odoo_undo_last 撤销。', schema: { type: 'object', properties: { ticket_id: { type: 'number', description: '工单 id(必填)' }, user_id: { type: 'number', description: '新负责人 user id(必填)' }, }, required: ['ticket_id', 'user_id'], }, async handler(p: { ticket_id: number; user_id: number }, ctx: Record<string, unknown>) { const client = getClient(ctx); if (!client) return notConnected(); try { await loggedWrite(client, ctx, { tool: 'odoo_ticket_assign', model: 'helpdesk.ticket', ids: [p.ticket_id], values: { user_id: p.user_id }, summary: `指派工单 #p.ticket_id 给 user #p.user_id`, }); return { success: true, message: `工单 #p.ticket_id 已指派给 user #p.user_id(可用 odoo_undo_last 撤销)` }; } catch (e) { return { success: false, message: String(e) }; } }, }); // ── 审批动作 ────────────────────────────────────────── api.registerTool({ name: 'odoo_approval_approve', description: '作为审批人批准一条审批请求(调 approval.request.action_approve)。用于"批了这条请假/采购申请"。注意:只能操作你本人是审批人的请求。', schema: { type: 'object', properties: { request_id: { type: 'number', description: '审批请求 id(必填)' } }, required: ['request_id'], }, async handler(p: { request_id: number }, ctx: Record<string, unknown>) { const client = getClient(ctx); if (!client) return notConnected(); try { await client.approveApprovalRequest(p.request_id); return { success: true, message: `审批请求 #p.request_id 已批准` }; } catch (e) { return { success: false, message: String(e) }; } }, }); api.registerTool({ name: 'odoo_approval_refuse', description: '作为审批人拒绝审批请求(调 approval.request.action_refuse)。用于"驳回这条申请"。', schema: { type: 'object', properties: { request_id: { type: 'number', description: '审批请求 id(必填)' } }, required: ['request_id'], }, async handler(p: { request_id: number }, ctx: Record<string, unknown>) { const client = getClient(ctx); if (!client) return notConnected(); try { await client.refuseApprovalRequest(p.request_id); return { success: true, message: `审批请求 #p.request_id 已拒绝` }; } catch (e) { return { success: false, message: String(e) }; } }, }); api.logger.info('[odoo] 77 个工具已注册(v1.10 — 共享凭据 + 跨渠道复用)'); } // ── 注册 before_prompt_build 钩子 ───────────────────────────────────────────── function registerHooks(api: OpenClawPluginApi) { api.on('before_prompt_build', async (_event: unknown, ctx: unknown) => { const aid = (ctx as { agentId?: string } | undefined)?.agentId?.trim() ?? 'default'; const todayStr = today(); const tomorrowStr = tomorrow(); // 尝试从持久化恢复连接(per-agent) let client = odooClients.get(aid); if (!client?.isAuthenticated()) { client = await tryRestoreAgent(api, aid) ?? undefined; } if (!client?.isAuthenticated()) { return { appendSystemContext: ` ## 辉火云企业套件插件 — 未连接 > **品牌口径(硬规则)**:对外沟通时一律称"辉火云企业套件"或"辉火云"。 > 不得出现"Odoo"、"欧度"或任何第三方 ERP 商标;内部模型名(如 project.task) > 和工具名(odoo_xxx)是技术标识符,仅在调试说明里出现,不要在面向用户的 > 正文里直接朗读。 > **共享凭据规则(v1.10)**:组织内只需有任意一个人配过一次凭据(默认会保存 > 为【组织共享凭据】),后续任何渠道(企微/钉钉/飞书)的任何成员 @ 机器人时, > **自动复用同一套凭据,禁止再次询问 URL/用户名/密码**。本会话之所以走到 > "未连接",是因为还没有任何人配过共享凭据,也没有 manifest 静态预填。 插件已加载,当前 agent (\`aid\`) 尚未连接到辉火云企业套件,且组织内也没有任何人配过共享凭据。当用户提到任何 ERP 相关操作(待办、任务、商机、客户、订单、工单、发票、会议、提醒、项目、工时、库存、员工、审批等),你应该: 1. **首先说明**:"看起来咱们组织还没有人配过辉火云连接。配一次之后,所有同事 @ 我都能用,不需要再输入。" 2. 询问: - **公司系统地址**(URL):例如 https://www.huo15.com - **用户名**(邮箱或登录名) - **密码** 3. **数据库名不需要主动询问** — odoo_connect 会自动检测(单库自动选、多库返列表) 4. 收集到 URL、用户名、密码后,调用 **odoo_connect**(默认 \`private=false\`,即保存为共享凭据 — 推荐) 5. 仅当用户明确说"只给我自己用"或"我不想让别人用"时,才传 \`private: true\` 6. **重要**:如果用户在群里 @ 你,更要解释清楚"配一次全员通用",避免每个成员都被反复问凭据 示例引导话术:"要使用辉火云企业套件,配一次咱们组织所有同事就都能用了。请告诉我:1) 系统地址 2) 用户名 3) 密码"`.trim(), }; } const info = client.getSessionInfo(); const credSource = configManager.getActiveSource(aid); const credSourceLabel = credSource === 'agent' ? '当前会话专属凭据' : credSource === 'shared' ? '组织共享凭据(全员复用)' : credSource === 'legacy' ? '历史遗留凭据' : 'manifest 静态预填'; return { appendSystemContext: ` ## 辉火云企业套件 已连接 > **品牌口径(硬规则)**:对外沟通时一律称"辉火云企业套件"或"辉火云"。 > 不得出现"Odoo"、"欧度"或任何第三方 ERP 商标。工具名(odoo_*)和技术模型名 > (如 project.task)仅在调试说明里出现,面向用户的正文请用中文业务术语 > ("任务"/"商机"/"工单"/"内部动态"而非"chatter"等)。 > **共享凭据规则(v1.10)**:当前会话用的凭据是【credSourceLabel】。 > 如果是【组织共享凭据】,意味着任何渠道(企微/钉钉/飞书)的任何成员 @ 机器人都 > 自动用这套,**绝对不要在群里再向用户询问 URL/用户名/密码**。 > 如果用户主动要换凭据,告诉他们调用 odoo_connect(默认仍是共享,private=true 则只覆盖自己)。 > 如果用户疑惑"为什么没问我密码",调用 odoo_whoami 给他看清当前来源。 **用户:** info.username(uid: info.uid)| **系统:** info.url | **agent:** aid **凭据来源:** credSourceLabel **今日:** todayStr | **明日:** tomorrowStr ### 工具速查(共 77 个) **基础**:odoo_connect · odoo_status · odoo_disconnect · odoo_whoami **任务**:odoo_create_task · odoo_list_tasks · odoo_update_task · odoo_get_task_stages · odoo_task_assign **活动**:odoo_create_activity · odoo_list_activities · odoo_activity_types · odoo_complete_activity · odoo_reschedule_activity **日历**:odoo_create_event · odoo_calendar_today · odoo_update_event · odoo_cancel_event **消息**:odoo_get_messages · odoo_send_message · odoo_message_post · odoo_message_log · odoo_message_history **邮件**:odoo_send_email · odoo_email_templates · odoo_email_from_template **附件**:odoo_attach_file · odoo_list_attachments · odoo_document_upload **关注者**:odoo_follow · odoo_unfollow **搜索**:odoo_search **CRM** :odoo_crm_pipeline · odoo_crm_create · odoo_crm_update · odoo_crm_won · odoo_crm_lost **项目**:odoo_project_overview · odoo_timesheet_log · odoo_project_create · odoo_project_update · odoo_milestone_create · odoo_milestone_done **销售**:odoo_sale_orders · odoo_purchase_orders **客服**:odoo_tickets · odoo_ticket_create · odoo_ticket_update · odoo_ticket_close · odoo_ticket_assign **财务**:odoo_invoices **联系人**:odoo_contacts · odoo_contact_create **库存**:odoo_stock_levels · odoo_stock_pickings **HR** :odoo_employees · odoo_leaves · odoo_attendances **审批**:odoo_approvals · odoo_approval_approve · odoo_approval_refuse **助手**:odoo_daily_briefing **通知基座**:odoo_notification_status · odoo_notification_channels · odoo_notification_test · odoo_notification_prefs · odoo_notification_reply **知识库**:odoo_knowledge_search · odoo_knowledge_read · odoo_knowledge_create · odoo_knowledge_update · odoo_knowledge_append · odoo_knowledge_tree · odoo_knowledge_favorite · odoo_knowledge_trash **批量/撤销**:odoo_bulk_update · odoo_undo_last ### 自然语言 → 工具映射(直接调用,无需询问) | 用户说 | 调用工具 | |--------|---------| | 今天有什么工作 / 每日概况 | **odoo_daily_briefing** | | 帮我写个待办 / 创建任务 | **odoo_create_task** | | 今日截止任务 / 今天要做什么 | **odoo_list_tasks**(today_only=true) | | 把任务 #X 标记完成 | **odoo_update_task**(stage_id=已完成阶段ID) | | 提醒我… | **odoo_create_activity** | | 安排会议 / 约个时间 | **odoo_create_event** | | 查看商机 / 销售管道 | **odoo_crm_pipeline** | | 新建商机 | **odoo_crm_create** | | 这个商机赢了 / 标记赢单 | **odoo_crm_won** | | 商机丢了 / 标记输单 | **odoo_crm_lost** | | 项目进展 / 里程碑进度 | **odoo_project_overview** | | 记录工时 X 小时 | **odoo_timesheet_log** | | 查看工单 / 待处理问题 | **odoo_tickets** | | 新建工单 / 提交问题 | **odoo_ticket_create** | | 查发票 / 逾期应收 | **odoo_invoices**(overdue_only=true) | | 查销售订单 | **odoo_sale_orders** | | 查采购订单 | **odoo_purchase_orders** | | 查客户 / 找联系人 | **odoo_contacts** | | 添加新客户 | **odoo_contact_create** | | 查库存 / 产品还有多少 | **odoo_stock_levels** | | 调拨单 / 出入库 | **odoo_stock_pickings** | | 查员工 / 某部门有谁 | **odoo_employees** | | 请假记录 | **odoo_leaves** | | 考勤 / 打卡 | **odoo_attendances** | | 审批 / 待审批 | **odoo_approvals** | | 查看消息 / 邮件通知 | **odoo_get_messages** | | 查活动类型 | **odoo_activity_types** | | 通知推送状态 / 企微/钉钉连上没 | **odoo_notification_status** | | 测试一下通知推送 | **odoo_notification_test** | | 列出已接入的渠道 | **odoo_notification_channels** | | 关闭通知 / 别发待办了 / 夜里静音 / 只接收紧急 | **odoo_notification_prefs** | | 模拟一次企微/钉钉回复写回系统 | **odoo_notification_reply** | | 找一下关于 X 的知识库文章 / 搜知识库 | **odoo_knowledge_search** | | 把这篇文章读给我 / 文章 #X 写了什么 | **odoo_knowledge_read** | | 新建知识库文章 / 记一下这个到知识库 | **odoo_knowledge_create** | | 改一下这篇文章的标题/正文 | **odoo_knowledge_update** | | 追加到文章 X 末尾 / 往文章里补一段 | **odoo_knowledge_append** | | 知识库长啥样 / 工作区里都有哪些文章 | **odoo_knowledge_tree** | | 收藏这篇 / 取消收藏 | **odoo_knowledge_favorite** | | 把这篇文章扔进回收站 / 删除文章 | **odoo_knowledge_trash** | | 那个活动做完了 / 把提醒 #X 标记完成 | **odoo_complete_activity** | | 活动挪到明天 / 提醒改到下周 | **odoo_reschedule_activity** | | 我要关注这条任务/商机 / 加我进关注 | **odoo_follow** | | 取消关注 / 别再给我推这条的变化 | **odoo_unfollow** | | 今天有什么会 / 查今日日程 | **odoo_calendar_today** | | 会议改时间 / 会议挪到 X 点 / 换会议室 | **odoo_update_event** | | 取消这场会 / 把会议归档 | **odoo_cancel_event** | | 发封邮件给客户 / 给 X 写封邮件 | **odoo_send_email** | | 有哪些邮件模板 / 找商机相关的模板 | **odoo_email_templates** | | 用模板发 / 用报价单模板发给他 | **odoo_email_from_template** | | 把这份合同附到商机 / 上传附件 | **odoo_attach_file** | | 这个商机/工单有哪些附件 | **odoo_list_attachments** | | 上传到文档库 / 归档到文件夹 | **odoo_document_upload** | | 把这批任务都改成完成 / 批量改阶段 | **odoo_bulk_update** | | 撤销上一步 / 撤回刚才那个 / 改错了 | **odoo_undo_last** | | 给商机/工单/任务下面留个进度说明 / 在 chatter 回一句 | **odoo_message_post** | | 记一下备注 / 留个内部记录(不发邮件) | **odoo_message_log** | | 这个记录都聊过什么 / 看看跟进历史 | **odoo_message_history** | | 开个新项目 / 新建项目 | **odoo_project_create** | | 改项目的负责人/日期/描述 | **odoo_project_update** | | 给项目加个里程碑 / 新建里程碑 | **odoo_milestone_create** | | 里程碑达成了 / 标记完成 | **odoo_milestone_done** | | 把这批任务都交给张三 / 指派任务 | **odoo_task_assign** | | 改工单的阶段/优先级/负责人 | **odoo_ticket_update** | | 关闭工单 / 工单处理完了 | **odoo_ticket_close** | | 把工单派给 X | **odoo_ticket_assign** | | 批这条 / 审批通过 | **odoo_approval_approve** | | 驳回 / 拒绝这条申请 | **odoo_approval_refuse** | | 查看当前用什么凭据 / 我的连接是哪套 / 为什么没问我密码 | **odoo_whoami** | | 断开连接 / 退出系统 | **odoo_disconnect** | ### 常用数据模型(技术内部标识,不在正文中朗读) project.task · project.project · project.milestone · mail.activity · calendar.event · crm.lead · crm.stage · sale.order · purchase.order · helpdesk.ticket · account.move · res.partner · hr.employee · hr.leave · hr.attendance · stock.quant · stock.picking · account.analytic.line · approval.request · planning.slot · knowledge.article · mail.template · mail.mail · mail.followers · ir.attachment · documents.document ### 日期 & 字段规范 - date 字段:YYYY-MM-DD,今天=todayStr,明天=tomorrowStr - datetime 字段:YYYY-MM-DD HH:MM:SS,默认上午 09:00:00,下午 14:00:00 - 优先级:0=普通 1=中 2高 3=紧急 - Many2one 读取返回 [id, "名称"],写入时传数字 id - 商机阶段可通过 odoo_search(model="crm.stage") 查询 - 活动类型可通过 odoo_activity_types 查询 `.trim(), }; }); api.logger.info('[odoo] before_prompt_build 钩子已注册(per-agent 隔离)'); } // ── 处理后端更新通知 ────────────────────────────────────────────────────────── /** * 辉火云企业套件事件 → NotificationEnvelope → 全局通知总线 * * 流程: * 1. 应用 per-agent 偏好(enabled / kinds / minPriority / quietHours) * 2. 缓存 envelope 溯源信息(供入站回复时定位 辉火云记录) * 3. publish 到 bus,渠道插件决定投递细节 * * 本方法不感知具体渠道。 */ function handleOdooUpdates(api: OpenClawPluginApi, updates: SyncUpdate[], aid: string) { if (updates.length === 0) return; const prefs = prefsManager.load(aid); const odooUrl = odooClients.get(aid)?.getSessionInfo().url; let dispatched = 0; let filtered = 0; for (const u of updates) { const env = toEnvelope(u, aid, odooUrl); const decision = shouldDeliver(env, prefs); if (!decision.deliver) { filtered += 1; api.logger.debug?.(`[odoo] agent=aid 丢弃 env.id: decision.reason`); continue; } // 记录 envelope → 原记录 映射,以便回复时可以写回 chatter if (env.origin?.model && env.origin?.resId) { envelopeCache.set(env.id, { agentId: aid, model: env.origin.model, resId: env.origin.resId, }); } notificationBus.publish(env).catch(err => { api.logger.error(`[odoo] bus publish 失败 env.id: String(err)`); }); dispatched += 1; } const subs = notificationBus.subscriberCount(); const transports = notificationBus.listTransports().map(t => t.name).join(',') || '无'; api.logger.info( `[odoo] agent=aid 发布 dispatched/updates.length 条(过滤 filtered,订阅者=subs,渠道=transports)`, ); } // ── 处理入站回复(渠道 → 辉火云内部动态)────────────────────────────────────── async function handleInboundReply(api: OpenClawPluginApi, reply: InboundReply): Promise<void> { const origin = envelopeCache.get(reply.envelopeId); if (!origin) { api.logger.warn?.(`[odoo] 入站回复找不到 envelope 溯源: reply.envelopeId(来自 reply.channel)`); return; } if (!origin.model || !origin.resId) { api.logger.warn?.(`[odoo] envelope reply.envelopeId 无可写回目标(缺 model/resId)`); return; } const client = odooClients.get(origin.agentId); if (!client?.isAuthenticated()) { api.logger.warn?.(`[odoo] agent=origin.agentId 未连接,忽略回复 reply.envelopeId`); return; } const bodyHtml = reply.html ? reply.html : `<p>escapeHtml(reply.body)</p>`; const subject = `来自 reply.channelreply.fromUser ? ` / ${reply.fromUser` : ''} 的回复`; try { const id = await client.call('mail.message', 'create', [{ model: origin.model, res_id: origin.resId, body: bodyHtml, subject, message_type: 'comment', subtype_xmlid: 'mail.mt_comment', }]); api.logger.info(`[odoo] 入站回复已写入 origin.model#origin.resId(mail.message String(id))`); } catch (e) { api.logger.error(`[odoo] 写回辉火云内部动态失败: String(e)`); } } function escapeHtml(s: string): string { return s .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } FILE:openclaw.plugin.json { "id": "odoo", "name": "火一五·辉火云企业套件插件", "description": "自然语言操作辉火云企业套件,77 个工具覆盖任务与指派、活动闭环(完成/改期)、日历(今日/改期/取消)、邮件(直发/模板)、内部动态 chatter(发评论/内部记录/历史)、附件/文档、关注者、CRM、项目/里程碑(新建/更新/标记完成)、工单闭环(改/关/派)、审批动作(通过/拒绝)、财务、库存、HR、知识库、批量更新、撤销上一步;内置跨渠道通知基座(NotificationBus):企微/钉钉/飞书订阅辉火云消息与活动提醒,per-agent 偏好过滤(kind/优先级/静音时段),入站回复自动写回内部动态;v1.10 共享凭据:组织内配一次,全员跨渠道(企微/钉钉/飞书)@龙虾 自动复用同一套凭据,禁止重复询问;新增 odoo_whoami 查看当前凭据来源", "version": "1.10.0", "configSchema": { "type": "object", "additionalProperties": false, "properties": { "odoo": { "type": "object", "description": "辉火云企业套件 连接配置", "additionalProperties": false, "properties": { "url": { "type": "string", "description": "辉火云企业套件 系统地址,如 https://www.huo15.com" }, "db": { "type": "string", "description": "数据库名称,如 huo15" }, "username": { "type": "string", "description": "用户名(邮箱或登录名)" }, "password": { "type": "string", "description": "密码" } }, "required": ["url", "db", "username", "password"] }, "sync": { "type": "object", "description": "通知同步配置", "additionalProperties": false, "properties": { "enabled": { "type": "boolean", "description": "是否启用后台通知同步", "default": true }, "intervalSeconds": { "type": "number", "description": "轮询间隔(秒),默认 30", "default": 30 }, "channels": { "type": "array", "description": "需要同步的通知类型", "items": { "type": "string", "enum": ["todo", "activity", "message", "email", "calendar"] }, "default": ["todo", "activity", "message"] } } } } } } FILE:package.json { "name": "@huo15/huo15-huihuoyun-odoo", "version": "1.10.0", "description": "火一五·辉火云企业套件插件 — 自然语言操作辉火云企业套件,实施经理助手,77 个工具覆盖任务、活动闭环、日历、邮件(含模板)、内部动态 chatter(发评论/内部记录/历史)、附件/文档、关注者、CRM、项目/里程碑治理、工单闭环(改/关/派)、审批通过/拒绝、财务、销售、知识库、批量更新、撤销;跨渠道通知基座(企微/钉钉/飞书)+ per-agent 偏好 + 入站回复写回内部动态 + v1.10 共享凭据:组织内配一次,全员跨渠道复用,禁止重复询问密码", "type": "module", "main": "index.ts", "openclaw": { "extensions": [ "./index.ts" ], "build": { "openclawVersion": "2026.4.10" }, "compat": { "pluginApi": ">=2026.2.24" } }, "keywords": [ "openclaw", "plugin", "huohuoyun", "huihuoyun", "erp", "huo15", "crm", "helpdesk", "invoice" ], "author": "jobzhao15", "license": "MIT", "peerDependencies": { "openclaw": ">=2026.2.24" }, "dependencies": {}, "devDependencies": { "typescript": "^5.5.0" }, "files": [ "index.ts", "openclaw.plugin.json", "src/**/*", "SKILL.md" ], "publishConfig": { "access": "public" }, "repository": { "type": "git", "url": "https://cnb.cool/huo15/tools/huo15-huihuoyun-odoo" } } FILE:src/modules/config-manager.ts /** * 辉火云企业套件插件配置管理器 — v3(共享凭据 + per-agent override) * * **v1.10 模型变化**: * - 默认凭据是"共享"的,存在 `default.json`;企微/钉钉/飞书任何渠道的任何 * agent 在没有自己独立配置时都自动 fallback 到共享凭据。 * - 某个 agent 想用自己的独立凭据,调用 odoo_connect 时传 private=true, * 配置会写入 `{agentId}.json`,只对该 agent 生效(优先级高于 shared)。 * * **Fallback 链**(load 时自动走一遍): * 1) `{agentId}.json` — 该 agent 显式独立配置(最高优先级) * 2) `default.json` — 共享凭据(首次 connect 默认写这里) * 3) `pluginConfig.odoo` — 由 openclaw.plugin.json 注入的静态配置(manifest 预填) * 4) legacy `odoo-config.json` — 旧版单文件,向下兼容 * * 第 3 层(pluginConfig)不在本模块处理,由调用方传入。load() 只走 1/2/4。 * * 存储路径: ~/.openclaw/plugin-configs/odoo/{agentId}.json */ import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync, readdirSync } from 'node:fs'; import { join } from 'node:path'; import type { OdooPluginConfig, OdooConfig } from '../types/index.js'; const CONFIG_BASE = '.openclaw/plugin-configs/odoo'; const LEGACY_FILE = '.openclaw/plugin-configs/odoo-config.json'; const SHARED_AGENT_ID = 'default'; export type ConfigSource = 'agent' | 'shared' | 'legacy' | 'none'; function sanitizeAgentId(agentId: string): string { return agentId.replace(/[^a-zA-Z0-9_\-]/g, '_').substring(0, 128) || SHARED_AGENT_ID; } export class ConfigManager { private baseDir: string; private legacyPath: string; constructor(homeDir: string = process.env['HOME'] ?? '/root') { this.baseDir = join(homeDir, CONFIG_BASE); this.legacyPath = join(homeDir, LEGACY_FILE); } /** 获取 agent 配置文件路径 */ private agentPath(agentId: string): string { return join(this.baseDir, `sanitizeAgentId(agentId).json`); } private sharedPath(): string { return join(this.baseDir, `SHARED_AGENT_ID.json`); } private readJson(path: string): OdooPluginConfig | null { try { if (!existsSync(path)) return null; return JSON.parse(readFileSync(path, 'utf-8')) as OdooPluginConfig; } catch { return null; } } /** * 加载配置(带 fallback chain) * * 优先级:agent 独立 → 共享(default)→ legacy 单文件 */ load(agentId: string = SHARED_AGENT_ID): OdooPluginConfig | null { const aid = sanitizeAgentId(agentId); // 1) 该 agent 自己的独立配置 if (aid !== SHARED_AGENT_ID) { const own = this.readJson(this.agentPath(aid)); if (own?.odoo) return own; } // 2) 共享(default)配置 —— 即使 aid === default 也走这里 const shared = this.readJson(this.sharedPath()); if (shared?.odoo) return shared; // 3) legacy 兼容 const legacy = this.readJson(this.legacyPath); if (legacy?.odoo) return legacy; return null; } /** * 判断 agent 是否有自己独立的配置文件(不走 fallback) * 用于 odoo_disconnect 判断"断开的是独立还是共享" */ hasOwnConfig(agentId: string): boolean { const aid = sanitizeAgentId(agentId); if (aid === SHARED_AGENT_ID) return false; return existsSync(this.agentPath(aid)); } /** * 判断共享配置是否存在 */ hasSharedConfig(): boolean { return existsSync(this.sharedPath()) || existsSync(this.legacyPath); } /** * 返回当前 agent 实际命中的配置来源 * agent — 命中 {agentId}.json * shared — 命中 default.json * legacy — 命中 odoo-config.json * none — 都没有(可能需要 pluginConfig 兜底,由调用方处理) */ getActiveSource(agentId: string = SHARED_AGENT_ID): ConfigSource { const aid = sanitizeAgentId(agentId); if (aid !== SHARED_AGENT_ID && existsSync(this.agentPath(aid))) return 'agent'; if (existsSync(this.sharedPath())) return 'shared'; if (existsSync(this.legacyPath)) return 'legacy'; return 'none'; } /** * 保存完整配置到指定文件 */ private save(config: OdooPluginConfig, targetPath: string): boolean { try { if (!existsSync(this.baseDir)) { mkdirSync(this.baseDir, { recursive: true }); } writeFileSync(targetPath, JSON.stringify(config, null, 2), 'utf-8'); return true; } catch { return false; } } /** * 保存 Odoo 连接凭据 * * @param odooConfig 要保存的凭据 * @param agentId 当前 agent(决定 scope=agent 时写哪个文件) * @param scope 'shared'(默认)写到 default.json,全员共用; * 'agent' 写到 {agentId}.json,仅当前 agent */ saveOdooConfig( odooConfig: OdooConfig, agentId: string = SHARED_AGENT_ID, scope: 'shared' | 'agent' = 'shared', ): boolean { const targetPath = scope === 'agent' ? this.agentPath(agentId) : this.sharedPath(); // 合并现有配置(保留 sync 等其他字段) const existing = this.readJson(targetPath) ?? {}; existing.odoo = odooConfig; return this.save(existing, targetPath); } /** * 清除当前 agent 的独立配置文件(如果存在) * 返回是否真删了东西 */ clearOwnConfig(agentId: string): boolean { try { const path = this.agentPath(agentId); if (existsSync(path)) { unlinkSync(path); return true; } return false; } catch { return false; } } /** * 清除共享凭据 —— 危险操作,会让所有无独立配置的 agent 断开 */ clearSharedConfig(): boolean { let cleared = false; try { if (existsSync(this.sharedPath())) { unlinkSync(this.sharedPath()); cleared = true; } } catch { /* noop */ } try { if (existsSync(this.legacyPath)) { unlinkSync(this.legacyPath); cleared = true; } } catch { /* noop */ } return cleared; } /** * 旧接口保留兼容 —— 默认行为是清除当前 agent 自己的配置(不碰共享) */ clear(agentId: string = SHARED_AGENT_ID): boolean { if (sanitizeAgentId(agentId) === SHARED_AGENT_ID) return this.clearSharedConfig(); return this.clearOwnConfig(agentId); } /** 列出所有已保存配置的 agentId(包括共享 default) */ listAgents(): string[] { try { if (!existsSync(this.baseDir)) return []; return readdirSync(this.baseDir) .filter(f => f.endsWith('.json')) .map(f => f.replace(/\.json$/, '')); } catch { return []; } } } FILE:src/modules/envelope-cache.ts /** * 信封溯源缓存 —— envelope.id → { agentId, model, resId } * * 入站回复流程:渠道回发一个 InboundReply{envelopeId, body}, * Odoo 插件要据此定位到原 Odoo 记录(model + res_id),写 chatter。 * 信封体积大,不想每条都全量留存;只缓存路由所需的最小元信息。 * * 策略:Map 的插入顺序天然 FIFO;超过 maxSize 时淘汰最旧的条目, * 同时按 TTL 过期失效。对实施经理场景足够(通常用户在几分钟内回复)。 */ export interface EnvelopeOrigin { agentId: string; model?: string; resId?: number; cachedAt: number; } export class EnvelopeCache { private map = new Map<string, EnvelopeOrigin>(); private readonly maxSize: number; private readonly ttlMs: number; constructor(maxSize: number = 500, ttlMs: number = 24 * 60 * 60 * 1000) { this.maxSize = maxSize; this.ttlMs = ttlMs; } set(envelopeId: string, origin: Omit<EnvelopeOrigin, 'cachedAt'>): void { if (this.map.has(envelopeId)) this.map.delete(envelopeId); this.map.set(envelopeId, { ...origin, cachedAt: Date.now() }); while (this.map.size > this.maxSize) { const first = this.map.keys().next(); if (first.done) break; this.map.delete(first.value); } } get(envelopeId: string): EnvelopeOrigin | undefined { const hit = this.map.get(envelopeId); if (!hit) return undefined; if (Date.now() - hit.cachedAt > this.ttlMs) { this.map.delete(envelopeId); return undefined; } return hit; } size(): number { return this.map.size; } clear(): void { this.map.clear(); } } FILE:src/modules/mutation-log.ts /** * 变更日志 —— per-agent 环形缓冲,用于"撤销上一步" * * 设计要点: * - 只记录"可逆的 write":记录 write 前的字段快照 + write 的新值, * undo 时把旧值写回去。 * - 不记录 create / unlink:create 的"撤销"在 Odoo 上是 unlink(不安全, * 可能触发级联),unlink 的"撤销"需要 restore(不一定存在)。这两类 * 通过 active=false 软删/恢复实现,但语义外部明确,不走 undo 路径。 * - 环形缓冲:超过 maxEntries 丢掉最旧的一条。 * - 存储:JSON 文件在 ~/.openclaw/plugin-configs/odoo/{agentId}.ops.log.json * - 并发:单进程 best-effort 同步写;per-agent 文件隔离,冲突窗口很窄, * 再说 undo 是低频操作,不上锁。 */ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { homedir } from 'node:os'; import { join } from 'node:path'; export interface MutationEntry { id: string; timestamp: number; tool: string; // e.g. "odoo_update_task", "odoo_bulk_update" model: string; // e.g. "project.task" ids: number[]; // 受影响记录的 id 列表 before: Record<string, unknown>[]; // per-id 变更前快照,顺序与 ids 对应 after: Record<string, unknown>; // write 时传入的新值 reversible: boolean; // 只有捕获到 before 快照时才算可逆 undone: boolean; summary: string; // 供用户确认用的一行描述 } export interface MutationLogOptions { maxEntries?: number; baseDir?: string; } export class MutationLog { private readonly maxEntries: number; private readonly baseDir: string; constructor(options: MutationLogOptions = {}) { this.maxEntries = options.maxEntries ?? 50; this.baseDir = options.baseDir ?? join(homedir(), '.openclaw', 'plugin-configs', 'odoo'); } private logPath(agentId: string): string { return join(this.baseDir, `agentId.ops.log.json`); } private load(agentId: string): MutationEntry[] { const path = this.logPath(agentId); if (!existsSync(path)) return []; try { const raw = readFileSync(path, 'utf8'); const parsed = JSON.parse(raw) as unknown; if (Array.isArray(parsed)) return parsed as MutationEntry[]; return []; } catch { return []; } } private save(agentId: string, entries: MutationEntry[]): void { if (!existsSync(this.baseDir)) mkdirSync(this.baseDir, { recursive: true }); const trimmed = entries.slice(-this.maxEntries); writeFileSync(this.logPath(agentId), JSON.stringify(trimmed, null, 2), 'utf8'); } /** 追加一条变更日志 */ append(agentId: string, entry: Omit<MutationEntry, 'id' | 'timestamp' | 'undone'>): MutationEntry { const full: MutationEntry = { ...entry, id: `m_Date.now().toString(36)_Math.random().toString(36).substring(2, 8)`, timestamp: Date.now(), undone: false, }; const entries = this.load(agentId); entries.push(full); this.save(agentId, entries); return full; } /** 列出最近的 N 条(最新在前),可选只显示可撤销的 */ list(agentId: string, options: { limit?: number; reversibleOnly?: boolean; includeUndone?: boolean } = {}): MutationEntry[] { const entries = this.load(agentId); let filtered = entries; if (options.reversibleOnly) filtered = filtered.filter(e => e.reversible); if (!options.includeUndone) filtered = filtered.filter(e => !e.undone); filtered = filtered.slice().reverse(); if (options.limit) filtered = filtered.slice(0, options.limit); return filtered; } /** 找最后一条可撤销条目(最新优先) */ findLastReversible(agentId: string): MutationEntry | null { const entries = this.load(agentId); for (let i = entries.length - 1; i >= 0; i--) { const e = entries[i]!; if (e.reversible && !e.undone) return e; } return null; } /** 标记某条已撤销(写回旧值后调用) */ markUndone(agentId: string, entryId: string): void { const entries = this.load(agentId); const found = entries.find(e => e.id === entryId); if (!found) return; found.undone = true; this.save(agentId, entries); } /** 清空日志(调试/测试用) */ clear(agentId: string): void { this.save(agentId, []); } } /** 共享单例(足够,因为 per-agent 文件已经隔离了) */ export const mutationLog = new MutationLog(); FILE:src/modules/notification-bus.ts /** * 通知总线(基座) —— 跨插件 pub/sub * * 设计目标: * - 本插件(欧度)只负责把 Odoo 事件打包成 NotificationEnvelope 并 publish * - 渠道插件(企微 / 钉钉 / 飞书 / webhook 等)以两种方式之一接收: * 1) subscribe(handler) —— 收到全部 envelope,自己决定投递策略 * 2) registerTransport({ name, deliver }) —— 由本插件或其他协调方显式 * 调用 bus.deliver(envelope, { channel: name, ... }) 时才触发 * - 基座不感知任何具体渠道;新增渠道无需改动本插件 * * 单例通过 `globalThis[Symbol.for('openclaw.huo15.notification-bus.v1')]` 共享, * 即便多个插件被分别打包成独立 ESM,只要运行在同一 Node 进程就能对接。 */ import type { NotificationEnvelope, ChannelTransport, ChannelTarget, DeliveryResult, InboundReply, ReplyResult, } from '../types/index.js'; const BUS_KEY = Symbol.for('openclaw.huo15.notification-bus.v1'); export type EnvelopeHandler = (envelope: NotificationEnvelope) => void | Promise<void>; export type ReplyHandler = (reply: InboundReply) => void | Promise<void>; export class NotificationBus { private handlers = new Set<EnvelopeHandler>(); private replyHandlers = new Set<ReplyHandler>(); private transports = new Map<string, ChannelTransport>(); /** 订阅所有 envelope —— 返回取消订阅函数 */ subscribe(handler: EnvelopeHandler): () => void { this.handlers.add(handler); return () => { this.handlers.delete(handler); }; } /** 当前订阅者数量 */ subscriberCount(): number { return this.handlers.size; } /** 注册渠道 transport —— 返回注销函数 */ registerTransport(transport: ChannelTransport): () => void { this.transports.set(transport.name, transport); return () => { const current = this.transports.get(transport.name); if (current === transport) this.transports.delete(transport.name); }; } /** 已注册的渠道列表 */ listTransports(): Array<{ name: string; description?: string }> { return [...this.transports.values()].map(t => ({ name: t.name, description: t.description, })); } hasTransport(name: string): boolean { return this.transports.has(name); } /** * 发布 envelope —— 并行通知所有订阅者 * * 单个订阅者抛错不影响其他订阅者;本方法不抛错。 */ async publish(envelope: NotificationEnvelope): Promise<void> { if (this.handlers.size === 0) return; const results = await Promise.allSettled( [...this.handlers].map(h => Promise.resolve().then(() => h(envelope))), ); for (const r of results) { if (r.status === 'rejected') { const reason = r.reason instanceof Error ? r.reason.message : String(r.reason); // 只在 stderr 留痕,避免影响主流程;真正的错误处理由订阅者自己负责 // eslint-disable-next-line no-console console.warn('[notification-bus] subscriber failed:', reason); } } } /** * 显式调用某个 transport 投递 envelope 到具体 target。 * 注意:publish() 已经把消息广播给所有 subscriber,大多数渠道直接 subscribe 即可; * 只有当你需要「本插件知道该发给谁、但具体怎么发由渠道实现」时才用 deliver。 */ async deliver(envelope: NotificationEnvelope, target: ChannelTarget): Promise<DeliveryResult> { const transport = this.transports.get(target.channel); if (!transport) { return { ok: false, channel: target.channel, error: `transport "target.channel" not registered`, }; } try { return await transport.deliver(envelope, target); } catch (e) { return { ok: false, channel: target.channel, error: e instanceof Error ? e.message : String(e), }; } } /* ═════════════════════════ 入站回复(渠道 → 源系统) ═════════════════════════ */ /** * 订阅入站回复 —— 返回取消订阅函数。 * 通常由源系统(Odoo)插件订阅;渠道插件不需要订阅自己发的回复。 */ onReply(handler: ReplyHandler): () => void { this.replyHandlers.add(handler); return () => { this.replyHandlers.delete(handler); }; } replySubscriberCount(): number { return this.replyHandlers.size; } /** * 渠道插件收到用户回复时调用此方法。 * 总线把 reply 并行发给所有 onReply 订阅者(通常只有 Odoo 插件一个)。 */ async reply(reply: InboundReply): Promise<ReplyResult> { if (this.replyHandlers.size === 0) { return { ok: false, handled: 0, errors: ['no reply handler registered'] }; } const errors: string[] = []; let handled = 0; const results = await Promise.allSettled( [...this.replyHandlers].map(h => Promise.resolve().then(() => h(reply))), ); for (const r of results) { if (r.status === 'fulfilled') { handled += 1; } else { errors.push(r.reason instanceof Error ? r.reason.message : String(r.reason)); } } return { ok: errors.length === 0, handled, errors: errors.length ? errors : undefined }; } } function resolveBus(): NotificationBus { const g = globalThis as Record<symbol, unknown>; let bus = g[BUS_KEY] as NotificationBus | undefined; if (!bus) { bus = new NotificationBus(); g[BUS_KEY] = bus; } return bus; } /** 全局单例 —— 所有 openclaw 插件共享同一个总线 */ export const notificationBus: NotificationBus = resolveBus(); FILE:src/modules/notification-poller.ts /** * Odoo 通知轮询服务 * * 定期轮询 Odoo,检测待办/活动/消息/邮件/日历的变化, * 将更新通知 OpenClaw 用户。 * * 改进点(相比 dev 版): * - 消息去重使用 highWaterMessageId(高水位线 id),不依赖 write_date 时间戳 * - 每轮 poll 前调用 ensureAuthenticated(),应对 Odoo 服务重启 * - 支持可选的 email / calendar 通道 */ import type { OdooClient } from './odoo-client.js'; import type { SyncUpdate } from '../types/index.js'; import { today, daysFromNow } from '../utils/date-utils.js'; type DomainItem = string | [string, string, unknown]; type Domain = DomainItem[]; export type NotificationCallback = (updates: SyncUpdate[]) => void; export class NotificationPoller { private client: OdooClient; private intervalId: ReturnType<typeof setInterval> | null = null; private callback: NotificationCallback | null = null; private intervalSeconds: number = 30; private channels: string[] = ['todo', 'activity', 'message']; private lastCheck: Date = new Date(); // 去重状态 private seenTaskIds: Set<number> = new Set(); private highWaterMessageId: number = 0; // mail.message 高水位线 private highWaterEmailId: number = 0; // mail.notification 高水位线 private seenCalendarIds: Set<number> = new Set(); constructor(client: OdooClient) { this.client = client; } /** 启动轮询 */ start( callback: NotificationCallback, options: { intervalSeconds?: number; channels?: string[] } = {}, ): void { this.callback = callback; this.intervalSeconds = options.intervalSeconds ?? 30; this.channels = options.channels ?? ['todo', 'activity', 'message']; // 立即执行一次初始同步(静默,不推送,仅建立基准水位) this.initBaseline().catch(() => void 0); this.intervalId = setInterval(() => { this.poll().catch(() => void 0); }, this.intervalSeconds * 1000); } /** 停止轮询 */ stop(): void { if (this.intervalId) { clearInterval(this.intervalId); this.intervalId = null; } this.callback = null; } /** 手动触发一次同步(返回发现的更新) */ async poll(): Promise<SyncUpdate[]> { try { await this.client.ensureAuthenticated(); } catch { return []; } const updates: SyncUpdate[] = []; for (const channel of this.channels) { try { switch (channel) { case 'todo': updates.push(...await this.checkTodos()); break; case 'activity': updates.push(...await this.checkActivities()); break; case 'message': updates.push(...await this.checkMessages()); break; case 'email': updates.push(...await this.checkEmails()); break; case 'calendar': updates.push(...await this.checkCalendar()); break; } } catch { // 单个通道失败不影响其他通道 } } this.lastCheck = new Date(); if (updates.length > 0 && this.callback) { this.callback(updates); } return updates; } /** 获取轮询状态 */ getStatus(): { running: boolean; lastCheck: Date; channels: string[] } { return { running: this.intervalId !== null, lastCheck: this.lastCheck, channels: this.channels, }; } // ==================== 私有方法 ==================== /** * 初始化基准水位线(首次启动时静默读取当前状态,后续只推送新增内容) */ private async initBaseline(): Promise<void> { try { await this.client.ensureAuthenticated(); } catch { return; } // 建立 task 基准 try { const uid = this.client.getUid() ?? 0; const tasks = await this.client.searchRead( 'project.task', [['user_ids', 'in', [uid]], ['active', '=', true]], ['id'], { limit: 200 }, ); for (const t of tasks.records) { this.seenTaskIds.add(t['id'] as number); } } catch { /* ignore */ } // 建立 message 高水位 try { const msgs = await this.client.searchRead( 'mail.message', [['message_type', '!=', 'notification']], ['id'], { limit: 1, order: 'id desc' }, ); if (msgs.records.length > 0) { this.highWaterMessageId = msgs.records[0]!['id'] as number; } } catch { /* ignore */ } // 建立 email 高水位 try { const emails = await this.client.searchRead( 'mail.notification', [['notification_type', '=', 'inbox']], ['id'], { limit: 1, order: 'id desc' }, ); if (emails.records.length > 0) { this.highWaterEmailId = emails.records[0]!['id'] as number; } } catch { /* ignore */ } // 建立 calendar 基准 try { const events = await this.client.searchRead( 'calendar.event', [['start', '>=', today()]], ['id'], { limit: 100 }, ); for (const e of events.records) { this.seenCalendarIds.add(e['id'] as number); } } catch { /* ignore */ } this.lastCheck = new Date(); } /** 检查待办任务新增/更新 */ private async checkTodos(): Promise<SyncUpdate[]> { const updates: SyncUpdate[] = []; const uid = this.client.getUid() ?? 0; const lastCheckStr = this.lastCheck.toISOString().replace('T', ' ').substring(0, 19); const domain: Domain = [ ['user_ids', 'in', [uid]], ['active', '=', true], ['write_date', '>', lastCheckStr], ]; const tasks = await this.client.searchRead( 'project.task', domain, ['id', 'name', 'stage_id', 'date_deadline', 'priority'], { limit: 30 }, ); for (const task of tasks.records) { const taskId = task['id'] as number; const action = this.seenTaskIds.has(taskId) ? 'update' : 'create'; this.seenTaskIds.add(taskId); updates.push({ type: 'todo', action, id: taskId, data: task, timestamp: Date.now() }); } return updates; } /** 检查今日及逾期活动提醒 */ private async checkActivities(): Promise<SyncUpdate[]> { const updates: SyncUpdate[] = []; const uid = this.client.getUid() ?? 0; const activities = await this.client.searchRead( 'mail.activity', [ ['user_id', '=', uid], ['date_deadline', '<=', today()], ['date_deadline', '>=', daysFromNow(-1)], ], ['id', 'summary', 'date_deadline', 'activity_type_id', 'res_model', 'res_id'], { limit: 20 }, ); for (const activity of activities.records) { updates.push({ type: 'activity', action: 'due', id: activity['id'] as number, data: activity, timestamp: Date.now(), }); } return updates; } /** 检查新消息(使用 id 高水位线去重) */ private async checkMessages(): Promise<SyncUpdate[]> { const updates: SyncUpdate[] = []; const messages = await this.client.searchRead( 'mail.message', [ ['message_type', '!=', 'notification'], ['id', '>', this.highWaterMessageId], ], ['id', 'subject', 'body', 'author_id', 'date', 'model', 'res_id'], { limit: 20, order: 'id asc' }, ); for (const message of messages.records) { const msgId = message['id'] as number; updates.push({ type: 'message', action: 'create', id: msgId, data: message, timestamp: Date.now() }); if (msgId > this.highWaterMessageId) { this.highWaterMessageId = msgId; } } return updates; } /** 检查新邮件通知(使用 id 高水位线去重) */ private async checkEmails(): Promise<SyncUpdate[]> { const updates: SyncUpdate[] = []; const emails = await this.client.searchRead( 'mail.notification', [ ['notification_type', '=', 'inbox'], ['is_read', '=', false], ['id', '>', this.highWaterEmailId], ], ['id', 'mail_message_id', 'notification_status', 'is_read'], { limit: 20, order: 'id asc' }, ); for (const email of emails.records) { const emailId = email['id'] as number; updates.push({ type: 'email', action: 'create', id: emailId, data: email, timestamp: Date.now() }); if (emailId > this.highWaterEmailId) { this.highWaterEmailId = emailId; } } return updates; } /** 检查日历事件(今明两天范围内的新事件) */ private async checkCalendar(): Promise<SyncUpdate[]> { const updates: SyncUpdate[] = []; const events = await this.client.searchRead( 'calendar.event', [ ['start', '>=', today()], ['start', '<=', daysFromNow(1)], ], ['id', 'name', 'start', 'stop', 'partner_ids'], { limit: 20 }, ); for (const event of events.records) { const eventId = event['id'] as number; const action = this.seenCalendarIds.has(eventId) ? 'update' : 'create'; this.seenCalendarIds.add(eventId); updates.push({ type: 'calendar', action, id: eventId, data: event, timestamp: Date.now() }); } return updates; } } FILE:src/modules/notification-prefs.ts /** * 每 agent 通知偏好 —— 持久化 + 默认值 + 过滤判定 * * 与 ConfigManager 同一个目录体系(~/.openclaw/plugin-configs/odoo/), * 但文件名加 `.prefs.json` 后缀,不与凭据混在同一文件里。 * * 过滤发生在 Odoo 插件一侧(生产者),bus 依然无感知。 */ import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync } from 'node:fs'; import { join } from 'node:path'; import type { NotificationPreferences, NotificationKind, NotificationPriority, NotificationEnvelope, } from '../types/index.js'; const CONFIG_BASE = '.openclaw/plugin-configs/odoo'; function sanitize(agentId: string): string { return agentId.replace(/[^a-zA-Z0-9_\-]/g, '_').substring(0, 128) || 'default'; } export const DEFAULT_PREFS: NotificationPreferences = { enabled: true, kinds: [], // 空 = 全放行 minPriority: 'low', // 全通过 updatedAt: 0, }; const PRIORITY_RANK: Record<NotificationPriority, number> = { low: 0, normal: 1, high: 2, urgent: 3, }; export class PrefsManager { private baseDir: string; constructor(homeDir: string = process.env['HOME'] ?? '/root') { this.baseDir = join(homeDir, CONFIG_BASE); } private pathFor(agentId: string): string { return join(this.baseDir, `sanitize(agentId).prefs.json`); } load(agentId: string = 'default'): NotificationPreferences { try { const p = this.pathFor(agentId); if (!existsSync(p)) return { ...DEFAULT_PREFS }; const parsed = JSON.parse(readFileSync(p, 'utf-8')) as Partial<NotificationPreferences>; return { ...DEFAULT_PREFS, ...parsed }; } catch { return { ...DEFAULT_PREFS }; } } save(prefs: NotificationPreferences, agentId: string = 'default'): boolean { try { if (!existsSync(this.baseDir)) mkdirSync(this.baseDir, { recursive: true }); writeFileSync( this.pathFor(agentId), JSON.stringify({ ...prefs, updatedAt: Date.now() }, null, 2), 'utf-8', ); return true; } catch { return false; } } patch( partial: Partial<NotificationPreferences>, agentId: string = 'default', ): NotificationPreferences { const merged: NotificationPreferences = { ...this.load(agentId), ...partial, updatedAt: Date.now() }; this.save(merged, agentId); return merged; } clear(agentId: string = 'default'): boolean { try { const p = this.pathFor(agentId); if (existsSync(p)) unlinkSync(p); return true; } catch { return false; } } } /** * 判断 envelope 在给定偏好下是否应该发出。 * * 使用纯函数便于测试;调用方自己决定是否 log 拒绝原因。 */ export function shouldDeliver( envelope: NotificationEnvelope, prefs: NotificationPreferences, now: Date = new Date(), ): { deliver: boolean; reason?: string } { if (!prefs.enabled) return { deliver: false, reason: 'notifications disabled' }; if (prefs.kinds.length > 0 && !prefs.kinds.includes(envelope.kind as NotificationKind)) { return { deliver: false, reason: `kind envelope.kind not in allowlist` }; } if (PRIORITY_RANK[envelope.priority] < PRIORITY_RANK[prefs.minPriority]) { // urgent 级别永远放行(强行突破静音/优先级过滤) if (envelope.priority !== 'urgent') { return { deliver: false, reason: `priority envelope.priority below prefs.minPriority` }; } } if (prefs.quietHours && inQuietHours(prefs.quietHours, now) && envelope.priority !== 'urgent') { return { deliver: false, reason: 'within quiet hours' }; } return { deliver: true }; } function parseHHMM(s: string): number | null { const m = /^(\d{1,2}):(\d{2})$/.exec(s.trim()); if (!m) return null; const h = Number(m[1]); const mi = Number(m[2]); if (Number.isNaN(h) || Number.isNaN(mi) || h > 23 || mi > 59) return null; return h * 60 + mi; } /** 支持跨午夜:start=22:00 end=08:00 代表 22:00~次日 08:00 */ export function inQuietHours( window: { start: string; end: string }, now: Date = new Date(), ): boolean { const start = parseHHMM(window.start); const end = parseHHMM(window.end); if (start === null || end === null) return false; const cur = now.getHours() * 60 + now.getMinutes(); return start <= end ? cur >= start && cur < end : cur >= start || cur < end; } FILE:src/modules/notification-router.ts /** * Odoo SyncUpdate → NotificationEnvelope 转换 * * 把 poller 产出的判别联合事件标准化成跨渠道统一的信封格式, * 让下游渠道(企微、钉钉 …)只针对 NotificationEnvelope 编程。 */ import type { SyncUpdate, NotificationEnvelope, NotificationPriority, } from '../types/index.js'; function priorityFromOdoo(raw: unknown): NotificationPriority { const s = String(raw ?? '0'); if (s === '3') return 'urgent'; if (s === '2') return 'high'; if (s === '1') return 'normal'; return 'low'; } function stripHtml(html: unknown): string { return String(html ?? '').replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim(); } function truncate(s: string, n: number): string { return s.length <= n ? s : s.substring(0, n - 1) + '…'; } function odooLink(odooUrl: string | undefined, model: string, id: number): { url: string; label?: string } | undefined { if (!odooUrl) return undefined; return { url: `odooUrl.replace(/\/$/, '')/odoo/action-base.action_open_view?model=encodeURIComponent(model)&id=id`, label: '在辉火云中打开' }; } /** * 把 SyncUpdate 转成 NotificationEnvelope * * @param update poller 产出的事件 * @param agentId 所属 OpenClaw agent * @param odooUrl Odoo 实例 URL(用于构造 deep-link) */ export function toEnvelope( update: SyncUpdate, agentId: string, odooUrl?: string, ): NotificationEnvelope { const d = update.data as Record<string, unknown>; const id = `odoo:agentId:update.type:update.id`; const createdAt = Date.now(); switch (update.type) { case 'todo': { const name = String(d['name'] ?? '(未命名待办)'); const deadline = typeof d['date_deadline'] === 'string' ? d['date_deadline'] : ''; const priority = priorityFromOdoo(d['priority']); const title = update.action === 'create' ? `新待办:name` : `待办更新:name`; const summary = deadline ? `name(截止 deadline)` : name; return { id, source: 'odoo', agentId, kind: 'todo', action: update.action, priority, title, summary, body: deadline ? `name\n截止:deadline` : name, link: odooLink(odooUrl, 'project.task', update.id), tags: ['odoo', 'todo', update.action], createdAt, origin: { url: odooUrl, model: 'project.task', resId: update.id }, raw: d, }; } case 'activity': { const summaryText = String(d['summary'] ?? '活动'); const deadline = String(d['date_deadline'] ?? ''); const resModel = String(d['res_model'] ?? ''); const resId = Number(d['res_id'] ?? 0); return { id, source: 'odoo', agentId, kind: 'activity', action: update.action, priority: 'high', title: `活动到期:summaryText`, summary: deadline ? `summaryText(deadline)` : summaryText, body: [ summaryText, deadline ? `截止:deadline` : '', resModel ? `关联:resModel#resId` : '', ].filter(Boolean).join('\n'), link: resModel && resId ? odooLink(odooUrl, resModel, resId) : undefined, tags: ['odoo', 'activity', 'due'], createdAt, origin: { url: odooUrl, model: resModel, resId }, raw: d, }; } case 'message': { const subject = String(d['subject'] ?? '(无主题)'); const bodyText = truncate(stripHtml(d['body']), 500); const authorArr = d['author_id']; const authorName = Array.isArray(authorArr) ? String(authorArr[1] ?? '系统') : '系统'; const model = String(d['model'] ?? ''); const resId = Number(d['res_id'] ?? 0); return { id, source: 'odoo', agentId, kind: 'message', action: update.action, priority: 'normal', title: `新消息:subject`, summary: `authorName:truncate(bodyText || subject, 80)`, body: bodyText, link: model && resId ? odooLink(odooUrl, model, resId) : undefined, tags: ['odoo', 'message'], createdAt, origin: { url: odooUrl, model, resId }, raw: d, }; } case 'email': { return { id, source: 'odoo', agentId, kind: 'email', action: update.action, priority: 'normal', title: '新邮件通知', summary: `您有一条新的辉火云邮件通知(id=update.id)`, body: `辉火云邮件通知\nnotification_id: update.id`, tags: ['odoo', 'email'], createdAt, origin: { url: odooUrl, model: 'mail.notification', resId: update.id }, raw: d, }; } case 'calendar': { const name = String(d['name'] ?? '日历事件'); const start = String(d['start'] ?? ''); const stop = String(d['stop'] ?? ''); return { id, source: 'odoo', agentId, kind: 'calendar', action: update.action, priority: 'normal', title: update.action === 'create' ? `新日历事件:name` : `日历更新:name`, summary: start ? `name(start)` : name, body: [name, start ? `开始:start` : '', stop ? `结束:stop` : ''].filter(Boolean).join('\n'), link: odooLink(odooUrl, 'calendar.event', update.id), tags: ['odoo', 'calendar', update.action], createdAt, origin: { url: odooUrl, model: 'calendar.event', resId: update.id }, raw: d, }; } } } FILE:src/modules/odoo-client.ts /** * Odoo JSON-RPC API 客户端 — v1.1 * * 支持 Odoo 19 Enterprise 的 JSON-RPC 接口。 * 覆盖模块:Session、Project、CRM、Sale、Purchase、 * Helpdesk、Accounting、HR、Stock、Mail/Activity */ import type { OdooConfig, OdooSession, OdooRecord, OdooError } from '../types/index.js'; import { today, tomorrow } from '../utils/date-utils.js'; type DomainItem = string | [string, string, unknown]; type Domain = DomainItem[]; function isOdooError(result: unknown): result is { error: OdooError } { return typeof result === 'object' && result !== null && 'error' in result; } export class OdooClient { private url: string; private db: string; private username: string; private password: string; private uid: number | null = null; private session_id: string | null = null; private user_context: Record<string, unknown> = {}; constructor(config: OdooConfig) { this.url = config.url.replace(/\/$/, ''); this.db = config.db; this.username = config.username; this.password = config.password; } // ==================== 会话管理 ==================== async authenticate(): Promise<OdooSession> { const result = await this.rpc('/web/session/authenticate', { db: this.db, login: this.username, password: this.password, }); if (isOdooError(result)) { throw new Error(`认证失败: JSON.stringify(result.error)`); } const session = result as OdooSession; if (!session.uid) throw new Error('认证失败:用户名或密码错误'); this.uid = session.uid; this.session_id = (session as unknown as Record<string, unknown>).session_id as string | null ?? null; this.user_context = session.user_context || {}; return session; } async checkSession(): Promise<boolean> { try { const result = await this.rpc('/web/session/get_session_info', {}); const r = result as Record<string, unknown>; return 'uid' in r && r['uid'] !== false && r['uid'] !== null; } catch { return false; } } async ensureAuthenticated(): Promise<void> { if (!this.isAuthenticated() || !(await this.checkSession())) { await this.authenticate(); } } async destroy(): Promise<void> { try { await this.rpc('/web/session/destroy', {}); } finally { this.uid = null; this.session_id = null; this.user_context = {}; } } isAuthenticated(): boolean { return this.uid !== null; } getUid(): number | null { return this.uid; } /** * 查询 Odoo 实例可用的数据库列表(无需认证) * Odoo 19 端点: POST /web/database/list */ static async listDatabases(url: string): Promise<string[]> { const endpoint = `url.replace(/\/$/, '')/web/database/list`; const payload = { jsonrpc: '2.0', method: 'call', id: Date.now(), params: {} }; const response = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); if (!response.ok) throw new Error(`HTTP response.status: response.statusText`); const data = await response.json() as { result?: string[]; error?: unknown }; if (data.error) throw new Error(`获取数据库列表失败: JSON.stringify(data.error)`); return data.result ?? []; } getSessionInfo(): { uid: number | null; username: string; url: string } { return { uid: this.uid, username: this.username, url: this.url }; } // ==================== 通用 ORM ==================== async searchRead( model: string, domain: Domain, fields: string[] = ['id', 'name'], options: { limit?: number; offset?: number; order?: string } = {}, ): Promise<{ length: number; records: OdooRecord[] }> { const { limit = 100, offset = 0, order = '' } = options; const result = await this.rpc('/web/dataset/call_kw', { model, method: 'search_read', args: [domain], kwargs: { fields, domain, limit, offset, order }, }); if (isOdooError(result)) throw new Error(`查询 model 失败: JSON.stringify(result.error)`); const records = Array.isArray(result) ? result as OdooRecord[] : []; return { length: records.length, records }; } async read(model: string, ids: number[], fields: string[] = ['id', 'name']): Promise<OdooRecord[]> { const result = await this.rpc('/web/dataset/call_kw', { model, method: 'read', args: [ids], kwargs: { fields } }); if (isOdooError(result)) throw new Error(`读取 model 失败: JSON.stringify(result.error)`); return result as OdooRecord[]; } async create(model: string, values: Record<string, unknown>): Promise<number> { const result = await this.rpc('/web/dataset/call_kw', { model, method: 'create', args: [values], kwargs: {} }); if (isOdooError(result)) throw new Error(`创建 model 失败: JSON.stringify(result.error)`); return result as number; } async write(model: string, ids: number[], values: Record<string, unknown>): Promise<boolean> { const result = await this.rpc('/web/dataset/call_kw', { model, method: 'write', args: [ids, values], kwargs: {} }); if (isOdooError(result)) throw new Error(`更新 model 失败: JSON.stringify(result.error)`); return result === true; } async unlink(model: string, ids: number[]): Promise<boolean> { const result = await this.rpc('/web/dataset/call_kw', { model, method: 'unlink', args: [ids], kwargs: {} }); if (isOdooError(result)) throw new Error(`删除 model 失败: JSON.stringify(result.error)`); return result === true; } async searchCount(model: string, domain: Domain): Promise<number> { const result = await this.rpc('/web/dataset/call_kw', { model, method: 'search_count', args: [domain], kwargs: {} }); if (isOdooError(result)) throw new Error(`计数 model 失败: JSON.stringify(result.error)`); return result as number; } async call(model: string, method: string, args: unknown[] = [], kwargs: Record<string, unknown> = {}): Promise<unknown> { const result = await this.rpc('/web/dataset/call_kw', { model, method, args, kwargs }); if (isOdooError(result)) throw new Error(`调用 model.method 失败: JSON.stringify(result.error)`); return result; } // ==================== Project / Task ==================== async createTask(values: { name: string; description?: string; project_id?: number; date_deadline?: string; user_ids?: number[]; priority?: '0' | '1' | '2' | '3'; }): Promise<number> { return this.create('project.task', { name: values.name, description: values.description || '', project_id: values.project_id || false, date_deadline: values.date_deadline || false, user_ids: values.user_ids ? [[6, false, values.user_ids]] : [[6, false, [this.uid ?? 1]]], priority: values.priority || '0', active: true, }); } /** * 获取我的任务(待办) * * Odoo 待办应用(To-Do)基于 project.task 模型, * 过滤条件:project_id=False(无项目=私人任务) * 项目任务在「项目」应用中管理,不在待办里。 * * Odoo project.task 状态机制: * - state 字段:01_in_progress / 04_waiting_normal / 1_done / 1_canceled 等 * - is_closed = state in ['1_done', '1_canceled'] * * stage_state 选项: * 'in_progress' — 进行中(未关闭,默认) * 'done' — 已完成 * 'all' — 全部 * * include_project — 是否包含项目任务(默认 false,待办应用模式) */ async getMyTasks(options: { limit?: number; project_id?: number; today_only?: boolean; stage_state?: 'in_progress' | 'done' | 'all'; state_filter?: string; stage_id?: number; include_project?: boolean; } = {}): Promise<OdooRecord[]> { // 待办应用过滤:project_id=False(私人任务),排除子任务 // 项目任务(project_id 有值)属于「项目」应用,不在待办里 const domain: Domain = [['user_ids', 'in', [this.uid ?? 0]], ['active', '=', true], ['project_id', '=', false], ['parent_id', '=', false]]; if (options.project_id) { // 如果指定了 project_id,则切换到项目任务模式 domain.length = 0; domain.push(['user_ids', 'in', [this.uid ?? 0]], ['active', '=', true], ['project_id', '=', options.project_id]); } if (options.today_only) domain.push(['date_deadline', '<=', today()]); const stageState = options.stage_state ?? 'in_progress'; if (stageState === 'in_progress') { domain.push(['state', 'not in', ['1_done', '1_canceled']]); } else if (stageState === 'done') { domain.push(['state', 'in', ['1_done', '1_canceled']]); } if (options.state_filter) { domain.length = domain.filter(d => !Array.isArray(d) || d[0] !== 'state').length; domain.push(['state', '=', options.state_filter]); } if (options.stage_id) domain.push(['stage_id', '=', options.stage_id]); const result = await this.searchRead('project.task', domain, ['id', 'name', 'description', 'date_deadline', 'stage_id', 'project_id', 'priority', 'user_ids', 'milestone_id', 'state'], { limit: options.limit ?? 50 }); return result.records; } /** 获取项目任务阶段列表(用于查找 stage_id) */ async getTaskStages(projectId?: number): Promise<OdooRecord[]> { const domain: Domain = []; if (projectId) domain.push(['project_ids', '=', projectId]); const result = await this.searchRead('project.task.type', domain, ['id', 'name', 'fold', 'sequence'], { order: 'sequence asc' }); return result.records; } async getProjectOverview(projectId?: number): Promise<OdooRecord[]> { const domain: Domain = [['active', '=', true]]; if (projectId) domain.push(['id', '=', projectId]); const result = await this.searchRead('project.project', domain, ['id', 'name', 'partner_id', 'user_id', 'date_start', 'date', 'task_count', 'open_task_count', 'closed_task_count'], { limit: 50 }); return result.records; } async getMilestones(projectId?: number): Promise<OdooRecord[]> { const domain: Domain = []; if (projectId) domain.push(['project_id', '=', projectId]); const result = await this.searchRead('project.milestone', domain, ['id', 'name', 'project_id', 'deadline', 'is_reached', 'reached_date', 'task_count', 'done_task_count', 'is_deadline_exceeded'], { limit: 50 }); return result.records; } async logTimesheet(values: { name: string; unit_amount: number; project_id?: number; task_id?: number; date?: string; }): Promise<number> { return this.create('account.analytic.line', { name: values.name, unit_amount: values.unit_amount, date: values.date || today(), user_id: this.uid ?? 1, project_id: values.project_id || false, task_id: values.task_id || false, }); } // ==================== CRM ==================== async getCrmPipeline(options: { limit?: number; stage_id?: number; user_id?: number; type?: 'lead' | 'opportunity'; won_status?: string; } = {}): Promise<OdooRecord[]> { const domain: Domain = [['active', '=', true]]; if (options.stage_id) domain.push(['stage_id', '=', options.stage_id]); if (options.user_id) domain.push(['user_id', '=', options.user_id]); else domain.push(['user_id', '=', this.uid ?? 0]); if (options.type) domain.push(['type', '=', options.type]); const result = await this.searchRead('crm.lead', domain, ['id', 'name', 'partner_id', 'stage_id', 'probability', 'expected_revenue', 'user_id', 'team_id', 'date_deadline', 'type', 'won_status', 'activity_ids', 'tag_ids'], { limit: options.limit ?? 50, order: 'stage_id asc, probability desc' }); return result.records; } async createCrmLead(values: { name: string; type?: 'lead' | 'opportunity'; partner_id?: number; expected_revenue?: number; probability?: number; user_id?: number; stage_id?: number; date_deadline?: string; description?: string; }): Promise<number> { return this.create('crm.lead', { name: values.name, type: values.type || 'opportunity', partner_id: values.partner_id || false, expected_revenue: values.expected_revenue || 0, probability: values.probability || 10, user_id: values.user_id || this.uid || 1, stage_id: values.stage_id || false, date_deadline: values.date_deadline || false, description: values.description || '', }); } async setCrmWon(leadIds: number[]): Promise<boolean> { await this.call('crm.lead', 'action_set_won', [leadIds]); return true; } async setCrmLost(leadIds: number[], lostReasonId?: number): Promise<boolean> { const kwargs: Record<string, unknown> = {}; if (lostReasonId) kwargs['additional_values'] = { lost_reason_id: lostReasonId }; await this.call('crm.lead', 'action_set_lost', [leadIds], kwargs); return true; } async getCrmStages(): Promise<OdooRecord[]> { const result = await this.searchRead('crm.stage', [], ['id', 'name', 'sequence', 'is_won', 'fold'], { order: 'sequence asc' }); return result.records; } // ==================== Sales ==================== async getSaleOrders(options: { limit?: number; state?: string; partner_id?: number; user_id?: number; } = {}): Promise<OdooRecord[]> { const domain: Domain = []; if (options.state) domain.push(['state', '=', options.state]); else domain.push(['state', 'not in', ['cancel']]); if (options.partner_id) domain.push(['partner_id', '=', options.partner_id]); if (options.user_id) domain.push(['user_id', '=', options.user_id]); const result = await this.searchRead('sale.order', domain, ['id', 'name', 'partner_id', 'state', 'date_order', 'amount_total', 'invoice_status', 'user_id', 'team_id', 'validity_date'], { limit: options.limit ?? 20, order: 'date_order desc' }); return result.records; } async confirmSaleOrder(orderId: number): Promise<unknown> { return this.call('sale.order', 'action_confirm', [[orderId]]); } // ==================== Purchase ==================== async getPurchaseOrders(options: { limit?: number; state?: string } = {}): Promise<OdooRecord[]> { const domain: Domain = []; if (options.state) domain.push(['state', '=', options.state]); else domain.push(['state', 'not in', ['cancel']]); const result = await this.searchRead('purchase.order', domain, ['id', 'name', 'partner_id', 'state', 'date_order', 'date_planned', 'amount_total', 'invoice_status', 'user_id'], { limit: options.limit ?? 20, order: 'date_order desc' }); return result.records; } // ==================== Helpdesk ==================== async getHelpdeskTickets(options: { limit?: number; stage_id?: number; user_id?: number; priority?: string; partner_id?: number; team_id?: number; } = {}): Promise<OdooRecord[]> { const domain: Domain = [['active', '=', true]]; if (options.user_id !== undefined) domain.push(['user_id', '=', options.user_id]); if (options.stage_id) domain.push(['stage_id', '=', options.stage_id]); if (options.priority) domain.push(['priority', '=', options.priority]); if (options.partner_id) domain.push(['partner_id', '=', options.partner_id]); if (options.team_id) domain.push(['team_id', '=', options.team_id]); const result = await this.searchRead('helpdesk.ticket', domain, ['id', 'name', 'ticket_ref', 'team_id', 'stage_id', 'user_id', 'partner_id', 'priority', 'kanban_state', 'sla_deadline', 'sla_fail', 'create_date', 'close_date'], { limit: options.limit ?? 30, order: 'priority desc, create_date desc' }); return result.records; } async createHelpdeskTicket(values: { name: string; team_id?: number; partner_id?: number; description?: string; priority?: '0' | '1' | '2' | '3'; user_id?: number; }): Promise<number> { return this.create('helpdesk.ticket', { name: values.name, team_id: values.team_id || false, partner_id: values.partner_id || false, description: values.description || '', priority: values.priority || '0', user_id: values.user_id || false, }); } async getHelpdeskStages(teamId?: number): Promise<OdooRecord[]> { const domain: Domain = []; if (teamId) domain.push(['team_ids', 'in', [teamId]]); const result = await this.searchRead('helpdesk.stage', domain, ['id', 'name', 'sequence', 'fold'], { order: 'sequence asc' }); return result.records; } // ==================== Accounting ==================== async getInvoices(options: { limit?: number; move_type?: string; state?: string; partner_id?: number; payment_state?: string; } = {}): Promise<OdooRecord[]> { const domain: Domain = [['move_type', 'in', options.move_type ? [options.move_type] : ['out_invoice', 'out_refund', 'in_invoice', 'in_refund']]]; if (options.state) domain.push(['state', '=', options.state]); if (options.partner_id) domain.push(['partner_id', '=', options.partner_id]); if (options.payment_state) domain.push(['payment_state', '=', options.payment_state]); const result = await this.searchRead('account.move', domain, ['id', 'name', 'move_type', 'partner_id', 'state', 'invoice_date', 'invoice_date_due', 'amount_total', 'payment_state', 'invoice_status'], { limit: options.limit ?? 20, order: 'invoice_date desc' }); return result.records; } async getOverdueInvoices(): Promise<OdooRecord[]> { const result = await this.searchRead('account.move', [ ['move_type', '=', 'out_invoice'], ['state', '=', 'posted'], ['payment_state', 'not in', ['paid', 'reversed']], ['invoice_date_due', '<', today()], ], ['id', 'name', 'partner_id', 'invoice_date_due', 'amount_total', 'payment_state'], { limit: 30, order: 'invoice_date_due asc' }); return result.records; } // ==================== 联系人 / 客户 ==================== async getPartners(options: { limit?: number; is_company?: boolean; customer_rank?: boolean; supplier_rank?: boolean; keyword?: string; } = {}): Promise<OdooRecord[]> { const domain: Domain = [['active', '=', true]]; if (options.is_company !== undefined) domain.push(['is_company', '=', options.is_company]); if (options.customer_rank) domain.push(['customer_rank', '>', 0]); if (options.supplier_rank) domain.push(['supplier_rank', '>', 0]); if (options.keyword) domain.push(['name', 'ilike', options.keyword]); const result = await this.searchRead('res.partner', domain, ['id', 'name', 'email', 'phone', 'mobile', 'is_company', 'city', 'country_id', 'customer_rank', 'supplier_rank', 'parent_id'], { limit: options.limit ?? 30, order: 'name asc' }); return result.records; } async createPartner(values: { name: string; email?: string; phone?: string; mobile?: string; is_company?: boolean; city?: string; street?: string; customer_rank?: number; supplier_rank?: number; parent_id?: number; }): Promise<number> { return this.create('res.partner', { name: values.name, email: values.email || false, phone: values.phone || false, mobile: values.mobile || false, is_company: values.is_company ?? false, city: values.city || false, street: values.street || false, customer_rank: values.customer_rank ?? 1, supplier_rank: values.supplier_rank ?? 0, parent_id: values.parent_id || false, }); } // ==================== 库存 ==================== async getStockLevels(options: { limit?: number; product_id?: number; location_id?: number; keyword?: string; } = {}): Promise<OdooRecord[]> { const domain: Domain = [['quantity', '>', 0]]; if (options.product_id) domain.push(['product_id', '=', options.product_id]); if (options.location_id) domain.push(['location_id', '=', options.location_id]); if (options.keyword) domain.push(['product_id.name', 'ilike', options.keyword]); const result = await this.searchRead('stock.quant', domain, ['id', 'product_id', 'location_id', 'lot_id', 'quantity', 'reserved_quantity', 'available_quantity'], { limit: options.limit ?? 50, order: 'product_id asc' }); return result.records; } async getStockPickings(options: { limit?: number; state?: string; picking_type?: string; } = {}): Promise<OdooRecord[]> { const domain: Domain = []; if (options.state) domain.push(['state', '=', options.state]); else domain.push(['state', 'not in', ['done', 'cancel']]); if (options.picking_type) domain.push(['picking_type_code', '=', options.picking_type]); const result = await this.searchRead('stock.picking', domain, ['id', 'name', 'partner_id', 'picking_type_id', 'state', 'scheduled_date', 'date_done', 'origin'], { limit: options.limit ?? 20, order: 'scheduled_date asc' }); return result.records; } // ==================== HR ==================== async getEmployees(options: { limit?: number; department_id?: number; active?: boolean; keyword?: string } = {}): Promise<OdooRecord[]> { const domain: Domain = [['active', '=', options.active !== false]]; if (options.department_id) domain.push(['department_id', '=', options.department_id]); if (options.keyword) domain.push(['name', 'ilike', options.keyword]); const result = await this.searchRead('hr.employee', domain, ['id', 'name', 'department_id', 'job_id', 'work_email', 'mobile_phone', 'parent_id', 'user_id'], { limit: options.limit ?? 50, order: 'name asc' }); return result.records; } async getLeaves(options: { limit?: number; state?: string; employee_id?: number } = {}): Promise<OdooRecord[]> { const domain: Domain = []; if (options.state) domain.push(['state', '=', options.state]); if (options.employee_id) domain.push(['employee_id', '=', options.employee_id]); else domain.push(['employee_id.user_id', '=', this.uid ?? 0]); const result = await this.searchRead('hr.leave', domain, ['id', 'name', 'employee_id', 'holiday_status_id', 'date_from', 'date_to', 'number_of_days', 'state'], { limit: options.limit ?? 20, order: 'date_from desc' }); return result.records; } async getAttendances(options: { limit?: number; employee_id?: number } = {}): Promise<OdooRecord[]> { const domain: Domain = []; if (options.employee_id) domain.push(['employee_id', '=', options.employee_id]); else domain.push(['employee_id.user_id', '=', this.uid ?? 0]); const result = await this.searchRead('hr.attendance', domain, ['id', 'employee_id', 'check_in', 'check_out', 'worked_hours'], { limit: options.limit ?? 20, order: 'check_in desc' }); return result.records; } // ==================== 审批 ==================== async getApprovals(options: { limit?: number; state?: string; my_requests?: boolean } = {}): Promise<OdooRecord[]> { const domain: Domain = []; if (options.state) domain.push(['request_status', '=', options.state]); if (options.my_requests) domain.push(['request_owner_id.user_id', '=', this.uid ?? 0]); const result = await this.searchRead('approval.request', domain, ['id', 'name', 'category_id', 'request_owner_id', 'request_status', 'date', 'date_confirmed', 'amount', 'reason'], { limit: options.limit ?? 20, order: 'date desc' }); return result.records; } // ==================== Mail / Activity ==================== async getTodayActivities(options: { limit?: number } = {}): Promise<OdooRecord[]> { const result = await this.searchRead('mail.activity', [['user_id', '=', this.uid ?? 0], ['date_deadline', '<=', today()]], ['id', 'summary', 'date_deadline', 'activity_type_id', 'res_model', 'res_id', 'note', 'state'], { limit: options.limit ?? 50 }); return result.records; } async createActivity(values: { res_model: string; res_id: number; activity_type_id: number; summary?: string; note?: string; date_deadline: string; user_id?: number; }): Promise<number> { return this.create('mail.activity', { res_model: values.res_model, res_id: values.res_id, activity_type_id: values.activity_type_id, summary: values.summary || '', note: values.note || '', date_deadline: values.date_deadline, user_id: values.user_id ?? this.uid ?? 1, }); } async getActivityTypes(): Promise<OdooRecord[]> { const result = await this.searchRead('mail.activity.type', [], ['id', 'name', 'icon', 'category', 'delay_count', 'delay_unit'], { order: 'name asc' }); return result.records; } /** * 完成活动(闭环)—— 调 mail.activity.action_feedback: * 活动标记为 done、写入反馈到源记录 chatter、从活动列表里移除。 * 这是 Odoo 活动闭环的正式 API,不要直接 unlink。 */ async completeActivity(id: number, feedback?: string): Promise<unknown> { return this.call('mail.activity', 'action_feedback', [[id]], { feedback: feedback || '', }); } /** 改期:把活动的 date_deadline 挪到新日期(YYYY-MM-DD) */ async rescheduleActivity(id: number, newDeadline: string): Promise<boolean> { return this.write('mail.activity', [id], { date_deadline: newDeadline }); } async createCalendarEvent(values: { name: string; start: string; stop: string; description?: string; partner_ids?: number[]; alarm_ids?: number[]; }): Promise<number> { return this.create('calendar.event', { name: values.name, start: values.start, stop: values.stop, description: values.description || '', partner_ids: values.partner_ids ? [[6, false, values.partner_ids]] : [[6, false, [this.uid ?? 1]]], alarm_ids: values.alarm_ids ? [[6, false, values.alarm_ids]] : false, }); } /** * 获取今日会议/日程 —— 覆盖 [今天 00:00, 明天 00:00) 区间里与我相关的事件。 * 包含:我是组织者、或我是参与者(partner_ids 含 my partner_id)。 */ async getCalendarToday(options: { limit?: number } = {}): Promise<OdooRecord[]> { const t = today(); const next = tomorrow(); const uid = this.uid ?? 0; // 我的 partner_id 需要先查一次(cached on session user_context 里没有,所以 read res.users) let myPartnerId: number | false = false; try { const users = await this.read('res.users', [uid], ['partner_id']); const pid = users[0]?.['partner_id']; if (Array.isArray(pid) && typeof pid[0] === 'number') myPartnerId = pid[0]; } catch { /* ignore */ } const domain: Domain = [ ['start', '>=', `t 00:00:00`], ['start', '<', `next 00:00:00`], ]; if (myPartnerId) { domain.unshift('|', ['user_id', '=', uid], ['partner_ids', 'in', [myPartnerId]]); } else { domain.unshift(['user_id', '=', uid]); } const result = await this.searchRead('calendar.event', domain, ['id', 'name', 'start', 'stop', 'duration', 'location', 'partner_ids', 'description', 'user_id', 'allday'], { limit: options.limit ?? 30, order: 'start asc' }); return result.records; } /** 更新日历事件(时间、地点、标题、描述、参与者) */ async updateCalendarEvent(id: number, values: { name?: string; start?: string; stop?: string; location?: string; description?: string; partner_ids?: number[]; }): Promise<boolean> { const payload: Record<string, unknown> = {}; if (values.name !== undefined) payload['name'] = values.name; if (values.start !== undefined) payload['start'] = values.start; if (values.stop !== undefined) payload['stop'] = values.stop; if (values.location !== undefined) payload['location'] = values.location; if (values.description !== undefined) payload['description'] = values.description; if (values.partner_ids !== undefined) payload['partner_ids'] = [[6, false, values.partner_ids]]; if (Object.keys(payload).length === 0) return true; return this.write('calendar.event', [id], payload); } /** 取消日历事件:active=false(软删除,保留历史),而不是 unlink */ async cancelCalendarEvent(id: number): Promise<boolean> { return this.write('calendar.event', [id], { active: false }); } // ==================== 关注者(followers)==================== // // Odoo 的 mail.thread 混入提供: // - message_subscribe(partner_ids, subtype_ids?) — 添加关注者 // - message_unsubscribe(partner_ids) — 移除关注者 // - message_follower_ids — 列表 // 任何继承 mail.thread 的模型(project.task、crm.lead、helpdesk.ticket、 // sale.order、res.partner 等)都能用。 async followRecord(model: string, resId: number, partnerIds?: number[]): Promise<unknown> { // 默认关注者 = 当前用户的 partner_id let targets = partnerIds; if (!targets || targets.length === 0) { const uid = this.uid ?? 0; const users = await this.read('res.users', [uid], ['partner_id']); const pid = users[0]?.['partner_id']; if (Array.isArray(pid) && typeof pid[0] === 'number') targets = [pid[0]]; else throw new Error('无法获取当前用户的 partner_id'); } return this.call(model, 'message_subscribe', [[resId]], { partner_ids: targets }); } async unfollowRecord(model: string, resId: number, partnerIds?: number[]): Promise<unknown> { let targets = partnerIds; if (!targets || targets.length === 0) { const uid = this.uid ?? 0; const users = await this.read('res.users', [uid], ['partner_id']); const pid = users[0]?.['partner_id']; if (Array.isArray(pid) && typeof pid[0] === 'number') targets = [pid[0]]; else throw new Error('无法获取当前用户的 partner_id'); } return this.call(model, 'message_unsubscribe', [[resId]], { partner_ids: targets }); } // ==================== 邮件(mail.mail + mail.template)==================== // // 两条路径: // 1) 随手发一封:用 message_post 发在某条记录的 chatter,Odoo 会自动 // 邮件化给 followers(如果 subtype 是 comment)。这是 Odoo 原生的"发邮件"方式。 // 2) 显式发邮件给任意地址:mail.mail create → send。适合真正"给外部发邮件"场景。 // 模板:mail.template.send_mail(res_id, force_send=True) 是 Odoo 原生的模板发送入口。 /** * 直接发送邮件(不依赖 chatter)。 * recipients 为 email 字符串数组(逗号分隔会被 Odoo 自行拆分,但我们显式传逗号拼接)。 * bodyHtml 建议是 HTML,纯文本会按 <br/> 保留换行。 */ async sendEmail(values: { subject: string; bodyHtml: string; recipients: string[]; cc?: string[]; bcc?: string[]; res_model?: string; res_id?: number; attachment_ids?: number[]; }): Promise<number> { const payload: Record<string, unknown> = { subject: values.subject, body_html: values.bodyHtml, email_to: values.recipients.join(','), email_from: false, // Odoo 会用当前用户的邮箱/公司邮箱 }; if (values.cc && values.cc.length > 0) payload['email_cc'] = values.cc.join(','); if (values.bcc && values.bcc.length > 0) (payload as Record<string, unknown>)['email_bcc'] = values.bcc.join(','); if (values.res_model) payload['model'] = values.res_model; if (values.res_id) payload['res_id'] = values.res_id; if (values.attachment_ids && values.attachment_ids.length > 0) { payload['attachment_ids'] = [[6, false, values.attachment_ids]]; } const id = await this.create('mail.mail', payload); // 立即发送(否则会等到 cron) await this.call('mail.mail', 'send', [[id]]); return id; } async getEmailTemplates(options: { model?: string; keyword?: string; limit?: number } = {}): Promise<OdooRecord[]> { const domain: Domain = []; if (options.model) domain.push(['model', '=', options.model]); if (options.keyword) domain.push(['name', 'ilike', options.keyword]); const result = await this.searchRead('mail.template', domain, ['id', 'name', 'model', 'subject', 'email_from', 'email_to', 'use_default_to', 'lang'], { limit: options.limit ?? 50, order: 'name asc' }); return result.records; } /** * 用模板发邮件。template_id 通过 getEmailTemplates 查。 * res_id 是模板关联模型的记录 id(模板的 model 字段决定)。 * force_send=true 立即入队发送。 */ async sendEmailFromTemplate(templateId: number, resId: number, options: { force_send?: boolean; email_values?: Record<string, unknown>; } = {}): Promise<unknown> { return this.call('mail.template', 'send_mail', [templateId, resId], { force_send: options.force_send ?? true, email_values: options.email_values ?? false, }); } // ==================== 附件(ir.attachment)/ 文档(documents.document)==================== // // ir.attachment 是 Odoo 通用附件表;传 datas(base64 编码)即可。 // 挂到记录:res_model + res_id 就会在该记录的 chatter/附件面板出现。 // documents.document 是 Enterprise 文档管理应用,可选 folder_id 归档。 async attachFile(values: { res_model: string; res_id: number; name: string; datas_base64: string; mimetype?: string; }): Promise<number> { return this.create('ir.attachment', { name: values.name, datas: values.datas_base64, res_model: values.res_model, res_id: values.res_id, type: 'binary', mimetype: values.mimetype || 'application/octet-stream', }); } async listAttachments(model: string, resId: number, options: { limit?: number } = {}): Promise<OdooRecord[]> { const result = await this.searchRead('ir.attachment', [['res_model', '=', model], ['res_id', '=', resId]], ['id', 'name', 'mimetype', 'file_size', 'create_date', 'create_uid', 'url', 'type'], { limit: options.limit ?? 50, order: 'create_date desc' }); return result.records; } async uploadDocument(values: { name: string; datas_base64: string; mimetype?: string; folder_id?: number; tag_ids?: number[]; }): Promise<number> { const payload: Record<string, unknown> = { name: values.name, datas: values.datas_base64, type: 'binary', mimetype: values.mimetype || 'application/octet-stream', }; if (values.folder_id) payload['folder_id'] = values.folder_id; if (values.tag_ids && values.tag_ids.length > 0) payload['tag_ids'] = [[6, false, values.tag_ids]]; return this.create('documents.document', payload); } async getUnreadMessages(options: { limit?: number } = {}): Promise<OdooRecord[]> { const result = await this.searchRead('mail.message', [['message_type', '!=', 'notification'], ['to_read', '=', true]], ['id', 'body', 'date', 'author_id', 'model', 'res_id', 'subject'], { limit: options.limit ?? 20 }); return result.records; } async getInboxNotifications(options: { limit?: number } = {}): Promise<OdooRecord[]> { const result = await this.searchRead('mail.notification', [['is_read', '=', false], ['notification_type', '=', 'inbox']], ['id', 'mail_message_id', 'notification_status', 'is_read', 'read_date'], { limit: options.limit ?? 20 }); return result.records; } // ==================== Knowledge(知识库 / knowledge.article)==================== // // Odoo 19 Enterprise 的 `knowledge.article` 模型要点: // - body 字段是 HTML // - category 由 internal_permission 计算得出: // internal_permission='write' → workspace,='none' → private(无 parent 时) // 子文章不需要 internal_permission,权限沿 parent 继承 // - DB 约束:顶层文章必须给 internal_permission;子文章必须给 parent_id // - 通过 action_toggle_favorite / action_send_to_trash / move_to 等 action 操作 // - is_user_favorite 是 compute + search,不能直接 write async searchKnowledgeArticles(options: { keyword?: string; category?: 'workspace' | 'private' | 'shared'; only_favorite?: boolean; only_roots?: boolean; // 只列顶层文章 parent_id?: number; // 列某文章的直接子节点 limit?: number; include_trashed?: boolean; } = {}): Promise<OdooRecord[]> { const domain: Domain = []; if (!options.include_trashed) domain.push(['to_delete', '=', false]); domain.push(['active', '=', true]); if (options.category) domain.push(['category', '=', options.category]); if (options.only_favorite) domain.push(['is_user_favorite', '=', true]); if (options.only_roots) domain.push(['parent_id', '=', false]); if (options.parent_id !== undefined) domain.push(['parent_id', '=', options.parent_id]); if (options.keyword) { domain.push('|', ['name', 'ilike', options.keyword], ['body', 'ilike', options.keyword]); } const result = await this.searchRead('knowledge.article', domain, ['id', 'name', 'icon', 'parent_id', 'root_article_id', 'category', 'is_user_favorite', 'favorite_count', 'last_edition_date', 'last_edition_uid', 'has_article_children', 'sequence'], { limit: options.limit ?? 30, order: 'sequence asc, last_edition_date desc, id desc' }); return result.records; } /** 读取单篇文章完整内容(含 body) */ async readKnowledgeArticle(id: number): Promise<OdooRecord | null> { const records = await this.read('knowledge.article', [id], ['id', 'name', 'icon', 'body', 'parent_id', 'root_article_id', 'category', 'internal_permission', 'inherited_permission', 'is_user_favorite', 'favorite_count', 'is_locked', 'to_delete', 'last_edition_date', 'last_edition_uid', 'sequence', 'has_article_children']); return records[0] ?? null; } /** * 创建知识库文章 * * - 顶层:必须传 category='workspace'|'private';自动映射到 internal_permission * workspace → 'write' (默认所有内部用户可编辑) * private → 'none' (仅所有者) * shared → 'none'(通常通过加 member 实现) * - 子文章:传 parent_id,其余权限继承 */ async createKnowledgeArticle(values: { name?: string; body?: string; // HTML icon?: string; // emoji parent_id?: number; category?: 'workspace' | 'private' | 'shared'; }): Promise<number> { const payload: Record<string, unknown> = { name: values.name || '未命名', body: values.body ?? false, icon: values.icon || false, }; if (values.parent_id) { payload['parent_id'] = values.parent_id; } else { const cat = values.category ?? 'private'; payload['internal_permission'] = cat === 'workspace' ? 'write' : 'none'; if (cat === 'workspace') payload['is_article_visible_by_everyone'] = true; } return this.create('knowledge.article', payload); } async updateKnowledgeArticle(id: number, values: { name?: string; body?: string; icon?: string; }): Promise<boolean> { const payload: Record<string, unknown> = {}; if (values.name !== undefined) payload['name'] = values.name; if (values.body !== undefined) payload['body'] = values.body; if (values.icon !== undefined) payload['icon'] = values.icon || false; if (Object.keys(payload).length === 0) return true; return this.write('knowledge.article', [id], payload); } /** * 在现有文章的 body 末尾追加一段 HTML —— 读-改-写(Odoo body 字段无原子 append)。 * 调用者负责把 markdown 之类的输入转成合法 HTML。 */ async appendKnowledgeArticle(id: number, htmlSuffix: string): Promise<boolean> { const rec = await this.readKnowledgeArticle(id); if (!rec) throw new Error(`文章 id 不存在`); const body = (rec['body'] as string | false) || ''; const next = `bodyhtmlSuffix`; return this.write('knowledge.article', [id], { body: next }); } /** 移动到新 parent 或在兄弟节点中排序 */ async moveKnowledgeArticle(id: number, options: { parent_id?: number | false; before_article_id?: number; category?: 'workspace' | 'private' | 'shared'; }): Promise<unknown> { return this.call('knowledge.article', 'move_to', [[id]], { parent_id: options.parent_id ?? false, before_article_id: options.before_article_id ?? false, category: options.category ?? false, }); } async toggleKnowledgeFavorite(id: number): Promise<unknown> { return this.call('knowledge.article', 'action_toggle_favorite', [[id]]); } async trashKnowledgeArticle(id: number): Promise<unknown> { return this.call('knowledge.article', 'action_send_to_trash', [[id]]); } async restoreKnowledgeArticle(id: number): Promise<unknown> { return this.call('knowledge.article', 'action_unarchive', [[id]]); } // ==================== v1.8 — Chatter / Project / Ticket / Approval ==================== // // Chatter = Odoo 的内置沟通轨迹。任何 mail.thread 混入的模型(几乎所有业务模型) // 都有 message_post() 方法: // - subtype 'mail.mt_comment' → 评论,会 email 给 followers // - subtype 'mail.mt_note' → 内部记录,只留痕不发邮件 // body 是 HTML;message_type 常用 'comment' / 'notification'。 /** * 在任意记录 chatter 发消息(评论,会通知 followers)。 * partner_ids 传进来会被当作额外收件人加入。 */ async postMessage(model: string, resId: number, values: { bodyHtml: string; subject?: string; partner_ids?: number[]; attachment_ids?: number[]; as_log?: boolean; // true = 内部记录(mt_note),false = 评论(mt_comment) }): Promise<number> { const kwargs: Record<string, unknown> = { body: values.bodyHtml, message_type: values.as_log ? 'notification' : 'comment', subtype_xmlid: values.as_log ? 'mail.mt_note' : 'mail.mt_comment', }; if (values.subject) kwargs['subject'] = values.subject; if (values.partner_ids && values.partner_ids.length > 0) { kwargs['partner_ids'] = values.partner_ids; } if (values.attachment_ids && values.attachment_ids.length > 0) { kwargs['attachment_ids'] = values.attachment_ids; } const result = await this.call(model, 'message_post', [[resId]], kwargs); if (typeof result === 'number') return result; // Odoo 19 返回的是 mail.message 记录(dict),取 id if (typeof result === 'object' && result !== null && 'id' in (result as Record<string, unknown>)) { const r = result as Record<string, unknown>; return typeof r['id'] === 'number' ? r['id'] : 0; } return 0; } /** 读取某条记录的 chatter 消息历史(最新在前) */ async getMessageHistory(model: string, resId: number, options: { limit?: number; include_notifications?: boolean; // 默认 false,过滤掉系统通知(auto-followers 之类) } = {}): Promise<OdooRecord[]> { const domain: Domain = [['model', '=', model], ['res_id', '=', resId]]; if (!options.include_notifications) { domain.push(['message_type', 'in', ['comment', 'email']]); } const result = await this.searchRead('mail.message', domain, ['id', 'date', 'author_id', 'email_from', 'subject', 'body', 'message_type', 'subtype_id'], { limit: options.limit ?? 20, order: 'date desc' }); return result.records; } // ------- Project / Milestone ------------------------------------------------ async createProject(values: { name: string; partner_id?: number; user_id?: number; // 项目负责人 date_start?: string; date?: string; // 结束日期 description?: string; privacy_visibility?: 'followers' | 'employees' | 'portal'; }): Promise<number> { return this.create('project.project', { name: values.name, partner_id: values.partner_id || false, user_id: values.user_id || this.uid || false, date_start: values.date_start || false, date: values.date || false, description: values.description || '', privacy_visibility: values.privacy_visibility || 'employees', active: true, }); } async createMilestone(values: { name: string; project_id: number; deadline?: string; }): Promise<number> { return this.create('project.milestone', { name: values.name, project_id: values.project_id, deadline: values.deadline || false, }); } // ------- Helpdesk ticket 更新/关闭 ---------------------------------------- /** * 查找某客服团队里 fold=true 的阶段(= 关闭阶段)。 * 不传 teamId 就找任何 fold=true 的阶段,返回最小 sequence 的那条。 */ async findHelpdeskClosedStage(teamId?: number): Promise<OdooRecord | null> { const domain: Domain = [['fold', '=', true]]; if (teamId) domain.push(['team_ids', 'in', [teamId]]); const result = await this.searchRead('helpdesk.stage', domain, ['id', 'name', 'sequence'], { limit: 1, order: 'sequence asc' }); return result.records[0] ?? null; } // ------- 审批 approval.request ------------------------------------------- // // 审批流状态机: // new → pending(action_confirm)→ approved(action_approve) // → refused (action_refuse) // → cancel (action_withdraw) // 我们只暴露 approve / refuse 两个最常见的审批人动作; // 发起人侧的 action_confirm 可以后续再加。 async approveApprovalRequest(id: number): Promise<unknown> { return this.call('approval.request', 'action_approve', [[id]]); } async refuseApprovalRequest(id: number): Promise<unknown> { return this.call('approval.request', 'action_refuse', [[id]]); } // ==================== 实施经理每日概况 ==================== async getDailyBriefing(): Promise<{ todayTasks: OdooRecord[]; overdueActivities: OdooRecord[]; openTickets: OdooRecord[]; overdueInvoices: OdooRecord[]; crmFollowUps: OdooRecord[]; unreadMessages: OdooRecord[]; }> { const uid = this.uid ?? 0; const [todayTasks, overdueActivities, openTickets, overdueInvoices, crmFollowUps, unreadMessages] = await Promise.all([ // 今日截止任务 this.searchRead('project.task', [['user_ids', 'in', [uid]], ['active', '=', true], ['date_deadline', '<=', today()]], ['id', 'name', 'project_id', 'date_deadline', 'priority', 'stage_id'], { limit: 20 }), // 逾期活动 this.searchRead('mail.activity', [['user_id', '=', uid], ['date_deadline', '<=', today()]], ['id', 'summary', 'date_deadline', 'activity_type_id', 'res_model', 'res_id', 'state'], { limit: 20 }), // 我的待处理工单 this.searchRead('helpdesk.ticket', [['user_id', '=', uid], ['active', '=', true]], ['id', 'name', 'ticket_ref', 'priority', 'stage_id', 'sla_deadline', 'sla_fail'], { limit: 10 }).catch(() => ({ records: [] })), // 逾期应收发票 this.getOverdueInvoices().then(r => ({ records: r })).catch(() => ({ records: [] })), // 需要跟进的商机(today activities) this.searchRead('crm.lead', [['user_id', '=', uid], ['active', '=', true], ['activity_date_deadline', '<=', today()]], ['id', 'name', 'partner_id', 'stage_id', 'probability', 'expected_revenue', 'activity_summary'], { limit: 10 }).catch(() => ({ records: [] })), // 未读消息 this.searchRead('mail.message', [['message_type', '!=', 'notification'], ['to_read', '=', true]], ['id', 'subject', 'author_id', 'date', 'model', 'res_id'], { limit: 10 }), ]); return { todayTasks: todayTasks.records, overdueActivities: overdueActivities.records, openTickets: openTickets.records, overdueInvoices: overdueInvoices.records, crmFollowUps: crmFollowUps.records, unreadMessages: unreadMessages.records, }; } // ==================== 私有传输层 ==================== private async rpc(endpoint: string, params: Record<string, unknown>): Promise<unknown> { const url = `this.urlendpoint`; const payload = { jsonrpc: '2.0', method: 'call', id: Date.now(), params }; const headers: Record<string, string> = { 'Content-Type': 'application/json' }; if (this.session_id) headers['Cookie'] = `session_id=this.session_id`; const response = await fetch(url, { method: 'POST', headers, body: JSON.stringify(payload) }); if (!response.ok) throw new Error(`HTTP response.status: response.statusText`); const data = await response.json() as { result?: unknown; error?: OdooError }; if (data.error) return { error: data.error }; return data.result; } } FILE:src/types/index.ts /** * 火一五·辉火云企业套件插件 — 类型定义 */ /** Odoo 连接配置 */ export interface OdooConfig { url: string; db: string; username: string; password: string; } /** Odoo 会话信息 */ export interface OdooSession { uid: number; session_id: string; user_context: Record<string, unknown>; company_id: number; partner_id: number; name: string; login: string; } /** Odoo API 错误 */ export interface OdooError { code: number; message: string; data?: { name: string; debug: string; message: string; arguments: unknown[]; }; } /** Odoo 记录(通用) */ export interface OdooRecord { id: number; [key: string]: unknown; } /** 同步更新 — 判别联合,用于 exhaustive switch */ export type SyncUpdate = | { type: 'todo'; action: 'create' | 'update' | 'delete'; id: number; data: OdooRecord; timestamp: number } | { type: 'activity'; action: 'due'; id: number; data: OdooRecord; timestamp: number } | { type: 'message'; action: 'create'; id: number; data: OdooRecord; timestamp: number } | { type: 'email'; action: 'create'; id: number; data: OdooRecord; timestamp: number } | { type: 'calendar'; action: 'create' | 'update'; id: number; data: OdooRecord; timestamp: number }; /* ═══════════════════════════════════════════════════════════════════════════ * 通知总线契约(跨插件) * * 本插件不关心消息最终走哪个渠道(企微 / 钉钉 / 飞书 / webhook …), * 只把 Odoo 事件封装成 NotificationEnvelope 并通过全局 bus 发布。 * 其他插件(@huo15/wecom、@huo15/dingtalk 等)注册为订阅者或 transport 即可。 * * 单一事实:`globalThis[Symbol.for('openclaw.huo15.notification-bus.v1')]` * ═══════════════════════════════════════════════════════════════════════════ */ export type NotificationKind = 'todo' | 'activity' | 'message' | 'email' | 'calendar'; export type NotificationPriority = 'low' | 'normal' | 'high' | 'urgent'; /** 跨渠道统一通知信封 */ export interface NotificationEnvelope { /** 稳定唯一 id,格式 `odoo:{agentId}:{kind}:{recordId}`,下游可据此去重 */ id: string; /** 来源插件 id */ source: 'odoo'; /** 所属 OpenClaw agent(通常对应一位最终用户) */ agentId: string; /** 通知种类 */ kind: NotificationKind; /** 动作:create / update / due / … */ action: string; /** 优先级 */ priority: NotificationPriority; /** 一行标题 */ title: string; /** 一句话摘要,纯文本 */ summary: string; /** 可选的较长纯文本正文 */ body?: string; /** 可选的 markdown 正文,渠道如支持优先使用 */ markdown?: string; /** 深链(回到 Odoo 原记录) */ link?: { url: string; label?: string }; /** 用于路由/过滤的标签 */ tags?: string[]; /** 生成时间戳(ms) */ createdAt: number; /** 源记录元信息,渠道可用来构造自己的 deep-link */ origin?: { url?: string; model?: string; resId?: number; }; /** 原始 Odoo 字段,供渠道自定义渲染 */ raw?: Record<string, unknown>; } /** 渠道投递目标(通用包),字段含义由各渠道 transport 自己解释 */ export interface ChannelTarget { channel: string; userId?: string; chatId?: string; extra?: Record<string, unknown>; } /** 渠道投递结果 */ export interface DeliveryResult { ok: boolean; channel: string; messageId?: string; error?: string; } /** * 渠道 transport 契约 —— 由渠道插件(企微、钉钉…)向总线注册。 * 本插件只调用 deliver(),不关心内部实现。 */ export interface ChannelTransport { /** 渠道标识:'wecom' | 'dingtalk' | 'feishu' | 'webhook' | … */ name: string; /** UI 描述(可选) */ description?: string; /** 把 envelope 投递给具体 target */ deliver(envelope: NotificationEnvelope, target: ChannelTarget): Promise<DeliveryResult>; /** 把 target 描述为人类可读字符串(可选,用于 UI) */ describeTarget?(target: ChannelTarget): string; } /** * 入站回复载荷(从渠道回到源系统) * * 典型场景:用户在企微/钉钉里对某条 Odoo 通知直接回复一句话, * 渠道插件把这段文字打包成 InboundReply 发回总线, * Odoo 插件收到后在对应记录的 chatter 里写一条 mail.message。 */ export interface InboundReply { /** 被回复的 envelope id */ envelopeId: string; /** 回复来自哪个渠道 */ channel: string; /** 渠道里回复的人(渠道自己解释) */ fromUser?: string; /** 纯文本正文 */ body: string; /** 可选的 HTML / Markdown 渲染 */ html?: string; /** 附件 URL(若渠道支持) */ attachments?: Array<{ url: string; name?: string; mime?: string }>; /** 渠道原始事件,供调试/审计 */ raw?: Record<string, unknown>; } /** 回复处理结果 */ export interface ReplyResult { ok: boolean; handled: number; errors?: string[]; } /** * 每 agent 的通知偏好 —— 让用户能说"别发待办通知"/"夜里静音" * * 过滤发生在生产者一侧(Odoo 插件);bus 依然是无感知的纯广播。 */ export interface NotificationPreferences { /** 主开关;false 时 Odoo 插件不再发布任何 envelope */ enabled: boolean; /** 允许发布的 kind 列表;空数组视为全放行 */ kinds: NotificationKind[]; /** 低于此优先级的不发 */ minPriority: NotificationPriority; /** 静音时段(24h 制,HH:MM,按服务器本地时区) */ quietHours?: { start: string; end: string }; /** 更新时间戳 */ updatedAt: number; } /** 意图解析结果 */ export interface IntentResult { intent: string; confidence: number; entities: Record<string, unknown>; model?: string; method?: string; } /** 自定义意图模式 */ export interface IntentPattern { pattern: string; intent: string; model?: string; method?: string; } /** 插件配置(对应 openclaw.plugin.json configSchema) */ export interface OdooPluginConfig { odoo?: OdooConfig; sync?: { enabled: boolean; intervalSeconds: number; channels: string[]; }; } FILE:src/utils/date-utils.ts /** * 日期工具函数 * * 解析相对日期(今天/明天/后天/下周等)并转换为 Odoo 格式 */ /** 返回今日日期字符串 YYYY-MM-DD */ export function today(): string { return new Date().toISOString().split('T')[0]!; } /** 返回明天日期字符串 YYYY-MM-DD */ export function tomorrow(): string { const d = new Date(); d.setDate(d.getDate() + 1); return d.toISOString().split('T')[0]!; } /** 返回 N 天后的日期字符串 YYYY-MM-DD */ export function daysFromNow(n: number): string { const d = new Date(); d.setDate(d.getDate() + n); return d.toISOString().split('T')[0]!; } /** * 格式化 Date 对象为 Odoo datetime 字符串 * 格式:YYYY-MM-DD HH:MM:SS */ export function formatOdooDatetime(date: Date): string { return date.toISOString().replace('T', ' ').substring(0, 19); } /** * 格式化 Date 对象为 Odoo date 字符串 * 格式:YYYY-MM-DD */ export function formatOdooDate(date: Date): string { return date.toISOString().split('T')[0]!; } /** * 解析相对日期描述,返回 Odoo 格式的 datetime 字符串 * * @param relative 相对词:'tomorrow' | 'day_after_tomorrow' | 'next_week' | null * @param explicitDate 显式日期字符串 YYYY-MM-DD,优先于 relative * @param slot 'start'(默认09:00)或 'end'(默认18:00) */ export function resolveRelativeDate( relative: string | null | undefined, explicitDate: string | null | undefined, slot: 'start' | 'end' = 'start', ): string { const now = new Date(); if (explicitDate) { const date = new Date(explicitDate); date.setHours(slot === 'start' ? 9 : 18, 0, 0, 0); return formatOdooDatetime(date); } if (relative === 'tomorrow') { now.setDate(now.getDate() + 1); } else if (relative === 'day_after_tomorrow' || relative === 'day after tomorrow') { now.setDate(now.getDate() + 2); } else if (relative === 'next_week' || relative === 'next week') { now.setDate(now.getDate() + 7); } else if (relative === 'this_week' || relative === 'this week') { const dayOfWeek = now.getDay(); const daysUntilFriday = ((5 - dayOfWeek) + 7) % 7 || 7; now.setDate(now.getDate() + daysUntilFriday); } now.setHours(slot === 'start' ? 9 : 18, 0, 0, 0); return formatOdooDatetime(now); } FILE:src/utils/md-to-html.ts /** * 极简 Markdown → HTML 转换 * * 范围:覆盖"聊天写知识库"场景的常见语法,不追求完整 CommonMark。 * - 段落(空行分段) * - 标题 # / ## / ### → h3/h4/h5(Odoo 文章内 h1/h2 通常留给结构块) * - 无序列表 - / * * - 有序列表 1. / 2. * - 行内:**bold** *italic* `code` [text](url) * - 换行:单换行 → <br> * * 若传入看起来已经是 HTML(检测到 `<tag>` 样式),原样返回。 */ const HTML_LIKE = /<(?:[a-z][\w-]*|!--|\/[a-z])/i; export function mdToHtml(input: string): string { if (!input) return ''; if (HTML_LIKE.test(input)) return input; // 按空行切分段落 const blocks = input.replace(/\r\n/g, '\n').split(/\n\s*\n/); return blocks.map(renderBlock).filter(Boolean).join('\n'); } function renderBlock(block: string): string { const lines = block.split('\n'); if (lines.length === 0) return ''; // 标题 const h = /^(#{1,3})\s+(.+)$/.exec(lines[0]!); if (h && lines.length === 1) { const level = Math.min(5, h[1]!.length + 2); // # → h3, ## → h4, ### → h5 return `<hlevel>renderInline(h[2]!)</hlevel>`; } // 有序列表 if (lines.every(l => /^\s*\d+\.\s+/.test(l))) { const items = lines.map(l => `<li>renderInline(l.replace(/^\s*\d+\.\s+/, ''))</li>`); return `<ol>items.join('')</ol>`; } // 无序列表 if (lines.every(l => /^\s*[-*]\s+/.test(l))) { const items = lines.map(l => `<li>renderInline(l.replace(/^\s*[-*]\s+/, ''))</li>`); return `<ul>items.join('')</ul>`; } // 普通段落 —— 保留软换行 const html = lines.map(renderInline).join('<br>'); return `<p>html</p>`; } function renderInline(text: string): string { let s = escapeHtml(text); // 链接必须在 bold/italic 之前,因为 url 里可能含 * s = s.replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>'); s = s.replace(/`([^`]+)`/g, '<code>$1</code>'); s = s.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>'); s = s.replace(/(?<!\*)\*([^*\s][^*]*)\*(?!\*)/g, '<em>$1</em>'); return s; } function escapeHtml(s: string): string { return s .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } FILE:tsconfig.json { "compilerOptions": { "target": "ES2022", "module": "ESNext", "moduleResolution": "bundler", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "outDir": "./dist", "rootDir": "./", "declaration": true, "declarationMap": true, "sourceMap": true, "types": ["node"] }, "include": [ "index.ts", "src/**/*.ts" ], "exclude": [ "node_modules", "dist" ] }
火一五·克劳德·龙虾增强插件 v5.7.8 — 全面适配 openclaw 2026.4.24:peerDep ^4.24 + build/compat 同步到 4.24 + 14 处 api.on 全部去掉 as any 改成 typed hook(hookName 联合类型 + handler 自动推断 Pl...
---
name: huo15-openclaw-enhance
description: "火一五·克劳德·龙虾增强插件 v5.7.8 — 全面适配 openclaw 2026.4.24:peerDep ^4.24 + build/compat 同步到 4.24 + 14 处 api.on 全部去掉 as any 改成 typed hook(hookName 联合类型 + handler 自动推断 PluginHookHandlerMap[K]) + manifest 加 enabledByDefault/uiHints/activation 元数据 + 修 self-check 之前被 as any 屏蔽的 PluginHookBeforeAgentReplyResult 类型不匹配 bug。继承 v5.7.7 session-lifecycle + v5.7.5 skill-recommender + v5.7.4 扫 bare pluginApi + v5.7.3 config-doctor + v5.7.2 hardening + v5.7.1 hot-fix + v5.7 transcript-search + v5.6 工具分层;捆绑 11 个配套 skill"
version: 5.7.9
homepage: https://cnb.cool/huo15/ai/huo15-openclaw-enhance
metadata: { "openclaw": { "emoji": "🦞", "requires": { "bins": [] } } }
---
# 火一五·克劳德·龙虾增强插件 v5.7.8
## 简介
`@huo15/openclaw-enhance` 是 **OpenClaw 2026.4.24+** 的**非侵入式**增强插件,对标 Claude Code 的 Agent Harness 体验。
**核心原则**:凡是龙虾原生有的功能一律不复制,重叠处以龙虾为准;只补龙虾没有的 Claude-Code 体验。
## v5.7.8 全面适配 openclaw 2026.4.24(2026-04-26 同日)
跑完整 SOP 第 1+2 步发现 openclaw 4.24 的 `api.on` 是**完全 typed**(`<K extends PluginHookName>(hookName: K, handler: PluginHookHandlerMap[K])`),enhance 之前 14 处 `api.on(...as any)` 都能去掉 cast。这是真正的"全面适配"——不是简单升 peerDep,而是让 enhance 利用 SDK 的全部类型信息。
| 维度 | v5.7.7 | v5.7.8 |
|---|---|---|
| `peerDependencies.openclaw` | `^2026.4.22` | **`^2026.4.24`** |
| `build.openclawVersion` | `2026.4.11`(落后 13 patch)| **`2026.4.24`** |
| `compat.pluginApi` | `>=2026.4.11` | **`>=2026.4.24`** |
| `api.on(...as any)` 使用次数 | 14 处 | **0 处** |
| `(event: any, ctx: any)` 使用次数 | 5 处 | **0 处** |
| `(ctx as any)?.agentId` 模式(hook 内部)| 9 处 | **0 处**(仅余 4 处 helper 函数内部,工具 ctx 用) |
| typecheck 错误数 | 0 | **0** |
| openclaw.plugin.json 顶层字段 | 5 | **8**(加 `enabledByDefault` / `uiHints` / `activation`)|
### 隐藏 bug 修复
去掉 `as any` 后 typecheck 暴露 **self-check.ts 长期被屏蔽的类型不匹配**:之前 `return {};` 试图返回 `PluginHookBeforeAgentReplyResult`,但该类型 `handled: boolean` 是必填的——空对象不合规。修法:所有"不接管"分支改成 `return;`(void),仅"阻断空回复"分支返回 `{ handled: true, reply: ..., reason: ... }`。
### 不破坏 openclaw 原生
- 所有 hook handler **return undefined(void)** — 不返回 `{block, prependContext}` 等控制信号时 → enhance 仅观察+附加,绝不改变 openclaw 决策
- typed hook handler 实际行为跟 untyped 完全一致 — 只是 TS 编译期能 narrow 类型,运行时无差异
- manifest 新加的 `enabledByDefault` / `uiHints` / `activation` 都是 openclaw 4.x 已有字段,不引入新依赖
## v5.7.7 session-lifecycle(2026-04-26 同日,跑完整 gap 调研后落地)
**调研依据**:跑了一次完整 SOP 第 1+2 步(Claude Code 官方 hooks 文档 + 反编译 Claude.app + openclaw 4.22 SDK)。发现 **openclaw 4.22 暴露 29 个 hook,enhance 之前只用 4 个**。落地最高 ROI 的 5 个 hook 闭环 session 生命周期:
| Hook | enhance 行为 | 落地表 |
|---|---|---|
| `session_start` | idle > 30min 时插入"🚀 会话开始/续启"章节占位 | `chapters` |
| `session_end` | 加"🏁 会话结束"章节 + flush in_progress todo 到 project memory(tag=session-flush, importance=4) | `chapters` + `memories` |
| `before_reset` | reset 前最后机会抢救最近 3 章节 + 全部未完成 todo 到 decision memory(tag=reset-rescue, importance=6)+ 推 notification | `memories` |
| `subagent_spawned` | 派生子 agent 时加"🤖 派生子 agent: X"章节 | `chapters` |
| `subagent_ended` | 子 agent 结束加"✅/❌ 子 agent 结束: X"章节 | `chapters` |
**防 noise factory 三层防御**(吸收 v5.7.1 教训):30 秒 dedup + 低 importance + 专用 tag(不进黑名单,用户下次会想恢复)。
## v5.7.5 skill-recommender(2026-04-26 同日)
**用户反馈**:"新增自动根据用户的需求自动挑选已经安装的技能,如果没有技能就把规划方案给出来。看看 Claude 是如何做的"
**调研**:反编译 `/Applications/Claude.app/Contents/Resources/app.asar`,发现 Claude 的 skill auto-discovery **本质是把所有 skill 的 name+description 拼成 `"Available skills: list."` 注入到 specialist agent 的 system prompt** —— 没有复杂算法,让 LLM 自己挑。
**enhance 改造**:照搬 name+description 匹配思路,但**改成按需工具**避免每轮 prompt 占 schema。新增模块 `skill-recommender` + 工具 `enhance_skill_recommend(query, limit?, includeUninstalled?, includePlanning?)`:
1. **启动期扫多路径**(WeCom / DingTalk 多 agent 场景关键):
- `~/.openclaw/skills/`
- `~/.openclaw/workspace/skills/`
- `~/.openclaw/workspace-*/skills/` ← **WeCom 多 agent 动态 workspace**(一开始漏扫,烟测才发现)
- `~/.openclaw/agents/*/skills/`
- `<cwd>/.claude/skills/`、`~/.claude/skills/`
- 实测用户机器扫到 56 个 skill 跨 27 个路径
2. **解析 SKILL.md frontmatter**(轻量正则,无 yaml 依赖):name + description + aliases
3. **CJK 双字滑窗 + alias 强 boost** 评分:
- JS `\w` 不含中日韩,直接 split `\s\W` 会让"代码简化"分成空数组
- 解决:CJK 连续段当整体 phrase + 长 ≥4 时滑动 2-grams
- alias 的 token 严格命中(如 "规划" === alias "规划")→ 保底 0.7 分
4. **三段式输出**:
- 🎯 已装 skill(命中 ≥ threshold=0.25)+ 召唤建议
- 📦 ClawHub 上未装的 huo15-* 候选(含 `openclaw skills install` 命令)
- 🛠️ 都没合适 → **自建 skill 规划**:建议 slug + frontmatter 模板 + 触发关键词 + 内容大纲 + **红线 #3 提醒**(必须先 ClawHub publish 再让 enhance 引用 slug,插件不内嵌 skill 内容)
**实测**:
| 查询 | 命中 | 分数 |
|---|---|---|
| "帮我 review 这个 PR" | huo15-openclaw-code-review | 0.60 |
| "设计一个 Web UI 原型" | huo15-openclaw-frontend-design | 0.94 |
| "代码简化" | huo15-openclaw-simplify | 1.00 |
| "做安全审查" | huo15-openclaw-security-review | 0.96 |
| "规划这个任务" | huo15-openclaw-plan-mode | 0.70(alias exact 命中保底)|
模块 `tier=2`(balanced 默认启用,minimal 不暴露 — 用户多半已知道用什么 skill)。
## v5.7.4 config-doctor 扩展:扫已装插件 bare pluginApi(2026-04-26 同日)
**用户反馈**:"提示插件要求 2026.2.24,但是我的 openclaw 已经是 2026.4.22"
**根因**:openclaw plugin compat 规则要求 `compat.pluginApi` 必须是 ranged spec(`>=X.Y.Z` / `^X.Y.Z` / `~X.Y.Z`)。**bare 字符串(如 `"2026.2.24"` 没前缀)= 精确匹配**,与当前 openclaw 不匹配时启动失败。用户实测:
- `~/.openclaw/extensions/tips/package.json` v1.0.0 → `pluginApi: "2026.4.11"` ❌
- `~/.openclaw/node_modules/@huo15/huo15-huihuoyun-odoo/package.json` v1.2.0(npm peerDep 残留)→ `pluginApi: "2026.2.24"` ❌
**新增**:`config-doctor` 启动期扫描 `~/.openclaw/extensions/*` + `~/.openclaw/node_modules/@huo15/*` + 无 scope 的 `node_modules/*`,对每个声明 `openclaw.extensions` 的包检查 `compat.pluginApi`。bare 命中 → 推仪表盘 + log warn + 给可粘贴 fix 命令。
```
⚠️ [plugin-bare-pluginApi] 已装插件 @huo15/wecom-tips 的 openclaw.compat.pluginApi="2026.4.11" 是 bare 版本,会被解读为精确匹配...
→ 修复: python3 -c "..."(一行 inline)
```
## v5.7.3 config-doctor(2026-04-26 同日)
直击用户高频反馈"装上插件还是 'Context limit exceeded'" — 根因往往不在插件,而在 `~/.openclaw/openclaw.json` 的两处陷阱:
1. **缺失 `agents.defaults.compaction.reserveTokensFloor`** — openclaw 4.22 把这个字段嵌套到 `agents.defaults` 里(4.11 时是顶层 `compaction`),老用户配置文件没自动迁移,用 4.22 默认值(很小)→ 长 session 必爆
2. **某个 model 的 `maxTokens` 占 `contextWindow` 一半以上** — 例如 MiniMax-M2.7 默认 maxTokens=131072 / contextWindow=204800,每轮预留输出就吃掉 64% budget。openclaw 把 maxTokens 当作"必须留给输出的 reserve",剩 73k 给 input/tools/memory,**任意几轮就爆**
### 新增
- **`src/modules/config-doctor.ts`** — 启动期 sync 读 openclaw.json 检查上述两类陷阱,发现后用 `notifyQueue.emit("config-doctor", ...)` 推到仪表盘 + log warn + 给可粘贴的 fix 命令(python3 一行原地改 JSON,**不调 child_process**)
- **工具:`enhance_config_doctor`** — 无参数,agent / 用户随时调一下,重新跑诊断(修完了配置可以再跑确认 ✅)
- **配置项:`config.configDoctor`** — `enabled` / `minReserveTokensFloor`(默认 5000)/ `maxReserveTokensFloor`(默认 100000)/ `maxModelMaxTokens`(默认 32000)
### 红线遵守
- **完全只读** ~/.openclaw/openclaw.json(红线 #1:不侵入式修改 openclaw)
- **不调 child_process**(红线 #4) — 修复命令是 python3 inline,由用户/cron-cli 执行
- **不暴露在 minimal 之外**?反过来:**tier=1 minimal 也启用** — 这是关键的"防爆 context"诊断,每个用户都该有
## v5.7.2 hardening(2026-04-26 同日)
继 v5.7.1 hot-fix 之后,对全代码库做了一次审计,修复 4 类潜在 bug:
- **进程内 Map LRU 上限** — `mode-gate` 的 `modeState` / `plannedActions` 和 `session-recap` 的 `lastRecapAt` 之前 keyed by `agentId::sessionId` **跨 session 永不清**。WeCom 多用户场景下 100+ session 会无限累积。现在加 200/200/500 三档 LRU cap,活跃 session 重新插入刷新顺序,老 session 自动淘汰
- **safety_log / notifications 启动期 TTL** — `getDb()` 时跑一次 `DELETE WHERE created_at < datetime('now', '-90 days')`,避免长期运行库无限增长。新增 `purgeOldSafetyLogs(retentionDays)` helper 给运维调
- **memory corpus tag 黑名单** — `auto-compact` / `auto-checkpoint` / `audit` / `internal` 这 4 个保留 tag 在 `scoreRelevance()` 入口直接 return 0,永不召回到 prompt(防御未来 hook 万一又写入 noise)
- **enhance_memory_store 拒收保留 tag** — 用户/agent 显式调 store 时若 tags 含保留词,立即返回错误而非写入
### bump openclaw peerDep `^2026.4.22`
之前 peerDep `>=2026.4.11`,但 npm global 已升到 2026.4.22(差 11 个 patch)。本地 SDK 类型定义同步升级;hook 名验证全部仍存在(`before_prompt_build` / `before_tool_call` / `after_tool_call` / `before_compaction` / `before_agent_reply`),无破坏性变更。
## v5.7.1 hot-fix(2026-04-26)
- **删除 `before_compaction` 噪音 hook** — 之前每次 openclaw auto-compact 都会以 `decision` 类、`auto-compact` tag 写入一条「[auto-compact] 对话上下文已压缩…」记忆。实测单 agent 24 小时积累 613 条全是噪音,关键词命中率虚高 0.4-0.5(过 corpus pruner 默认 0.5 阈值),把真正的 user/project/feedback 决策记忆挤出 prompt 上下文
- **新增工具 `enhance_memory_purge`** — 按 `tag` / `category` / `contentLike` 批量清理当前 agent 记忆,`dry_run` 默认 true(仅预览匹配数)。一键清理历史噪音:`enhance_memory_purge tag="auto-compact" dry_run=false`
- **首次启动自动迁移**:升级到 5.7.1 后即不再生成新噪音;旧噪音留待用户用 purge 工具或直接 SQL 清
## v5.7 新特性(2026-04-25)
- **历史会话搜索(transcript-search)** — 照搬 Claude Desktop `transcriptSearchWorker` 算法(解包 `/Applications/Claude.app/Contents/Resources/app.asar` 抽出参考实现):
- 流式扫 `~/.openclaw/agents/<agentId>/sessions/*.jsonl`,行级 JSON.parse
- `extractText` 兼容 `string` / `[{type:"text", text}]` 数组
- `indexOf` 子串匹配 + ±80 字符 snippet
- 79 个 session 中扫 30 个 → 3–5 ms 找到 5 个 hits(实测)
- 完全只读、不建索引、不建表 — 不动 openclaw 任何东西
- **工具:`enhance_transcript_search`** — `query` 必填;可选 `agentId / limit / includeReset / caseSensitive`
- 模块 tier=2,默认 balanced/full 即可见(minimal 下不暴露)
## v5.6 新特性(2026-04-24)
- **工具分层(toolTier)** — 按 minimal/balanced/full 三档暴露工具 schema,降低每轮 prompt 固定底座(解决长会话 context 提早爆满)
- `minimal`(10 工具):仅核心层 — 记忆 / 状态栏 / spawn / 模式 / 章节 / installer / integrator
- `balanced`(18 工具,默认):+ todo / 章节 / 定时任务桥
- `full`(26 工具):+ workflow / safety / task-planner / session-recap / skill-doctor
- **Workflow 5→2 工具合并** — `enhance_workflow_define / _list / _delete / _tasks / enhance_task` 合并为 `enhance_workflow`(action=define/list/delete/tasks)+ `enhance_task`(保留独立 action 派发器)
- **工具描述全面压缩** — 26 个工具描述从 ~4610 字符 → ~1750 字符(-62%),每轮 prompt 节省约 1400 token,prompt cache 更稳
## 一键安装
```bash
openclaw plugins install @huo15/openclaw-enhance
openclaw restart
```
安装后访问仪表盘:`http://localhost:18789/plugins/enhance/`
## 核心能力
- **分类记忆(corpus supplement + 两段式)** — user / project / feedback / reference / decision 五类,额外支持 `why`(背景/约束)和 `howToApply`(套用时机),对齐 Claude Code feedback/project 记忆体例;通过 `registerMemoryCorpusSupplement` 合入龙虾原生 memory 搜索,**不自建第二套向量库**;搜索结果 deterministic 排序(score → importance → updated → id)保持 prompt cache 稳定
- **工具安全观察员** — 只分类错误 + 建议退避,完全尊重龙虾 `tools.allow/deny`
- **任务 / 章节 / 模式闸门 + ExitPlanMode** — TodoWrite / mark_chapter / plan-explore 模式;`enhance_exit_plan_mode` 在 plan 模式下提交计划给用户审批,自动把计划期间被拦截的写入意图打包成 decision 记忆
- **状态栏(含可观测性)** — `enhance_statusline` 额外展示当前模型、思考档、fast 模式、消息通道、会话 ID;HTTP 端点 `/plugins/enhance/api/statusline` 输出 JSON 供仪表盘嵌入
- **技能巡检 / 子任务一键派发** — `enhance_spawn_task` 返回可直接粘贴到终端的 `openclaw agent` CLI 命令,支持跨 agent 派发和思考档选择
- **定时任务桥** — 登记定时工作流时返回一条 `openclaw cron add` 命令;**调度归龙虾 cron-cli**,插件只管触发时注入 instructions
- **多 Agent 隔离** — 完美适配 WeCom 插件的动态 Agent,记忆/任务/章节/宠物全部按 `agentId` 隔离
- **增强仪表盘** — 小火苗宠物 + 记忆/任务/章节/定时全景
## 与龙虾原生的关系
| 能力 | 龙虾原生 | enhance 策略 |
|------|---------|--------------|
| 记忆向量库 | ✅ 龙虾负责 | 不复制,改为 corpus supplement 并入搜索 |
| 工具 allow/deny | ✅ 龙虾负责 | 只观察,不拦截 |
| Cron 调度 | ✅ 龙虾 cron-cli | 不管理调度,只在触发时注入上下文 |
| 技能安装 | ✅ ClawHub | 只读巡检,不擅自安装 |
## 增强技能(自动注入 `workspace/skills/`)
**工作流模式(4 个)**
- `huo15-openclaw-plan-mode` — 结构化规划模式
- `huo15-openclaw-explore-mode` — 深度探索模式
- `huo15-openclaw-verify-mode` — 验证检查模式
- `huo15-openclaw-memory-curator` — 记忆整理
**设计能力(v5.4 新增,对标 Anthropic frontend-design + huashu-design 生态)**
- `huo15-openclaw-frontend-design` — 高保真 Web UI 原型 + 5 美学流派 + 反 AI Slop 硬红线
- `huo15-openclaw-design-director` — 设计方向顾问(3 方向反差对比 + 强制推荐)
- `huo15-openclaw-brand-protocol` — 品牌规范抓取(Ask/Search/Download/Verify/Codify 5 步)
- `huo15-openclaw-design-critique` — 5 维设计评审(美学/可用性/品牌/内容/实现)
**开发辅助(v5.5.1 新增,对标 Claude Code /simplify / /security-review / /review)**
- `huo15-openclaw-simplify` — 代码简化三维审查(复用/质量/效率)+ 分级修复清单
- `huo15-openclaw-security-review` — 六类漏洞矩阵(密钥/注入/XSS/SSRF/权限/依赖)+ CVSS 分级
- `huo15-openclaw-code-review` — PR 五维综合评审(设计/实现/测试/安全/可维护)+ 可粘贴评论
详见 [README.md](./README.md) 与 [CHANGELOG.md](./CHANGELOG.md)。
## 链接
- npm: https://www.npmjs.com/package/@huo15/openclaw-enhance
- 仓库: https://cnb.cool/huo15/ai/huo15-openclaw-enhance
- License: MIT
- 公司: 青岛火一五信息科技有限公司 — www.huo15.com
FILE:CHANGELOG.md
# CHANGELOG
本插件语义化版本号与龙虾适配版本解耦:`package.json.version` 为插件自身的发布版本,`openclaw.build.openclawVersion` 为目标龙虾版本。
## 5.7.8 — 2026-04-26(全面适配 openclaw 2026.4.24:typed hooks + manifest 元数据补齐)
**用户反馈**:"enhance 插件帮我全面适配 openclaw 最新版本"
跑 SOP 第 1+2 步:(a) `npm view openclaw version` → **2026.4.24**(latest,比当前用的 4.22 多 1 个 minor);(b) 升级本地 SDK + 跑 typecheck 看不兼容点;(c) 盘点 `as any` 找清理空间。
### 关键发现
读 openclaw 4.24 SDK 类型定义 `node_modules/openclaw/dist/plugin-sdk/src/plugins/types.d.ts`:
```typescript
on: <K extends PluginHookName>(
hookName: K,
handler: PluginHookHandlerMap[K],
opts?: { priority?: number }
) => void;
```
`api.on` 是**完全 typed**!每个 hook 名都对应具体的 `(event, ctx)` 类型:
- `before_tool_call: (event: PluginHookBeforeToolCallEvent, ctx: PluginHookToolContext) => PluginHookBeforeToolCallResult | void`
- `session_start: (event: PluginHookSessionStartEvent, ctx: PluginHookSessionContext) => void`
- `subagent_spawned: (event: PluginHookSubagentSpawnedEvent, ctx: PluginHookSubagentContext) => void`
- ... 全 29 个 hook 都有 typed signature
但 enhance 当前 **14 处 `api.on(...as any, (event: any, ctx: any) => ...)`** —— 把 SDK 的 typed 体验全屏蔽了。这是历史遗留,因为最早期某些版本的 openclaw SDK 没有 typed hook。现在 SDK 完全支持,enhance 该跟上。
### 新增
- **`peerDependencies.openclaw: ^2026.4.22 → ^2026.4.24`** — 跟随 latest tag
- **`openclaw.build.openclawVersion: 2026.4.11 → 2026.4.24`** — 同步(落后了 13 个 patch)
- **`openclaw.compat.pluginApi: >=2026.4.11 → >=2026.4.24`** — 同步
- **`openclaw.plugin.json` 加 3 个新字段**:
- `enabledByDefault: true` — 装上后默认启用,免去用户手工 `enabled: true` 配置
- `uiHints` — control-ui 渲染配置面板时用的 widget 提示(switch / select / 中文 label)
- `activation.onAgentHarnesses: ["claude", "openclaw-default"]` — 声明在哪些 agent harness 下激活
### 修改
- **`api.on(...as any) → api.on(...)` 14 处全清理**:
- `src/modules/session-lifecycle.ts`(5 个 hook:session_start/end/before_reset/subagent_spawned/subagent_ended)
- `src/modules/scheduled-tasks-bridge.ts`(before_prompt_build)
- `src/modules/self-check.ts`(before_agent_reply)
- `src/modules/session-recap.ts`(before_prompt_build)
- `src/modules/task-planner.ts`(before_prompt_build)
- `src/modules/workflow-hooks.ts`(before_prompt_build)
- `src/modules/tool-safety.ts`(before_tool_call + after_tool_call)
- `src/modules/mode-gate.ts`(before_tool_call)
- **`(event: any, ctx: any) → (event, ctx)` 5 处**:让 TS 自动从 PluginHookHandlerMap[K] 推断
- **`(ctx as any)?.agentId → ctx?.agentId` 9 处**:每个 PluginHook*Context 都有 `agentId?: string` 字段,无需 cast
- **`pickAgentId(ctx: unknown) → pickAgentId(ctx: { agentId?: string } | undefined)`** 6 个文件:structural typing 兼容 hook ctx 和 tool ctx 两种调用源
### 修复(typecheck 暴露的隐藏 bug)
- **`src/modules/self-check.ts`**:去掉 `as any` 后 TS 报 `Type '{ handled?: undefined; ... }' is not assignable to PluginHookBeforeAgentReplyResult`。根因是 `PluginHookBeforeAgentReplyResult.handled: boolean` **必填**,但 enhance 之前在"不接管"分支返回 `{};`。修法:所有 "不接管"分支改成 `return;`(void),仅"阻断空回复"分支返回 `{ handled: true, reply: {...}, reason: "..." }`。
### 不破坏
- 全部 14 处 hook handler **return undefined(void)** 时跟之前 `return {}` 行为完全一致 —— openclaw 把"void 返回值"和"空对象"等同视为"不接管"
- typed handler 跟 untyped 运行时无差异,只是编译期能 narrow 类型
- 没改任何 SQLite schema、没引入新 npm 依赖
- 老用户从 v5.7.7 升级零成本
### 剩余 `as any` 统计
| 用途 | 数量 | 是否合理 |
|---|---|---|
| `api.on(...as any)` | **0**(之前 14) | ✅ 完全清理 |
| SQLite `.get() as any[]` / `.all() as any[]` | 8 | ✅ better-sqlite3 设计返回 unknown,必须 cast |
| `registerTool factory ((ctx) => ({...})) as any` | 8 | ✅ SDK factory 模式 type-erasure 限制 |
| `(globalThis as any).process` | 1 | ✅ globalThis 类型限制 |
| `(event as any).prompt` | 2 | ⚠️ 待 SDK 暴露 PluginHookBeforePromptBuildEvent 类型时清 |
| `(ctx as any)` 在 helper 内部 | 4 | ⚠️ structural typing 不收紧也可(runtime 行为正确) |
| 其它(错误/边界) | ~32 | 大多是 ts-pattern 风格的边界处理 |
| **总计** | **55** | (从 v5.7.7 的 ~98 降到 55,约 -44%)|
### 设计决策
- **为什么不再追求 0 个 `as any`**:剩下的 55 处都是 SDK / 第三方库设计限制(better-sqlite3 / TypeBox factory),强行清理会引入运行时风险或 schema 复杂度
- **为什么 manifest 加 `activation.onAgentHarnesses`**:当 openclaw 之后引入 lazy plugin loading(现在还没),enhance 能精确声明在哪种 agent 类型下激活,避免 `claude` 之外的 agent harness 误加载
- **为什么 typed handler return `void` 而非 `{}`**:openclaw runtime 对 hook 返回值的处理是 `if (result && typeof result === "object") { ...apply hints... }`,`undefined` 跟 `{}` 行为一致;但 typed signature 要求 `PluginHookXxxResult | void`,`{}` 不满足"必填字段都填"
- **为什么不展开 `(event as any).prompt`**:`PluginHookBeforePromptBuildEvent` 类型存在但 SDK 没顶层 export;强制从子路径导入会增加耦合,等下次 SDK 升级 export 表了再清
### 不冲突 openclaw 4.24 的检查清单
| 检查项 | 状态 |
|---|---|
| 不修改 openclaw 源码 | ✅ |
| 所有 hook handler return void / 已知合法 result | ✅(已通过 typecheck 验证)|
| openclaw 4.24 hook 名都仍存在 | ✅(PluginHookName union 包含全部 29 个)|
| 不复制龙虾原生 memory / cron / tools.allow/deny / channel manifest | ✅ |
| `enabledByDefault` / `uiHints` / `activation` 字段在 4.24 manifest type 里都已定义 | ✅(PluginManifest 含这三个字段)|
| 模块清单中没有跟 openclaw 4.x 重叠的功能 | ✅(之前 v5.2 重写已经把 context-pruner 删了改成 corpus supplement,本轮再次 verify) |
### 调研依据
- `npm view openclaw version` → 2026.4.24
- `node_modules/openclaw/dist/plugin-sdk/src/plugins/hook-types.d.ts: PluginHookHandlerMap`:29 个 hook 完整 typed signature
- `node_modules/openclaw/dist/plugin-sdk/src/plugins/manifest.d.ts: PluginManifest`:完整 manifest 字段(含未用过的 enabledByDefault / uiHints / activation)
- 详见 KB `~/knowledge/huo15/2026-04-27-openclaw-enhance-v578-typed-hooks-postmortem.md`
---
## 5.7.7 — 2026-04-26(session-lifecycle:接入 openclaw 4.22 五个 hook 闭环 session 生命周期)
**用户反馈**:"结合 claude 官网的能力描述和本地 claude code 源码看看我们的 enhance 插件还有哪些可以完善的。但是不能干扰 openclaw 最新版的既有能力和跟 openclaw 最新版冲突。"
跑了完整 SELF_ITERATE.md SOP 第 1+2 步(信息更新 + gap 比对):
### 调研发现
- **Claude Code 官方 docs**(hooks 页)暴露 27 个 hook event names:SessionStart / UserPromptSubmit / PreToolUse / PermissionRequest / PermissionDenied / PostToolBatch / Stop / SubagentStart / SubagentStop / TaskCreated / TaskCompleted / InstructionsLoaded / ConfigChange / CwdChanged / FileChanged / PreCompact / PostCompact / Elicitation / Notification / WorktreeCreate / WorktreeRemove / SessionEnd 等
- **openclaw 2026.4.22 SDK** (`hook-types.d.ts: PluginHookName`) 暴露 **29 个 hook**,跟 Claude Code 一一对应(命名风格不同:`before_/after_/_end` 而非 Claude 的 `Pre/Post/Stop`)
- **enhance 之前只用 4 个 hook**:`before_prompt_build` / `before_tool_call` / `after_tool_call` / `before_agent_reply`
- **反编译 Claude.app** 看到 29 张 SQLite 表,最有价值的没用过:artifacts 多版本 + frames 树状对话历史 + notes 用户批注
### ROI top 5 候选
| # | 候选 | 工作量 | 价值 |
|---|---|---|---|
| **1** | **session-lifecycle**(接 session_start + session_end + before_reset + subagent_*)| ~250 行 | **🔴 立即闭环 session 生命周期** |
| 2 | tool-result-optimizer(接 tool_result_persist 大结果截断+摘要)| ~100 行 | 🟡 长 session 减负 |
| 3 | message_received hook 自动 skill 推荐 | ~60 行 | 🟡 跟 v5.7.5 接合 |
| 4 | artifacts 多版本管理(轻量 SQLite)| ~250 行 | 🔴 但量大留 v5.8 |
| 5 | frames 父子 session 关系跟踪 | ~150 行 | 🟠 留 v5.8 |
**v5.7.7 落地候选 #1**(最高 ROI)。
### 新增
- **`src/modules/session-lifecycle.ts`**(~250 行)— 接入 5 个 hook:
- `session_start` → idle > 30min 时插入"🚀 会话开始 / 续启"章节占位(不强制每个 session 都加章节,避免噪音)
- `session_end` → 加"🏁 会话结束"章节 + flush 未完成 in_progress todo 到 project memory(专用 tag `session-flush`, importance=4)
- `before_reset` → reset 前最后机会抢救最近 3 章节 + 全部未完成 todo 到 decision memory(专用 tag `reset-rescue`, importance=6)+ 推 notification 提醒
- `subagent_spawned` → 派生子 agent 时插入"🤖 派生子 agent: X"章节
- `subagent_ended` → 子 agent 结束插入"✅/❌ 子 agent 结束: X"章节
- **`types.ts: SessionLifecycleConfig`** — 5 个 hook 各自可关,含 `debug` 开关
- **`openclaw.plugin.json`** configSchema 加 `sessionLifecycle` 段
- **模块 `tier=1`** minimal 也启用——这是核心生命周期补全,**零工具 schema**(纯 hook 监听,不占 prompt 容量)
### 防 noise factory 三层防御(吸收 v5.7.1 教训)
v5.7.1 删了 `before_compaction` 噪音 hook 后总结了"不要在高频 hook 里无脑写记忆"。新增的 5 个 hook 也是高频(`session_start` 在多 agent 场景每分钟可能多次触发),所以严格控写:
1. **30 秒 dedup**:每个 hook 触发按 `event:agentId:sessionId` 拼 key 进 LRU Map(`MAX_RECENT_ENTRIES=500` + FIFO 淘汰),30 秒内重复触发跳过
2. **重要性低 + 专用 tag**:`session-flush` importance=4(不是用户主动决策);`reset-rescue` importance=6(reset 前抢救偏重要)。**故意不进 corpus pruner 黑名单**——这些是用户下次想恢复的实质内容,让 pruner 按相关度自然评分
3. **try-catch 包裹**:每个 hook handler 全包,错误只 log 不抛——绝不让插件因 hook 异常 crash
### 不破坏
- 完全只读 openclaw 状态,不修改任何龙虾原生表/文件
- 写入 `chapters` / `memories` 是 enhance 自有表(不污染龙虾原生 memory,v5.5.0 的 corpus supplement 边界保持)
- 没改 SQLite schema、没引入新 npm 依赖
- 没新增 enhance_* 工具(纯 hook 监听)
- 用户可单独关任意 hook(`config.sessionLifecycle.enableSessionStart = false`)
### 设计决策
- **为什么 tier=1**:纯 hook 监听零工具 schema,不占 prompt 容量;session 生命周期是核心补全(minimal 用户也该有);
- **为什么 session_start 不是每次都加章节**:避免每分钟一个 session 都新加章节造成 chapter 表膨胀;只在 idle > 30min 时加(用户真正"重启"的场景)
- **为什么 before_reset 比 session_end 重要性高**:reset 是用户主动清理,比自然结束激进;importance=6 vs 4 让 corpus pruner 优先返回 reset 抢救的内容
- **为什么不进 corpus pruner 黑名单**:tag=session-flush / reset-rescue 的内容是用户下次想恢复的工作("上次未完成的 X"),跟 v5.7.1 删的 auto-compact noise(信息量为 0)本质不同
- **为什么 subagent hook 也加章节而非 memory**:spawn 链路属于"事件流"(用户想看时间线),章节比记忆更适合;下游可以用 chapter timeline 看派生关系
### 跟 openclaw 4.22 不冲突的检查
- ✅ openclaw 自己的 `before_compaction` 等 hook 仍正常工作(enhance 不复制不抢占)
- ✅ openclaw 原生 memory 系统不受影响(enhance 只写 enhance-memory.sqlite)
- ✅ openclaw 原生 cron-cli / tools.allow/deny / memory 向量库 enhance 全部不复制
- ✅ openclaw 4.22 类型定义 `PluginHookName` 包含全部 5 个 hook,无破坏性变更
### 调研依据
- 反编译 `/Applications/Claude.app/Contents/Resources/app.asar`:`loadSkills` / `loadSkillContent` / artifacts 多版本 / frames 树状历史
- WebFetch `code.claude.com/docs/en/hooks`:27 个 hook event names + 输入字段
- `/Users/jobzhao/workspace/projects/openclaw/huo15-openclaw-enhance/node_modules/openclaw/dist/plugin-sdk/src/plugins/hook-types.d.ts: PluginHookName`:openclaw 4.22 的 29 个 hook 完整列表
- 详见 KB `~/knowledge/huo15/2026-04-26-openclaw-enhance-v577-session-lifecycle-postmortem.md`
---
## 5.7.5 — 2026-04-26(skill-recommender:按需求自动挑 skill / 推荐未装 / 给自建规划)
**用户反馈**:"新增自动根据用户的需求自动挑选已经安装的技能,如果没有技能就把规划方案给出来。看看 Claude 是如何做的"
### 调研:反编译 Claude Desktop 看它怎么做的
反编译 `/Applications/Claude.app/Contents/Resources/app.asar`(参见 SELF_ITERATE.md 第 1 节"反编译 Claude Desktop")发现:
- `index.js` 里有 `loadSkills()` / `loadSkillContent()` 函数
- system prompt 拼接处有这一句:`"Available skills: i.join(", ")."`
- 也就是说 Claude Desktop 的 skill auto-discovery 算法**本质是 "name+description 列表注入 system prompt 让 LLM 自己挑"**——没有复杂算法
但 enhance 不能照搬"每轮 prompt 注入"——会增加每轮 token 量、抹掉 v5.6 toolTier 减负的努力。所以改成**按需工具**。
### 新增
- **`src/modules/skill-recommender.ts`** — 三段式推荐器:
1. **启动期扫多路径**(关键修复,WeCom 多 agent 场景):
- `~/.openclaw/skills/`
- `~/.openclaw/workspace/skills/`
- `~/.openclaw/workspace-*/skills/` ← **WeCom / DingTalk 多 agent 动态 workspace**
- `~/.openclaw/agents/*/skills/`
- `<cwd>/.claude/skills/`、`~/.claude/skills/`
- 用户实测扫到 56 个 skill 跨 27 个路径(一开始只扫 4 个固定路径漏扫,烟测才发现)
2. **解析 SKILL.md frontmatter**:轻量正则提取 `name` / `description` / `aliases`,**无 yaml 依赖**(zero-deps 红线)
3. **CJK-aware 评分**:
- 问题:JS `\w` 不含中日韩,直接 `split(/\s\W/)` 会让"代码简化"变成空数组
- 解决:连续 CJK 段当整体 phrase + 长 ≥4 时滑动 2-grams(`"代码简化" → ["代码简化","代码","码简","简化"]`)
- alias exact 命中保底 0.7(典型场景:query "规划XX" → alias "规划" 严格命中)
4. **未装候选 + 自建规划**(fallback 阶梯):
- 已装命中 < threshold=0.25 → 列 ClawHub 上未装的 huo15-* + `openclaw skills install <slug>` 命令
- 都没合适 → **自建 skill 规划**:建议 slug(含中文 placeholder) + frontmatter 模板 + 触发关键词 + 内容大纲 6 章 + **红线 #3 提醒**(先 ClawHub publish 再让 enhance 引用,插件绝不内嵌 skill 内容)
- **工具:`enhance_skill_recommend`** — `query` 必填,可选 `limit`(1-20) / `includeUninstalled`(默认 true) / `includePlanning`(默认 true)
- **`types.ts: SkillRecommenderConfig`** — `enabled` / `installedThreshold` / `cacheTtlSec`
- **`openclaw.plugin.json`** configSchema 加 `skillRecommender` 段
- **`KNOWN_HUO15_SKILLS` 内置 metadata 表** — 11 个 huo15-openclaw-* skill 的 description + aliases 硬编码兜底(避免运行时查 ClawHub 网络依赖)
### 实测精度
| 查询 | 命中 skill | 分数 |
|---|---|---|
| "帮我 review 这个 PR" | huo15-openclaw-code-review | 0.60(首位)|
| "设计一个 Web UI 原型" | huo15-openclaw-frontend-design | 0.94(首位)|
| "代码简化" | huo15-openclaw-simplify | 1.00(满分)|
| "做安全审查" | huo15-openclaw-security-review | 0.96(首位)|
| "规划这个任务" | huo15-openclaw-plan-mode | 0.70(alias exact 命中保底)|
| "深度探索这块代码" | huo15-openclaw-explore-mode | 0.30(首位)|
### 设计决策
- **为什么按需工具而非每轮 prompt 注入**:v5.6 toolTier 已经在为 prompt cache 减负,注入 56 个 skill 描述会让每轮多 ~3-5k token;改成工具用户/agent 主动调更省
- **为什么 tier=2 而非 tier=1**:用户多半已知道自己装了什么 skill,按需查询不是常驻刚需;balanced/full 默认可见即可
- **为什么内置 KNOWN_HUO15_SKILLS metadata 表**:未装的 skill 没有 SKILL.md 可解析,但要给推荐就需要 description;硬编码 11 个 huo15-* 的 metadata 跟 `CLAW_HUB_SKILLS` 列表保持一致,零网络依赖
- **为什么 CJK 双字滑窗而非真正分词**:上 jieba 是 ~5MB 词典 + 1MB 引擎;双字滑窗虽然有少量误命中("代码简化" 也产 "码简"),但召回率显著提升且 zero-deps
- **为什么自建规划在结尾强调红线 #3**:用户硬约束"skill 必须先发 ClawHub 再让 enhance 引用,插件不内嵌",每次给规划都要复刻这个工作流
### 不破坏
- 完全只读 skill 目录,不修改任何 SKILL.md
- 没改 SQLite schema、没引入新 npm 依赖
- 启动期 fire-and-forget 扫描,缓存 60 秒(可调);扫不到不影响插件正常工作
- 工具 schema 极简(4 参数);按需调用不占常驻 prompt
### 调研依据
- 反编译 Claude Desktop loadSkills + "Available skills: list." 注入模式
- 用户实测 query 烟测:6 类查询全部首位命中
- 详见 KB `~/knowledge/huo15/2026-04-26-openclaw-enhance-v575-skill-recommender-postmortem.md`
---
## 5.7.4 — 2026-04-26(config-doctor 扩展:扫已装插件 bare pluginApi)
用户反馈:**"提示插件要求 2026.2.24,但是我的 openclaw 已经是 2026.4.22"**。第一反应是 enhance 自己的问题,但实际是另外两个插件违反了 openclaw plugin compat 规则。
### 根因
按 [`MEMORY/openclaw_plugin_compat_rules.md`](https://...): "compat.pluginApi MUST be ">=X.Y.Z" range, never bare version (bare = exact match, breaks on runtime drift)"。
用户实测两处违规:
- `~/.openclaw/extensions/tips/package.json` v1.0.0 → `pluginApi: "2026.4.11"`(bare)
- `~/.openclaw/node_modules/@huo15/huo15-huihuoyun-odoo/package.json` v1.2.0(npm peerDep 残留)→ `pluginApi: "2026.2.24"`(bare)
openclaw 启动扫 node_modules 看到 bare → 解读为精确匹配 2026.2.24 → 跟当前 4.22 不匹配 → 报错"插件要求 2026.2.24"。
### 新增
- **`src/modules/config-doctor.ts: isBarePluginApi(spec)`** — 检测字符串是否是 ranged spec:
- 带前缀 `>=` `<=` `>` `<` `^` `~` `*` `=` → 合规
- 含空格组合 range(如 `">=1.0 <2.0"`)→ 合规
- 数字开头无前缀(如 `"2026.4.11"`)→ **bare 违规**
- **`src/modules/config-doctor.ts: scanInstalledPluginsForBarePluginApi(openclawDir)`** — 扫描三类路径下的 `package.json`:
1. `{openclawDir}/extensions/*/package.json`(openclaw 实际启用的)
2. `{openclawDir}/node_modules/@huo15/*/package.json`(@huo15 scope 下的)
3. `{openclawDir}/node_modules/*/package.json`(无 scope 的)
- 只检查声明了 `openclaw.extensions` 或 `peerDependencies.openclaw` 的包
- 命中 bare → 加 `CheckResult` 推到主报告 + 给 python3 inline fix 命令
- 工具 `enhance_config_doctor` 输出自动多一段"已装插件 pluginApi 健康度"
### 不破坏
- 完全只读用户文件系统,绝不修改任何 package.json
- 启动检查失败 try-catch 静默
- 扫描复杂度 O(已装插件数),单次启动 < 50ms(实测 5 个插件 < 10ms)
### 已立即修用户当前安装
- `~/.openclaw/extensions/tips/package.json`: bare `2026.4.11` → `>=2026.4.11`
- `~/.openclaw/node_modules/@huo15/huo15-huihuoyun-odoo/package.json`: bare `2026.2.24` → `>=2026.2.24`
- 备份分别在同目录 `.bak.before-bare-fix` / `.bak`
### 经验沉淀
- **bare pluginApi 是 silent breakage 的常见来源** —— 插件作者写时通常想表达"最低版本",但忘了加 `>=`,部署时被解读为精确匹配
- **enhance config-doctor 的诊断范围应该覆盖整个 openclaw 状态目录**,不只 openclaw.json —— 任何会让 openclaw 启动失败的配置陷阱都该报警
- **写在 SELF_ITERATE.md 第 5 节作为发布前自查项**:发版前 npm pack && grep `pluginApi` 看是不是 ranged
### 调研依据
- 用户反馈:"提示插件要求 2026.2.24,但是我的 openclaw 已经是 2026.4.22"
- KB `~/knowledge/huo15/2026-04-26-openclaw-enhance-v574-plugin-bare-pluginApi-postmortem.md`
---
## 5.7.3 — 2026-04-26(config-doctor:'Context limit exceeded' 高频反馈兜底)
用户实测:装了 v5.7.2 之后仍然报 `Context limit exceeded. ... agents.defaults.compaction.reserveTokensFloor to 20000 or higher`。**这不是 enhance 插件的问题**,而是 openclaw 自身配置:(1) 缺失 `agents.defaults.compaction.reserveTokensFloor`(4.22 把这个字段从顶层 `compaction.*` 移到 `agents.defaults.compaction.*`,老用户配置文件没自动迁移);(2) MiniMax-M2.7 maxTokens=131072 / contextWindow=204800,每轮预留输出吃 64% budget,剩 73k 给 input + tools + memory + history,几轮必爆。但用户**装的就是 enhance**,看到爆 context 第一反应是"插件的锅",所以 enhance 必须主动诊断 + 报警把根因信号给到用户。
### 新增
- **`src/modules/config-doctor.ts`** — 启动期同步读 `~/.openclaw/openclaw.json` 检查:
1. `agents.defaults.compaction.reserveTokensFloor` 缺失 / < `minReserveTokensFloor`(default 5000) / > `maxReserveTokensFloor`(default 100000)
2. 任意 `models.providers[*].models[*].maxTokens ≥ contextWindow/2 && > maxModelMaxTokens`(default 32000)
- 检查到问题:`api.logger.warn` + `notifyQueue.emit(level=warn, source="config-doctor", ...)` 推仪表盘
- **完全只读,绝不修改用户配置**(红线 #1)
- 修复命令是 python3 inline JSON 改写一行(红线 #4:不调 child_process / 红线 #5:不在插件里 exec 安装命令)
- **工具:`enhance_config_doctor`** — 无参数,按需重跑诊断(修完用来确认 ✅)
- **`types.ts: ConfigDoctorConfig`** + **`openclaw.plugin.json: configSchema.configDoctor`**
- **`NotificationSource`** 加 `"config-doctor"` 通道
### 设计决策
- **不让插件自动改配置** — 违反零侵入红线 + 用户失去掌控感。给可粘贴命令是平衡点
- **tier=1 minimal 也启用** — 这是关键防爆 context 诊断;minimal 用户更需要这个警告
- **fix 命令选 python3 而非 jq** — python3 macOS/Linux 默认装;jq 不一定有
- **maxModelMaxTokens 默认 32000** — 常用模型 maxTokens 8192-16384,32000 是合理保守阈值
### 不破坏
- 没改任何 openclaw 文件 / enhance SQLite schema 没动
- 新工具 schema 极简(0 参数),单轮 prompt 增加约 30 token
- 老配置无 `configDoctor` 段时默认 enabled=true(用户被动获益)
### 调研依据
- 用户反馈截图:`Context limit exceeded. ... reserveTokensFloor to 20000 or higher`
- 用户当前 `~/.openclaw/openclaw.json` 实测:缺 `agents.defaults.compaction` 段;MiniMax-M2.7 配置 wizard 默认 maxTokens=131072 太大
- 已修用户配置(备份在 `~/.openclaw/openclaw.json.bak.before-compaction-fix-*`)
- 详见 KB `~/knowledge/huo15/2026-04-26-openclaw-enhance-v573-config-doctor-postmortem.md`
---
## 5.7.2 — 2026-04-26(hardening:审计 + 4 类潜在 bug 修复 + 升 peerDep ^2026.4.22)
继 v5.7.1 hot-fix 之后,对全代码库做了一次彻底审计(详见 `docs/SELF_ITERATE.md` 第 4 节 fast-track 流程 + KB post-mortem),用 Explore agent 列了 15 项候选,挑了 4 项 ROI 最高的批量修复。**这是 v5.7.1 的延伸防御层 — 修的都是"现在还没炸但长期运行会炸"的渐进式退化 bug**。
### 修复
- **`src/modules/mode-gate.ts`** — `modeState` / `plannedActions` 两个进程内 Map 之前 keyed by `agentId::sessionId` 永不清理(plan→normal 只清 plannedActions 不清 modeState)。WeCom 多 agent 场景 24h 内可能累积数千 session 状态。
- 加 `MAX_STATE_ENTRIES = 200` / `MAX_PLANNED_ENTRIES = 200`
- 新增 `evictOldest()` helper:利用 Map 的 insertion-order 迭代特性,FIFO 淘汰最早 entry
- 写入前 `if (map.has(key)) map.delete(key)` 让活跃 session 刷新到队尾,避免被误淘汰
- **`src/modules/session-recap.ts`** — 同上,`lastRecapAt` Map 加 `MAX_RECAP_ENTRIES = 500` cap
- **`src/utils/sqlite-store.ts`** — `getDb()` 启动时跑一次 `DELETE FROM safety_log/notifications WHERE created_at < datetime('now', '-90 days')`,try-catch 包裹失败静默;新增 `purgeOldSafetyLogs(db, retentionDays = 90)` helper 给运维 / 工具调用
- **`src/modules/memory-integrator.ts`** — 新增 `TAG_BLACKLIST = {auto-compact, auto-checkpoint, audit, internal}`,`scoreRelevance()` 入口若 `isBlacklisted(memory.tags)` 直接 `return 0`。**这是 v5.7.1 修复的最终兜底**:即便未来某个 hook 又写入 noise,pruner 也不会召回到 prompt
- **`src/modules/structured-memory.ts`** — `enhance_memory_store` 工具检查 tags,若含保留词立即返回错误 `❌ 拒绝存储:tag "..." 是 enhance 保留的系统类标签` 而非写入。防止用户/agent 显式滥用保留 tag
- **`package.json`** — `peerDependencies.openclaw` 从 `>=2026.4.11` 升到 `^2026.4.22`;本地 node_modules openclaw 同步到 4.22;typecheck 通过;hook 名验证全部存在无破坏性变更
### 设计决策
- **为什么 cap 选 200 / 200 / 500**:mode-gate 状态比较"决策性"(200 个活跃 session 对单 agent 已经很多),session-recap 防抖表偏审计性(500 个 session 的 lastRecapAt 也只有 ~12KB 内存)。值故意保守以防误淘活跃数据
- **为什么 TTL 选 90 天而非 30**:safety_log 是审计性数据,跨季度排查事故场景需要保留至少 1 季度。90 天是"季度复盘 + 一周缓冲"
- **为什么 corpus 黑名单做兜底而非依赖 v5.7.1 的删 hook**:删 hook 只解决了已知一个 noise 来源,未来若有新模块又自动写入 audit/internal tag,黑名单这一层能保证 prompt 不被污染。**深度防御**
### 不破坏
- 所有现有 SQLite schema 没改(v5 schema 兼容);老用户升级无需迁移
- 所有工具名 / API 没改;老脚本兼容
- LRU cap 只影响进程内 Map,重启后恢复,**不影响任何持久化数据**
- 升 peerDep 后老用户仍跑 4.11 的话仍可用(`as any` 屏蔽类型差异,hook 名都还在)
### 调研依据
- 用 Explore agent 跑了一遍审计(详见 KB `~/knowledge/huo15/2026-04-26-openclaw-enhance-v572-hardening-postmortem.md`),输出 15 项候选 bug 清单 + ROI top 3
- 升级 openclaw 4.22 后看 `dist/plugin-sdk/*.d.ts` 确认 hook 名和 ctx 字段无破坏性变更
---
## 5.7.1 — 2026-04-26(hot-fix:删除 before_compaction 噪音 hook + 新增 memory_purge 工具)
**线上 bug 修复**:v5.5.x 引入的 `before_compaction` hook 会在每次 openclaw auto-compact 时把"已压缩"事件作为 `decision` 类、`auto-compact` tag 写入 SQLite — 单条信息量为 0,但因为 tag/content 含 `auto / compact / enhance_memory_search` 等通用词,相关度普遍 0.43-0.51,过 corpus pruner 默认 0.5 阈值。**用户实测库里 613 条全是这种噪音**,把真正的 user/project/feedback 决策完全挤出了 prompt 上下文。
### 删除
- **`src/modules/structured-memory.ts`** — 移除 `api.on("before_compaction", ...)` 那段 hook(22 行)。从此 enhance 不再因为 compact 事件本身写入任何记忆。如果用户真要审计「啥时候 compact 过」,应当走 openclaw 自己的 session 日志,不该污染 enhance 结构化记忆库。
### 新增
- **`src/utils/sqlite-store.ts: purgeMemories()`** — 按 `agentId + tag/category/contentLike` 批量删除 + 可选 dry-run。`tag`/`contentLike` 用 SQL `LIKE %?%` 子串匹配。
- **工具:`enhance_memory_purge`** — 暴露给 agent。`tag` / `category` / `contentLike` 至少传一个;`dry_run` 默认 true(仅返回匹配数,不删除)。一键清理本 bug 历史残留:
```
enhance_memory_purge tag="auto-compact" dry_run=false
```
### 用户侧手工清理(如果还没升级)
```bash
sqlite3 ~/.openclaw/memory/enhance-memory.sqlite \
"DELETE FROM memories WHERE tags LIKE '%auto-compact%'; VACUUM;"
```
### 经验沉淀
- **不要在 `before_compaction` / `before_agent_reply` / `after_tool_call` 这类高频 hook 里无脑写记忆**。本来想做"留时间戳方便回查"的好心,结果变成 noise factory
- **写记忆前过 importance + tag 黑名单**:以后 enhance 自动写入的记忆必须在 corpus supplement 检索阶段再过一遍黑名单(计划 v5.8 实施)
- **审计能力 ≠ 决策记忆**:审计性事件(compact / mode 切换 / hook 触发统计)应该单独写到 audit log 文件或独立表,不能跟 user/project/feedback/reference/decision 这五类决策性记忆混存
### 调研依据
参见本仓库 [`docs/SELF_ITERATE.md`](./docs/SELF_ITERATE.md) v5.7.1 条目,本地 KB `~/knowledge/huo15/2026-04-26-openclaw-enhance-v571-memory-noise-bug-postmortem.md`。
---
## 5.7.0 — 2026-04-25(transcript-search:照搬 Claude Desktop 算法)
延续 v5.5.1 路线图里的 v5.7 候选「⭐ transcript search 会话搜索」。**反编译参考**了 `/Applications/Claude.app/Contents/Resources/app.asar` 里的 `transcript-search-worker/transcriptSearchWorker.js`(94 行官方实现),发现 Claude Desktop **不用 SQL FTS5**,是流式扫 JSONL + `indexOf` 的极简方案。直接照搬到 openclaw 的 session 目录,省下了 v5.5.1 路线图里"建 session_messages 新表 + FTS"的工作量。
### 新增
- **`src/modules/transcript-search.ts`** — 流式扫 `~/.openclaw/agents/<agentId>/sessions/*.jsonl`:
- `extractText`:兼容 `string` / `[{type:"text", text}]` 数组 / 单 block 对象三种 content 形态(与 Anthropic 标准对齐)
- `makeSnippet`:±80 字符 radius,开头/结尾用 `…` 表示截断
- `listSessionFiles`:mtime 倒序,跳过 `.deleted.` / `.checkpoint.` / `.trajectory.`,可选包含 `.reset.`(默认不包含)
- `scanFile`:单文件 first-match 策略 — 每个 session 只贡献一条 hit(与 Claude Desktop 一致),保证 limit=10 是"找 10 个不同 session"
- 实测:79 个 session 中扫 30 个 → **3-5 ms** 找到 5 hits
- **工具:`enhance_transcript_search`** — `query` 必填,可选 `agentId / limit (1-50) / includeReset / caseSensitive`
- **`types.ts: TranscriptSearchConfig`** — 新配置段 `config.transcriptSearch.enabled`
- **`openclaw.plugin.json`** — `configSchema.transcriptSearch` 暴露给龙虾配置 UI
- **`index.ts`** — 模块清单加「历史会话搜索」,**tier=2**(balanced 默认就启用,minimal 下不暴露)
### 设计原则
- **零侵入**:完全只读 openclaw session 目录,不建表、不建索引、不写任何文件
- **零依赖**:用 `node:fs` + `node:readline` + `node:path`,没引新包
- **零侵犯隐私**:搜索范围严格限制在当前 ctx.agentId(除非显式传 agentId 参数)
### 设计决策:为什么不用 SQL FTS5
参照 Claude Desktop 反编译实现:
| 维度 | SQL FTS5 | 流式扫 JSONL(照搬) |
|------|---------|---------------------|
| 实现复杂度 | 高(建表 + 触发器同步 + 索引重建) | 低(一个 worker,~200 行) |
| 写入开销 | 每次消息要 INSERT FTS | 0 |
| 跟 openclaw 同步 | 容易 drift(agent reset / 删除时索引脏) | 永远是源数据 |
| 性能 | 查询 ms 级,但需要持续维护 | 3-5 ms(79 个 session 扫 30 个)— **同 SLA** |
| 故障域 | 索引坏了影响搜索 | 单文件坏了不影响其它 session |
结论:在 session 数量级 ≤ 100 的场景下,FTS5 没有任何价值。Claude Desktop 用了几年都没建 FTS,我们也不建。
### 不破坏的兼容点
- 不改任何 openclaw 文件、不动 enhance 自己的 SQLite 库
- 工具 schema 极简(5 个参数),不增加 prompt 负担
- minimal toolTier 用户不会看到这个工具
---
## 5.6.0 — 2026-04-24(工具分层 + workflow 5→2 合并 + 描述压缩)
针对实际使用中"long session 仅 15% 上下文使用率即触发 Context limit exceeded"的现象做容量优化。根因有二:(a) 用户的 `~/.openclaw/openclaw.json` 把 `compaction.reserveTokensFloor` 误设为 `200000`(>205k 总窗),每次压缩都失败 — 需要用户侧改回 `20000`;(b) 插件这边一次性暴露 29 个工具 schema,每轮 prompt 固定底座过重。本版本聚焦 (b)。
### 新增
- **`types.ts: ToolTier` 类型 + `EnhancePluginConfig.toolTier`** — 新增工具分层枚举 `"minimal" | "balanced" | "full"`,默认 `"balanced"`。
- **`openclaw.plugin.json: configSchema.toolTier`** — 暴露给龙虾配置 UI,三档可选。
### 变更
- **`index.ts`** — 模块清单增加 `tier: 1 | 2 | 3` 字段;启动期按 `TIER_MAX[toolTier]` 过滤,超出层级的工具模块整个不 register(连 schema 都不进 prompt)。
- tier 1 常驻层(minimal 即可见,10 工具):结构化记忆 / 状态栏 / spawn / 模式闸门 / 章节标记 / installer / integrator
- tier 2 均衡层(balanced 默认,+8 工具,共 18):todo / 章节 / 定时任务桥
- tier 3 完整层(full,+8 工具,共 26):workflow / safety / task-planner / session-recap / skill-doctor
- 非工具模块(仪表盘、通知、自检、prompt-enhancer、kb-corpus)一律 tier 1:它们不占 tool schema、不影响 per-turn 成本。
- **`workflow-hooks.ts` 5→2 工具合并** — `enhance_workflow_define / _list / _delete` 三个独立工具合并为单一 `enhance_workflow`(`action=define/list/delete/tasks`);`enhance_task` 保留独立但仍是 action 派发器。`before_prompt_build` 触发逻辑、所有工作流持久化和评估辅助函数全部保留。
- **批量描述压缩** — 24 个工具的 description 字段从多行 `[...].join("\n")` 压成单行 ≤ 80 字符;总字符量 ~4610 → ~1750(-62%),按中文 ≈0.5 token/字 估算每轮 prompt 节省约 1400 token。压缩注重保留触发关键词,不改 parameters schema。
- 最大幅压缩:`enhance_memory_store`(~700 字 → 38 字)、`enhance_exit_plan_mode`、`enhance_install_skills`
- `enhance_todo_list` 已经 ≤ 80 字,未改
### 行为变化
- 默认 `balanced` 模式下 **不暴露** workflow / safety / task-planner / session-recap / skill-doctor 工具。如果你需要这些能力(特别是工作流自动化和 plan-mode-审批闭环),请在 `openclaw.json.plugins.enhance` 配置里加 `"toolTier": "full"` 并重启。
- session-recap 的 `before_prompt_build` hook 在 balanced 下**不**生效(模块整体没注册);如果你依赖 75min idle 自动回顾,需 `toolTier: "full"`。
- 升级后总工具数 29 → 26(workflow 5→2),即使切回 full 也比 v5.5.1 少 3 个。
### 配置示例
```jsonc
// ~/.openclaw/openclaw.json
{
"plugins": {
"enhance": {
"toolTier": "minimal" // 极致省 schema,仅留 10 工具
// "toolTier": "full" // 全功能,26 工具
}
},
"compaction": {
"reserveTokensFloor": 20000 // ⚠️ 不要设 200000,会比总窗还大
}
}
```
### 不破坏的兼容点
- 所有工具名(`enhance_*`)都保留,旧的 `enhance_workflow_define` 等命名外部没用过(只在内部 register),改成 `enhance_workflow` 不破坏任何用户脚本。
- SQLite schema 完全没动;现有记忆 / 任务 / 章节数据无需迁移。
- npm 包对外 API 没变(`definePluginEntry` 出口不变)。
### 修复(顺手)
- **session-recap.ts** — `buildRecapText` 引用了 `MemoryEntry` 上不存在的 `key` / `rule` 字段(v5.5.1 编译错但未被 CI 拦下),全 full tier 场景下生成 decision 段会运行时抛 `TypeError`。修正为 `d.content` 后兼容正确字段并裁切 80 字符。
### 调研依据
参见本仓库 [`docs/v5.6-context-pressure-postmortem.md`](./docs/v5.6-context-pressure-postmortem.md)(如该文件存在),以及本地 KB 条目 `~/knowledge/huo15/2026-04-24-openclaw-context-pressure-postmortem.md`。
---
## 5.5.1 — 2026-04-24(session-recap + 3 个开发辅助 skill)
在 v5.5.0「三层记忆协调」基础上,对齐 Claude Code 2026 Q2 最新能力谱调研结果,补齐两块高频能力:**会话回顾**与**开发辅助三件套**。
### 新增(plugin 模块)
- **`src/modules/session-recap.ts`** — 对齐 Claude Code 75min idle auto-summary。当检测到当前 agent/session 距上次活动 > `recapIdleMinutes`(默认 75),在 `before_prompt_build` 自动 prependContext 一段"你上次到这儿"的回顾(最近章节 + in_progress/pending todo + 最近 decision 记忆)。
- 工具 `enhance_session_recap` 支持手动触发(不受 idle 阈值限制)
- 进程内防抖表避免重复 recap:两次间隔 < `recapMinIntervalMinutes`(默认 30)直接 skip
- 非侵入:只读三张已有表(chapters / todos / memories),**不建新表、不改现有 schema**
- 可通过 `config.sessionRecap.enabled = false` 关闭
- **`types.ts: SessionRecapConfigType`** — 新配置段 `config.sessionRecap`。
### 新增(3 个开发辅助 skill,通过 huo15-skills 分发)
对齐 Claude Code 原生 `/simplify` / `/security-review` / `/review` 三件套,全部自研、MIT:
- **`huo15-openclaw-simplify` v1.0.0** — 代码简化三维审查(复用 / 质量 / 效率)+ 分级修复清单 + 🔴必改/🟡建议/🟢可选。严格硬红线:不跨文件重命名、不改测试断言、不引入新依赖、不跑格式化器、不碰 generated 代码。
- **`huo15-openclaw-security-review` v1.0.0** — 六类漏洞矩阵(密钥 / 注入 / XSS / SSRF / 权限 / 危险依赖)+ CVSS-like 四档严重度(🔴Critical / 🟠High / 🟡Medium / 🟢Low)+ CWE 编号对照。硬红线:不 exec `npm audit`、不改历史、不明文打印密钥。
- **`huo15-openclaw-code-review` v1.0.0** — PR 五维综合评审(设计 / 实现 / 测试 / 安全 / 可维护)+ 可粘贴 markdown 评论。`gh` CLI 命令走 return-cliCmd 模式(禁 child_process 铁律)。硬红线:不 `gh pr review --approve`、不 `gh pr merge`、不自动 `gh pr comment`。
### 变更(enhance 插件内)
- **`skill-installer.ts`**:`CLAW_HUB_SKILLS` 从 8 扩到 **11**(+simplify / security-review / code-review)。
- **`skill-doctor.ts`**:`EXPECTED_SKILLS` 同步到 11;tool description 从"8 个"改"11 个"。
- **`index.ts`**:新增「会话回顾」模块条目,默认启用。
### 设计决策:为什么 session-recap 是 Plugin 而非 Skill
参照 [MEMORY.md Plugin vs Skill Decision](../../../.claude/projects/-Users-jobzhao/memory/plugin_vs_skill_decision.md):
- 需要 `before_prompt_build` hook → Skill 做不了
- 需要跨进程状态(lastRecapAt 防抖表 + SQLite 只读查询)→ Skill 做不了
- 触发条件是"idle 时长"的系统级信号,不是用户语义意图 → Plugin 更合适
反之,3 个开发辅助能力是"用户说'帮我 review'时自动加载最佳实践"的语义触发场景,天然 Skill。
### 调研依据
[2026-04-24 Claude Code 能力全景调研(115 条)](../../../knowledge/huo15/2026-04-24-claude-code-capability-survey-and-enhance-roadmap.md):enhance v5.4 已覆盖 TodoWrite/mark_chapter/plan-mode/ExitPlanMode/statusline 等核心 harness;本版本补齐 session-recap;后续 v5.6/5.7 规划补 hook-observer / path-rules / transcript search。
---
## 5.5.0 — 2026-04-23(三层记忆/知识库协调)
本次聚焦「龙虾原生 memory / enhance 结构化记忆 / KB wiki」三者的职责切分和聚合搜索。
### 新增
- **`src/modules/kb-corpus.ts`** — 新增 corpus supplement,把 huo15-openclaw-openai-knowledge-base 技能的**共享知识库**(`~/.openclaw/kb/shared/wiki/`)注册为龙虾 `memory` 的 `corpus="kb"`。调用 `memory_search` 会同时搜到 enhance-memory + shared KB wiki,无需切换工具。
- **`types.ts: KbCorpusConfigType`** — 新配置段 `config.kbCorpus`,可调阈值、路径、debug。
- **`index.ts`** — 注册「共享知识库语料」模块,默认启用(`kbCorpus.enabled !== false`)。
### 变更
- **`structured-memory.ts: enhance_memory_store` 的 tool description** — 增加 L2/L3 边界提示:「本工具只存规则/为什么/怎么做的短条目;长文档请走 `kb-ingest` 入共享 KB」。
### 三层协调总览
| 层 | 存什么 | 存储 | 隔离 | corpus |
|----|--------|------|------|--------|
| L1 龙虾原生 memory | 向量+FTS 底座 | `~/.openclaw/memory/<agent>.sqlite` | per-agent | `memory` |
| L2 enhance 结构化记忆 | 规则/反馈/决策(短) | `enhance-memory.sqlite` | per-agent | `enhance` |
| L3 共享知识库 | 事实/文档/教程(长) | `~/.openclaw/kb/shared/wiki/*.md` | 跨 agent | `kb` |
### 配套更新
- `huo15-openclaw-openai-knowledge-base` skill v2.5.0 — 所有 `kb-*` 脚本新增 `--scope agent|shared`;`kb-search` 默认聚合搜 agent+shared+obsidian;新增 `kb-scope.sh` 公共库。
---
## 5.4.0 — 2026-04-23(对齐 2026 Q2 设计能力生态)
本次聚焦"设计能力"这一纵向领域,对标 Anthropic 官方 `frontend-design` skill(277k+ 安装)与中文圈 `alchaincyf/huashu-design`(画术,4.6k★)的设计理念,但**全部内容自研**(避开 huashu 仅限个人使用的 license 限制)。
### 新增(4 个设计能力 skill)
通过 [huo15-skills monorepo](https://cnb.cool/huo15/ai/huo15-skills) 分发,首次安装会自动从 clawhub 拉取:
- **`huo15-openclaw-frontend-design` v1.0.0** — 高保真 Web UI 原型生成。5 大美学流派(BOLD-MINIMAL / EDITORIAL / BRUTALIST / RETRO-FUTURE / ORGANIC)+ 8 条反 AI Slop 硬红线(禁 Inter/Roboto、禁紫渐变、禁 emoji 当图标等)+ Junior/Full 两趟渲染工作流 + Playwright 自验证 CLI(延续"禁 child_process"铁律)。对标 Anthropic frontend-design。
- **`huo15-openclaw-design-director` v1.0.0** — 设计方向顾问。内置 20 条设计哲学库(极简/编辑/前卫/东方/功能 5 派)+ 3 方向反差生成规则(1 保守 + 1 反差 + 1 中间)+ 五维对比矩阵 + 强制推荐表态。
- **`huo15-openclaw-brand-protocol` v1.0.0** — 品牌规范抓取。5 步硬流程 Ask / Search / Download / Verify+Extract / Codify,产出结构化 `brand-spec.md`。返回 curl / Playwright CLI 命令让用户执行,**不调 child_process**。
- **`huo15-openclaw-design-critique` v1.0.0** — 5 维设计评审(美学/可用性/品牌一致/内容/实现)+ Keep/Fix/Quick Wins 三分类 + ASCII 雷达图。木桶短板决定总分,命中硬红线美学直接 ≤ 2。
### 变更(enhance 插件内)
- **`skill-installer.ts`**:`CLAW_HUB_SKILLS` 列表从 4 扩展到 8,加入 4 个设计 skill。
- **`skill-doctor.ts`**:`EXPECTED_SKILLS` 同步到 8;tool description 更新。
- **README.md / SKILL.md**:技能清单分为「工作流模式」和「设计能力」两段,加入新增 4 个 skill 的说明。
### 设计决策(为什么做成 Skill 而非 Plugin 模块)
遵循 [MEMORY.md Plugin vs Skill Decision](../../../.claude/projects/-Users-jobzhao/memory/plugin_vs_skill_decision.md) 框架:设计能力是"当用户做 X 时自动应用最佳实践"的**语义触发场景**,不需要 hook / 新 tool / 跨进程状态,因此 Skill-first。Plugin 仅扩展了 `CLAW_HUB_SKILLS` 列表做发现和巡检。
### 与 OpenClaw 原生 / 其他 huo15 技能的边界
| 能力 | 归属 |
|------|------|
| Web UI / HTML 原型 | `huo15-openclaw-frontend-design`(新) |
| 设计方向选型 | `huo15-openclaw-design-director`(新) |
| 品牌规范抓取 | `huo15-openclaw-brand-protocol`(新) |
| 设计评审打分 | `huo15-openclaw-design-critique`(新) |
| PPT 演示稿 | `huo15-openclaw-ppt`(已有) |
| Word / PDF | `huo15-openclaw-office-doc`(已有) |
### 商用合规
本批 4 个 skill 内容**全部自研**,仅参考 Anthropic frontend-design(Anthropic 自有 license)和 huashu-design(仅限个人使用)的**结构设计与设计理念**,不 vendor 其 markdown 文本。@huo15/* 系列所有发布物为商用合规,License 保持 MIT。
---
## 5.2.0 — 2026-04-22(对齐 OpenClaw 2026.4.11)
本次是一次**方向性重写**:把之前"跟着龙虾跑"的模式彻底倒转为"给龙虾打补丁"。核心原则——**凡是龙虾有的,我们绝不复制;凡是龙虾没的 Claude-Code 体验,我们填平**。
### 破坏性变更
- **记忆整合从 prompt-inject 改为 corpus supplement**:
- 旧版本 `context-pruner` 模块通过 `before_prompt_build` 把打分后的记忆直接 `prependContext`,**与龙虾原生 memory injection 重复/竞争**。
- 新版本删除 `context-pruner`,在 `memory-integrator` 中改用 `api.registerMemoryCorpusSupplement(...)`(2026.4.11 新增 API),把 enhance 分类记忆作为**并列 corpus** 交给龙虾排序——龙虾是主,插件是补。
- **tool-safety 降级为观察员**:
- 旧版本尝试"重试"(在 `after_tool_call` 中"retry"并不会真的重新调用工具,是假动作)。
- 新版本只做错误分类 + 指数退避建议 + 60s TTL 观察窗,**完全不与龙虾原生 `tools.allow/deny` 竞争**。每个工具描述都声明"若与龙虾配置冲突以龙虾为准"。
- `package.json` peerDep:`openclaw >= 2026.4.11`;旧版本不再支持。
- `openclaw.plugin.json` 升至 v2.2.0,重写了 configSchema(新增 7 个模块的开关)。
### 新增(对齐 Claude Code Agent Harness)
- **`enhance_todo_write` / `enhance_todo_update` / `enhance_todo_list`**:对齐 Claude Code `TodoWrite`;SQLite `todos` 表 + 会话隔离;出现多个 `in_progress` 自动发通知警告。
- **`enhance_mark_chapter` / `enhance_chapter_list`**:对齐 Claude Code `mark_chapter`,为 session 打时间线。
- **`enhance_set_mode` / `enhance_current_mode`**:plan / explore / normal 三模式;前两者下 `before_tool_call`(priority=950)阻止 Write/Edit/NotebookEdit + 破坏性 Bash(`rm`、`mv`、`curl -X POST` 等),直到模式切回 normal 或 `exec()` 显式批准。
- **`enhance_statusline`**:line/detail/json 三格式状态快照;HTTP 路由交给 dashboard 统一托管。
- **`enhance_skill_doctor`**:只读巡检 4 个 huo15-\*-mode 技能;缺失时给出 `clawhub install` 命令,**不擅自安装**。
- **`enhance_spawn_task`**:孵化子任务;由于龙虾无 spawn 原语,**只记录不伪装执行**,存为 `category=project, tag=spawn-task` 记忆条目。
- **`enhance_loop_register` / `enhance_loop_list` / `enhance_loop_disable`**:登记定时工作流并返回**一条 `openclaw cron add` CLI 命令**,调度生命周期归龙虾 `cron-cli` 管理;触发时 `before_prompt_build` 识别 `[enhance-loop:{name}]` 前缀并注入 instructions。
### 仪表盘增强
- `/plugins/enhance/api/statusline` — 供 Control UI / 外部嵌入
- `/plugins/enhance/api/todos` — 最近 session 的 todo 快照
- `/plugins/enhance/api/chapters` — 章节时间线
- `/plugins/enhance/api/loops` — 定时工作流登记
- `/plugins/enhance/api/spawn-tasks` — 已孵化子任务
- UI 新增 4 个面板(Todos / Chapters / Loops / Spawn Tasks),切 agent 时全量刷新
### 修复
- 修复 `tool-safety.ts` 中因 `)),` 误写导致的多 tool 注册被串成逗号表达式的 bug。
- 修复 `memory-integrator.ts` 的 `registerMemoryCorpusSupplement` 签名误用(2026.4.11 SDK 公共面是单参,内部全局是双参,之前混了)。
- 移除 `statusline.ts` 内对不存在的 `api.http.registerRoute` 的调用。
### 迁移指引(从 v1.x 升级)
1. 在你的 `openclaw.json` 里把 `plugins.entries.enhance.version` 升到 `^5.2`。
2. 若你之前依赖 `contextPruner.*` 配置段,**现在可以删除**;打分逻辑已并入 corpus supplement(配置项改名为 `memory.relevanceThreshold` / `memory.maxContextEntries`)。
3. 若你期望 enhance 实现自动重试,请**改用龙虾原生的 `tools.retry.*` 配置**;enhance 只给观察数据。
4. Cron 工作流:旧版 `workflows` 触发词仍可用;若要真正定时触发,改用 `enhance_loop_register`,按返回的命令手动跑 `openclaw cron add`。
---
## 5.1.2 — 历史版本
见 git 历史。
FILE:PLAN.md
# enhance 插件改进规划
> 基于 AI Agent Harness 六层能力架构分析(2026-04-14)
## 源码目录
`~/workspace/projects/openclaw/huo15-openclaw-enhance/`
## 当前模块清单(v1.9.0)
| 模块 | 文件 | 功能 | 状态 |
|------|------|------|------|
| 结构化记忆 | `modules/structured-memory.ts` | SQLite 持久化,5分类记忆 | ✅ 已有 |
| 工具安全 | `modules/tool-safety.ts` | hardblock/block/log 三级拦截 | ✅ 已有 |
| 提示词增强 | `modules/prompt-enhancer.ts` | 追加质量准则 | ✅ 已有 |
| 工作流自动化 | `modules/workflow-hooks.ts` | 触发词→固定指令 | ✅ 已有 |
| 仪表盘 | `modules/dashboard.ts` | 统计面板 | ✅ 已有 |
| 小火苗+贴士 | `modules/flame-pet.ts` | XP系统+每日贴士 | ✅ 已有 |
| **输出自检** | `modules/self-check.ts` | before_agent_reply 输出验证 | ✅ **新增** |
| **Context裁剪** | `modules/context-pruner.ts` | before_prompt_build 记忆过滤 | ✅ **新增** |
---
## 已完成(v1.9.0 — 第1次迭代)
### ✅ `self-check.ts`(评估与观测 — P0)
- Hook: `before_agent_reply` — 在输出发送前拦截
- 检测:空输出、NO_REPLY、超长输出、错误关键词
- 非阻断:问题只记录到 SQLite safety_log
- 可选阻断:空输出时可选择拦截并返回错误提示
- 配置项:`checkEmpty`、`checkNoReply`、`checkErrorKeywords`、`checkExcessiveLength`、`blockOnEmpty`
### ✅ `context-pruner.ts`(信息边界 — P0)
- Hook: `before_prompt_build` — 记忆注入前过滤
- 四维评分:关键词重合度(50%)+ 分类权重(30%)+ 重要性(10%)+ 新鲜度(10%)
- 阈值可配置(默认 0.25),最多注入 10 条
- 替代 Claude Code 的 `findRelevantMemories()` LLM 筛选(本地轻量方案)
---
## 待做(规划)
### 🟡 P1 — 第2次迭代
- `tool-safety.ts` 增加自动重试(429 指数退避、500 重试、新增错误分类)
- `task-planner.ts`(执行编排)— 任务分解工具
### 🟢 P2 — 第3次迭代
- 记忆整合:废弃 enhance structured-memory,桥接到 OpenClaw memory-core
- 工作流增强:条件分支 + 状态追踪
---
## 注意事项
- 所有模块必须通过 `before_prompt_build` 或工具暴露,不修改 openclaw 核心
- 多 Agent 隔离:所有状态读取 `ctx.agentId`
- SQLite 表结构不变,兼容现有数据
- 仪表盘自动反映新模块的统计数据
## 已完成(v2.0.0 — 第2次迭代)
### ✅ `tool-safety.ts` 增强 — 自动重试
- `after_tool_call` hook:错误分类(rate_limit/server_error/network_error/auth_error/timeout/unknown)
- 429 → 指数退避(最多5次,baseDelay 1s,multiplier 2x)
- 5xx → 重试3次(baseDelay 2s)
- 网络超时 → 重试2-3次(baseDelay 1s)
- 401/403 权限错误不重试(直接标记为 auth_error)
- `enhance_retry_status` 工具:查询当前待重试任务
- 配置项:`enableRetry: true`(默认开启)
### ✅ `task-planner.ts`(执行编排 — P1)
- Tool: `enhance_plan_task` — 目标 → 结构化子任务分解
- Hook: `before_prompt_build` 自动检测触发词并注入规划提示
- 启发式分解规则:代码开发、Odoo系统、反思模式、通用分解
- 支持 mode: plan/analyze/reflect
## 🐛 Bug修复(v2.0.1)
### ✅ 消除全部10个TS类型错误
- **根因**:OpenClaw SDK 的 `AgentTool` 类型要求 `label: string`,但运行时 Jiti 解析器不检查此字段
- **修复**:`api.registerTool(...)` 工厂函数改为 `api.registerTool(( ... as any), ...)`
- **结果**:`npx tsc --noEmit` → 0 errors
## ✅ P2 完成(v2.1.0)
### ✅ 记忆整合 — `memory-integrator.ts`
- `registerMemoryCapability` 把 enhance SQLite 注册为 OpenClaw corpus supplement
- OpenClaw 搜索记忆时会同时返回 enhance 的分类记忆
- 新工具 `enhance_memory_export`:导出所有记忆为 JSON(可同步到 Obsidian/KB)
### ✅ 工作流增强 — `workflow-hooks.ts` 全面升级
- **正则触发**:触发词支持 `/pattern/flags` 语法
- **条件分支**:支持 keyword/regex/time_range/day_of_week 条件评估
- **任务状态**:新增 `enhance_task` 工具(create/update/list/get/delete)
- **看板视图**:新增 `enhance_workflow_tasks` 工具
- 任务状态持久化到 `workflows/workflow-tasks.json`(跨 session 追踪)
FILE:README.md
# 火一五·克劳德·龙虾增强插件
---
<div align="center">
<img src="https://tools.huo15.com/uploads/images/system/logo-colours.png" alt="火一五Logo" style="width: 120px; height: auto; display: inline; margin: 0;" />
</div>
<div align="center">
<h3>打破信息孤岛,用一套系统驱动企业增长</h3>
<h3>加速企业用户向全场景人工智能机器人转变</h3>
</div>
<div align="center">
| 🏫 教学机构 | 👨🏫 讲师 | 📧 联系方式 | 💬 QQ群 | 📺 配套视频 |
|:-----------:|:--------:|:------------------:|:-----------:|:-----------------------------------:|
| 逸寻智库 | Job | [email protected] | 1093992108 | [📺 B站视频](https://space.bilibili.com/400418085) |
</div>
---
## 简介
**火一五·克劳德·龙虾增强插件 v5.7.8** 是 [OpenClaw 2026.4.24+](https://github.com/openclaw/openclaw) 的**非侵入式**增强插件,对标 Claude Code 的 Agent Harness 体验 + 设计能力套件 + 开发辅助套件;**所有能力重叠处都以龙虾为准**,绝不复制或覆盖龙虾原生功能。
完全通过公共 Plugin SDK 实现,**不修改任何核心代码**,一键安装即可使用。
(非龙虾团队开发)
### v5.7.8 全面适配 openclaw 2026.4.24(2026-04-26 同日)
| 维度 | 改动 |
|---|---|
| `peerDependencies.openclaw` | `^2026.4.22` → **`^2026.4.24`** |
| `build.openclawVersion` | `2026.4.11` → **`2026.4.24`** |
| `compat.pluginApi` | `>=2026.4.11` → **`>=2026.4.24`** |
| `api.on(...as any)` 14 处 → **0 处** | 全部改成 typed hook,让 SDK PluginHookHandlerMap[K] 自动推断 event/ctx |
| `(event: any, ctx: any)` 5 处 → **0 处** | 同上 |
| `openclaw.plugin.json` 加 3 字段 | `enabledByDefault: true` / `uiHints` / `activation.onAgentHarnesses` |
| 修隐藏 bug | self-check.ts 的 `PluginHookBeforeAgentReplyResult.handled` 必填问题之前被 `as any` 屏蔽,现在 typecheck 强制修对 |
### v5.7.7 session-lifecycle:接入 openclaw 4.22 五个 hook 闭环 session 生命周期(2026-04-26 同日)
跑了完整 SOP 第 1+2 步后发现 **openclaw 4.22 暴露 29 个 hook,enhance 只用了 4 个**。落地最高 ROI 的 5 个 hook:
| Hook | 行为 |
|---|---|
| `session_start` | idle > 30min 时插入"🚀 会话开始/续启"章节占位 |
| `session_end` | 加"🏁 会话结束"章节 + flush in_progress todo 到 project memory |
| `before_reset` | reset 前抢救最近 3 章节 + 全部未完成 todo 到 decision memory + 推 notification |
| `subagent_spawned` / `subagent_ended` | 派生/结束自动落 chapter(跟 enhance_spawn_task 闭环)|
防 noise factory 三层防御:30 秒 dedup + 低 importance + 专用 tag(吸收 v5.7.1 教训)。
### v5.7.5 skill-recommender:按需求挑 skill / 推荐未装 / 给自建规划(2026-04-26 同日)
调研:反编译 Claude Desktop 发现 skill auto-discovery 本质是 `"Available skills: list."` 注入到 system prompt。enhance 改成**按需工具**避免每轮 prompt 占 schema:
工具 `enhance_skill_recommend(query, ...)` 三段式输出:
| 段 | 内容 | 触发条件 |
|---|---|---|
| 🎯 已装 skill | 按相关度排序 + 召唤建议 | 命中 ≥ threshold |
| 📦 ClawHub 未装候选 | 11 个 huo15-* + `openclaw skills install <slug>` | 默认包含 |
| 🛠️ 自建规划 | slug + frontmatter 模板 + 触发词 + 内容大纲 + 红线 #3 提醒 | 已装命中 < threshold |
实测精度:
| 查询 | 命中 | 分数 |
|---|---|---|
| "帮我 review 这个 PR" | huo15-openclaw-code-review | 0.60 |
| "代码简化" | huo15-openclaw-simplify | 1.00 |
| "做安全审查" | huo15-openclaw-security-review | 0.96 |
关键修复:扫 `~/.openclaw/workspace-*/skills/`(WeCom 多 agent 隔离的子工作区)— 用户机器实测扫到 **56 个 skill 跨 27 个路径**。
### v5.7.4 config-doctor 扩展:扫已装插件 bare pluginApi(2026-04-26 同日)
用户反馈:"提示插件要求 2026.2.24,但是我的 openclaw 已经是 2026.4.22" — 这是**其它插件**的 `compat.pluginApi` 写成 bare 字符串(精确匹配)导致 openclaw 启动失败。enhance 主动扫所有装的 plugin package.json,检测违规并给 fix 命令。
实测命中:
- `~/.openclaw/extensions/tips/package.json` v1.0.0 → `pluginApi: "2026.4.11"`(bare)
- `~/.openclaw/node_modules/@huo15/huo15-huihuoyun-odoo/package.json` v1.2.0 → `pluginApi: "2026.2.24"`(bare)
### v5.7.3 config-doctor(2026-04-26 同日,继 v5.7.2)
直击高频反馈"装上插件还是 'Context limit exceeded'"。**这不是 enhance 的锅**,是 openclaw 自身配置陷阱:
- **缺失 `agents.defaults.compaction.reserveTokensFloor`** — openclaw 4.22 把字段从顶层 `compaction.*` 挪到嵌套路径,老用户配置文件没自动迁移 → 用 4.22 默认值(很小) → 长 session 必爆
- **某 model maxTokens ≥ contextWindow/2** — 例如 MiniMax-M2.7 默认 maxTokens=131072 / contextWindow=204800,每轮预留输出吃 64% budget → 剩 73k 给 input/tools/memory/history 几轮必爆
**新增 `enhance_config_doctor` 模块(tier=1,minimal 也启用)**:
- 启动期 sync 读 `~/.openclaw/openclaw.json` 检查上述两类陷阱
- 发现问题:log warn + 推仪表盘通知 + 给可粘贴 fix 命令(python3 inline JSON 改写,**不调 child_process**,**不擅自改用户配置**)
- 工具 `enhance_config_doctor` 按需重检(修完用来确认 ✅)
### v5.7.2 hardening(2026-04-26 同日,继 v5.7.1)
对全代码库做了一次审计,修 4 类潜在 bug + 升 peerDep `^2026.4.22`:
- **进程内 Map LRU 上限** — `mode-gate` / `session-recap` 之前跨 session 永不清,多 agent 场景会泄漏;现在加 200/200/500 三档 cap + FIFO 淘汰
- **safety_log / notifications 启动期 TTL** — `getDb()` 自动清 90 天前旧记录
- **memory corpus tag 黑名单** — `auto-compact / auto-checkpoint / audit / internal` 永不召回,防御未来 noise hook
- **enhance_memory_store 拒收保留 tag** — 用户/agent 显式滥用保留词时立即报错
### v5.7.1 hot-fix(2026-04-26)
**修:删除把每次 auto-compact 事件作为 decision 类记忆插入的 `before_compaction` hook。**
- 之前实测单 agent 24h 积累 **613 条全为噪音**(tag=auto-compact),关键词命中率 0.43-0.51 普遍过 0.5 阈值,把真正的决策记忆挤出 prompt 上下文
- 新增工具 `enhance_memory_purge` — 按 `tag` / `category` / `contentLike` 批量清理,`dry_run` 默认 true(仅预览匹配数)
- 历史噪音清理一行:`enhance_memory_purge tag="auto-compact" dry_run=false`,或 `sqlite3 ~/.openclaw/memory/enhance-memory.sqlite "DELETE FROM memories WHERE tags LIKE '%auto-compact%'; VACUUM;"`
### v5.7 新特性(2026-04-25)
**📜 历史会话搜索 — 照搬 Claude Desktop 实现**
> 反编译参考 `/Applications/Claude.app/Contents/Resources/app.asar` 里的 `transcriptSearchWorker.js`(94 行官方实现)— 发现 Claude Desktop 不用 SQL FTS5,纯流式扫 JSONL + indexOf。直接搬到 openclaw 的 `~/.openclaw/agents/<agent>/sessions/*.jsonl`。
| 工具 | 用途 | 实测性能 |
|------|------|---------|
| `enhance_transcript_search` | 全文搜历史会话,找『我上次怎么做的』 | 79 个 session 中扫 30 个 → **3-5 ms** 找到 5 hits |
参数:`query` 必填;可选 `agentId / limit (1-50) / includeReset / caseSensitive`。
模块 `tier=2`(balanced/full 默认启用,minimal 下不暴露)。
### v5.6 新特性(2026-04-24)
**针对 long session 提早爆 context 的容量优化**
| 配置项 | 暴露工具数 (v5.7) | 适用场景 |
|--------|-----------|---------|
| `toolTier: "minimal"` | 10 | 上下文极紧 / 最小核心模式(记忆、状态栏、章节、模式、spawn) |
| `toolTier: "balanced"` *(默认)* | 19 | 多数日常会话 — 加 todo / 章节标记 / 定时任务桥 / **transcript-search** |
| `toolTier: "full"` | 27 | 需要工作流自动化 / safety / session-recap / skill-doctor 时 |
- **工具分层(toolTier)** — 按需暴露 schema,每轮 prompt 减负
- **Workflow 5→2 工具合并** — 用 `action=` 派发器收敛同类操作
- **26 个工具描述压缩 -62%** — 每轮 prompt 节省约 1400 token
> ⚠️ 如果你的 `~/.openclaw/openclaw.json` 中 `compaction.reserveTokensFloor` ≥ 100000,请改回 **20000**(>205k 总窗会让每次压缩都失败)。这是 openclaw 配置项,与本插件无关。
### 核心特性
- **多 Agent 隔离** — 完美适配 WeCom 插件的动态 Agent 功能,每个企微用户/群组拥有独立的记忆、任务、章节、宠物与定时工作流
- **结构化记忆(corpus supplement)** — 按 user/project/feedback/reference/decision 五类分类存储,**通过 `registerMemoryCorpusSupplement` 并入龙虾 `memory` 搜索结果**,不自建第二套向量库
- **工具安全补丁** — 仅作为**观察员**存在(尊重龙虾原生 `tools.allow/deny`),统计错误分类、给出退避建议,从不擅自重试或硬拦截
- **提示词增强** — 仅保留 `qualityGuidelines`,其它早已由龙虾系统提示词覆盖,不重复
- **任务/章节/模式闸门** — Claude Code TodoWrite / mark_chapter / plan-explore 的龙虾化实现;模式闸门在 `before_tool_call` 阻止计划/探索模式误触写操作
- **状态栏 / 技能巡检 / 子任务孵化** — 一行看全当前状态;诊断技能目录缺失;把"现在不该做"的副作用登记为延期任务
- **定时任务桥** — 登记工作流时返回一条 `openclaw cron add` 命令,**调度归龙虾**,插件只负责触发时装填上下文
- **增强仪表盘(含小火苗宠物)** — Web UI 实时查看记忆 / 任务 / 章节 / 定时 / 宠物状态,支持按 Agent 筛选
---
## 一键安装
```bash
openclaw plugins install @huo15/openclaw-enhance
```
重启 OpenClaw 生效:
```bash
openclaw restart
```
安装完成后访问仪表盘:`http://localhost:18789/plugins/enhance/`
---
## 功能模块(v5.6.0 全量,标注分层)
> 标注 `[L1/L2/L3]` 的是工具模块,分别在 minimal / balanced / full 三档下暴露给模型;非工具模块(仪表盘 / 提示词 / kb-corpus / 自检)一律常驻。
| 模块 | 分层 | 说明 | Agent 工具 |
|------|------|------|-----------|
| **分类记忆(并入龙虾搜索)** | L1 | user/project/feedback/reference/decision 五类;作为 corpus supplement 与龙虾 `memory` 合并排名 | `enhance_memory_store` `enhance_memory_search` `enhance_memory_review` |
| **状态栏** | L1 | 一行/详情/json 三格式快照(模式、任务、记忆、宠物、通知) | `enhance_statusline` |
| **子任务派发** | L1 | 返回可粘贴的 `openclaw agent` CLI 命令,跨 agent 派发 | `enhance_spawn_task` |
| **模式闸门** | L1 | plan / explore / normal;前两种下 `before_tool_call` 阻止写操作;含 ExitPlanMode 审批 | `enhance_set_mode` `enhance_current_mode` `enhance_exit_plan_mode` |
| **章节标记** | L2 | session 级「mark_chapter」 | `enhance_mark_chapter` `enhance_chapter_list` |
| **任务追踪** | L2 | Claude Code TodoWrite 语义;SQLite 持久化;会警告多 in_progress | `enhance_todo_write` `enhance_todo_update` `enhance_todo_list` |
| **定时任务桥** | L2 | 返回 `openclaw cron add` CLI 命令,尊重龙虾原生 cron-cli | `enhance_loop_register` `enhance_loop_list` `enhance_loop_disable` |
| **历史会话搜索(v5.7)** | L2 | 流式扫 `~/.openclaw/agents/<agent>/sessions/*.jsonl`,照搬 Claude Desktop 算法(无索引、无新表) | `enhance_transcript_search` |
| **工作流自动化(v5.6 合并)** | L3 | 触发词 → 行为指令注入;CRUD 收敛到单工具(action 派发) | `enhance_workflow` `enhance_task` |
| **工具安全观察** | L3 | 错误分类(429/5xx/网络)+ 指数退避建议;不拦截,不重试 | `enhance_safety_log` `enhance_retry_status` `enhance_safety_rules` |
| **任务规划** | L3 | 把多步任务拆解保存为 plan 工件 | `enhance_task_plan` |
| **会话回顾(75min idle)** | L3 | idle 自动 prependContext「上次到这儿」 | `enhance_session_recap` |
| **技能巡检** | L3 | 只读检查 11 个增强技能安装状态 + 给出 clawhub 修复命令 | `enhance_skill_doctor` |
| **技能安装器** | L1 | 返回 11 个配套 skill 的一键安装 CLI 命令(不执行) | `enhance_install_skills` |
| **记忆整合** | L1 | hook 注入:把命中的记忆与查询条件合成上下文片段 | `enhance_memory_consolidate` |
| **提示词增强** | — | 追加 `qualityGuidelines`,其它已由龙虾系统提示词覆盖 | 自动(hook 注入) |
| **共享知识库语料** | — | 桥接 `~/.openclaw/kb/shared/` 到龙虾 `memory_search`(corpus="kb") | 自动(corpus supplement) |
| **输出自检** | — | 空响应/错误关键词检查 | 自动(after-response hook) |
| **增强仪表盘** | — | Web UI:记忆 / 任务 / 章节 / 定时 / 孵化子任务 / 小火苗 | `http://localhost:18789/plugins/enhance/` |
## 与龙虾原生的关系(设计契约)
| 能力 | 龙虾原生 | enhance 策略 |
|------|---------|--------------|
| 记忆向量库(LanceDB) | ✅ 龙虾负责 | **enhance 不自建**;改为 corpus supplement 并入搜索 |
| 记忆系统提示词 | ✅ 龙虾负责 | enhance 只在段落底部追加一行工具说明(如果龙虾提供 `registerMemoryPromptSupplement`) |
| 工具 allow/deny | ✅ 龙虾负责 | enhance 只**观察**结果、做错误分类;不拦截 |
| 任务清单 / 计划文件 | ⚠️ 无对应原语 | enhance 独立实现(SQLite),语义对齐 Claude Code |
| Cron 调度 | ✅ 龙虾 cron-cli | enhance 不管理调度;只在触发时注入 instructions |
| 技能安装 | ✅ ClawHub | enhance 只读巡检,不擅自安装 |
---
## 增强技能
安装时会自动注入 8 个增强技能到 `workspace/skills/`(4 个工作流 + 4 个设计):
### 工作流模式
| 技能 | 说明 | 灵感来源 |
|------|------|---------|
| `huo15-openclaw-plan-mode` | 结构化规划模式 — 执行复杂任务前先做需求分析、方案设计、风险评估 | Claude Code Plan Agent |
| `huo15-openclaw-explore-mode` | 深度探索模式 — 只读调研代码库/系统/话题后再给出结论 | Claude Code Explore Agent |
| `huo15-openclaw-verify-mode` | 验证检查模式 — 检查工作成果、运行测试、验证假设 | Claude Code Verification Agent |
| `huo15-openclaw-memory-curator` | 记忆整理 — 定期审查记忆、提取洞察、清理过期条目 | Claude Code auto-memory |
### 设计能力(v5.4 新增)
| 技能 | 说明 | 灵感来源 |
|------|------|---------|
| `huo15-openclaw-frontend-design` | 高保真 Web UI 原型 + 5 美学流派 + 反 AI Slop 硬红线 + Junior/Full 两趟渲染 | Anthropic frontend-design skill |
| `huo15-openclaw-design-director` | 设计方向顾问 — 5 流派 × 20 哲学 → 3 方向反差对比 + 强制推荐 | huashu-design 方向选型模式 |
| `huo15-openclaw-brand-protocol` | 品牌规范抓取 — Ask/Search/Download/Verify/Codify 5 步 → brand-spec.md | huashu Brand Protocol 5-step |
| `huo15-openclaw-design-critique` | 5 维设计评审 — 美学/可用性/品牌/内容/实现 + Keep/Fix/Quick Wins 三分类 | Web Design review 社区共识 |
---
## 配置说明
在 `openclaw.json` 的 `plugins.entries.enhance.config` 中配置各模块:
```json
{
"plugins": {
"allow": ["enhance"],
"entries": {
"enhance": {
"enabled": true,
"config": {
"toolTier": "balanced",
"memory": {
"enabled": true,
"autoCapture": true,
"maxContextEntries": 5
},
"safety": {
"enabled": true,
"rules": [
{ "tool": "exec", "pattern": "rm -rf *", "action": "block", "reason": "危险命令" },
{ "tool": "exec", "pattern": "sudo *", "action": "block", "reason": "禁止 sudo" },
{ "tool": "file_write", "pathPattern": "*.env", "action": "block", "reason": "禁止写入环境变量文件" }
],
"defaultAction": "allow"
},
"prompt": {
"enabled": true,
"sections": ["qualityGuidelines", "memoryContext"]
},
"workflows": { "enabled": true },
"dashboard": { "enabled": true }
}
}
}
}
}
```
### `toolTier`(v5.6 新增)
| 取值 | 工具数 | 暴露的工具模块 | 适用场景 |
|------|--------|----------------|----------|
| `"minimal"` | 10 | 记忆 + 状态栏 + spawn + 模式 + 章节安装器 + integrator | 上下文紧 / 长会话 / 极简核心 |
| `"balanced"` *(默认)* | 19 | minimal + todo + 章节标记 + 定时任务桥 + **transcript-search (v5.7)** | 多数日常使用 |
| `"full"` | 27 | 全部,含 workflow / safety / task-planner / session-recap / skill-doctor | 工作流自动化 / 完整 harness |
修改 `toolTier` 后需要 `openclaw restart` 才能生效。
### 安全规则配置
| 字段 | 说明 |
|------|------|
| `tool` | 工具名称,支持通配符(如 `exec`、`file_*`) |
| `pattern` | 参数匹配模式,支持通配符(如 `rm -rf *`) |
| `pathPattern` | 文件路径匹配模式(如 `*.env`、`/etc/*`) |
| `action` | 匹配后动作:`block`(拦截)/ `log`(记录)/ `allow`(放行) |
| `reason` | 规则说明(可选) |
### 提示词段落配置
可选段落:`taskClassification`(任务分类)、`qualityGuidelines`(质量指引)、`memoryContext`(记忆上下文)、`safetyAwareness`(安全意识)
---
## 与 WeCom 动态 Agent 配合
当 WeCom 插件启用 `dynamicAgents` 后,每个用户/群组被分配独立的 `agentId`(如 `wecom-acct-ws-dm-hidaomax`)。增强包自动实现:
1. **记忆隔离** — 用户 A 存的记忆,用户 B 看不到
2. **日志隔离** — 每个用户的安全事件独立记录
3. **工作流隔离** — 用户 A 定义的工作流不影响用户 B
4. **上下文隔离** — 提示词增强只注入当前用户的记忆
**实现原理**:
- 工具使用 `OpenClawPluginToolFactory` 模式,从 `ctx.agentId` 获取当前 Agent
- 钩子从 `ctx.agentId` 获取当前 Agent
- SQLite 所有表包含 `agent_id` 列,查询时自动按 Agent 过滤
仪表盘支持 `?agent=wecom-acct-ws-dm-hidaomax` 参数查看特定用户数据。
---
## 设计理念
借鉴 Claude Code 的核心设计模式,适配 OpenClaw 的插件架构:
| 维度 | Claude Code 原版 | 火一五·克劳德·龙虾增强插件适配 |
|------|-----------------|--------------|
| 记忆系统 | 6 层记忆 + Agent frontmatter | 5 类分类 + SQLite agent_id 隔离 |
| 权限安全 | 5 层权限模型 + 异步分类器 | 规则匹配 + block/log/allow + 审计日志 |
| 提示词工程 | Memoized sections + 优先级系统 | 可配置段落 + appendSystemContext 注入 |
| Agent 系统 | 3 种 Agent 类型 + frontmatter 配置 | 4 个增强技能 + OpenClaw skill 系统 |
| 工作流 | 17 个生命周期事件 | 触发词驱动 + before_prompt_build 注入 |
---
## 版本历史
见 [CHANGELOG.md](./CHANGELOG.md)。
## License
MIT
---
<div align="center">
**公司名称:** 青岛火一五信息科技有限公司
**联系邮箱:** [email protected] | **QQ群:** 1093992108
---
**关注逸寻智库公众号,获取更多资讯**
<img src="https://tools.huo15.com/uploads/images/system/qrcode_yxzk.jpg" alt="逸寻智库公众号二维码" style="width: 200px; height: auto; margin: 10px 0;" />
</div>
---
FILE:docs/SELF_ITERATE.md
# enhance 持续自我迭代 SOP
> 用户硬要求(2026-04-25):
>
> 1. **不断自我迭代** — 每 3 天对照 Claude Code(官方 docs / 本地 npm 源码 / 反编译 Claude Desktop APP)做一次能力 gap 调研,挑高 ROI 候选落地。
> 2. **零侵入** — 永远不动 openclaw 核心代码、不复制龙虾原生功能。重叠功能以龙虾为准。
> 3. **skill 走 ClawHub** — 任何要新增 / 修改的 skill **必须先在本地 `huo15-skills/` 里写好 → 发布到 ClawHub → 然后让本插件的 `skill-installer.ts` 引用 slug**。**插件代码里绝不内嵌 skill 内容**。
本文档把这套迭代节奏沉淀成 SOP,每次迭代结束直接更新这里。
---
## 1. 三个信息源
| 源 | 路径 | 用途 |
|---|---|---|
| **Claude Code 官方 docs** | https://docs.claude.com/en/docs/claude-code/ + 子页 | 最权威能力清单 — 看 hooks/skills/slash-commands/sessions/modes 各页 |
| **Claude Code npm 包源码** | `~/.nvm/versions/node/<ver>/lib/node_modules/@anthropic-ai/claude-code/` | `sdk-tools.d.ts` 揭示 SDK 工具/Hook/Agent 的真实类型定义;`bin/` 入口 |
| **Claude Desktop APP(反编译)** | `/Applications/Claude.app/Contents/Resources/app.asar` | 解包后 `.vite/build/` 下有完整业务逻辑 — workers、UI、native helpers 都在 |
### 反编译 Claude Desktop(验证可行)
```bash
# 解包(不修改原 app)
mkdir -p /tmp/claude-app-extract
npx --yes @electron/asar extract /Applications/Claude.app/Contents/Resources/app.asar /tmp/claude-app-extract
# 关键工程目录
ls /tmp/claude-app-extract/.vite/build/
# ├─ index.js # 主进程 bundle (10k+ 行)
# ├─ index.pre.js # 预加载
# ├─ mainView.js / mainWindow.js / quickWindow.js / aboutWindow.js / buddy.js
# ├─ coworkArtifact.js / findInPage.js / computerUseTeach.js
# ├─ mcp-runtime/{directMcpHost.js, nodeHost.js}
# ├─ shell-path-worker/shellPathWorker.js
# ├─ sqlite-worker/sqliteWorker.node.js
# └─ transcript-search-worker/transcriptSearchWorker.js ← v5.7 灵感来源
```
**v5.7 transcript-search 就是这样找到的** —— Claude Desktop 用纯流式扫 JSONL + indexOf,**不用 SQL FTS5**。我们直接照搬,省下了 v5.5.1 路线图里"建 session_messages 新表 + FTS"的工作量。
清理:`rm -rf /tmp/claude-app-extract` 不留痕。
---
## 2. 候选迭代池(按 ROI 排序,每次更新)
| # | 候选 | 来源 | 形态 | 估算 | 状态 |
|---|------|------|------|------|------|
| ✅ | **transcript-search** | Claude Desktop transcriptSearchWorker | Plugin 模块 | ~200 行 | 已落地 v5.7.0 |
| ✅ | **before_compaction 噪音 hook 删除 + memory_purge 工具** | 用户实测 enhance 库 613 条全为 auto-compact 噪音 | Plugin hot-fix | ~80 行净改动 | 已落地 **v5.7.1**(2026-04-26 计划外 hot-fix)|
| ✅ | **hardening 套件**(Map LRU + safety_log TTL + corpus tag 黑名单)| Explore agent 全代码审计 + 防御未来类似 v5.7.1 的 noise factory | Plugin patch | ~120 行 | 已落地 **v5.7.2**(2026-04-26 同日延伸防御)|
| ✅ | **config-doctor 启动期诊断** | 用户装 v5.7.2 仍爆 'Context limit exceeded',根因是 openclaw 配置陷阱(缺 reserveTokensFloor / model maxTokens 过大),enhance 主动诊断把信号给到用户 | Plugin 模块 + 工具 | ~200 行 | 已落地 **v5.7.3**(2026-04-26 同日,calendar 外第 3 次 hot-fix)|
| ✅ | **config-doctor 扩展扫已装插件 bare pluginApi** | 用户报"提示插件要求 2026.2.24"实际是其它插件违反 ">=X.Y.Z" 规则;扫所有装的 plugin package.json 检测 bare → 给 fix 命令 | Plugin 模块扩展 | ~80 行净增 | 已落地 **v5.7.4**(2026-04-26 同日,calendar 外第 4 次 hot-fix)|
| ✅ | **skill-recommender 按需求自动挑 skill** | 用户提"看看 Claude 是怎么做的"——反编译发现 Claude Desktop 就是 name+description 注入 system prompt 让 LLM 挑;enhance 改成按需工具:扫多路径(含 WeCom workspace-*)+ CJK 双字滑窗 + alias 强 boost + 三段式(已装 / 未装 / 自建规划) | Plugin 模块 + 工具 | ~270 行 | 已落地 **v5.7.5/6**(2026-04-26 同日第 5 次)|
| ✅ | **session-lifecycle 接 openclaw 4.22 五个 hook** | 用户要求"结合 claude 官网+本地源码看 enhance 还能补啥"——跑完整 SOP 发现 openclaw 4.22 暴露 29 hook,enhance 只用 4 个;接 session_start/end/before_reset/subagent_*/ended 闭环生命周期 | Plugin 模块 | ~250 行 | 已落地 **v5.7.7**(2026-04-26 同日第 6 次)|
| 1 | **tool-result-optimizer**(接 tool_result_persist 大结果截断+摘要)| openclaw 4.22 hook | Plugin 模块 | ~100 行 | 待选(长 session 减负)|
| 2 | **artifacts 多版本管理(轻量)** | Claude Desktop artifacts 表 | Plugin 模块 + SQLite | ~250 行 | 待选(v5.8)|
| 3 | **frames 父子 session 关系** | Claude Desktop frames 表 | Plugin 模块 + SQLite | ~150 行 | 待选(v5.8)|
| 4 | **auto-memory-curator cron 触发** | enhance 已有 skill,缺定时器 | Plugin 模块 | ~40 行 + cron 命令 | 待选 |
| 2 | **path-rules**(plan/explore 写入静态参数白名单)| Claude Code Settings | Plugin 模块 | ~150 行 | 待选 |
| 3 | **WeCom push notification 桥接** | Claude Code Notifications | Plugin 模块 + WeCom webhook | ~100 行(需 @huo15/wecom 协作)| 待选 |
| 4 | **skill-creator** skill | Claude Code 内置 skill | **Skill**(先发 ClawHub 再让 enhance 引用)| 半天 | 待选 |
| 5 | **less-permission-prompts** skill | Claude Code 内置 skill | **Skill** | 半天 | 待选 |
| 6 | **init-soul** skill | Claude Code 内置 skill | **Skill** | 半天 | 待选 |
| 7 | **cowork artifact**(多 agent 协作产物管理)| Claude Desktop coworkArtifact.js | Plugin 模块(待调研)| 1 天 | 待选 |
**ROI 排序原则**:
1. **有现成实现可参考**(如 transcript-search 有 Claude Desktop worker)> 凭空设计
2. **Plugin 模块** ROI 通常 > Skill(Plugin 改一次所有 agent 受益,Skill 要语义召唤)
3. **解决用户实测痛点**(如 long session 找不回历史)> 锦上添花
4. **完全非侵入**(只读 / 自建数据)> 需要 hook 配合
5. **代码量 < 200 行**(一次能写完)> 大工程
---
## 3. 标准迭代流程(每 3 天一次)
### Step 1 — 信息更新(10–15 min)
```bash
# 1. 拉最新 docs(用 WebFetch 或浏览器)
# 最常变化的页:hooks / skills / sessions / modes / recent-additions
# 2. 重装 Claude Code 到最新(看 sdk-tools.d.ts 有没有新 type)
npm i -g @anthropic-ai/claude-code
# 3. 检查 Claude Desktop 有没有自动更新
ls -la /Applications/Claude.app/Contents/Info.plist | head -3
```
### Step 2 — Gap 比对(15–30 min)
```bash
# 列 enhance 当前的 hook + tool 注册
grep -RnE 'api\.(on|registerTool|registerMemory)' \
/Users/jobzhao/workspace/projects/openclaw/huo15-openclaw-enhance/src \
/Users/jobzhao/workspace/projects/openclaw/huo15-openclaw-enhance/index.ts
# 列 Claude Code SDK 的所有 hook + tool 类型
grep -E '^(export|tool: ")' \
~/.nvm/versions/node/$(node -v | tr -d v)/lib/node_modules/@anthropic-ai/claude-code/sdk-tools.d.ts \
| head -60
```
把当前候选池里的 #1–#7 与最新清单 diff,更新候选状态。
### Step 3 — 选 1 个 ROI 最高的落地(1–4 h)
按下面的 Plugin vs Skill 决策树挑形态:
```
新需求来了 →
├─ 用户一句话召唤 + 单次输出? → Skill
├─ 需要 hook / 跨 session 状态 / DB? → Plugin
├─ 用户可能不装 plugin 也想用? → Skill
└─ 形态混合? → Plugin(主动)+ Skill(人工召唤)
```
### Step 4 — 发布(半小时)
**Plugin 模块发布(v5.X.Y)**:
```bash
cd /Users/jobzhao/workspace/projects/openclaw/huo15-openclaw-enhance
# typecheck
npx tsc --noEmit
# bump 版本(package.json + openclaw.plugin.json + SKILL.md + CHANGELOG.md + README.md)
# commit + tag + push
git push origin main && git push origin vX.Y.Z
git push github main && git push github vX.Y.Z
# 双发布
npm publish --access public "--//registry.npmjs.org/:_authToken=npm_<TOKEN>"
CLAWHUB_TOKEN=clh_<TOKEN> clawhub publish . --workdir . --dir . --version X.Y.Z --tags latest,plugin
```
**Skill 发布(必须先 ClawHub 后插件引用,⚠️ 用户硬要求)**:
```bash
# 1. 先在 huo15-skills 仓库写 skill
cd /Users/jobzhao/workspace/projects/openclaw/huo15-skills
# 编辑 huo15-openclaw-<name>/SKILL.md 等
# 2. 发布到 ClawHub
CLAWHUB_TOKEN=clh_<TOKEN> clawhub publish ./huo15-openclaw-<name> --version 1.0.0
# 3. 等 ClawHub 索引可见(搜索能找到)
clawhub search huo15-openclaw-<name>
# 4. 然后到 enhance 仓库 src/modules/skill-installer.ts 把 slug 加到 CLAW_HUB_SKILLS
# src/modules/skill-doctor.ts 同步加到 EXPECTED_SKILLS
# bump enhance 版本 → 走上面的 plugin 发布流程
```
⚠️ **绝对不要在插件代码里内嵌 skill 内容**。Skill 必须独立发版,插件只引用 slug。
### Step 5 — 沉淀(10 min)
更新两处:
1. **本仓库 `docs/SELF_ITERATE.md`** 候选池 — 把已落地的标 ✅,新发现的加进去
2. **本地 KB `~/knowledge/huo15/`** — 一次发布写一篇 markdown 完整 post-mortem(含 design 决策、实测数据、踩过的坑)
---
## 4. 历史迭代记录
| 日期 | 版本 | 主题 | 来源 | 落地形态 |
|------|------|------|------|---------|
| 2026-04-23 | v5.4.0 | 设计能力套件(4 个 skill) | huashu-design + Anthropic frontend-design | 4 Skills |
| 2026-04-24 | v5.5.0 | 三层记忆/KB 协调(corpus="kb") | Claude Code memory 文档 | Plugin 模块 |
| 2026-04-24 | v5.5.1 | 开发辅助三件套 + session-recap | Claude Code /simplify /security-review /review + idle recap | 3 Skills + 1 Plugin 模块 |
| 2026-04-24 | v5.6.0 | 工具分层 + workflow 5→2 + 描述压缩 | Long session context pressure 实测 | Plugin 容量优化 |
| 2026-04-25 | v5.7.0 | transcript-search(流式扫 jsonl) | 反编译 Claude Desktop transcriptSearchWorker | Plugin 模块 |
| 2026-04-26 | v5.7.1 | hot-fix:删 before_compaction 噪音 hook + 加 memory_purge | 用户实测 enhance 库 613 条全为 auto-compact 噪音 | Plugin hot-fix |
| 2026-04-26 | v5.7.2 | hardening:Map LRU + safety_log TTL + corpus tag 黑名单 + peerDep 4.22 | Explore agent 全代码审计后挑 4 项 ROI 最高的批量修 | Plugin patch |
| 2026-04-26 | v5.7.3 | config-doctor:启动期诊断 openclaw.json 陷阱(reserveTokensFloor 缺失 / model maxTokens 过大)| 用户实测装 v5.7.2 仍爆 'Context limit exceeded',根因在 openclaw 配置而非插件 | Plugin 模块 |
| 2026-04-26 | v5.7.4 | config-doctor 扫已装插件 bare pluginApi | 用户报"插件要求 2026.2.24"实际是其它插件违反 ranged spec 规则 | Plugin 模块扩展 |
| 2026-04-26 | v5.7.5/6 | skill-recommender:按需求挑已装 skill / 推荐未装 / 给自建规划 | 用户提"看看 Claude 是怎么做的"——反编译 Claude Desktop loadSkills 启发 | Plugin 模块 |
| 2026-04-26 | **v5.7.7** | **session-lifecycle:接入 openclaw 4.22 的 session_start/end/before_reset/subagent_*/ended 五个 hook 闭环生命周期** | **跑完整 SOP 发现 openclaw 4.22 暴露 29 hook,enhance 只用 4 个;ROI top 5 候选 #1** | **Plugin 模块** |
下一次迭代锚点:**2026-04-28**(每 3 天间隔;如果有新 Claude Code release 或线上 bug 反馈提前触发)。
### 关于"诊断 vs 修复"的边界
v5.7.3 严格遵守"**诊断不修复**" — 即便 enhance 完全有能力 read/write `~/.openclaw/openclaw.json`,也只 `readFileSync` 不 `writeFileSync`。理由:
1. 红线 #1:不侵入式修改 openclaw(配置文件属于 openclaw 控制范围)
2. 用户对配置的掌控感 — 自己复制粘贴一行 python3 命令,至少看到改了啥
3. 排除责任 — 万一 fix 命令出错(比如把字段值打错),损失只是用户那一刻的副作用,不会让 enhance 担责"我装了插件配置就被改坏了"
**未来若加任何"建议改 openclaw 配置"的功能,硬约束:return-cliCmd 模式(输出 fix 命令字符串),永不 fs.writeFileSync 用户配置**。
### 发版前自查 checklist(v5.7.4 启示)
每次发布 plugin 前必跑:
```bash
# 1. 自查本插件 compat.pluginApi 是 ranged
grep -E '"pluginApi"' package.json openclaw.plugin.json
# 必须看到 ">=X.Y.Z" / "^X.Y.Z" / "~X.Y.Z"
# 绝不能看到裸的 "X.Y.Z" — 那会被 openclaw 解读为精确匹配
# 2. typecheck
npx tsc --noEmit
# 3. 跑一次本地 enhance_config_doctor 看自己安装目录有没有 bare plugin
# (拿到 v5.7.4+ 之后此项自动)
```
为什么这条这么重要:v5.7.4 修的 bug 就是其它两个 huo15 插件作者(包括我自己)写 bare 字符串的失误造成的。**bare pluginApi 是 silent breakage** — 当时跑得好好,运行时一升级 openclaw 就炸。每次发版都自查能避免下个用户遭罪。
### 关于 hot-fix 的额外约束(v5.7.1 启示)
线上 bug(用户截图反馈)属于 **calendar 外触发** — 不等 cron 任务,立刻按照下面 fast-track 流程处理:
1. 用 Grep 直接定位 bug 代码(不要 Plan)
2. 修复 + typecheck(不要 release plan)
3. SQL 直接清用户残留数据(如本次 613 条),先 `cp ... .bak.before-vX.Y.Z-hotfix` 备份
4. 走标准发布流程(commit → tag → push 双 remote → npm + clawhub)
5. 把 bug 加进 SELF_ITERATE.md 候选池标 ✅,写一篇 KB post-mortem
6. 不影响下次 cron 调度(cron 还是 2026-04-28 跑)
---
## 5. 红线清单(永远不踩)
1. ❌ 不修改 openclaw 核心代码 / 不动 openclaw 仓库
2. ❌ 不复制龙虾原生功能(记忆向量库、tools.allow/deny、cron 调度、技能安装)
3. ❌ 插件代码里不内嵌 skill 内容(skill 必须独立发版到 ClawHub)
4. ❌ 不用 child_process(企业扫描器拦截 — 见 KB「No child_process in published plugins」)
5. ❌ 不在 plugin 里写 npm/pip/cli 一类的安装命令执行(必须用 return-cliCmd 模式让用户 / cron 执行)
6. ❌ ClawHub publish 不要一小时内发 5 个以上 new slug(rate limit)
7. ❌ 提交不带 secrets(即使是 publish-credentials.md 里的 token,不出现在 commit message 或 code)
FILE:index.ts
/**
* 龙虾增强包 (OpenClaw Enhancement Kit)
*
* 非侵入式增强插件(v1.2.3 — 多 Agent 隔离):
* - 模块1: 结构化记忆系统(按 agentId 隔离,借鉴 Claude Code auto-memory)
* - 模块2: 工具安全守卫(按 agentId 记录日志,借鉴 Claude Code 权限系统)
* - 模块3: 提示词增强(按 agentId 注入上下文,借鉴 Claude Code systemPromptSections)
* - 模块4: 工作流自动化(按 agentId 隔离工作流,借鉴 Claude Code hooks 事件驱动)
* - 模块5: 增强仪表盘(支持按 Agent 筛选)
*
* 完全适配 WeCom 插件的动态 Agent 功能:
* - 工具使用 OpenClawPluginToolFactory 模式,从 ctx.agentId 获取当前 Agent
* - 钩子从 ctx.agentId 获取当前 Agent
* - 每个企微用户/群组的数据完全隔离
*/
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { registerStructuredMemory } from "./src/modules/structured-memory.js";
import { registerTaskPlanner } from "./src/modules/task-planner.js";
import { registerToolSafety } from "./src/modules/tool-safety.js";
import { registerPromptEnhancer } from "./src/modules/prompt-enhancer.js";
import { registerWorkflowHooks } from "./src/modules/workflow-hooks.js";
import { registerDashboard } from "./src/modules/dashboard.js";
import { registerSelfCheck } from "./src/modules/self-check.js";
import { registerMemoryIntegrator } from "./src/modules/memory-integrator.js";
import { registerTodoTracker } from "./src/modules/todo-tracker.js";
import { registerChapterMarks } from "./src/modules/chapter-marks.js";
import { registerModeGate } from "./src/modules/mode-gate.js";
import { registerStatusline } from "./src/modules/statusline.js";
import { registerSpawnTask } from "./src/modules/spawn-task.js";
import { registerSkillDoctor } from "./src/modules/skill-doctor.js";
import { registerScheduledTasksBridge } from "./src/modules/scheduled-tasks-bridge.js";
import { registerSkillInstaller, CLAW_HUB_SKILLS } from "./src/modules/skill-installer.js";
import { registerKbCorpus } from "./src/modules/kb-corpus.js";
import { registerSessionRecap } from "./src/modules/session-recap.js";
import { registerTranscriptSearch } from "./src/modules/transcript-search.js";
import { registerConfigDoctor } from "./src/modules/config-doctor.js";
import { registerSkillRecommender } from "./src/modules/skill-recommender.js";
import { registerSessionLifecycle } from "./src/modules/session-lifecycle.js";
import { createNotificationQueue } from "./src/modules/notification-queue.js";
import { resolveOpenClawHome } from "./src/utils/resolve-home.js";
import { getDb } from "./src/utils/sqlite-store.js";
import type { EnhancePluginConfig, ToolTier } from "./src/types.js";
import { existsSync } from "node:fs";
import { join } from "node:path";
/**
* 工具分层映射(v5.6):
* - L1 minimal: 记忆核心 / 状态栏 / spawn / 模式 / 章节 / installer / integrator
* - L2 balanced (default): +todo / 定时任务桥
* - L3 full: +workflow / safety / task-planner / session-recap / skill-doctor
*
* 只会载入 tier 内的模块,其他模块整个不 register(省下 tool schema 全部重量)。
*/
type Tier = 1 | 2 | 3;
const TIER_MAX: Record<ToolTier, Tier> = {
minimal: 1,
balanced: 2,
full: 3,
};
export default definePluginEntry({
id: "enhance",
name: "龙虾增强包 (OpenClaw Enhancement Kit)",
description: "结构化记忆、工具安全守卫、提示词增强、工作流自动化、仪表盘",
register(api) {
const config = (api.pluginConfig ?? {}) as EnhancePluginConfig;
const toolTier: ToolTier = config.toolTier ?? "balanced";
const maxTier: Tier = TIER_MAX[toolTier];
// 初始化共享数据库和通知队列
const openclawHome = resolveOpenClawHome(api);
const db = getDb(openclawHome);
const notifyQueue = createNotificationQueue(db, config.notifications);
// 模块清单(v5.6 新增 tier 字段):
// tier 1 = 常驻层(最高 ROI,任何时候都暴露)
// tier 2 = 均衡层(常用但非必须,默认启用)
// tier 3 = 完整层(专业场景,minimal/balanced 下不暴露)
// 非工具类模块(仪表盘、通知、自检、prompt-enhancer、kb-corpus)标 tier 1:它们不占工具 schema,不影响 per-turn 成本。
const modules: Array<{ name: string; tier: Tier; enabled: boolean; load: () => void }> = [
// ── 工具模块(占 tool schema)──
{
name: "结构化记忆",
tier: 1,
enabled: config.memory?.enabled !== false,
load: () => registerStructuredMemory(api, config.memory),
},
{
name: "状态栏",
tier: 1,
enabled: config.statusline?.enabled !== false,
load: () => registerStatusline(api, db, notifyQueue),
},
{
name: "子任务派发",
tier: 1,
enabled: true,
load: () => registerSpawnTask(api),
},
{
name: "模式闸门",
tier: 1,
enabled: config.mode?.enabled === true,
load: () => registerModeGate(api, config.mode, notifyQueue),
},
{
name: "技能安装器",
tier: 1,
enabled: true,
load: () => registerSkillInstaller(api),
},
{
name: "记忆整合",
tier: 1,
enabled: config.memory?.enabled !== false,
load: () => registerMemoryIntegrator(api, config.contextPruner),
},
{
name: "章节标记",
tier: 2,
enabled: config.chapters?.enabled !== false,
load: () => registerChapterMarks(api),
},
{
name: "任务追踪",
tier: 2,
enabled: config.todos?.enabled !== false,
load: () => registerTodoTracker(api, notifyQueue),
},
{
name: "定时任务桥",
tier: 2,
enabled: config.scheduledTasks?.enabled !== false,
load: () => registerScheduledTasksBridge(api),
},
{
name: "历史会话搜索",
tier: 2,
enabled: config.transcriptSearch?.enabled !== false,
load: () => registerTranscriptSearch(api),
},
{
name: "工作流自动化",
tier: 3,
enabled: config.workflows?.enabled !== false,
load: () => registerWorkflowHooks(api, config.workflows),
},
{
name: "工具安全",
tier: 3,
enabled: config.safety?.enabled !== false,
load: () => registerToolSafety(api, config.safety),
},
{
name: "任务规划",
tier: 3,
enabled: true,
load: () => registerTaskPlanner(api),
},
{
name: "会话回顾",
tier: 3,
enabled: config.sessionRecap?.enabled !== false,
load: () => registerSessionRecap(api, config.sessionRecap),
},
{
name: "技能巡检",
tier: 3,
enabled: true,
load: () => registerSkillDoctor(api),
},
// ── 非工具模块(不占 tool schema,tier 不影响)──
{
name: "提示词增强",
tier: 1,
enabled: config.prompt?.enabled !== false,
load: () => registerPromptEnhancer(api, config.prompt),
},
{
name: "输出自检",
tier: 1,
enabled: config.selfCheck?.enabled !== false,
load: () => registerSelfCheck(api, config.selfCheck),
},
{
name: "仪表盘",
tier: 1,
enabled: config.dashboard?.enabled !== false,
load: () => registerDashboard(api, config.dashboard, notifyQueue, db),
},
{
name: "共享知识库语料",
tier: 1,
enabled: config.kbCorpus?.enabled !== false,
load: () => registerKbCorpus(api, config.kbCorpus),
},
{
// v5.7.3: 启动期诊断 ~/.openclaw/openclaw.json 陷阱配置
// tier=1,minimal 也启用——这是关键的 'Context limit exceeded' 兜底诊断
name: "配置诊断",
tier: 1,
enabled: config.configDoctor?.enabled !== false,
load: () => registerConfigDoctor(api, config.configDoctor, notifyQueue),
},
{
// v5.7.5: 按用户需求挑已装 skill / 推荐未装 / 给自建规划
// tier=2 balanced 默认启用;minimal 不暴露(用户多半已经知道用啥 skill)
name: "技能推荐",
tier: 2,
enabled: config.skillRecommender?.enabled !== false,
load: () => registerSkillRecommender(api, config.skillRecommender),
},
{
// v5.7.7: 接入 openclaw 4.22 的 session_start/end/before_reset/subagent_* hook
// tier=1 minimal 也启用——这是核心生命周期补全,零工具 schema(纯 hook 监听)
name: "会话生命周期",
tier: 1,
enabled: config.sessionLifecycle?.enabled !== false,
load: () => registerSessionLifecycle(api, config.sessionLifecycle, notifyQueue),
},
// 智能贴士已合并到小火苗模块(before_prompt_build 统一输出)
// {
// name: "智能贴士",
// tier: 3,
// enabled: config.tips?.enabled !== false,
// load: () => { console.error("[idx] loading spinner-tips..."); registerSpinnerTips(api, config.tips, notifyQueue); },
// },
];
const loaded: string[] = [];
const skipped: string[] = [];
for (const mod of modules) {
if (!mod.enabled) continue;
if (mod.tier > maxTier) {
skipped.push(mod.name);
continue;
}
try {
mod.load();
loaded.push(mod.name);
} catch (err) {
api.logger.error(`[enhance] 模块「mod.name」加载失败: String(err)`);
}
}
if (skipped.length > 0) {
api.logger.info(
`[enhance] toolTier=toolTier:按分层策略跳过 skipped.length 个模块(skipped.join("、"))。改 config.toolTier = "full" 可全部启用。`,
);
}
// 首次启动提示:配套技能需手动安装(插件不执行外部命令,只给提示)
try {
const globalSkillsDir = join(openclawHome, "workspace", "skills");
const missing = CLAW_HUB_SKILLS.filter(
(s) => !existsSync(join(globalSkillsDir, s)),
);
if (missing.length > 0) {
api.logger.info(
`[enhance] 检测到 missing.length 个配套技能未安装:missing.join("、");` +
`调用 enhance_install_skills 获取一键安装命令,或手动运行 clawhub install <技能名> --dir globalSkillsDir`,
);
}
} catch {
// 静默跳过(非关键路径)
}
api.logger.info(`[enhance] 龙虾增强包 v5.6.0 已加载(toolTier=toolTier,非侵入式,不重复龙虾原生功能),启用模块: loaded.join("、")`);
},
});
FILE:openclaw.plugin.json
{
"id": "enhance",
"name": "火一五·克劳德·龙虾增强插件",
"description": "非侵入式增强 OpenClaw 2026.4.24+:记忆整合、工具安全、任务/章节/工作流、模式闸门、statusline、定时任务桥接、仪表盘、生命周期监听。v5.7.8 全面适配 openclaw 4.24 SDK:peerDep 4.24 + build/compat 同步 + 14 处 api.on 全 typed 去 as any + 加 enabledByDefault/uiHints/activation 元数据",
"version": "2.4.9",
"enabledByDefault": true,
"uiHints": {
"toolTier": { "control": "select", "label": "工具分层" },
"configDoctor.enabled": { "control": "switch", "label": "配置诊断" },
"skillRecommender.enabled": { "control": "switch", "label": "技能推荐" },
"sessionLifecycle.enabled": { "control": "switch", "label": "会话生命周期" },
"transcriptSearch.enabled": { "control": "switch", "label": "历史会话搜索" }
},
"activation": {
"onAgentHarnesses": ["claude", "openclaw-default"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"toolTier": {
"type": "string",
"enum": ["minimal", "balanced", "full"],
"default": "balanced",
"description": "工具分层(v5.6+):minimal=10 工具仅核心 / balanced=18 工具默认 / full=26 工具完整。降低每轮 prompt 工具 schema 总量。修改后需重启 openclaw。"
},
"memory": {
"type": "object",
"description": "增强记忆:本地 SQLite 分类记忆 + 通过 corpus supplement 喂给龙虾原生记忆引擎(不绕过龙虾)",
"properties": {
"enabled": { "type": "boolean", "default": true },
"autoCapture": { "type": "boolean", "default": true },
"maxContextEntries": { "type": "number", "default": 5 }
}
},
"safety": {
"type": "object",
"description": "工具安全补充:仅补充龙虾原生 tools.allow/deny 未覆盖的细粒度规则",
"properties": {
"enabled": { "type": "boolean", "default": true },
"rules": {
"type": "array",
"items": {
"type": "object",
"properties": {
"tool": { "type": "string" },
"pattern": { "type": "string" },
"pathPattern": { "type": "string" },
"action": { "type": "string", "enum": ["block", "hardblock", "log", "allow"] },
"reason": { "type": "string" }
},
"required": ["tool", "action"]
},
"default": []
},
"defaultAction": { "type": "string", "enum": ["allow", "log"], "default": "allow" },
"enableRetry": { "type": "boolean", "default": true }
}
},
"prompt": {
"type": "object",
"description": "提示词增强:仅注入龙虾内置系统提示词缺失的章节(qualityGuidelines)",
"properties": {
"enabled": { "type": "boolean", "default": true },
"sections": {
"type": "array",
"items": { "type": "string", "enum": ["qualityGuidelines"] },
"default": ["qualityGuidelines"]
}
}
},
"workflows": {
"type": "object",
"description": "工作流触发器(关键词 → 指令注入)",
"properties": {
"enabled": { "type": "boolean", "default": true }
}
},
"todos": {
"type": "object",
"description": "Claude-Code 风格任务追踪(enhance_todo_write)",
"properties": {
"enabled": { "type": "boolean", "default": true }
}
},
"chapters": {
"type": "object",
"description": "会话章节标记(enhance_mark_chapter,仪表盘可跳转)",
"properties": {
"enabled": { "type": "boolean", "default": true }
}
},
"mode": {
"type": "object",
"description": "模式闸门:plan/explore 模式下自动拦截写入类工具",
"properties": {
"enabled": { "type": "boolean", "default": false },
"defaultMode": { "type": "string", "enum": ["normal", "plan", "explore"], "default": "normal" }
}
},
"statusline": {
"type": "object",
"description": "状态栏:项目/模式/记忆命中/宠物状态实时快照(HTTP /plugins/enhance/api/statusline)",
"properties": {
"enabled": { "type": "boolean", "default": true }
}
},
"scheduledTasks": {
"type": "object",
"description": "定时任务桥:注册龙虾 cron-cli 可触发的工作流钩子",
"properties": {
"enabled": { "type": "boolean", "default": true }
}
},
"transcriptSearch": {
"type": "object",
"description": "v5.7 历史会话全文搜索:流式扫 ~/.openclaw/agents/<agent>/sessions/*.jsonl,照搬 Claude Desktop transcriptSearchWorker 算法(无索引、无新表、纯只读)",
"properties": {
"enabled": { "type": "boolean", "default": true }
}
},
"configDoctor": {
"type": "object",
"description": "v5.7.3 启动期诊断 openclaw.json 陷阱配置(缺失 reserveTokensFloor / model maxTokens 过大),只读不修改用户配置",
"properties": {
"enabled": { "type": "boolean", "default": true },
"minReserveTokensFloor": { "type": "number", "default": 5000 },
"maxReserveTokensFloor": { "type": "number", "default": 100000 },
"maxModelMaxTokens": { "type": "number", "default": 32000 }
}
},
"skillRecommender": {
"type": "object",
"description": "v5.7.5 按用户需求挑已装 skill / 推荐未装 huo15-* / 给自建规划。算法照搬 Claude Desktop loadSkills 的 name+description 匹配,按需工具暴露",
"properties": {
"enabled": { "type": "boolean", "default": true },
"installedThreshold": { "type": "number", "default": 0.25, "description": "已装命中相关度阈值,低于则触发未装/自建建议" },
"cacheTtlSec": { "type": "number", "default": 60, "description": "skill 扫描结果缓存秒数" }
}
},
"sessionLifecycle": {
"type": "object",
"description": "v5.7.7 接入 openclaw 4.22 的 session_start / session_end / before_reset / subagent_spawned / subagent_ended hook 闭环 session 生命周期。每个 hook 都做 30 秒 dedup 去重 + 仅写入 enhance 自有表,不污染龙虾原生 memory",
"properties": {
"enabled": { "type": "boolean", "default": true },
"enableSessionStart": { "type": "boolean", "default": true },
"enableSessionEnd": { "type": "boolean", "default": true },
"enableBeforeReset": { "type": "boolean", "default": true },
"enableSubagent": { "type": "boolean", "default": true },
"debug": { "type": "boolean", "default": false }
}
},
"dashboard": {
"type": "object",
"properties": {
"enabled": { "type": "boolean", "default": true }
}
},
"pet": {
"type": "object",
"description": "小火苗宠物 + 智能贴士",
"properties": {
"enabled": { "type": "boolean", "default": true },
"name": { "type": "string" },
"color": { "type": "string", "enum": ["orange", "blue", "purple", "green", "white"] }
}
},
"tips": {
"type": "object",
"properties": {
"enabled": { "type": "boolean", "default": true },
"injectInPrompt": { "type": "boolean", "default": false },
"cooldownMinutes": { "type": "number", "default": 30 }
}
},
"notifications": {
"type": "object",
"properties": {
"enabled": { "type": "boolean", "default": true },
"maxRetained": { "type": "number", "default": 200 }
}
},
"selfCheck": {
"type": "object",
"properties": {
"enabled": { "type": "boolean", "default": true },
"checkEmpty": { "type": "boolean", "default": true },
"checkNoReply": { "type": "boolean", "default": true },
"checkErrorKeywords": { "type": "boolean", "default": true },
"checkExcessiveLength": { "type": "boolean", "default": false },
"maxLength": { "type": "number", "default": 20000 },
"blockOnEmpty": { "type": "boolean", "default": false }
}
},
"contextPruner": {
"type": "object",
"description": "记忆相关性过滤(仅用于 corpus supplement 的 search 排序,不绕过龙虾原生注入)",
"properties": {
"enabled": { "type": "boolean", "default": true },
"threshold": { "type": "number", "default": 0.5 },
"maxEntries": { "type": "number", "default": 5 },
"debug": { "type": "boolean", "default": false }
}
}
}
}
}
FILE:package.json
{
"name": "@huo15/openclaw-enhance",
"version": "5.7.9",
"description": "火一五·克劳德·龙虾增强插件 v5.7.8 — 全面适配 openclaw 2026.4.24:peerDep ^4.24 + build/compat 同步到 4.24 + 14 处 api.on 全部去掉 as any 改成 typed hook(hookName 联合类型 + handler 自动推断 PluginHookHandlerMap[K]) + manifest 加 enabledByDefault/uiHints/activation 元数据 + 修 self-check 之前被 as any 屏蔽的 PluginHookBeforeAgentReplyResult 类型不匹配 bug。继承 v5.7.7 session-lifecycle + v5.7.5 skill-recommender + v5.7.4 扫 bare pluginApi + v5.7.3 config-doctor + v5.7.2 hardening + v5.7.1 hot-fix + v5.7 transcript-search + v5.6 工具分层。",
"type": "module",
"main": "index.ts",
"openclaw": {
"extensions": [
"./index.ts"
],
"build": {
"openclawVersion": "2026.4.24"
},
"compat": {
"pluginApi": ">=2026.4.24"
}
},
"bin": {
"openclaw-enhance-setup": "./scripts/setup.sh"
},
"keywords": [
"openclaw",
"plugin",
"enhance",
"memory",
"safety",
"agent"
],
"author": "jobzhao15",
"license": "MIT",
"peerDependencies": {
"openclaw": "^2026.4.24"
},
"dependencies": {
"@sinclair/typebox": "^0.34.49",
"better-sqlite3": "^11.0.0"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.0",
"typescript": "^5.5.0"
},
"files": [
"index.ts",
"openclaw.plugin.json",
"src/**/*",
"templates/**/*",
"scripts/**/*"
],
"repository": {
"type": "git",
"url": "https://cnb.cool/huo15/ai/huo15-openclaw-enhance"
}
}
FILE:scripts/setup.sh
#!/usr/bin/env bash
#
# 龙虾增强包 — 一键安装脚本
#
# 用法:
# 方式1: npx @huo15/openclaw-enhance setup
# 方式2: bash <(curl -fsSL https://raw.githubusercontent.com/jobzhao15/openclaw-enhance/main/scripts/setup.sh)
#
set -euo pipefail
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m'
OPENCLAW_DIR="-$HOME/.openclaw"
PLUGIN_ID="enhance"
NPM_PACKAGE="@huo15/openclaw-enhance"
echo -e "CYAN🦞 龙虾增强包 (OpenClaw Enhancement Kit) 安装程序NC"
echo ""
# ── 1. 检测 OpenClaw ──
if [ ! -f "$OPENCLAW_DIR/openclaw.json" ]; then
echo -e "RED✗ 未检测到 OpenClaw 安装($OPENCLAW_DIR/openclaw.json 不存在)NC"
echo " 请先安装 OpenClaw: https://openclaw.com"
exit 1
fi
echo -e "GREEN✓NC 检测到 OpenClaw: $OPENCLAW_DIR"
# ── 2. 安装插件 ──
echo ""
echo -e "CYAN正在安装插件...NC"
if command -v openclaw &>/dev/null; then
openclaw plugins install "$NPM_PACKAGE" || {
echo -e "YELLOW⚠ openclaw plugins install 失败,尝试手动安装...NC"
INSTALL_DIR="$OPENCLAW_DIR/extensions/openclaw-enhance"
mkdir -p "$INSTALL_DIR"
cd "$INSTALL_DIR"
npm init -y --silent 2>/dev/null || true
npm install "$NPM_PACKAGE" --silent
cd - >/dev/null
}
else
echo -e "YELLOW⚠ 未找到 openclaw CLI,使用手动安装模式NC"
INSTALL_DIR="$OPENCLAW_DIR/extensions/openclaw-enhance"
mkdir -p "$INSTALL_DIR"
cd "$INSTALL_DIR"
npm init -y --silent 2>/dev/null || true
npm install "$NPM_PACKAGE" --silent
cd - >/dev/null
fi
echo -e "GREEN✓NC 插件已安装"
# ── 3. 定位插件文件 ──
PLUGIN_DIR=""
for candidate in \
"$OPENCLAW_DIR/extensions/openclaw-enhance/node_modules/$NPM_PACKAGE" \
"$OPENCLAW_DIR/extensions/openclaw-enhance" \
"$(dirname "$(readlink -f "$0" 2>/dev/null || echo "$0")")/.."; do
if [ -f "$candidate/openclaw.plugin.json" ]; then
PLUGIN_DIR="$candidate"
break
fi
done
if [ -z "$PLUGIN_DIR" ]; then
echo -e "YELLOW⚠ 无法定位插件文件目录,跳过技能和模板安装NC"
echo " 你可以手动从 npm 包中复制 skills/ 和 templates/ 到工作区"
else
# ── 4. 复制增强技能 ──
echo ""
echo -e "CYAN正在安装增强技能...NC"
SKILLS_DIR="$OPENCLAW_DIR/workspace/skills"
mkdir -p "$SKILLS_DIR"
for skill in plan-mode explore-mode verify-mode memory-curator; do
if [ -d "$PLUGIN_DIR/skills/$skill" ]; then
if [ -d "$SKILLS_DIR/$skill" ]; then
echo -e " YELLOW⚠ $skill 已存在,跳过NC"
else
cp -r "$PLUGIN_DIR/skills/$skill" "$SKILLS_DIR/"
echo -e " GREEN✓NC $skill"
fi
fi
done
# ── 5. 应用工作区模板补丁 ──
echo ""
echo -e "CYAN正在应用工作区增强...NC"
WS_DIR="$OPENCLAW_DIR/workspace"
for file in AGENTS.md SOUL.md; do
patch_file="$PLUGIN_DIR/templates/file%.md.enhance-patch.md"
target_file="$WS_DIR/$file"
if [ ! -f "$patch_file" ]; then
continue
fi
if [ ! -f "$target_file" ]; then
echo -e " YELLOW⚠ $target_file 不存在,跳过NC"
continue
fi
# 检查是否已经打过补丁
if grep -q "龙虾增强包补丁" "$target_file" 2>/dev/null; then
echo -e " YELLOW⚠ $file 已包含增强补丁,跳过NC"
continue
fi
# 备份并追加
cp "$target_file" "target_file.bak.$(date +%Y%m%d%H%M%S)"
cat "$patch_file" >> "$target_file"
echo -e " GREEN✓NC $file 已增强(备份: file.bak.*)"
done
fi
# ── 6. 打印完成信息 ──
echo ""
echo -e "GREEN═══════════════════════════════════════════NC"
echo -e "GREEN 🦞 龙虾增强包安装完成!NC"
echo -e "GREEN═══════════════════════════════════════════NC"
echo ""
echo " 已安装模块:"
echo " 📦 结构化记忆 (enhance_memory_store/search/review)"
echo " 🛡️ 工具安全守卫 (enhance_safety_log/rules)"
echo " ✨ 提示词增强 (自动注入)"
echo " 🔄 工作流自动化 (enhance_workflow_define/list/delete)"
echo " 📊 仪表盘 (http://localhost:18789/plugins/enhance/)"
echo ""
echo " 已安装技能:"
echo " 📋 plan-mode — 结构化规划"
echo " 🔍 explore-mode — 深度探索"
echo " ✅ verify-mode — 验证检查"
echo " 🧠 memory-curator — 记忆整理"
echo ""
echo -e " CYAN重启 OpenClaw 使插件生效:NC"
echo " openclaw restart"
echo ""
echo -e " CYAN配置(可选):NC"
echo " 编辑 $OPENCLAW_DIR/openclaw.json → plugins.entries.enhance.config"
echo ""
FILE:src/modules/chapter-marks.ts
/**
* 模块: 章节标记(Claude-Code 风格 mark_chapter)
*
* 把长会话切成可回溯的章节。仪表盘用它生成时间线。
* 与龙虾原生 session 的关系:龙虾只有 session 本身,没有章节概念;
* 本模块是纯新增,完全不影响龙虾 session 内部状态。
*/
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import type { OpenClawPluginToolContext } from "openclaw/plugin-sdk/core";
import { Type } from "@sinclair/typebox";
import { resolveOpenClawHome } from "../utils/resolve-home.js";
import { getDb, addChapter, listChapters } from "../utils/sqlite-store.js";
import { DEFAULT_AGENT_ID } from "../types.js";
function pickAgentId(ctx: { agentId?: string } | undefined): string {
return ((ctx?.agentId ?? DEFAULT_AGENT_ID).trim() || DEFAULT_AGENT_ID);
}
function pickSessionId(ctx: { sessionKey?: string; sessionId?: string } | undefined): string {
return ((ctx?.sessionKey ?? ctx?.sessionId ?? "") + "").trim();
}
export function registerChapterMarks(api: OpenClawPluginApi) {
const openclawDir = resolveOpenClawHome(api);
const db = getDb(openclawDir);
api.registerTool(
((ctx: OpenClawPluginToolContext) => ({
name: "enhance_mark_chapter",
description: "标记会话章节(阶段切换时用,一会话 3-8 个)",
parameters: Type.Object({
title: Type.String({ description: "短名词短语,<40 字" }),
summary: Type.Optional(Type.String({ description: "一行摘要" })),
}),
async execute(_id: string, params: Record<string, unknown>) {
const agentId = pickAgentId(ctx);
const sessionId = pickSessionId(ctx);
const title = String(params.title ?? "").trim();
if (!title) {
return {
content: [{ type: "text" as const, text: "章节标题不能为空。" }],
};
}
const chapter = addChapter(db, agentId, sessionId, title.slice(0, 80), String(params.summary ?? "").trim());
return {
content: [
{
type: "text" as const,
text: `✓ 章节已标记 #chapter.id:chapter.titlechapter.summary ? ` — ${chapter.summary` : ""}`,
},
],
};
},
})) as any,
{ name: "enhance_mark_chapter" },
);
api.registerTool(
((ctx: OpenClawPluginToolContext) => ({
name: "enhance_chapter_list",
description: "查看 session 或 Agent 章节时间线",
parameters: Type.Object({
scope: Type.Optional(
Type.Union([Type.Literal("session"), Type.Literal("agent")], {
description: "session(默认)|agent",
}),
),
limit: Type.Optional(Type.Integer({ description: "默认 50" })),
}),
async execute(_id: string, params: Record<string, unknown>) {
const agentId = pickAgentId(ctx);
const sessionId = pickSessionId(ctx);
const limit = Number(params.limit ?? 50);
const scope = params.scope === "agent" ? undefined : sessionId;
const rows = listChapters(db, agentId, scope, limit);
if (rows.length === 0) {
return {
content: [{ type: "text" as const, text: `暂无章节记录 (agent: agentId)。` }],
};
}
const lines = rows.map(
(c) => `· c.created_at · #c.id c.titlec.summary ? ` — ${c.summary` : ""}`,
);
return {
content: [{ type: "text" as const, text: `章节时间线(rows.length 条):\nlines.join("\n")` }],
};
},
})) as any,
{ name: "enhance_chapter_list" },
);
api.logger.info("[enhance] 章节标记模块已加载(enhance_mark_chapter / chapter_list)");
}
FILE:src/modules/config-doctor.ts
/**
* v5.7.3 配置诊断模块
*
* 启动期 + 工具按需,主动检查 ~/.openclaw/openclaw.json 里常见的"导致 Context limit exceeded"陷阱:
*
* 1. 缺失 agents.defaults.compaction.reserveTokensFloor — openclaw 4.22 默认值过小
* 2. reserveTokensFloor 异常(< 5000 或 > 100000)
* 3. 任意 model 的 maxTokens 占 contextWindow 一半以上 且 > 32000
* (openclaw 把 maxTokens 当作必须留给输出的 reserve,吃掉太多 budget)
*
* 红线:
* - 完全只读 openclaw.json,绝不修改用户配置(红线 #1:不侵入式修改 openclaw)
* - 不用 child_process(红线 #4)
* - 修复命令通过 return-cliCmd 模式给出,让用户手工或 cron-cli 执行(红线 #5)
*/
import { readFileSync, existsSync, readdirSync, statSync } from "node:fs";
import { join } from "node:path";
import { Type } from "@sinclair/typebox";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import type { OpenClawPluginToolContext } from "openclaw/plugin-sdk/core";
import { resolveOpenClawHome } from "../utils/resolve-home.js";
import type { ConfigDoctorConfig, NotificationQueue } from "../types.js";
import { DEFAULT_AGENT_ID } from "../types.js";
/**
* v5.7.4: 检测某条 pluginApi / peerDep 范围是否是合规的 ranged spec。
* "2026.4.11" → bare(精确匹配,被 openclaw 4.22 拒绝)
* ">=2026.4.11" / "^2026.4.11" / "~2026.4.11" / "*" / undefined → OK
*
* 见 KB `~/.claude/projects/-Users-jobzhao/memory/openclaw_plugin_compat_rules.md`
*/
function isBarePluginApi(spec: unknown): spec is string {
if (typeof spec !== "string") return false;
const s = spec.trim();
if (!s) return false;
// 任何带前缀的 range 都 OK
if (/^(>=|<=|>|<|\^|~|\*|=)/.test(s)) return false;
// 含空格说明是组合 range(如 ">=1.0 <2.0")也 OK
if (/\s/.test(s)) return false;
// 剩下的就是 bare 字符串(如 "2026.4.11")
return /^\d/.test(s);
}
/**
* v5.7.4: 扫所有已装插件的 package.json 检测 bare pluginApi
* 扫描路径:
* - {openclawDir}/extensions/<plugin>/package.json
* - {openclawDir}/node_modules/@huo15/<plugin>/package.json(旧 npm peerDep 残留)
* - {openclawDir}/node_modules/<plugin>/package.json(无 scope 的)
*/
function scanInstalledPluginsForBarePluginApi(openclawDir: string): CheckResult[] {
const results: CheckResult[] = [];
const dirsToScan: string[] = [];
// extensions 目录
const extDir = join(openclawDir, "extensions");
if (existsSync(extDir)) {
try {
for (const name of readdirSync(extDir)) {
const p = join(extDir, name);
if (statSync(p).isDirectory()) dirsToScan.push(p);
}
} catch {
/* 静默 */
}
}
// node_modules 下的 @huo15/* 和无 scope 的(不递归子 node_modules)
const nmDir = join(openclawDir, "node_modules");
if (existsSync(nmDir)) {
try {
for (const name of readdirSync(nmDir)) {
if (name === ".bin" || name === ".package-lock.json") continue;
const p = join(nmDir, name);
try {
if (!statSync(p).isDirectory()) continue;
} catch {
continue;
}
if (name.startsWith("@")) {
// scope: 进入扫子目录
try {
for (const sub of readdirSync(p)) {
const sp = join(p, sub);
try {
if (statSync(sp).isDirectory()) dirsToScan.push(sp);
} catch {
/* skip */
}
}
} catch {
/* skip */
}
} else {
dirsToScan.push(p);
}
}
} catch {
/* 静默 */
}
}
for (const dir of dirsToScan) {
const pkgPath = join(dir, "package.json");
if (!existsSync(pkgPath)) continue;
let pkg: any;
try {
pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
} catch {
continue;
}
// 只扫声明了 openclaw extensions 的包
if (!pkg?.openclaw?.extensions && !pkg?.peerDependencies?.openclaw) continue;
const name = pkg?.name ?? dir;
const compatApi = pkg?.openclaw?.compat?.pluginApi;
if (isBarePluginApi(compatApi)) {
const fixed = `>=compatApi`;
results.push({
ok: false,
level: "warn",
category: "plugin-bare-pluginApi",
message: `已装插件 name(pkgPath)的 openclaw.compat.pluginApi="compatApi" 是 bare 版本,会被解读为精确匹配,与当前 openclaw 不兼容时启动报错"插件要求 compatApi"`,
fixCommand: `python3 -c "import json,pathlib;p=pathlib.Path('pkgPath');c=json.loads(p.read_text());c.setdefault('openclaw',{}).setdefault('compat',{})['pluginApi']='fixed';p.write_text(json.dumps(c,indent=2,ensure_ascii=False));print('OK')"`,
});
}
}
return results;
}
interface CheckResult {
ok: boolean;
level: "info" | "warn" | "error";
category: string;
message: string;
fixCommand?: string;
}
/**
* 同步读 openclaw.json 并检查 — 调用方负责 try-catch 隔离启动失败
*/
export function checkOpenClawConfig(
openclawDir: string,
options: ConfigDoctorConfig = {},
): CheckResult[] {
const minFloor = options.minReserveTokensFloor ?? 5000;
const maxFloor = options.maxReserveTokensFloor ?? 100000;
const maxModelMax = options.maxModelMaxTokens ?? 32000;
const path = join(openclawDir, "openclaw.json");
const results: CheckResult[] = [];
if (!existsSync(path)) {
return [
{
ok: false,
level: "warn",
category: "config-missing",
message: `未找到 path(openclaw 还没初始化过)`,
fixCommand: "openclaw configure",
},
];
}
let cfg: Record<string, any>;
try {
cfg = JSON.parse(readFileSync(path, "utf-8"));
} catch (err) {
return [
{
ok: false,
level: "error",
category: "config-parse-failed",
message: `openclaw.json 解析失败:(err as Error).message`,
fixCommand: "检查 ~/.openclaw/openclaw.json 的 JSON 语法(多余逗号 / 引号未闭合等)",
},
];
}
// 1. agents.defaults.compaction.reserveTokensFloor
const compaction = cfg?.agents?.defaults?.compaction ?? {};
const reserveFloor = compaction.reserveTokensFloor;
if (reserveFloor === undefined || reserveFloor === null) {
results.push({
ok: false,
level: "warn",
category: "compaction-missing-reserveTokensFloor",
message:
"缺少 agents.defaults.compaction.reserveTokensFloor — openclaw 4.22 默认值偏小,长 session 容易 'Context limit exceeded'。推荐 20000",
fixCommand:
`python3 -c "import json,pathlib;p=pathlib.Path.home()/'.openclaw'/'openclaw.json';c=json.loads(p.read_text());c.setdefault('agents',{}).setdefault('defaults',{}).setdefault('compaction',{})['reserveTokensFloor']=20000;p.write_text(json.dumps(c,indent=2,ensure_ascii=False));print('OK: reserveTokensFloor=20000')"`,
});
} else if (typeof reserveFloor !== "number") {
results.push({
ok: false,
level: "error",
category: "compaction-invalid-type",
message: `agents.defaults.compaction.reserveTokensFloor=JSON.stringify(reserveFloor) 不是 number`,
fixCommand: "改 ~/.openclaw/openclaw.json: agents.defaults.compaction.reserveTokensFloor = 20000(数字,不带引号)",
});
} else if (reserveFloor < minFloor) {
results.push({
ok: false,
level: "warn",
category: "compaction-low-reserveTokensFloor",
message: `agents.defaults.compaction.reserveTokensFloor=reserveFloor 偏低(推荐 ≥ 20000,至少 ≥ minFloor)`,
fixCommand: "改 ~/.openclaw/openclaw.json: agents.defaults.compaction.reserveTokensFloor = 20000",
});
} else if (reserveFloor > maxFloor) {
results.push({
ok: false,
level: "error",
category: "compaction-huge-reserveTokensFloor",
message: `agents.defaults.compaction.reserveTokensFloor=reserveFloor 太大(≥ maxFloor),可能让每次压缩都失败`,
fixCommand: "改 ~/.openclaw/openclaw.json: agents.defaults.compaction.reserveTokensFloor = 20000",
});
}
// 2. 各 model maxTokens 检查
const providers = cfg?.models?.providers ?? {};
if (typeof providers === "object" && providers !== null) {
for (const [pname, pval] of Object.entries(providers)) {
const models = (pval as any)?.models ?? [];
if (!Array.isArray(models)) continue;
for (const m of models) {
const ctx = Number(m?.contextWindow ?? 0);
const maxT = Number(m?.maxTokens ?? 0);
const id = String(m?.id ?? "?");
if (!ctx || !maxT) continue;
// maxTokens 占 contextWindow 一半以上 且 > maxModelMax 阈值
if (maxT >= ctx / 2 && maxT > maxModelMax) {
results.push({
ok: false,
level: "warn",
category: "model-maxTokens-too-large",
message: `pname/id: maxTokens=maxT 占 contextWindow=ctx 一半以上(Math.round((maxT / ctx) * 100)%),每轮预留输出会吃掉太多 budget。建议 ≤ 16384`,
fixCommand: `改 ~/.openclaw/openclaw.json: models.providers.pname.models[].maxTokens(id=id)从 maxT 改为 16384`,
});
}
}
}
}
// v5.7.4: 扫所有已装插件的 bare pluginApi(违反 ">=X.Y.Z" 规则的会被 openclaw 拒绝)
const pluginIssues = scanInstalledPluginsForBarePluginApi(openclawDir);
results.push(...pluginIssues);
if (results.length === 0) {
return [
{
ok: true,
level: "info",
category: "config-ok",
message: "openclaw 配置 + 已装插件 pluginApi 全部健康(reserveTokensFloor / model maxTokens / plugin compat 均合规)",
},
];
}
return results;
}
function formatResults(results: CheckResult[]): string {
return results
.map((r) => {
const icon = r.ok ? "✅" : r.level === "error" ? "❌" : "⚠️";
let line = `icon [r.category] r.message`;
if (r.fixCommand) line += `\n → 修复: r.fixCommand`;
return line;
})
.join("\n\n");
}
export function registerConfigDoctor(
api: OpenClawPluginApi,
config: ConfigDoctorConfig | undefined,
notifyQueue: NotificationQueue,
) {
const openclawDir = resolveOpenClawHome(api);
// 启动期检查(fire-and-forget,绝不阻塞插件加载)
try {
const results = checkOpenClawConfig(openclawDir, config);
const issues = results.filter((r) => !r.ok);
if (issues.length > 0) {
api.logger.warn(`[enhance-config-doctor] 检测到 issues.length 项 openclaw.json 配置问题:`);
for (const r of issues) {
api.logger.warn(` • [r.level] r.category: r.message`);
if (r.fixCommand) api.logger.warn(` → 修复: r.fixCommand`);
}
const hasError = issues.some((r) => r.level === "error");
notifyQueue.emit(
DEFAULT_AGENT_ID,
hasError ? "warn" : "warn",
"config-doctor",
`enhance: openclaw.json 检测到 issues.length 项配置问题`,
formatResults(issues) + "\n\n(运行 enhance_config_doctor 工具看完整诊断)",
);
} else {
api.logger.info("[enhance-config-doctor] openclaw.json 配置健康");
}
} catch (err) {
// 启动检查失败不能让插件 crash —— 静默 + log
api.logger.error(
`[enhance-config-doctor] 启动检查失败(不影响插件其它功能): (err as Error).message`,
);
}
// 工具:手动触发完整诊断(输出可粘贴的 fix 命令)
api.registerTool(
((_ctx: OpenClawPluginToolContext) => ({
name: "enhance_config_doctor",
description: "诊断 ~/.openclaw/openclaw.json 是否有 reserveTokensFloor / maxTokens 等导致 'Context limit exceeded' 的陷阱配置;只读,给修复命令不自动改",
parameters: Type.Object({}),
async execute() {
const results = checkOpenClawConfig(openclawDir, config);
const text = formatResults(results);
return { content: [{ type: "text" as const, text }] };
},
})) as any,
{ name: "enhance_config_doctor" },
);
api.logger.info("[enhance] 配置诊断模块已加载(只读,不修改用户配置)");
}
FILE:src/modules/dashboard.ts
/**
* 模块5: 增强仪表盘(多 Agent 隔离版)
*
* registerHttpRoute handler 签名:
* (req: IncomingMessage, res: ServerResponse) => Promise<boolean | void>
*
* 这是 Node.js 原生 HTTP handler,不是 Web Fetch API。
*/
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import type { IncomingMessage, ServerResponse } from "node:http";
import type Database from "better-sqlite3";
import {
getDb,
getMemoryStats,
getSafetyStats,
getRecentMemories,
getRecentSafetyEvents,
getAllAgentIds,
getOrCreatePet,
getLatestTodos,
listTodos,
listChapters,
listScheduledBindings,
searchMemories,
} from "../utils/sqlite-store.js";
import { resolveOpenClawHome } from "../utils/resolve-home.js";
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { DEFAULT_AGENT_ID, type DashboardConfig, type Workflow, type NotificationQueue } from "../types.js";
import { buildSnapshot } from "./statusline.js";
function loadAllWorkflows(openclawDir: string): Workflow[] {
const path = join(openclawDir, "memory", "enhance-workflows.json");
if (!existsSync(path)) return [];
try {
return JSON.parse(readFileSync(path, "utf-8"));
} catch {
return [];
}
}
function parseUrl(req: IncomingMessage): URL {
return new URL(req.url || "/", `http://req.headers.host || "localhost"`);
}
function sendJson(res: ServerResponse, data: unknown): void {
const body = JSON.stringify(data);
res.writeHead(200, {
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(body),
});
res.end(body);
}
function sendHtml(res: ServerResponse, html: string): void {
res.writeHead(200, {
"Content-Type": "text/html; charset=utf-8",
"Content-Length": Buffer.byteLength(html),
});
res.end(html);
}
const DASHBOARD_HTML = `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>龙虾增强包 — 仪表盘</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#0f1117;color:#e0e0e0;padding:24px;max-width:1060px;margin:0 auto}
h1{font-size:1.8em;margin-bottom:8px;color:#ff6b35}
.subtitle{color:#888;margin-bottom:16px;font-size:0.95em}
.agent-bar{display:flex;align-items:center;gap:12px;margin-bottom:24px;flex-wrap:wrap}
.agent-bar label{color:#888;font-size:0.9em}
.agent-bar select{background:#1a1d27;color:#e0e0e0;border:1px solid #2a2d37;border-radius:6px;padding:6px 12px;font-size:0.9em}
.agent-bar .current{color:#ff6b35;font-size:0.85em;font-weight:600}
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:16px;margin-bottom:32px}
.card{background:#1a1d27;border-radius:12px;padding:20px;border:1px solid #2a2d37}
.card h3{font-size:0.8em;color:#888;text-transform:uppercase;letter-spacing:1px;margin-bottom:8px}
.card .value{font-size:2em;font-weight:700;color:#ff6b35}
.card .label{font-size:0.75em;color:#666;margin-top:4px}
.section{margin-bottom:32px}
.section h2{font-size:1.2em;margin-bottom:12px;color:#ccc;border-bottom:1px solid #2a2d37;padding-bottom:8px}
table{width:100%;border-collapse:collapse}
th,td{text-align:left;padding:8px 12px;border-bottom:1px solid #1a1d27}
th{color:#888;font-size:0.75em;text-transform:uppercase;letter-spacing:1px}
td{font-size:0.85em}
.badge{display:inline-block;padding:2px 8px;border-radius:4px;font-size:0.75em;font-weight:600}
.badge-block{background:#ff4444;color:#fff}
.badge-log{background:#444;color:#ccc}
.badge-allow{background:#2a5a2a;color:#8f8}
.agent-tag{background:#2a2d37;color:#ff6b35;padding:1px 6px;border-radius:3px;font-size:0.75em;margin-left:4px}
.empty{color:#555;font-style:italic;padding:16px 0}
footer{text-align:center;color:#444;font-size:0.8em;margin-top:40px}
</style>
</head>
<body>
<div style="display:flex;align-items:center;justify-content:space-between">
<div><h1>🦞 龙虾增强包 <span id="petBadge" style="font-size:0.5em"></span></h1>
<p class="subtitle">OpenClaw Enhancement Kit — Multi-Agent Dashboard</p></div>
<div id="notifBell" style="position:relative;cursor:pointer;font-size:1.5em" onclick="toggleNotif()" title="通知">🔔<span id="notifCount" style="position:absolute;top:-4px;right:-8px;background:#ff4444;color:#fff;border-radius:50%;font-size:0.45em;padding:2px 6px;display:none"></span></div>
</div>
<div id="notifPanel" style="display:none;background:#1a1d27;border:1px solid #2a2d37;border-radius:8px;padding:12px;margin-bottom:16px;max-height:240px;overflow-y:auto"></div>
<div class="agent-bar">
<label>Agent:</label>
<select id="agentSelect" onchange="switchAgent(this.value)">
<option value="">全部 (聚合)</option>
</select>
<span class="current" id="currentAgent"></span>
</div>
<div class="grid" id="stats"></div>
<div class="section">
<h2>最近记忆</h2>
<div id="memories"></div>
</div>
<div class="section">
<h2>安全事件</h2>
<div id="safety"></div>
</div>
<div class="section">
<h2>当前任务 (TodoWrite)</h2>
<div id="todos"></div>
</div>
<div class="section">
<h2>章节时间线</h2>
<div id="chapters"></div>
</div>
<div class="section">
<h2>定时工作流 (openclaw cron 桥)</h2>
<div id="loops"></div>
</div>
<div class="section">
<h2>子任务孵化 (spawn-task)</h2>
<div id="spawnTasks"></div>
</div>
<div class="section">
<h2>工作流 (旧式触发词)</h2>
<div id="workflows"></div>
</div>
<footer>龙虾增强包 v2.2.0 — 非侵入式增强 · 记忆以龙虾为主 | <a href="/plugins/enhance/pet" style="color:#ff6b35">🔥 小火苗</a></footer>
<script>
// 本地仪表盘:仅请求同域 /plugins/enhance/api/status,无外部网络调用
var currentAgent = '';
function esc(s) {
return String(s || '')
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>');
}
function card(title, value, label) {
var el = document.createElement('div');
el.className = 'card';
el.innerHTML = '<h3>' + esc(title) + '</h3><div class="value">' + esc(String(value)) + '</div><div class="label">' + esc(label) + '</div>';
return el.outerHTML;
}
function buildTable(headers, rows) {
if (!rows.length) return '<p class="empty">暂无数据</p>';
var head = '<tr>' + headers.map(function(h){ return '<th>' + esc(h) + '</th>'; }).join('') + '</tr>';
return '<table>' + head + rows.join('') + '</table>';
}
function switchAgent(v) {
currentAgent = v;
var u = new URL(location.href);
if (v) {
u.searchParams.set('agent', v);
} else {
u.searchParams.delete('agent');
}
history.replaceState(null, '', u);
load();
}
function load() {
var apiPath = '/plugins/enhance/api/status';
if (currentAgent) {
apiPath += '?agent=' + encodeURIComponent(currentAgent);
}
fetch(apiPath)
.then(function(resp) { return resp.json(); })
.then(function(d) {
// Agent 选择器
var sel = document.getElementById('agentSelect');
var opts = '<option value="">全部 (聚合)</option>';
d.agents.forEach(function(a) {
opts += '<option value="' + esc(a) + '"' + (a === currentAgent ? ' selected' : '') + '>' + esc(a) + '</option>';
});
sel.innerHTML = opts;
document.getElementById('currentAgent').textContent = currentAgent ? '当前: ' + currentAgent : '全部 Agent 聚合视图';
// 统计卡片
document.getElementById('stats').innerHTML = [
card('Agent 数', d.agents.length, '个'),
card('记忆总数', d.memory.total, '条'),
card('用户记忆', d.memory.user || 0, '条'),
card('项目记忆', d.memory.project || 0, '条'),
card('安全事件', d.safety.total, '次'),
card('已拦截', d.safety.blocked, '次'),
card('工作流', d.workflows.length, '个'),
].join('');
// 最近记忆表
var memRows = d.recentMemories.map(function(e) {
return '<tr><td>#' + e.id + '</td><td><span class="agent-tag">' + esc(e.agent_id) + '</span></td><td>' + esc(e.category) + '</td><td>' + esc(e.content).slice(0, 50) + '</td><td>' + esc(e.created_at) + '</td></tr>';
});
document.getElementById('memories').innerHTML = buildTable(['ID','Agent','类型','内容','时间'], memRows);
// 安全事件表
var sfRows = d.recentSafety.map(function(e) {
return '<tr><td><span class="badge badge-' + esc(e.action) + '">' + esc(e.action) + '</span></td><td><span class="agent-tag">' + esc(e.agent_id) + '</span></td><td>' + esc(e.tool) + '</td><td>' + esc(e.params || '').slice(0, 35) + '</td><td>' + esc(e.created_at) + '</td></tr>';
});
document.getElementById('safety').innerHTML = buildTable(['动作','Agent','工具','参数','时间'], sfRows);
// 工作流表
var wfRows = d.workflows.map(function(e) {
return '<tr><td>' + esc(e.name) + '</td><td><span class="agent-tag">' + esc(e.agent_id || 'main') + '</span></td><td>' + esc(e.trigger) + '</td><td>' + (e.enabled ? '✅' : '⏸') + '</td></tr>';
});
document.getElementById('workflows').innerHTML = buildTable(['名称','Agent','触发词','状态'], wfRows);
});
}
function statusIcon(s) {
if (s === 'completed') return '✅';
if (s === 'in_progress') return '▶';
return '⭕';
}
function loadTodos() {
var path = '/plugins/enhance/api/todos';
if (currentAgent) path += '?agent=' + encodeURIComponent(currentAgent);
fetch(path).then(function(r){return r.json()}).then(function(d){
if (!d.todos || !d.todos.length) {
document.getElementById('todos').innerHTML = '<p class="empty">暂无 todos。Agent 可调用 enhance_todo_write 登记。</p>';
return;
}
var rows = d.todos.map(function(t){
return '<tr><td>' + statusIcon(t.status) + '</td><td>' + esc(t.content) + '</td><td style="color:#666">' + esc(t.active_form || '') + '</td><td>' + esc(t.updated_at) + '</td></tr>';
});
document.getElementById('todos').innerHTML = buildTable(['状态','任务','进行中表述','更新'], rows);
}).catch(function(){});
}
function loadChapters() {
var path = '/plugins/enhance/api/chapters';
if (currentAgent) path += '?agent=' + encodeURIComponent(currentAgent);
fetch(path).then(function(r){return r.json()}).then(function(d){
if (!d.chapters || !d.chapters.length) {
document.getElementById('chapters').innerHTML = '<p class="empty">暂无章节。Agent 可调用 enhance_mark_chapter。</p>';
return;
}
var rows = d.chapters.slice(0, 20).map(function(c){
return '<tr><td>' + esc(c.created_at) + '</td><td>' + esc(c.title) + '</td><td style="color:#888">' + esc(c.summary || '') + '</td></tr>';
});
document.getElementById('chapters').innerHTML = buildTable(['时间','标题','摘要'], rows);
}).catch(function(){});
}
function loadLoops() {
var path = '/plugins/enhance/api/loops';
if (currentAgent) path += '?agent=' + encodeURIComponent(currentAgent);
fetch(path).then(function(r){return r.json()}).then(function(d){
if (!d.loops || !d.loops.length) {
document.getElementById('loops').innerHTML = '<p class="empty">暂无定时工作流。调用 enhance_loop_register 登记。</p>';
return;
}
var rows = d.loops.map(function(l){
return '<tr><td>' + (l.enabled ? '●' : '○') + '</td><td>' + esc(l.name) + '</td><td><span class="agent-tag">' + esc(l.agent_id) + '</span></td><td><code>' + esc(l.cron_ref) + '</code></td><td>' + esc(l.last_fired_at || '从未') + '</td></tr>';
});
document.getElementById('loops').innerHTML = buildTable(['状态','名称','Agent','cron','上次触发'], rows);
}).catch(function(){});
}
function loadSpawnTasks() {
var path = '/plugins/enhance/api/spawn-tasks';
if (currentAgent) path += '?agent=' + encodeURIComponent(currentAgent);
fetch(path).then(function(r){return r.json()}).then(function(d){
if (!d.entries || !d.entries.length) {
document.getElementById('spawnTasks').innerHTML = '<p class="empty">暂无孵化子任务。调用 enhance_spawn_task 登记。</p>';
return;
}
var rows = d.entries.map(function(e){
var text = esc((e.content || '').slice(0, 160));
return '<tr><td>#' + e.id + '</td><td>' + esc(e.created_at) + '</td><td style="white-space:pre-wrap">' + text + '</td></tr>';
});
document.getElementById('spawnTasks').innerHTML = buildTable(['ID','时间','内容摘要'], rows);
}).catch(function(){});
}
function toggleNotif() {
var p = document.getElementById('notifPanel');
p.style.display = p.style.display === 'none' ? 'block' : 'none';
}
function loadNotif() {
var path = '/plugins/enhance/api/notifications';
if (currentAgent) path += '?agent=' + encodeURIComponent(currentAgent);
fetch(path).then(function(r){return r.json()}).then(function(d){
var countEl = document.getElementById('notifCount');
if (d.unread > 0) { countEl.textContent = d.unread; countEl.style.display = 'inline'; }
else { countEl.style.display = 'none'; }
var panel = document.getElementById('notifPanel');
if (!d.recent.length) { panel.innerHTML = '<p class="empty">暂无通知</p>'; return; }
panel.innerHTML = d.recent.map(function(n){
var icon = n.level === 'success' ? '✅' : n.level === 'warn' ? '⚠️' : 'ℹ️';
return '<div style="padding:4px 0;border-bottom:1px solid #2a2d37;font-size:0.85em">' + icon + ' <b>' + esc(n.title) + '</b> <span style="color:#666;font-size:0.8em">' + esc(n.created_at) + '</span></div>';
}).join('');
});
}
function loadPetBadge() {
var path = '/plugins/enhance/api/pet';
if (currentAgent) path += '?agent=' + encodeURIComponent(currentAgent);
fetch(path).then(function(r){return r.json()}).then(function(d){
if (d && d.name) {
document.getElementById('petBadge').innerHTML = '🔥 ' + esc(d.name) + ' Lv.' + d.level;
}
});
}
var params = new URLSearchParams(location.search);
currentAgent = params.get('agent') || '';
function refreshAll() {
load();
loadNotif();
loadPetBadge();
loadTodos();
loadChapters();
loadLoops();
loadSpawnTasks();
}
refreshAll();
// 原 switchAgent 只触发 load(),这里扩展为全量刷新
switchAgent = function(v){
currentAgent = v;
var u = new URL(location.href);
if (v) u.searchParams.set('agent', v); else u.searchParams.delete('agent');
history.replaceState(null, '', u);
refreshAll();
};
</script>
</body>
</html>`;
const PET_PAGE_HTML = `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>小火苗 — 龙虾增强包</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#0f1117;color:#e0e0e0;padding:24px;max-width:600px;margin:0 auto;text-align:center}
h1{font-size:2em;margin-bottom:4px;color:#ff6b35}
.subtitle{color:#888;margin-bottom:24px}
.flame-container{position:relative;width:200px;height:200px;margin:0 auto 24px}
.flame{width:60px;height:80px;border-radius:50% 50% 50% 50% / 60% 60% 40% 40%;position:absolute;bottom:40px;left:50%;transform:translateX(-50%);animation:flicker 1.5s ease-in-out infinite alternate}
.flame.orange{background:linear-gradient(to top,#ff4500,#ff8c00,#ffd700);box-shadow:0 0 30px #ff4500,0 0 60px #ff8c0088}
.flame.blue{background:linear-gradient(to top,#1e90ff,#00bfff,#87ceeb);box-shadow:0 0 30px #1e90ff,0 0 60px #00bfff88}
.flame.purple{background:linear-gradient(to top,#8b00ff,#da70d6,#dda0dd);box-shadow:0 0 30px #8b00ff,0 0 60px #da70d688}
.flame.green{background:linear-gradient(to top,#228b22,#32cd32,#90ee90);box-shadow:0 0 30px #228b22,0 0 60px #32cd3288}
.flame.white{background:linear-gradient(to top,#dcdcdc,#f5f5f5,#fff);box-shadow:0 0 30px #dcdcdc,0 0 60px #ffffff88}
.base{width:50px;height:20px;background:#555;border-radius:0 0 8px 8px;position:absolute;bottom:24px;left:50%;transform:translateX(-50%)}
@keyframes flicker{0%{transform:translateX(-50%) scale(1) rotate(-2deg)}50%{transform:translateX(-50%) scale(1.05) rotate(1deg)}100%{transform:translateX(-50%) scale(0.97) rotate(-1deg)}}
.info{background:#1a1d27;border-radius:12px;padding:20px;border:1px solid #2a2d37;margin-bottom:16px;text-align:left}
.info h2{color:#ff6b35;font-size:1.1em;margin-bottom:12px}
.stat-row{display:flex;align-items:center;gap:8px;margin-bottom:6px;font-size:0.9em}
.stat-bar{flex:1;height:8px;background:#2a2d37;border-radius:4px;overflow:hidden}
.stat-fill{height:100%;border-radius:4px;background:linear-gradient(90deg,#ff6b35,#ffd700)}
.xp-bar{width:100%;height:12px;background:#2a2d37;border-radius:6px;overflow:hidden;margin:8px 0}
.xp-fill{height:100%;border-radius:6px;background:linear-gradient(90deg,#ff6b35,#ff8c00)}
.actions{display:flex;gap:8px;justify-content:center;flex-wrap:wrap}
.actions button{background:#1a1d27;color:#ff6b35;border:1px solid #ff6b35;border-radius:8px;padding:8px 16px;cursor:pointer;font-size:0.9em}
.actions button:hover{background:#ff6b35;color:#0f1117}
#msg{color:#888;margin-top:12px;font-size:0.9em;min-height:1.5em}
a{color:#ff6b35}
</style>
</head>
<body>
<h1 id="petTitle">🔥 小火苗</h1>
<p class="subtitle" id="petPersonality"></p>
<div class="flame-container">
<div class="flame orange" id="flameEl"></div>
<div class="base"></div>
</div>
<div class="info">
<h2>等级 <span id="lvl"></span> — <span id="sizeLabel"></span></h2>
<div class="xp-bar"><div class="xp-fill" id="xpBar" style="width:0%"></div></div>
<p style="font-size:0.8em;color:#888" id="xpText"></p>
</div>
<div class="info">
<h2>属性</h2>
<div class="stat-row">🌡️ 温暖 <span id="sWarmth">0</span><div class="stat-bar"><div class="stat-fill" id="bWarmth"></div></div></div>
<div class="stat-row">💡 明亮 <span id="sBrightness">0</span><div class="stat-bar"><div class="stat-fill" id="bBrightness"></div></div></div>
<div class="stat-row">🪨 稳定 <span id="sStability">0</span><div class="stat-bar"><div class="stat-fill" id="bStability"></div></div></div>
<div class="stat-row">✨ 灵感 <span id="sSpark">0</span><div class="stat-bar"><div class="stat-fill" id="bSpark"></div></div></div>
<div class="stat-row">🔋 耐力 <span id="sEndurance">0</span><div class="stat-bar"><div class="stat-fill" id="bEndurance"></div></div></div>
</div>
<div class="actions">
<button onclick="interact('feed')">🍎 喂食</button>
<button onclick="interact('pat')">🤚 拍拍</button>
</div>
<p id="msg"></p>
<p style="margin-top:24px"><a href="/plugins/enhance">← 返回仪表盘</a></p>
<script>
function loadPet() {
fetch('/plugins/enhance/api/pet')
.then(function(r){return r.json()})
.then(function(d) {
document.getElementById('petTitle').innerHTML = '🔥 ' + d.name;
document.getElementById('petPersonality').textContent = d.personality;
document.getElementById('lvl').textContent = 'Lv.' + d.level;
document.getElementById('sizeLabel').textContent = d.size;
var xpNeeded = 50 + d.level * 30;
document.getElementById('xpBar').style.width = (d.xp / xpNeeded * 100) + '%';
document.getElementById('xpText').textContent = d.xp + ' / ' + xpNeeded + ' XP (累计 ' + d.total_xp + ')';
var fl = document.getElementById('flameEl');
fl.className = 'flame ' + d.color;
var scale = d.size === 'tiny' ? 0.6 : d.size === 'small' ? 0.8 : d.size === 'medium' ? 1 : 1.3;
fl.style.transform = 'translateX(-50%) scale(' + scale + ')';
['warmth','brightness','stability','spark','endurance'].forEach(function(k){
var v = d.stats[k] || 0;
document.getElementById('s'+k.charAt(0).toUpperCase()+k.slice(1)).textContent = v;
document.getElementById('b'+k.charAt(0).toUpperCase()+k.slice(1)).style.width = v + '%';
});
});
}
function interact(action) {
fetch('/plugins/enhance/api/pet/interact', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:action})})
.then(function(r){return r.json()})
.then(function(d){ document.getElementById('msg').textContent = d.message; loadPet(); });
}
loadPet();
</script>
</body>
</html>`;
export function registerDashboard(api: OpenClawPluginApi, _config?: DashboardConfig, notifyQueue?: NotificationQueue, sharedDb?: Database.Database) {
const openclawDir = resolveOpenClawHome(api);
api.registerHttpRoute({
path: "/plugins/enhance",
match: "prefix",
auth: "plugin",
handler: async (req: IncomingMessage, res: ServerResponse) => {
const url = parseUrl(req);
const pathname = url.pathname;
if (pathname === "/plugins/enhance/api/status") {
const db = sharedDb ?? getDb(openclawDir);
const agentFilter = url.searchParams.get("agent") || undefined;
const agents = getAllAgentIds(db);
const memoryStats = getMemoryStats(db, agentFilter);
const safetyStats = getSafetyStats(db, agentFilter);
const recentMemories = agentFilter
? getRecentMemories(db, agentFilter, 15)
: (() => {
const all: any[] = [];
for (const aid of agents) {
all.push(...getRecentMemories(db, aid, 5));
}
return all.sort((a: any, b: any) => b.created_at.localeCompare(a.created_at)).slice(0, 15);
})();
const recentSafety = getRecentSafetyEvents(db, agentFilter, 15);
const allWorkflows = loadAllWorkflows(openclawDir);
const workflows = agentFilter
? allWorkflows.filter((w) => w.agent_id === agentFilter)
: allWorkflows;
sendJson(res, { agents, memory: memoryStats, safety: safetyStats, recentMemories, recentSafety, workflows });
return true;
}
// 宠物 JSON API
if (pathname === "/plugins/enhance/api/pet") {
const db = sharedDb ?? getDb(openclawDir);
const agentId = url.searchParams.get("agent") || DEFAULT_AGENT_ID;
const pet = getOrCreatePet(db, agentId);
sendJson(res, pet);
return true;
}
// 宠物互动 API
if (pathname === "/plugins/enhance/api/pet/interact" && req.method === "POST") {
const db = sharedDb ?? getDb(openclawDir);
let body = "";
for await (const chunk of req) body += chunk;
try {
const { action, agentId: aid } = JSON.parse(body);
const agentId = aid || DEFAULT_AGENT_ID;
const { addPetXp: addXp } = await import("../utils/sqlite-store.js");
if (action === "feed") {
const { pet, leveledUp } = addXp(db, agentId, 10, { warmth: 2 });
let msg = `pet.name 开心地吃了一口!+10 XP`;
if (leveledUp) {
msg += ` 升级到 Lv.pet.level!`;
notifyQueue?.emit(agentId, "success", "pet", `🔥 pet.name 升级到 Lv.pet.level!`);
}
sendJson(res, { ok: true, message: msg });
} else if (action === "pat") {
const { pet, leveledUp } = addXp(db, agentId, 3, { warmth: 1 });
let msg = `pet.name 开心地跳了跳!+3 XP`;
if (leveledUp) {
msg += ` 升级到 Lv.pet.level!`;
notifyQueue?.emit(agentId, "success", "pet", `🔥 pet.name 升级到 Lv.pet.level!`);
}
sendJson(res, { ok: true, message: msg });
} else {
sendJson(res, { ok: false, message: "未知操作" });
}
} catch {
sendJson(res, { ok: false, message: "请求解析失败" });
}
return true;
}
// 通知 API
if (pathname === "/plugins/enhance/api/notifications") {
const agentId = url.searchParams.get("agent") || undefined;
const limit = parseInt(url.searchParams.get("limit") ?? "20", 10);
const recent = notifyQueue?.getRecent(agentId, limit) ?? [];
const unread = notifyQueue?.getUnreadCount(agentId) ?? 0;
sendJson(res, { recent, unread });
return true;
}
// 状态栏快照 JSON(供 Control UI / 外部嵌入)
if (pathname === "/plugins/enhance/api/statusline") {
const db = sharedDb ?? getDb(openclawDir);
const agentId = url.searchParams.get("agent") || DEFAULT_AGENT_ID;
const sessionId = url.searchParams.get("session") || "";
const snap = notifyQueue ? buildSnapshot(db, agentId, sessionId, notifyQueue) : null;
sendJson(res, snap ?? { error: "notifyQueue not available" });
return true;
}
// Todos 列表(最近一个 session)
if (pathname === "/plugins/enhance/api/todos") {
const db = sharedDb ?? getDb(openclawDir);
const agentId = url.searchParams.get("agent") || DEFAULT_AGENT_ID;
const todos = getLatestTodos(db, agentId);
sendJson(res, { agentId, todos });
return true;
}
// Chapter marks
if (pathname === "/plugins/enhance/api/chapters") {
const db = sharedDb ?? getDb(openclawDir);
const agentId = url.searchParams.get("agent") || DEFAULT_AGENT_ID;
const sessionId = url.searchParams.get("session") || undefined;
const chapters = listChapters(db, agentId, sessionId, 50);
sendJson(res, { agentId, chapters });
return true;
}
// 定时工作流桥列表
if (pathname === "/plugins/enhance/api/loops") {
const db = sharedDb ?? getDb(openclawDir);
const agentId = url.searchParams.get("agent") || undefined;
const loops = listScheduledBindings(db, agentId);
sendJson(res, { loops });
return true;
}
// 子任务孵化清单(从 memory 里过滤 tag=spawn-task)
if (pathname === "/plugins/enhance/api/spawn-tasks") {
const db = sharedDb ?? getDb(openclawDir);
const agentId = url.searchParams.get("agent") || DEFAULT_AGENT_ID;
const entries = searchMemories(db, agentId, { keyword: "spawn-task", limit: 30 });
sendJson(res, { agentId, entries });
return true;
}
// 宠物独立页面
if (pathname === "/plugins/enhance/pet") {
sendHtml(res, PET_PAGE_HTML);
return true;
}
// 默认: 仪表盘 HTML
sendHtml(res, DASHBOARD_HTML);
return true;
},
});
api.logger.info("[enhance] 仪表盘模块已加载(v2.2.0:Todos / 章节 / 定时 / Spawn-task),访问 /plugins/enhance/");
}
FILE:src/modules/kb-corpus.ts
/**
* 模块: 知识库语料(KB Corpus)
*
* 设计原则(非侵入 + 职责分离):
* - 把 huo15-openclaw-openai-knowledge-base 技能的**共享知识库** wiki 挂为
* 龙虾原生 memory 的 corpus supplement(corpus="kb")。
* - 只读源:扫描 ~/.openclaw/kb/shared/wiki/*.md,不写入。
* - 与 enhance-memory 解耦:
* L2 enhance-memory:结构化「规则/为什么」(短条目)
* L3 KB wiki:长文档「事实/资料」(整篇 MD)
* - 不处理 agent-scope kb(那是 per-agent 私有,不跨 agent 共享,不入 corpus)。
*
* Public API 对接(openclaw 2026.4.11):
* api.registerMemoryCorpusSupplement(supplement) — 单参
*/
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join, relative, resolve } from "node:path";
import { homedir } from "node:os";
const PLUGIN_CORPUS_ID = "kb";
const DEFAULT_SHARED_KB = join(homedir(), ".openclaw", "kb", "shared");
const WIKI_SUBDIR = "wiki";
// ── 龙虾记忆 SDK 本地类型(与 openclaw memory-state.ts 对齐) ──
interface MemoryCorpusSearchResult {
corpus: string;
path: string;
title?: string;
kind?: string;
score: number;
snippet: string;
id?: string;
startLine?: number;
endLine?: number;
citation?: string;
provenanceLabel?: string;
sourceType?: string;
sourcePath?: string;
updatedAt?: string;
}
interface MemoryCorpusGetResult {
corpus: string;
path: string;
title?: string;
kind?: string;
content: string;
fromLine: number;
lineCount: number;
id?: string;
provenanceLabel?: string;
sourceType?: string;
sourcePath?: string;
updatedAt?: string;
}
interface MemoryCorpusSupplement {
search(params: {
query: string;
maxResults?: number;
agentSessionKey?: string;
}): Promise<MemoryCorpusSearchResult[]>;
get(params: {
lookup: string;
fromLine?: number;
lineCount?: number;
agentSessionKey?: string;
}): Promise<MemoryCorpusGetResult | null>;
}
export interface KbCorpusConfig {
enabled?: boolean;
/** 共享 KB 根目录,默认 ~/.openclaw/kb/shared */
sharedKbPath?: string;
/** 相关性阈值(0-1),低于此分数不返回,默认 0.3(KB 门槛低于 memory) */
threshold?: number;
/** 单次 search 最多返回几条,默认 5 */
maxResults?: number;
debug?: boolean;
}
// ── 扫描 shared wiki 目录 ──
interface WikiEntry {
absPath: string;
relPath: string; // 相对 wiki/ 的路径,作为 id
title: string;
content: string;
updatedAt: string;
}
function listMarkdown(dir: string): string[] {
const out: string[] = [];
let items: string[];
try {
items = readdirSync(dir);
} catch {
return out;
}
for (const item of items) {
if (item.startsWith(".") || item.startsWith("_")) continue;
const full = join(dir, item);
let st;
try {
st = statSync(full);
} catch {
continue;
}
if (st.isDirectory()) {
out.push(...listMarkdown(full));
} else if (item.endsWith(".md")) {
out.push(full);
}
}
return out;
}
function loadWikiEntry(wikiRoot: string, absPath: string): WikiEntry | null {
let content: string;
let mtime: Date;
try {
content = readFileSync(absPath, "utf8");
mtime = statSync(absPath).mtime;
} catch {
return null;
}
const titleMatch = content.match(/^#\s+(.+)$/m);
const title = titleMatch ? titleMatch[1].trim() : absPath.split("/").pop() ?? absPath;
return {
absPath,
relPath: relative(wikiRoot, absPath),
title,
content,
updatedAt: mtime.toISOString(),
};
}
// ── 相关性评分(KB 专用:title 权重最高,子串匹配 + token 匹配) ──
function scoreEntry(entry: WikiEntry, query: string): number {
const q = query.toLowerCase();
const titleLower = entry.title.toLowerCase();
const contentLower = entry.content.toLowerCase();
if (!q) return 0;
let score = 0;
// title 完整匹配强信号
if (titleLower.includes(q)) score += 0.6;
// token 匹配
const tokens = q.split(/[\s\W]+/).filter((t) => t.length >= 2);
if (tokens.length > 0) {
const titleHits = tokens.filter((t) => titleLower.includes(t)).length;
const contentHits = tokens.filter((t) => contentLower.includes(t)).length;
score += 0.25 * (titleHits / tokens.length);
score += 0.2 * (contentHits / tokens.length);
}
// content 子串命中额外加分(长文档不应被 token 稀释)
if (contentLower.includes(q)) score += 0.15;
return Math.min(1, score);
}
function buildSnippet(content: string, query: string, width = 300): string {
const q = query.toLowerCase();
const lower = content.toLowerCase();
const idx = lower.indexOf(q);
if (idx < 0) return content.slice(0, width).trim();
const start = Math.max(0, idx - Math.floor(width / 3));
const end = Math.min(content.length, start + width);
const prefix = start > 0 ? "…" : "";
const suffix = end < content.length ? "…" : "";
return prefix + content.slice(start, end).trim() + suffix;
}
function findEntryByLookup(wikiRoot: string, lookup: string): string | null {
const normalized = lookup.replace(/^kb:\/\//, "").replace(/^\//, "");
const candidates = [
resolve(wikiRoot, normalized),
resolve(wikiRoot, normalized.endsWith(".md") ? normalized : `normalized.md`),
];
for (const c of candidates) {
try {
if (!c.startsWith(resolve(wikiRoot))) continue; // 防止越界
if (statSync(c).isFile()) return c;
} catch {
// ignore
}
}
// 兜底:扫描匹配相对路径
for (const abs of listMarkdown(wikiRoot)) {
if (relative(wikiRoot, abs) === normalized) return abs;
if (relative(wikiRoot, abs) === `normalized.md`) return abs;
}
return null;
}
// ── 构建 MemoryCorpusSupplement ──
function buildKbCorpus(
api: OpenClawPluginApi,
config: Required<Pick<KbCorpusConfig, "sharedKbPath" | "threshold" | "maxResults" | "debug">>,
): MemoryCorpusSupplement {
const wikiRoot = join(config.sharedKbPath, WIKI_SUBDIR);
return {
async search({ query, maxResults }) {
const cleanQuery = (query ?? "").trim();
if (!cleanQuery) return [];
const files = listMarkdown(wikiRoot);
if (files.length === 0) return [];
const limit = maxResults ?? config.maxResults;
const scored: Array<{ entry: WikiEntry; score: number }> = [];
for (const f of files) {
const entry = loadWikiEntry(wikiRoot, f);
if (!entry) continue;
const score = scoreEntry(entry, cleanQuery);
if (score >= config.threshold) {
scored.push({ entry, score });
}
}
scored.sort((a, b) => {
if (b.score !== a.score) return b.score - a.score;
return b.entry.updatedAt.localeCompare(a.entry.updatedAt);
});
if (config.debug) {
api.logger.info(
`[enhance] kb corpus.search query="cleanQuery.slice(0, 60)" → scored.length/files.length 条`,
);
}
return scored.slice(0, limit).map(({ entry, score }) => ({
corpus: PLUGIN_CORPUS_ID,
path: `kb://entry.relPath`,
title: entry.title,
kind: "wiki",
score,
snippet: buildSnippet(entry.content, cleanQuery),
id: entry.relPath,
citation: `@kb:entry.relPath`,
provenanceLabel: "shared-kb",
sourceType: "wiki_markdown",
sourcePath: entry.absPath,
updatedAt: entry.updatedAt,
}));
},
async get({ lookup, fromLine = 1, lineCount = 200 }) {
const abs = findEntryByLookup(wikiRoot, lookup);
if (!abs) return null;
const entry = loadWikiEntry(wikiRoot, abs);
if (!entry) return null;
const lines = entry.content.split("\n");
const startLine = Math.max(1, Math.min(fromLine, lines.length));
const endLine = Math.min(startLine + lineCount - 1, lines.length);
const selected = lines.slice(startLine - 1, endLine);
return {
corpus: PLUGIN_CORPUS_ID,
path: `kb://entry.relPath`,
title: entry.title,
kind: "wiki",
content: selected.join("\n"),
fromLine: startLine,
lineCount: selected.length,
id: entry.relPath,
provenanceLabel: "shared-kb",
sourceType: "wiki_markdown",
sourcePath: entry.absPath,
updatedAt: entry.updatedAt,
};
},
};
}
// ── 主注册入口 ──
export function registerKbCorpus(api: OpenClawPluginApi, options: KbCorpusConfig = {}) {
if (options.enabled === false) {
api.logger.info("[enhance] kb corpus 已禁用(config.kbCorpus.enabled=false)");
return;
}
const resolved = {
sharedKbPath: options.sharedKbPath ?? DEFAULT_SHARED_KB,
threshold: options.threshold ?? 0.3,
maxResults: options.maxResults ?? 5,
debug: options.debug ?? false,
};
if (typeof api.registerMemoryCorpusSupplement !== "function") {
api.logger.warn(
"[enhance] 当前 openclaw 版本未提供 registerMemoryCorpusSupplement;kb corpus 跳过",
);
return;
}
const corpus = buildKbCorpus(api, resolved);
try {
api.registerMemoryCorpusSupplement(corpus);
api.logger.info(
`[enhance] kb corpus 已注册(共享知识库 → memory_search;path=resolved.sharedKbPath)`,
);
} catch (err) {
api.logger.error(`[enhance] kb corpus 注册失败: err`);
}
// 在 memory system prompt 加一行说明
if (typeof api.registerMemoryPromptSupplement === "function") {
try {
api.registerMemoryPromptSupplement(() => [
"- `corpus=\"kb\"` 来自 huo15-openclaw-openai-knowledge-base 技能的**共享知识库**(长文档/外部资料);当需要事实性资料而非行为规则时优先。",
]);
} catch (err) {
api.logger.warn(`[enhance] kb prompt supplement 注册失败: err`);
}
}
}
FILE:src/modules/memory-integrator.ts
/**
* 模块: 记忆整合(Memory Integrator)
*
* 设计原则(非侵入):
* - 不复制龙虾的原生记忆,仅通过 registerMemoryCorpusSupplement 把 enhance 的 SQLite
* 分类记忆喂给龙虾的记忆引擎。
* - 搜索评分在 corpus.search() 内部完成(合并原 context-pruner 逻辑),
* 不再通过 before_prompt_build 绕过龙虾的原生注入路径。
* - 龙虾构建 system prompt 时会自己决定是否、如何注入 corpus 结果 —— 我们只做数据源。
*
* Public API 对接(openclaw 2026.4.11):
* api.registerMemoryCorpusSupplement(supplement) — 单参,龙虾内部挂 pluginId
* api.registerMemoryPromptSupplement(builder) — 可选,只追加提示词段
*/
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { resolveOpenClawHome } from "../utils/resolve-home.js";
import { getDb, searchMemories } from "../utils/sqlite-store.js";
import { DEFAULT_AGENT_ID } from "../types.js";
import type { MemoryEntry } from "../types.js";
const PLUGIN_CORPUS_ID = "enhance";
// ── 龙虾记忆 SDK 本地类型(与 openclaw memory-state.ts 完全对齐) ──
interface MemoryCorpusSearchResult {
corpus: string;
path: string;
title?: string;
kind?: string;
score: number;
snippet: string;
id?: string;
startLine?: number;
endLine?: number;
citation?: string;
source?: string;
provenanceLabel?: string;
sourceType?: string;
sourcePath?: string;
updatedAt?: string;
}
interface MemoryCorpusGetResult {
corpus: string;
path: string;
title?: string;
kind?: string;
content: string;
fromLine: number;
lineCount: number;
id?: string;
provenanceLabel?: string;
sourceType?: string;
sourcePath?: string;
updatedAt?: string;
}
interface MemoryCorpusSupplement {
search(params: {
query: string;
maxResults?: number;
agentSessionKey?: string;
}): Promise<MemoryCorpusSearchResult[]>;
get(params: {
lookup: string;
fromLine?: number;
lineCount?: number;
agentSessionKey?: string;
}): Promise<MemoryCorpusGetResult | null>;
}
// ── 集成器配置(合并自原 ContextPrunerConfig) ──
export interface MemoryIntegratorOptions {
/** 相关性阈值(0-1),低于此分数的记忆不会返回给龙虾,默认 0.5 */
threshold?: number;
/** 单次 search 最多返回几条,默认 5 */
maxEntries?: number;
/** 调试日志 */
debug?: boolean;
}
// ── v5.7.2 防御性 tag 黑名单 ──
// 任何带有这些 tag 的记忆条目在 corpus.search 中直接 score=0(永不召回)。
// 防止类似 v5.7.1 修复的 [auto-compact] noise 再次混进 prompt。
// 即便未来 enhance 又冒出"高频 hook 自动写入",这层兜底也能消除其影响。
const TAG_BLACKLIST = new Set([
"auto-compact",
"auto-checkpoint",
"audit",
"internal",
]);
function isBlacklisted(tags: string): boolean {
if (!tags) return false;
// tags 是逗号分隔字符串
return tags
.split(",")
.map((t) => t.trim().toLowerCase())
.some((t) => TAG_BLACKLIST.has(t));
}
// ── 相关性评分(沿用原 context-pruner 权重模型) ──
const STOP_WORDS = new Set([
"the", "a", "an", "is", "are", "was", "were", "be", "been", "being",
"have", "has", "had", "do", "does", "did", "will", "would", "could",
"should", "may", "might", "must", "can", "to", "of", "in", "for",
"on", "with", "at", "by", "from", "as", "into", "through", "during",
"before", "after", "above", "below", "between", "under", "again",
"further", "then", "once", "here", "there", "when", "where", "why",
"how", "all", "each", "few", "more", "most", "other", "some", "such",
"no", "nor", "not", "only", "own", "same", "so", "than", "too", "very",
"just", "but", "and", "or", "if", "because", "until", "while",
"about", "against", "this", "that", "these", "those", "am", "it", "its",
]);
function scoreRelevance(memory: MemoryEntry, query: string): number {
// v5.7.2: tag 黑名单兜底——audit / auto-compact 等系统类记忆永不召回
if (isBlacklisted(memory.tags)) return 0;
const queryLower = query.toLowerCase();
const contentLower = memory.content.toLowerCase();
const tagsLower = memory.tags.toLowerCase();
const queryTokens = queryLower
.split(/[\s\W]+/)
.filter((t) => t.length > 1)
.filter((t) => !STOP_WORDS.has(t));
let keywordScore = 0;
if (queryTokens.length > 0) {
const matched = queryTokens.filter(
(t) => contentLower.includes(t) || tagsLower.includes(t),
);
keywordScore = matched.length / queryTokens.length;
} else if (queryLower.length > 2) {
keywordScore =
contentLower.includes(queryLower) || tagsLower.includes(queryLower) ? 0.5 : 0;
}
const categoryWeight: Record<string, number> = {
project: 0.3,
decision: 0.25,
user: 0.2,
feedback: 0.15,
reference: 0.1,
};
const catScore = categoryWeight[memory.category] ?? 0.1;
const importanceScore = ((memory.importance ?? 5) / 10) * 0.1;
let freshnessScore = 0.1;
try {
const ageDays = (Date.now() - new Date(memory.created_at).getTime()) / 86_400_000;
if (ageDays <= 7) freshnessScore = 0.1;
else if (ageDays <= 30) freshnessScore = 0.1 * (1 - (ageDays - 7) / 23);
else freshnessScore = 0;
} catch {
freshnessScore = 0.05;
}
return Math.min(1, Math.max(0, keywordScore * 0.5 + catScore + importanceScore + freshnessScore));
}
// ── agentId / lookup 辅助 ──
function extractAgentId(sessionKey: string | undefined): string {
if (!sessionKey) return DEFAULT_AGENT_ID;
if (sessionKey.startsWith("agent:")) return sessionKey.slice(6) || DEFAULT_AGENT_ID;
return sessionKey;
}
function extractIdFromLookup(lookup: string): number | null {
if (/^\d+$/.test(lookup)) return parseInt(lookup, 10);
const match = lookup.match(/(\d+)(?:\/[^/]*)?$/);
return match ? parseInt(match[1], 10) : null;
}
// ── SQLite 访问 ──
interface EnhanceMemoryRow {
id: number;
category: string;
content: string;
tags: string;
importance: number;
agent_id: string;
created_at: string;
why?: string | null;
how_to_apply?: string | null;
}
function getMemoryById(db: any, agentId: string, id: number): EnhanceMemoryRow | null {
try {
return db
.prepare(
"SELECT id, category, content, tags, importance, agent_id, created_at, why, how_to_apply FROM memories WHERE id = ? AND agent_id = ?",
)
.get(id, agentId) as EnhanceMemoryRow | null;
} catch {
return null;
}
}
function listRecentMemories(db: any, agentId: string, limit: number): EnhanceMemoryRow[] {
try {
return db
.prepare(
"SELECT id, category, content, tags, importance, agent_id, created_at, why, how_to_apply FROM memories WHERE agent_id = ? ORDER BY created_at DESC LIMIT ?",
)
.all(agentId, limit) as EnhanceMemoryRow[];
} catch {
return [];
}
}
/** 组合成供龙虾 memory 引擎读取的多段式正文:Content + Why + How-to-apply。 */
function formatMemoryBody(memory: Pick<MemoryEntry, "content" | "why" | "how_to_apply">): string {
const parts: string[] = [memory.content];
if (memory.why && memory.why.trim()) {
parts.push(`\n**Why:** memory.why.trim()`);
}
if (memory.how_to_apply && memory.how_to_apply.trim()) {
parts.push(`**How to apply:** memory.how_to_apply.trim()`);
}
return parts.join("\n");
}
// ── 构建 MemoryCorpusSupplement ──
function buildEnhanceCorpus(
api: OpenClawPluginApi,
options: MemoryIntegratorOptions,
): MemoryCorpusSupplement {
const openclawDir = resolveOpenClawHome(api);
const db = getDb(openclawDir);
const threshold = options.threshold ?? 0.5;
const defaultMax = options.maxEntries ?? 5;
const debug = options.debug ?? false;
return {
async search({ query, maxResults, agentSessionKey }) {
const cleanQuery = (query ?? "").trim();
if (!cleanQuery) return [];
const agentId = extractAgentId(agentSessionKey);
const all = searchMemories(db, agentId, { limit: 100 });
if (all.length === 0) return [];
const limit = maxResults ?? defaultMax;
// Deterministic tie-break so prompt cache prefix stays stable across turns.
// Order: score DESC → importance DESC → updated_at DESC → id DESC.
const scored = all
.map((m) => ({ memory: m, score: scoreRelevance(m, cleanQuery) }))
.filter(({ score }) => score >= threshold)
.sort((a, b) => {
if (b.score !== a.score) return b.score - a.score;
const ai = a.memory.importance ?? 5;
const bi = b.memory.importance ?? 5;
if (bi !== ai) return bi - ai;
const at = new Date(a.memory.updated_at || a.memory.created_at).getTime();
const bt = new Date(b.memory.updated_at || b.memory.created_at).getTime();
if (bt !== at) return bt - at;
return b.memory.id - a.memory.id;
})
.slice(0, limit);
if (debug) {
api.logger.info(
`[enhance] corpus.search query="cleanQuery.slice(0, 60)" → scored.length/all.length 条 (agent: agentId)`,
);
}
return scored.map(({ memory, score }) => ({
corpus: PLUGIN_CORPUS_ID,
path: `memory://enhance/memory.category/memory.id`,
title: `[memory.category.toUpperCase()] memory.content.slice(0, 60)`,
kind: memory.category,
score,
snippet: formatMemoryBody(memory).slice(0, 400),
id: String(memory.id),
citation: `@enhance:memory.id`,
provenanceLabel: "enhance-memory",
sourceType: "structured_memory",
sourcePath: `enhance://memory/memory.category/memory.id`,
updatedAt: memory.created_at,
}));
},
async get({ lookup, fromLine = 1, lineCount = 100, agentSessionKey }) {
const id = extractIdFromLookup(lookup);
if (!id) return null;
const agentId = extractAgentId(agentSessionKey);
const row = getMemoryById(db, agentId, id);
if (!row) return null;
const fullBody = formatMemoryBody({
content: row.content,
why: row.why ?? undefined,
how_to_apply: row.how_to_apply ?? undefined,
});
const lines = fullBody.split("\n");
const startLine = Math.max(1, Math.min(fromLine, lines.length));
const endLine = Math.min(startLine + lineCount - 1, lines.length);
const selected = lines.slice(startLine - 1, endLine);
return {
corpus: PLUGIN_CORPUS_ID,
path: `memory://enhance/row.category/row.id`,
title: `[row.category.toUpperCase()] row.content.slice(0, 60)`,
kind: row.category,
content: selected.join("\n"),
fromLine: startLine,
lineCount: selected.length,
id: String(row.id),
provenanceLabel: "enhance-memory",
sourceType: "structured_memory",
sourcePath: `enhance://memory/row.category/row.id`,
updatedAt: row.created_at,
};
},
};
}
// ── 主注册入口 ──
export function registerMemoryIntegrator(
api: OpenClawPluginApi,
options: MemoryIntegratorOptions = {},
) {
const corpus = buildEnhanceCorpus(api, options);
// 龙虾 2026.4.11 公共 API:单参签名。api-builder 会自动把 pluginId 挂上。
if (typeof api.registerMemoryCorpusSupplement === "function") {
try {
api.registerMemoryCorpusSupplement(corpus);
api.logger.info("[enhance] corpus supplement 已注册(enhance 分类记忆并入 openclaw memory 搜索)");
} catch (err) {
api.logger.error(`[enhance] corpus supplement 注册失败: err`);
}
} else {
api.logger.warn(
"[enhance] 当前 openclaw 版本未提供 registerMemoryCorpusSupplement;记忆整合跳过(建议升级到 2026.4.11+)",
);
}
// 可选:在 memory system prompt 里追加一行说明(仅当龙虾暴露此 API)
if (typeof api.registerMemoryPromptSupplement === "function") {
try {
api.registerMemoryPromptSupplement(({ availableTools }) => {
if (availableTools.has("enhance_memory_search") || availableTools.has("enhance_memory_store")) {
return [
"- `enhance_memory_*` 工具提供分类记忆(user/project/feedback/reference/decision);已通过 corpus supplement 并入 `memory` 搜索结果。",
];
}
return [];
});
} catch (err) {
api.logger.warn(`[enhance] prompt supplement 注册失败: err`);
}
}
// enhance_memory_export — 导出为 JSON,方便同步到 Obsidian / KB
api.registerTool(((ctx: any) => ({
name: "enhance_memory_export",
description: "导出当前 Agent 全部 enhance 记忆为 JSON(可同步到 Obsidian/KB)",
parameters: {},
async execute(_id: string, _params: Record<string, unknown>): Promise<any> {
const openclawDir = resolveOpenClawHome(api);
const db = getDb(openclawDir);
const agentId = ((ctx?.agentId as string | undefined) ?? DEFAULT_AGENT_ID).trim();
const rows = listRecentMemories(db, agentId, 1000);
const json = JSON.stringify(
{
source: "huo15-openclaw-enhance",
version: "2.2.0",
exported_at: new Date().toISOString(),
agent_id: agentId,
count: rows.length,
memories: rows,
},
null,
2,
);
return {
content: [
{
type: "text" as const,
text: `已导出 rows.length 条记忆 (agent: agentId):\n\njson`,
},
],
};
},
})) as any, { name: "enhance_memory_export" });
api.logger.info("[enhance] 记忆整合模块已加载(corpus supplement + 相关性评分 + 导出工具)");
}
FILE:src/modules/mode-gate.ts
/**
* 模块: 模式闸门(plan / explore)
*
* Claude Code 的 plan mode / explore mode 在龙虾里没有等价物。
* 本模块通过 before_tool_call 钩子,在 plan/explore 模式下拦截写入类工具。
*
* 模式切换通过 enhance_set_mode 工具完成(每 agent+session 独立)。
*
* 默认禁用(defaultMode: "normal"),需要在配置里显式 enabled:true 才介入。
* 与龙虾 tools.allow/deny 互不冲突:即使本模块允许,龙虾仍会独立检查。
*/
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import type { OpenClawPluginToolContext } from "openclaw/plugin-sdk/core";
import { Type } from "@sinclair/typebox";
import type { AgentMode, ModeConfig, NotificationQueue } from "../types.js";
import { DEFAULT_AGENT_ID } from "../types.js";
import { resolveOpenClawHome } from "../utils/resolve-home.js";
import { getDb, storeMemory } from "../utils/sqlite-store.js";
// ── 写入类工具名(子串匹配,大小写不敏感) ──
// 只拦截"会改动外部状态"的工具,搜索/读取类不受影响。
const MUTATING_TOOL_PATTERNS = [
"edit", "write", "patch", "apply",
"create_file", "create-file",
"rm", "delete",
"mv", "move",
"install", "uninstall",
"commit", "push", "merge", "rebase", "reset",
"exec", "run_command",
];
const BASH_LIKE = ["bash", "shell", "execute", "run"];
const DESTRUCTIVE_BASH_TOKENS = [
"rm ", "rm -", "sudo ", "git push", "git commit", "git reset --hard",
"git rebase", "git merge", "chmod ", "chown ", "mv ",
"npm install", "pnpm install", "bun install",
"> ", ">> ", "| sh", "| bash", "curl ", "wget ",
];
const modeState = new Map<string, AgentMode>();
/** Ring buffer of tool calls the model attempted during plan mode — surfaced at ExitPlanMode time. */
interface PlannedAction {
toolName: string;
paramsSummary: string;
at: string;
}
const plannedActions = new Map<string, PlannedAction[]>();
const MAX_PLANNED = 50;
/**
* v5.7.2: LRU caps to prevent unbounded growth across long-lived processes (agents 跨 session 永不清).
* Map iteration is insertion order, so deleting `keys().next()` evicts the oldest entry.
*/
const MAX_STATE_ENTRIES = 200;
const MAX_PLANNED_ENTRIES = 200;
function evictOldest<K, V>(map: Map<K, V>, capacity: number) {
while (map.size >= capacity) {
const oldest = map.keys().next().value;
if (oldest === undefined) break;
map.delete(oldest);
}
}
function stateKey(agentId: string, sessionId: string): string {
return `agentId::sessionId`;
}
function getMode(agentId: string, sessionId: string, fallback: AgentMode): AgentMode {
return modeState.get(stateKey(agentId, sessionId)) ?? fallback;
}
function setMode(agentId: string, sessionId: string, mode: AgentMode) {
const key = stateKey(agentId, sessionId);
// Refresh insertion order on update so active sessions don't get evicted.
if (modeState.has(key)) modeState.delete(key);
evictOldest(modeState, MAX_STATE_ENTRIES);
modeState.set(key, mode);
// Leaving plan mode clears the captured plan (a fresh plan mode starts clean).
if (mode !== "plan") plannedActions.delete(key);
}
function recordPlannedAction(agentId: string, sessionId: string, toolName: string, params: Record<string, unknown>) {
const key = stateKey(agentId, sessionId);
const arr = plannedActions.get(key) ?? [];
if (!plannedActions.has(key)) {
evictOldest(plannedActions, MAX_PLANNED_ENTRIES);
}
const summary = (() => {
const keyPairs = Object.entries(params).slice(0, 3);
if (keyPairs.length === 0) return "";
return keyPairs
.map(([k, v]) => `k=JSON.stringify(v).slice(0, 60)`)
.join(", ");
})();
arr.push({ toolName, paramsSummary: summary, at: new Date().toISOString() });
if (arr.length > MAX_PLANNED) arr.splice(0, arr.length - MAX_PLANNED);
plannedActions.set(key, arr);
}
function takePlannedActions(agentId: string, sessionId: string): PlannedAction[] {
const key = stateKey(agentId, sessionId);
const arr = plannedActions.get(key) ?? [];
plannedActions.delete(key);
return arr;
}
function isMutatingTool(toolName: string, params: Record<string, unknown>): boolean {
const lower = toolName.toLowerCase();
if (MUTATING_TOOL_PATTERNS.some((p) => lower.includes(p))) return true;
if (BASH_LIKE.some((p) => lower.includes(p))) {
const command = String(params.command ?? params.input ?? params.cmd ?? "").toLowerCase();
if (!command) return false;
return DESTRUCTIVE_BASH_TOKENS.some((t) => command.includes(t));
}
return false;
}
function pickAgentId(ctx: { agentId?: string } | undefined): string {
return ((ctx?.agentId ?? DEFAULT_AGENT_ID).trim() || DEFAULT_AGENT_ID);
}
function pickSessionId(ctx: { sessionKey?: string; sessionId?: string } | undefined): string {
return ((ctx?.sessionKey ?? ctx?.sessionId ?? "") + "").trim();
}
export function registerModeGate(
api: OpenClawPluginApi,
config: ModeConfig | undefined,
notifyQueue: NotificationQueue,
) {
const defaultMode: AgentMode = config?.defaultMode ?? "normal";
// v5.7.8: typed via openclaw 4.24 SDK
api.on(
"before_tool_call",
(event, ctx) => {
const agentId = pickAgentId(ctx);
const sessionId = pickSessionId(ctx);
const mode = getMode(agentId, sessionId, defaultMode);
if (mode === "normal") return;
const toolName = event?.toolName ?? "";
const params = event?.params ?? {};
if (toolName.startsWith("enhance_set_mode") || toolName.startsWith("enhance_current_mode")) {
return;
}
if (!isMutatingTool(toolName, params)) return;
const reason =
mode === "plan"
? "当前处于 plan 模式:只做规划与只读勘察,写入类工具被拦截。请在规划完成后调用 enhance_exit_plan_mode 提交计划给用户审批。"
: "当前处于 explore 模式:只允许只读勘察,写入类工具被拦截。";
if (mode === "plan") {
recordPlannedAction(agentId, sessionId, toolName, params);
}
notifyQueue.emit(agentId, "warn", "workflow", `mode 模式拦截`, `工具 toolName 被 mode 模式拦截。`);
return { block: true, blockReason: reason };
},
{ priority: 950 },
);
api.registerTool(
((ctx: OpenClawPluginToolContext) => ({
name: "enhance_set_mode",
description: "切换 session 模式:normal(无限制)|plan(拦截写入)|explore(只读)",
parameters: Type.Object({
mode: Type.Union([Type.Literal("normal"), Type.Literal("plan"), Type.Literal("explore")], {
description: "目标模式",
}),
}),
async execute(_id: string, params: Record<string, unknown>) {
const agentId = pickAgentId(ctx);
const sessionId = pickSessionId(ctx);
const target = params.mode as AgentMode;
setMode(agentId, sessionId, target);
return {
content: [{ type: "text" as const, text: `✓ 已切换至 target 模式 (agent: agentId)` }],
};
},
})) as any,
{ name: "enhance_set_mode" },
);
api.registerTool(
((ctx: OpenClawPluginToolContext) => ({
name: "enhance_current_mode",
description: "查看当前 session 的运行模式",
parameters: Type.Object({}),
async execute() {
const agentId = pickAgentId(ctx);
const sessionId = pickSessionId(ctx);
const mode = getMode(agentId, sessionId, defaultMode);
return {
content: [{ type: "text" as const, text: `当前模式: mode (agent: agentId)` }],
};
},
})) as any,
{ name: "enhance_current_mode" },
);
// ── enhance_exit_plan_mode: Claude-Code 风格的 Plan 审批闭环 ──
const openclawDir = resolveOpenClawHome(api);
const db = getDb(openclawDir);
api.registerTool(
((ctx: OpenClawPluginToolContext) => ({
name: "enhance_exit_plan_mode",
description: "提交 plan 给用户审批并退出 plan 模式(保存为 decision 记忆)",
parameters: Type.Object({
plan: Type.String({ description: "计划正文(Markdown)" }),
why: Type.Optional(Type.String({ description: "动机/约束" })),
autoApprove: Type.Optional(
Type.Boolean({ description: "是否直接切到 normal,默认 false" }),
),
}),
async execute(_id: string, params: Record<string, unknown>) {
const agentId = pickAgentId(ctx);
const sessionId = pickSessionId(ctx);
const mode = getMode(agentId, sessionId, defaultMode);
const plan = String(params.plan ?? "").trim();
const why = String(params.why ?? "").trim();
const autoApprove = Boolean(params.autoApprove);
if (!plan) {
return { content: [{ type: "text" as const, text: "plan 必填。" }] };
}
if (mode !== "plan") {
return {
content: [
{
type: "text" as const,
text: `当前不在 plan 模式(mode=mode),无需提交计划;若要进入 plan 先调用 enhance_set_mode plan。`,
},
],
};
}
const captured = takePlannedActions(agentId, sessionId);
const actionsBlock =
captured.length === 0
? "(plan 期间未触发写入拦截)"
: captured
.map((a, i) => ` i + 1. a.toolNamea.paramsSummary ? ` [${a.paramsSummary]` : ""}`)
.join("\n");
const memoryContent = [
`【plan 模式退出】`,
plan,
``,
`拦截到的意图操作:`,
actionsBlock,
].join("\n");
const entry = storeMemory(
db,
agentId,
"decision",
memoryContent,
"plan,exit-plan",
7,
sessionId,
{
why: why || "plan 模式退出前的决策快照",
howToApply: autoApprove
? "已自动切到 normal 模式,可以开始执行 plan 列出的写入操作。"
: "等待用户批准:用户 /enhance_set_mode normal 后可开始执行 plan。",
},
);
if (autoApprove) {
setMode(agentId, sessionId, "normal");
notifyQueue.emit(
agentId,
"success",
"workflow",
"plan 审批:auto-approved",
`plan #entry.id 已自动批准,切到 normal 模式`,
);
} else {
notifyQueue.emit(
agentId,
"info",
"workflow",
"plan 待审批",
`plan #entry.id 已提交,等待用户批准(调用 enhance_set_mode normal)`,
);
}
return {
content: [
{
type: "text" as const,
text: [
`✓ plan 已提交(记忆 #entry.id)`,
autoApprove ? " 已自动批准并切到 normal 模式,可以开始写入。" : " 等待用户批准。用户可调用 enhance_set_mode normal 放行。",
captured.length > 0 ? ` plan 期间捕获到 captured.length 个被拦截的写入意图。` : "",
]
.filter(Boolean)
.join("\n"),
},
],
structuredContent: {
planId: entry.id,
plan,
why: why || undefined,
capturedActions: captured,
autoApproved: autoApprove,
mode: autoApprove ? "normal" : "plan",
},
};
},
})) as any,
{ name: "enhance_exit_plan_mode" },
);
api.logger.info(
`[enhance] 模式闸门模块已加载(默认 defaultMode;plan/explore 拦截写入;enhance_exit_plan_mode 提交审批)`,
);
}
export function getCurrentMode(agentId: string, sessionId: string): AgentMode {
return modeState.get(stateKey(agentId, sessionId)) ?? "normal";
}
FILE:src/modules/notification-queue.ts
/**
* 通知中心 — 内部事件队列,供仪表盘消费展示
* 纯内部模块,不注册任何工具。
*/
import type Database from "better-sqlite3";
import type { NotificationQueue, NotificationLevel, NotificationSource, NotificationConfig } from "../types.js";
import {
emitNotification,
getRecentNotifications,
getUnreadNotificationCount,
markNotificationRead,
pruneNotifications,
} from "../utils/sqlite-store.js";
export function createNotificationQueue(
db: Database.Database,
config?: NotificationConfig,
): NotificationQueue {
const maxRetained = config?.maxRetained ?? 100;
return {
emit(agentId, level, source, title, detail) {
emitNotification(db, agentId, level, source, title, detail);
pruneNotifications(db, maxRetained);
},
getRecent(agentId?, limit?) {
return getRecentNotifications(db, agentId, limit).map((row: any) => ({
...row,
read: !!row.read,
}));
},
getUnreadCount(agentId?) {
return getUnreadNotificationCount(db, agentId);
},
markRead(id) {
markNotificationRead(db, id);
},
prune(max) {
pruneNotifications(db, max);
},
};
}
FILE:src/modules/prompt-enhancer.ts
/**
* 模块3: 提示词增强(多 Agent 隔离版)
*
* before_prompt_build hook 签名:
* event: { prompt, messages }
* ctx: { runId, agentId, sessionKey, sessionId, workspaceDir, messageProvider, trigger, channelId }
*/
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { DEFAULT_AGENT_ID, type PromptConfig, type PromptSection } from "../types.js";
// 仅保留 openclaw 内置系统提示词未覆盖的段落:
// - taskClassification 已移除:openclaw "## Execution Bias" 已覆盖
// - safetyAwareness 已移除:openclaw "## Safety" 已覆盖
// - memoryInstructions 已移除:openclaw memory-core "## Memory Recall" 已覆盖
const SECTIONS: Record<PromptSection, string> = {
qualityGuidelines: [
"## 响应质量准则(增强包)",
"- 直接给出答案或行动,不要先复述用户说了什么",
"- 一句话能说清的不要用三句话",
"- 修 bug 不要顺手重构周围代码",
"- 不要添加请求之外的功能、注释或类型标注",
"- 不要为不可能发生的场景做错误处理",
"- 三行相似代码好过一个过早的抽象",
"- 失败时先诊断原因,不要盲目重试",
"- 做破坏性操作前先确认(删除、force push、覆盖未保存的修改)",
].join("\n"),
};
export function registerPromptEnhancer(api: OpenClawPluginApi, config?: PromptConfig) {
// 默认只启用质量准则;memoryInstructions 仅作为托底(结构化记忆模块禁用时才需要)
const enabledSections: PromptSection[] = config?.sections ?? ["qualityGuidelines"];
api.on("before_prompt_build", (_event, ctx) => {
const agentId = (ctx?.agentId ?? DEFAULT_AGENT_ID).trim();
const parts: string[] = [];
for (const section of enabledSections) {
const content = SECTIONS[section];
if (content) parts.push(content);
}
if (parts.length === 0) return {};
return {
appendSystemContext: [
`\n\n<!-- enhance-prompt agent:agentId -->`,
...parts,
].join("\n\n"),
};
});
api.logger.info(`[enhance] 提示词增强模块已加载,启用段落: enabledSections.join(", ")`);
}
FILE:src/modules/scheduled-tasks-bridge.ts
/**
* 模块: 定时任务桥(scheduled-tasks bridge)
*
* 龙虾提供了 cron-cli(`openclaw cron ...`)用于创建/列出定时任务,但插件侧没有 API 能直接
* 注册 cron。本模块的桥接策略:
*
* 1. 用户/Agent 通过 enhance_loop_register 声明一个"希望定时触发的工作流"。
* 2. 本模块把声明写入 scheduled_task_bindings 表,并在响应里**返回一条确切的 openclaw cron
* CLI 命令**,由用户一键复制执行(我们不擅自代替用户运行 CLI)。
* 3. Cron 真正触发时,会回调 openclaw 的主循环并通过一个约定的入口消息重新唤起 session,
* 此时 enhance 的 before_prompt_build 看到消息前缀 `[enhance-loop:{name}]` 就能把对应
* binding 的 instructions 注入上下文(和 workflow-hooks 复用)。
*
* 这样做的好处:
* - 不绕过龙虾的 cron-cli(尊重"host 调度"原则)。
* - Plugin 只负责登记 + 触发时装填上下文,cron 生命周期归龙虾管。
* - 用户可以直接用 `openclaw cron list` 看到真实调度状态,不会出现"双源真相"。
*/
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import type { OpenClawPluginToolContext } from "openclaw/plugin-sdk/core";
import { Type } from "@sinclair/typebox";
import { resolveOpenClawHome } from "../utils/resolve-home.js";
import {
getDb,
upsertScheduledBinding,
listScheduledBindings,
disableScheduledBinding,
touchScheduledBindingFired,
} from "../utils/sqlite-store.js";
import { DEFAULT_AGENT_ID } from "../types.js";
const LOOP_MARKER_RE = /^\[enhance-loop:([^\]]+)\]/;
function pickAgentId(ctx: { agentId?: string } | undefined): string {
return ((ctx?.agentId ?? DEFAULT_AGENT_ID).trim() || DEFAULT_AGENT_ID);
}
function quote(s: string): string {
return `'s.replace(/'/g, "'\\''")'`;
}
export function registerScheduledTasksBridge(api: OpenClawPluginApi) {
const openclawDir = resolveOpenClawHome(api);
const db = getDb(openclawDir);
// ── Hook: before_prompt_build ── 识别 loop 前缀并注入对应 binding 的 instructions
// v5.7.8: typed via openclaw 4.24 SDK
api.on("before_prompt_build", (event, ctx) => {
const prompt = String((event as { prompt?: string } | undefined)?.prompt ?? "");
const match = prompt.match(LOOP_MARKER_RE);
if (!match) return;
const agentId = pickAgentId(ctx);
const name = match[1].trim();
const binding = listScheduledBindings(db, agentId).find((b) => b.name === name && b.enabled === 1);
if (!binding) return;
touchScheduledBindingFired(db, binding.id);
const header = `【定时任务 name】已由 openclaw cron 触发。按下面的指令执行:\n`;
return {
prependContext: header + binding.instructions,
};
});
// ── Tool: enhance_loop_register ──
api.registerTool(
((ctx: OpenClawPluginToolContext) => ({
name: "enhance_loop_register",
description: "登记定时工作流,返回一条 openclaw cron add 命令供用户挂到调度器",
parameters: Type.Object({
name: Type.String({ description: "任务名(agent 内唯一)" }),
cron: Type.String({ description: "cron 表达式" }),
instructions: Type.String({ description: "触发时注入给 Agent 的指令" }),
}),
async execute(_id: string, params: Record<string, unknown>) {
const agentId = pickAgentId(ctx);
const name = String(params.name ?? "").trim();
const cron = String(params.cron ?? "").trim();
const instructions = String(params.instructions ?? "").trim();
if (!name || !cron || !instructions) {
return {
content: [{ type: "text" as const, text: "name/cron/instructions 均必填。" }],
};
}
const binding = upsertScheduledBinding(db, agentId, name, cron, instructions);
const triggerMessage = `[enhance-loop:name] 定时触发,请执行 name 工作流。`;
const cliCmd = `openclaw cron add --name quote(`enhance-${name`)} --cron quote(cron) --message quote(triggerMessage) --agent quote(agentId)`;
return {
content: [
{
type: "text" as const,
text: [
`✓ 已登记定时工作流 #binding.id:name(agent: agentId)`,
"",
"下一步(手动挂到龙虾原生调度器):",
"```",
cliCmd,
"```",
"",
"触发时 enhance 会拦截消息前缀,把以下内容注入给 Agent:",
"---",
instructions,
"---",
"",
"停用:enhance_loop_disable(name)",
].join("\n"),
},
],
};
},
})) as any,
{ name: "enhance_loop_register" },
);
api.registerTool(
((ctx: OpenClawPluginToolContext) => ({
name: "enhance_loop_list",
description: "列出当前 Agent 已登记的定时工作流",
parameters: Type.Object({}),
async execute() {
const agentId = pickAgentId(ctx);
const rows = listScheduledBindings(db, agentId);
if (rows.length === 0) {
return {
content: [{ type: "text" as const, text: `暂无定时工作流 (agent: agentId)。` }],
};
}
const lines = rows.map(
(r) =>
`"○" r.name · cron=r.cron_ref · last_fired=r.last_fired_at ?? "never"\n r.instructions.slice(0, 100)""`,
);
return {
content: [{ type: "text" as const, text: `定时工作流 (rows.length):\nlines.join("\n\n")` }],
};
},
})) as any,
{ name: "enhance_loop_list" },
);
api.registerTool(
((ctx: OpenClawPluginToolContext) => ({
name: "enhance_loop_disable",
description: "停用定时工作流(软删除,仍需手动 openclaw cron remove)",
parameters: Type.Object({
name: Type.String({ description: "登记时的 name" }),
}),
async execute(_id: string, params: Record<string, unknown>) {
const agentId = pickAgentId(ctx);
const name = String(params.name ?? "").trim();
const ok = disableScheduledBinding(db, agentId, name);
const cliCmd = `openclaw cron remove --name quote(`enhance-${name`)}`;
return {
content: [
{
type: "text" as const,
text: ok
? `✓ enhance 侧已停用 name。\n别忘了去龙虾原生调度器里移除:\n cliCmd`
: `未找到名为 name 的定时工作流。`,
},
],
};
},
})) as any,
{ name: "enhance_loop_disable" },
);
api.logger.info("[enhance] 定时任务桥模块已加载(enhance_loop_register/list/disable,通过 openclaw cron-cli 挂载)");
}
FILE:src/modules/self-check.ts
/**
* 模块: 输出自检(Self-Check)
*
* Hook: before_agent_reply
* 时机: AI 回复发送前,拦截检查
* 作用: 检测空输出、错误关键词、格式异常,记录问题但不阻断正常输出
*
* 来源: 对标 Claude Code "评估与观测" 能力(Agent Harness 六层第5层)
*/
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { resolveOpenClawHome } from "../utils/resolve-home.js";
import { getDb, logSafetyEvent } from "../utils/sqlite-store.js";
import { DEFAULT_AGENT_ID } from "../types.js";
// ── 配置 ──
export interface SelfCheckConfig {
/** 是否启用 */
enabled?: boolean;
/** 检查空输出 */
checkEmpty?: boolean;
/** 检查 NO_REPLY 标记 */
checkNoReply?: boolean;
/** 检查错误关键词 */
checkErrorKeywords?: boolean;
/** 检查超长输出 */
checkExcessiveLength?: boolean;
/** 超长阈值(字符),默认 10000 */
maxLength?: number;
/** 错误关键词列表 */
errorKeywords?: string[];
/** 是否阻断(true=拦截,false=仅记录) */
blockOnEmpty?: boolean;
}
const DEFAULT_ERROR_KEYWORDS = [
"cannot",
"unable to",
"failed to",
"error occurred",
"sorry",
"apologi",
"something went wrong",
"unexpected error",
];
function scoreKeywordMatch(text: string, keywords: string[]): number {
const lower = text.toLowerCase();
return keywords.filter((k) => lower.includes(k.toLowerCase())).length;
}
export function registerSelfCheck(api: OpenClawPluginApi, config?: SelfCheckConfig) {
const openclawDir = resolveOpenClawHome(api);
const db = getDb(openclawDir);
const enabled = config?.enabled !== false;
if (!enabled) return;
const checkEmpty = config?.checkEmpty !== false;
const checkNoReply = config?.checkNoReply !== false;
const checkErrorKeywords = config?.checkErrorKeywords !== false;
const checkExcessiveLength = config?.checkExcessiveLength !== false;
const maxLength = config?.maxLength ?? 10000;
const errorKeywords = config?.errorKeywords ?? DEFAULT_ERROR_KEYWORDS;
const blockOnEmpty = config?.blockOnEmpty ?? false;
// ── Hook: before_agent_reply ──(v5.7.8: typed via openclaw 4.24 SDK)
api.on("before_agent_reply", (event, ctx) => {
const agentId = (ctx?.agentId ?? DEFAULT_AGENT_ID).trim();
const body: string = event?.cleanedBody ?? "";
const issues: string[] = [];
// 1. 检查空输出
if (checkEmpty && body.trim().length === 0) {
issues.push("empty_output");
api.logger.warn(`[enhance-selfcheck] 检测到空输出 (agent: agentId)`);
}
// 2. 检查 NO_REPLY 标记(正常,静默放行)
if (checkNoReply) {
const trimmed = body.trim().toUpperCase();
if (trimmed === "NO_REPLY" || trimmed === "HEARTBEAT_OK") {
return; // void = 不接管
}
}
// 3. 检查超长输出
if (checkExcessiveLength && body.length > maxLength) {
issues.push(`excessive_length:body.length`);
api.logger.warn(
`[enhance-selfcheck] 检测到超长输出 body.length chars (agent: agentId),超过阈值 maxLength`,
);
}
// 4. 检查错误关键词(只记录,不阻断)
if (checkErrorKeywords && body.trim().length > 0) {
const errorScore = scoreKeywordMatch(body, errorKeywords);
if (errorScore >= 2) {
issues.push(`error_keywords:errorScore`);
api.logger.warn(
`[enhance-selfcheck] 检测到 errorScore 个错误关键词 (agent: agentId): body.slice(0, 100)`,
);
}
}
// 5. 记录到 safety_log
if (issues.length > 0) {
logSafetyEvent(
db,
agentId,
"self_check",
issues.join(" | "),
"log",
JSON.stringify({ issues, bodyLength: body.length }),
"self_check_output_issue",
);
}
// 6. 空输出阻断(可选)
if (blockOnEmpty && issues.includes("empty_output")) {
return {
handled: true,
reply: {
text: "⚠️ 未能生成有效回复,请重试。",
isError: true,
},
reason: "self_check: empty output blocked",
};
}
// void = 不接管,让原回复正常发送
return;
});
api.logger.info("[enhance] 输出自检模块已加载(before_agent_reply hook,非阻断式)");
}
FILE:src/modules/session-lifecycle.ts
/**
* v5.7.7 Session Lifecycle — 接 openclaw 4.22 的 session_start / session_end /
* before_reset / subagent_spawned / subagent_ended 五个 hook,闭环 session
* 生命周期。
*
* 调研依据:openclaw plugin-sdk hook-types.d.ts 暴露 29 个 hook,enhance 之前
* 只用了 4 个(before_prompt_build / before_tool_call / after_tool_call /
* before_agent_reply)。Claude Code 官方 hook 体系(SessionStart / Stop /
* SubagentStart / SubagentStop)在 openclaw 这边都有对应。
*
* 红线遵守:
* - 完全是非侵入式增强 — 只读 enhance 自己的 SQLite,不动 openclaw 任何状态
* - 不复制龙虾原生功能 — openclaw 自己有 hook 框架,enhance 只挂回调
* - 写入用 enhance 自有表(chapters/memories),不污染龙虾原生 memory
* - 高频 hook(如 session_start 在多 agent 场景每分钟可能多次触发)严格控
* 写入:单 hook 最多写 1-2 条记录,并带 dedup tag
*/
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { resolveOpenClawHome } from "../utils/resolve-home.js";
import {
getDb,
addChapter,
listChapters,
getLatestTodos,
storeMemory,
} from "../utils/sqlite-store.js";
import type { SessionLifecycleConfig, NotificationQueue } from "../types.js";
import { DEFAULT_AGENT_ID } from "../types.js";
/** v5.7.8: typed ctx — openclaw 4.24 暴露的具体 context 类型 */
function pickAgentId(ctx: { agentId?: string } | undefined): string {
return ((ctx?.agentId ?? DEFAULT_AGENT_ID).trim() || DEFAULT_AGENT_ID);
}
function pickSessionId(ctx: { sessionKey?: string; sessionId?: string } | undefined): string {
return ((ctx?.sessionKey ?? ctx?.sessionId ?? "") + "").trim();
}
/** 限流去重表:避免短时间多次触发同一 hook 重复写记录(v5.7.1 反思:高频 hook 写持久化是 noise factory) */
const recentEvents = new Map<string, number>();
const DEDUP_WINDOW_MS = 30_000; // 30 秒
const MAX_RECENT_ENTRIES = 500;
function shouldFire(key: string): boolean {
const now = Date.now();
const last = recentEvents.get(key) ?? 0;
if (now - last < DEDUP_WINDOW_MS) return false;
// LRU eviction
if (recentEvents.size >= MAX_RECENT_ENTRIES) {
const oldest = recentEvents.keys().next().value;
if (oldest !== undefined) recentEvents.delete(oldest);
}
// 重新插入刷新顺序
if (recentEvents.has(key)) recentEvents.delete(key);
recentEvents.set(key, now);
return true;
}
export function registerSessionLifecycle(
api: OpenClawPluginApi,
config: SessionLifecycleConfig | undefined,
notifyQueue: NotificationQueue,
) {
const openclawDir = resolveOpenClawHome(api);
const db = getDb(openclawDir);
const enableSessionStart = config?.enableSessionStart !== false;
const enableSessionEnd = config?.enableSessionEnd !== false;
const enableBeforeReset = config?.enableBeforeReset !== false;
const enableSubagent = config?.enableSubagent !== false;
const debug = config?.debug === true;
// ── Hook: session_start ─────────────────────────────────────────────────
// 用途:每个新会话起点 —— 给一个章节占位(如果距上一个 chapter 已经过去 > 30min)+ 推通知
// 不做:不自动 inject prompt 上下文(那是 session-recap 模块的职责,避免重复)
if (enableSessionStart) {
try {
api.on("session_start", (_event, ctx) => {
try {
const agentId = pickAgentId(ctx);
const sessionId = pickSessionId(ctx);
const dedupKey = `start:agentId:sessionId`;
if (!shouldFire(dedupKey)) return undefined;
// 看上一个 chapter 距离多久
const chapters = listChapters(db, agentId, sessionId, 1);
const last = chapters[0];
const lastMs = last ? new Date(last.created_at).getTime() : 0;
const idleMin = lastMs ? Math.round((Date.now() - lastMs) / 60_000) : Infinity;
// 仅当 idle > 30min 或者根本没 chapter 时才插入新章节占位
if (idleMin >= 30) {
const title = idleMin === Infinity
? "🚀 会话开始"
: `🚀 会话续启(距上次 idleMin 分钟)`;
addChapter(db, agentId, sessionId, title, `session_start hook 自动标记`);
if (debug) api.logger.info(`[enhance-lifecycle] session_start → 添加章节: title`);
}
} catch (err) {
api.logger.error(`[enhance-lifecycle] session_start handler 错误: (err as Error).message`);
}
return undefined; // void hook
});
} catch {
// hook 不可用时静默
}
}
// ── Hook: session_end ───────────────────────────────────────────────────
// 用途:会话结束自动 mark_chapter + flush 未结束的 in_progress todo 到 decision memory
if (enableSessionEnd) {
try {
api.on("session_end", (_event, ctx) => {
try {
const agentId = pickAgentId(ctx);
const sessionId = pickSessionId(ctx);
const dedupKey = `end:agentId:sessionId`;
if (!shouldFire(dedupKey)) return undefined;
// 1) 自动加一个收尾章节
addChapter(db, agentId, sessionId, "🏁 会话结束", "session_end hook 自动标记");
// 2) 抢救未完成的 in_progress todo 到 decision memory(带保留 tag 防 noise factory)
const latestTodos = getLatestTodos(db, agentId);
const inProgress = latestTodos.filter((t) => t.status === "in_progress");
if (inProgress.length > 0) {
const summary = inProgress
.map((t, i) => `i + 1. t.content.slice(0, 100)`)
.join("\n");
storeMemory(
db,
agentId,
"project",
`[lifecycle:flush] 会话结束时仍有 inProgress.length 条 in_progress todo 未完成:\nsummary`,
"session-flush", // 不进 corpus pruner 黑名单 — 用户下次会想恢复,让 pruner 按相关度自然评分
4, // 重要性低(不是用户主动决策)
sessionId,
{
why: "下次会话恢复时可从这里查看上次未完成的工作",
howToApply: "新 session 开始时 search memory 看是否有 [lifecycle:flush] 条目",
},
);
if (debug) api.logger.info(`[enhance-lifecycle] session_end → flush inProgress.length 条 in_progress todo`);
}
} catch (err) {
api.logger.error(`[enhance-lifecycle] session_end handler 错误: (err as Error).message`);
}
return undefined;
});
} catch {
// 静默
}
}
// ── Hook: before_reset ─────────────────────────────────────────────────
// 用途:reset 前最后机会,把 in_progress todo + 最近一条 chapter 抢救到 decision memory
// 区别于 session_end:reset 是用户主动清理,更激进,需要更彻底的"最后挽救"
if (enableBeforeReset) {
try {
api.on("before_reset", (_event, ctx) => {
try {
const agentId = pickAgentId(ctx);
const sessionId = pickSessionId(ctx);
const dedupKey = `reset:agentId:sessionId`;
if (!shouldFire(dedupKey)) return undefined;
const latestTodos = getLatestTodos(db, agentId);
const open = latestTodos.filter((t) => t.status !== "completed").slice(0, 10);
const recentChap = listChapters(db, agentId, sessionId, 3);
if (open.length === 0 && recentChap.length === 0) return undefined;
const lines: string[] = [];
if (recentChap.length > 0) {
lines.push("最近章节:");
for (const c of recentChap) lines.push(` • c.title(c.summary?.slice(0, 60) ?? "")`);
}
if (open.length > 0) {
lines.push("");
lines.push("未完成 todo:");
for (const t of open) lines.push(` • [t.status] t.content.slice(0, 100)`);
}
const body = lines.join("\n");
storeMemory(
db,
agentId,
"decision",
`[lifecycle:reset] reset 前抢救(new Date().toISOString().slice(0, 19)):\nbody`,
"reset-rescue", // 不进黑名单 — 用户下次新 session 会查
6, // reset 前抢救偏重要(用户即将清空状态)
sessionId,
{
why: "reset 后这些信息可能丢失,提前固化",
howToApply: "下次新会话用 enhance_memory_search [lifecycle:reset] 查看历史",
},
);
if (debug) api.logger.info(`[enhance-lifecycle] before_reset → 抢救 recentChap.length 章节 + open.length todo`);
notifyQueue.emit(
agentId,
"warn",
"config-doctor",
`enhance: 即将 reset,已固化 recentChap.length 章节 + open.length todo 到 decision memory`,
body.slice(0, 500),
);
} catch (err) {
api.logger.error(`[enhance-lifecycle] before_reset handler 错误: (err as Error).message`);
}
return undefined;
});
} catch {
// 静默
}
}
// ── Hook: subagent_spawned / subagent_ended ────────────────────────────
// 用途:spawn 链路追踪 — 每次派生子 agent 时自动加一个章节,结束时再加一个
// 跟 enhance_spawn_task 闭环(之前只返回 CLI 命令,现在也跟踪生命周期)
if (enableSubagent) {
try {
// subagent_spawned: ctx 字段是 { runId?, childSessionKey?, requesterSessionKey? }(无 agentId)
// event 字段是 { agentId, label?, mode, requester?, threadRequested, runId, childSessionKey }
api.on("subagent_spawned", (event, ctx) => {
try {
// ctx 没 agentId — 用 requesterSessionKey 当 sessionId 关联到父 session 的 chapter
const sessionId = (ctx?.requesterSessionKey ?? "") + "";
const child = event?.label ?? event?.agentId ?? "?";
const childAgentId = event?.agentId ?? DEFAULT_AGENT_ID;
const dedupKey = `spawn:childAgentId:sessionId:child`;
if (!shouldFire(dedupKey)) return;
addChapter(
db,
DEFAULT_AGENT_ID, // 父章节挂在 main agent(subagent ctx 拿不到 requester agentId)
sessionId,
`🤖 派生子 agent: child`,
event?.requester
? `mode=event.mode, channel=event.requester.channel ?? "?", runId=event.runId?.slice(0, 12) ?? "?"`
: "subagent_spawned hook 自动标记",
);
if (debug) api.logger.info(`[enhance-lifecycle] subagent_spawned → child`);
} catch (err) {
api.logger.error(`[enhance-lifecycle] subagent_spawned handler 错误: (err as Error).message`);
}
});
api.on("subagent_ended", (event, ctx) => {
try {
const sessionId = (ctx?.requesterSessionKey ?? event?.targetSessionKey ?? "") + "";
const child = event?.targetSessionKey?.slice(-12) ?? "?";
const dedupKey = `end-spawn:sessionId:child`;
if (!shouldFire(dedupKey)) return;
const outcome = event?.outcome ?? "ok";
const icon = outcome === "ok" ? "✅" : outcome === "error" ? "❌" : "⚠️";
addChapter(
db,
DEFAULT_AGENT_ID,
sessionId,
`icon 子 agent 结束: child`,
`outcome=outcome, reason=event?.reason?.slice(0, 100) ?? "?"`,
);
if (debug) api.logger.info(`[enhance-lifecycle] subagent_ended → child (outcome)`);
} catch (err) {
api.logger.error(`[enhance-lifecycle] subagent_ended handler 错误: (err as Error).message`);
}
});
} catch {
// 静默
}
}
api.logger.info(
`[enhance] 会话生命周期模块已加载(hooks: "",
enableSessionEnd ? "session_end" : "",
enableBeforeReset ? "before_reset" : "",
enableSubagent ? "subagent_spawned/ended" : "",
].filter(Boolean).join(" / "))`,
);
}
FILE:src/modules/session-recap.ts
/**
* 模块: 会话回顾(Session Recap)— 对齐 Claude Code 75-min idle auto-summary
*
* 触发条件(AND):
* 1. before_prompt_build 看到当前 agent/session 的上一次活动距现在 > recapIdleMinutes(默认 75)
* 2. 距离上次 recap 已超过 recapMinIntervalMinutes(默认 30)或本进程内从未 recap 过此 agent
*
* 恢复内容(prependContext 里追加):
* - 最近 1 个章节(title + summary)
* - 最近 3 条 in_progress/pending todo
* - 最近 2 条 decision 类记忆
* - 一句"你刚回来,上次到这儿"的引导
*
* 非侵入式保证:
* - 只读三张已有表(chapters / todos / memories),不建新表
* - 纯追加 prependContext,绝不覆盖龙虾原生 system prompt
* - 可通过 config.sessionRecap.enabled = false 关闭
* - 同时提供 enhance_session_recap 工具,用户可手动"给我做个回顾"
*/
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import type { OpenClawPluginToolContext } from "openclaw/plugin-sdk/core";
import { Type } from "@sinclair/typebox";
import { resolveOpenClawHome } from "../utils/resolve-home.js";
import { getDb, listChapters, getLatestTodos, searchMemories } from "../utils/sqlite-store.js";
import { DEFAULT_AGENT_ID } from "../types.js";
export interface SessionRecapConfig {
enabled?: boolean;
/** 距上次活动多久(分钟)就认为是 idle,默认 75 */
recapIdleMinutes?: number;
/** 两次 recap 的最小间隔(分钟),防抖,默认 30 */
recapMinIntervalMinutes?: number;
/** recap 里章节数,默认 1 */
maxChapters?: number;
/** recap 里 todo 数,默认 3 */
maxTodos?: number;
/** recap 里 decision 记忆数,默认 2 */
maxDecisions?: number;
}
function pickAgentId(ctx: { agentId?: string } | undefined): string {
return ((ctx?.agentId ?? DEFAULT_AGENT_ID).trim() || DEFAULT_AGENT_ID);
}
function pickSessionId(ctx: { sessionKey?: string; sessionId?: string } | undefined): string {
return ((ctx?.sessionKey ?? ctx?.sessionId ?? "") + "").trim();
}
function parseSqliteTs(s: string | undefined): number {
if (!s) return 0;
// SQLite datetime('now') 格式: "YYYY-MM-DD HH:MM:SS" (UTC)
const t = Date.parse(s.includes("T") ? s : s.replace(" ", "T") + "Z");
return isNaN(t) ? 0 : t;
}
function fmtGap(ms: number): string {
const min = Math.floor(ms / 60000);
if (min < 60) return `min 分钟前`;
const h = Math.floor(min / 60);
if (h < 24) return `h 小时前`;
return `Math.floor(h / 24) 天前`;
}
/**
* 构建一次 recap 文本;若没有任何素材返回空串(调用方可据此决定是否注入)。
*/
export function buildRecapText(
db: ReturnType<typeof getDb>,
agentId: string,
sessionId: string,
cfg: Required<Pick<SessionRecapConfig, "maxChapters" | "maxTodos" | "maxDecisions">>,
idleMs: number,
): string {
const lines: string[] = [];
const chapters = listChapters(db, agentId, sessionId || undefined, cfg.maxChapters);
const todos = getLatestTodos(db, agentId).filter((t) => t.status === "in_progress" || t.status === "pending").slice(0, cfg.maxTodos);
const decisions = searchMemories(db, agentId, { category: "decision", limit: cfg.maxDecisions });
if (chapters.length === 0 && todos.length === 0 && decisions.length === 0) {
return "";
}
lines.push(`【会话回顾】你上次活动是 fmtGap(idleMs),帮你把关键状态拉回来:`);
lines.push("");
if (chapters.length > 0) {
lines.push("— 最近章节 —");
for (const ch of chapters) {
lines.push(` · #ch.id ch.titlech.summary ? ` — ${ch.summary` : ""}`);
}
lines.push("");
}
if (todos.length > 0) {
lines.push("— 待办 —");
for (const t of todos) {
const status = t.status === "in_progress" ? "🔄" : "⏳";
lines.push(` status t.content`);
}
lines.push("");
}
if (decisions.length > 0) {
lines.push("— 关键决定 —");
for (const d of decisions) {
const text = (d.content ?? "").trim();
lines.push(` · text.slice(0, 80)""`);
}
lines.push("");
}
lines.push("(本回顾由 @huo15/openclaw-enhance session-recap 自动生成;若不需要可 config.sessionRecap.enabled=false)");
return lines.join("\n");
}
/**
* 查当前 agent/session 最近一次活动时间(取三张表里最大 updated_at / created_at 作代理)
*/
function getLastActivityMs(
db: ReturnType<typeof getDb>,
agentId: string,
sessionId: string,
): number {
const rows = db
.prepare(
`SELECT MAX(ts) AS ts FROM (
SELECT MAX(updated_at) AS ts FROM memories WHERE agent_id = ?
UNION ALL
SELECT MAX(created_at) AS ts FROM chapters WHERE agent_id = ?""
UNION ALL
SELECT MAX(updated_at) AS ts FROM todos WHERE agent_id = ?
)`,
)
.all(...(sessionId ? [agentId, agentId, sessionId, agentId] : [agentId, agentId, agentId])) as Array<{ ts: string | null }>;
return parseSqliteTs(rows[0]?.ts ?? undefined);
}
export function registerSessionRecap(api: OpenClawPluginApi, config?: SessionRecapConfig) {
if (config?.enabled === false) return;
const openclawDir = resolveOpenClawHome(api);
const db = getDb(openclawDir);
const idleMinutes = config?.recapIdleMinutes ?? 75;
const minIntervalMinutes = config?.recapMinIntervalMinutes ?? 30;
const maxChapters = config?.maxChapters ?? 1;
const maxTodos = config?.maxTodos ?? 3;
const maxDecisions = config?.maxDecisions ?? 2;
// 进程内防抖表:agentId + sessionId → lastRecapAt(ms)
// v5.7.2: 加 LRU cap 防止跨 session 永不清造成内存泄漏
const lastRecapAt = new Map<string, number>();
const MAX_RECAP_ENTRIES = 500;
// ── Hook: before_prompt_build ── 自动 recap
// v5.7.8: typed via openclaw 4.24 SDK
api.on("before_prompt_build", (_event, ctx) => {
const agentId = pickAgentId(ctx);
const sessionId = pickSessionId(ctx);
const key = `agentId::sessionId`;
const now = Date.now();
const lastActivity = getLastActivityMs(db, agentId, sessionId);
if (!lastActivity) return;
const idleMs = now - lastActivity;
if (idleMs < idleMinutes * 60_000) return;
const lastRecap = lastRecapAt.get(key) ?? 0;
if (now - lastRecap < minIntervalMinutes * 60_000) return;
const text = buildRecapText(
db,
agentId,
sessionId,
{ maxChapters, maxTodos, maxDecisions },
idleMs,
);
if (!text) return;
// LRU eviction: 重新插入 key 让活跃 session 不被淘汰
if (lastRecapAt.has(key)) lastRecapAt.delete(key);
while (lastRecapAt.size >= MAX_RECAP_ENTRIES) {
const oldest = lastRecapAt.keys().next().value;
if (oldest === undefined) break;
lastRecapAt.delete(oldest);
}
lastRecapAt.set(key, now);
return { prependContext: text };
});
// ── Tool: enhance_session_recap ── 手动触发
api.registerTool(
((ctx: OpenClawPluginToolContext) => ({
name: "enhance_session_recap",
description: "手动生成会话回顾(章节+待办+关键决定)",
parameters: Type.Object({
scope: Type.Optional(
Type.Union([Type.Literal("session"), Type.Literal("agent")], {
description: "session(默认)|agent",
}),
),
}),
async execute(_id: string, params: Record<string, unknown>) {
const agentId = pickAgentId(ctx);
const sessionId = pickSessionId(ctx);
const scope = params.scope === "agent" ? "" : sessionId;
const now = Date.now();
const lastActivity = getLastActivityMs(db, agentId, scope);
const idleMs = lastActivity ? now - lastActivity : 0;
const text = buildRecapText(
db,
agentId,
scope,
{ maxChapters, maxTodos, maxDecisions },
idleMs,
);
if (!text) {
return {
content: [{ type: "text" as const, text: "暂无可回顾的内容(没有章节/待办/决定记忆)。" }],
};
}
return { content: [{ type: "text" as const, text }] };
},
})) as any,
{ name: "enhance_session_recap" },
);
}
FILE:src/modules/skill-doctor.ts
/**
* 模块: 技能巡检(enhance_skill_doctor)
*
* 龙虾原生技能(skills)存在于 `{openclawHome}/workspace/skills/` 下。
* 当本插件通过 ClawHub 安装 huo15-openclaw-{explore,memory,plan,verify}-mode 到 workspace,
* 用户容易碰到:技能目录缺失 / 残缺 / 未更新。
*
* 本工具只做"只读诊断+提示安装命令",不自动重新安装(避免 cold path 上擅自动用 clawhub)。
* 真正安装由 index.ts 的 startup 流程统一处理。
*/
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import type { OpenClawPluginToolContext } from "openclaw/plugin-sdk/core";
import { Type } from "@sinclair/typebox";
import { existsSync, statSync, readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { resolveOpenClawHome } from "../utils/resolve-home.js";
const EXPECTED_SKILLS = [
"huo15-openclaw-explore-mode",
"huo15-openclaw-memory-curator",
"huo15-openclaw-plan-mode",
"huo15-openclaw-verify-mode",
"huo15-openclaw-frontend-design",
"huo15-openclaw-design-director",
"huo15-openclaw-brand-protocol",
"huo15-openclaw-design-critique",
"huo15-openclaw-simplify",
"huo15-openclaw-security-review",
"huo15-openclaw-code-review",
];
interface SkillInfo {
id: string;
path: string;
installed: boolean;
hasSkillMd: boolean;
version?: string;
name?: string;
}
function inspect(skillsDir: string, id: string): SkillInfo {
const path = join(skillsDir, id);
if (!existsSync(path) || !statSync(path).isDirectory()) {
return { id, path, installed: false, hasSkillMd: false };
}
const entries = readdirSync(path);
const manifestName = entries.find((f) => /^skill\.(md|json|yaml|yml)$/i.test(f));
let version: string | undefined;
let name: string | undefined;
if (manifestName) {
try {
const raw = readFileSync(join(path, manifestName), "utf8");
const versionMatch = raw.match(/version:\s*["']?([\d.]+)/i);
const nameMatch = raw.match(/name:\s*["']?([^"'\n]+)/i);
version = versionMatch?.[1];
name = nameMatch?.[1]?.trim();
} catch {
// ignore
}
}
return { id, path, installed: true, hasSkillMd: !!manifestName, version, name };
}
export function registerSkillDoctor(api: OpenClawPluginApi) {
api.registerTool(
((_ctx: OpenClawPluginToolContext) => ({
name: "enhance_skill_doctor",
description: "巡检 enhance 11 个配套技能是否齐全可读,输出修复建议",
parameters: Type.Object({
workspace: Type.Optional(Type.String({ description: "workspace 目录" })),
}),
async execute(_id: string, params: Record<string, unknown>) {
const openclawHome = resolveOpenClawHome(api);
const workspace = (params.workspace as string | undefined)?.trim() || join(openclawHome, "workspace");
const skillsDir = join(workspace, "skills");
if (!existsSync(skillsDir)) {
return {
content: [
{
type: "text" as const,
text: [
`❌ 技能目录不存在: skillsDir`,
"",
"修复建议:",
` mkdir -p "skillsDir"`,
...EXPECTED_SKILLS.map((id) => ` clawhub install id --dir "skillsDir"`),
].join("\n"),
},
],
};
}
const results = EXPECTED_SKILLS.map((id) => inspect(skillsDir, id));
const missing = results.filter((r) => !r.installed);
const broken = results.filter((r) => r.installed && !r.hasSkillMd);
const ok = results.filter((r) => r.installed && r.hasSkillMd);
const lines: string[] = [`🔬 技能巡检 · workspace: workspace`, `目录: skillsDir`, ""];
if (ok.length) {
lines.push(`✓ 正常 (ok.length):`);
for (const s of ok) {
lines.push(` · s.ids.version ? ` @ ${s.version` : ""}s.name ? ` — ${s.name` : ""}`);
}
lines.push("");
}
if (broken.length) {
lines.push(`⚠️ 存在目录但缺少 SKILL.md/json (broken.length):`);
for (const s of broken) lines.push(` · s.id`);
lines.push("");
}
if (missing.length) {
lines.push(`✗ 未安装 (missing.length):`);
for (const s of missing) lines.push(` · s.id`);
lines.push("");
lines.push("修复建议(逐个重新安装):");
for (const s of missing) {
lines.push(` clawhub install s.id --dir "skillsDir"`);
}
}
if (!missing.length && !broken.length) {
lines.push("🎉 四个技能均正常。");
}
return { content: [{ type: "text" as const, text: lines.join("\n") }] };
},
})) as any,
{ name: "enhance_skill_doctor" },
);
api.logger.info("[enhance] 技能巡检模块已加载(enhance_skill_doctor)");
}
FILE:src/modules/skill-installer.ts
/**
* 模块: 技能安装器(enhance_install_skills)
*
* 设计原则:插件不执行任何外部进程,只返回可复制的 CLI 命令。
* - 某些企业扫描器会把"执行外部命令"相关的 Node.js 内置模块 import
* 标记为高危并整包拦截;改为"返回命令让用户自己跑"彻底绕开。
* - 与 scheduled-tasks-bridge / enhance_spawn_task 的 cliCmd 语义保持一致:
* 非侵入、可审计、可复制。
*/
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import type { OpenClawPluginToolContext } from "openclaw/plugin-sdk/core";
import { Type } from "@sinclair/typebox";
import { existsSync } from "node:fs";
import { join } from "node:path";
import { resolveOpenClawHome } from "../utils/resolve-home.js";
export const CLAW_HUB_SKILLS = [
"huo15-openclaw-explore-mode",
"huo15-openclaw-memory-curator",
"huo15-openclaw-plan-mode",
"huo15-openclaw-verify-mode",
// v5.4 新增:设计能力四件套(对标 Anthropic frontend-design + huashu-design 生态)
"huo15-openclaw-frontend-design",
"huo15-openclaw-design-director",
"huo15-openclaw-brand-protocol",
"huo15-openclaw-design-critique",
// v5.4.1 新增:开发辅助三件套(对标 Claude Code /simplify + /security-review + /review)
"huo15-openclaw-simplify",
"huo15-openclaw-security-review",
"huo15-openclaw-code-review",
];
export function registerSkillInstaller(api: OpenClawPluginApi) {
const openclawHome = resolveOpenClawHome(api);
const defaultSkillsDir = join(openclawHome, "workspace", "skills");
api.registerTool(
((_ctx: OpenClawPluginToolContext) => ({
name: "enhance_install_skills",
description: "返回安装 enhance 配套技能的 clawhub install CLI 命令(不执行)",
parameters: Type.Object({
dir: Type.Optional(
Type.String({
description: `安装目录,默认 defaultSkillsDir`,
}),
),
missingOnly: Type.Optional(
Type.Boolean({
description: "只生成未安装的,默认 true",
}),
),
}),
async execute(_id: string, params: Record<string, unknown>) {
const targetDir = String(params.dir ?? defaultSkillsDir);
const missingOnly = params.missingOnly !== false;
const status = CLAW_HUB_SKILLS.map((name) => ({
name,
installed: existsSync(join(targetDir, name)),
}));
const targets = missingOnly
? status.filter((s) => !s.installed).map((s) => s.name)
: CLAW_HUB_SKILLS;
if (targets.length === 0) {
return {
content: [
{
type: "text" as const,
text: `✓ 所有 4 个配套技能已在 targetDir 安装完毕,无需操作。`,
},
],
structuredContent: { dir: targetDir, status, targets: [], cliCmds: [] },
};
}
const cliCmds = targets.map(
(name) => `clawhub install name --dir JSON.stringify(targetDir)`,
);
const oneLiner = cliCmds.join(" && ");
const lines = [
`→ 复制以下命令到终端执行,即可安装 targets.length 个配套技能到:`,
` targetDir`,
``,
`一键执行(全部):`,
` oneLiner`,
``,
`分步执行:`,
...cliCmds.map((c) => ` c`),
``,
`当前状态:`,
...status.map((s) => ` "×" s.name`),
];
return {
content: [{ type: "text" as const, text: lines.join("\n") }],
structuredContent: {
dir: targetDir,
status,
targets,
cliCmds,
oneLiner,
},
};
},
})) as any,
{ name: "enhance_install_skills" },
);
api.logger.info("[enhance] 技能安装器已加载(enhance_install_skills,只返回命令不执行)");
}
FILE:src/modules/skill-recommender.ts
/**
* v5.7.5 Skill Recommender — 按用户需求挑已装 skill / 推荐未装 skill / 给自建规划
*
* 设计灵感来源:反编译 Claude Desktop(/Applications/Claude.app/...transcriptSearchWorker / loadSkills)
* 发现 Claude 的 skill auto-discovery 本质是 "Available skills: list." 注入到 system prompt 让 LLM
* 自己挑。我们换成"按需工具"避免每轮 prompt 占 schema:
*
* 1. 启动期扫所有 skill 路径,解析 SKILL.md frontmatter(name + description + aliases)
* 2. 工具 enhance_skill_recommend(query) 按 query 算相关度排序
* 3. 已装命中 < threshold → 列 ClawHub 上 enhance 自带的 11 个 huo15-* 候选 + 安装命令
* 4. ClawHub 也没合适的 → 给"自建 skill"规划(frontmatter 模板 + 触发关键词 + 内容大纲建议)
*
* 红线遵守:
* - 完全只读 skill 目录,不修改任何 SKILL.md
* - 不调 child_process(红线 #4),安装命令走 return-cliCmd 模式(红线 #5)
* - 自建规划只是文本建议,绝不在插件里内嵌 skill 内容(红线 #3)
*/
import { readFileSync, existsSync, readdirSync, statSync } from "node:fs";
import { join } from "node:path";
import { homedir } from "node:os";
import { Type } from "@sinclair/typebox";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import type { OpenClawPluginToolContext } from "openclaw/plugin-sdk/core";
import { resolveOpenClawHome } from "../utils/resolve-home.js";
import { CLAW_HUB_SKILLS } from "./skill-installer.js";
import type { SkillRecommenderConfig } from "../types.js";
interface SkillEntry {
slug: string;
name: string;
description: string;
aliases: string[];
path: string;
/** "openclaw" = ~/.openclaw/skills, "workspace" = workspace/skills, "project" = cwd/.claude/skills */
source: string;
}
/** 已知的 huo15-* skill metadata(fallback 用,避免依赖 ClawHub 在线查询)*/
const KNOWN_HUO15_SKILLS: Record<string, { description: string; aliases: string[] }> = {
"huo15-openclaw-plan-mode": {
description: "结构化规划模式 — 在执行复杂任务前先做系统性规划。对标 Claude Code Plan Agent",
aliases: ["plan", "规划", "计划", "plan mode"],
},
"huo15-openclaw-explore-mode": {
description: "深度探索模式 — 阅读理解大型代码库 / 文档,先扫面再深挖",
aliases: ["explore", "探索", "调研", "读代码"],
},
"huo15-openclaw-verify-mode": {
description: "验证检查模式 — 改完代码后系统性自检(typecheck / 测试 / 行为验证)",
aliases: ["verify", "验证", "测试", "自检"],
},
"huo15-openclaw-memory-curator": {
description: "记忆整理 — 周期性合并/去重/裁剪 enhance 三层记忆库",
aliases: ["memory", "记忆整理", "记忆清理"],
},
"huo15-openclaw-frontend-design": {
description: "高保真 Web UI 原型生成 — 5 美学流派 + 反 AI Slop 硬红线 + Playwright 自验证",
aliases: ["前端", "Web UI", "原型", "frontend", "design"],
},
"huo15-openclaw-design-director": {
description: "设计方向顾问 — 3 方向反差对比 + 强制推荐 + 五维矩阵",
aliases: ["设计方向", "设计选型", "design director"],
},
"huo15-openclaw-brand-protocol": {
description: "品牌规范抓取 — Ask/Search/Download/Verify+Extract/Codify 5 步流程",
aliases: ["品牌", "brand", "VI", "logo"],
},
"huo15-openclaw-design-critique": {
description: "5 维设计评审 — 美学/可用性/品牌一致/内容/实现 + Keep/Fix/Quick Wins",
aliases: ["设计评审", "设计审查", "design critique"],
},
"huo15-openclaw-simplify": {
description: "代码简化三维审查 — 复用 / 质量 / 效率 + 分级修复清单(🔴必改/🟡建议/🟢可选)",
aliases: ["simplify", "简化", "重构", "代码简化"],
},
"huo15-openclaw-security-review": {
description: "六类漏洞矩阵安全审查 — 密钥/注入/XSS/SSRF/权限/危险依赖 + CVSS 分级",
aliases: ["security", "安全审查", "漏洞", "security review"],
},
"huo15-openclaw-code-review": {
description: "PR 五维综合评审 — 设计/实现/测试/安全/可维护 + 可粘贴 markdown 评论",
aliases: ["code review", "review", "PR review", "代码审查"],
},
};
const STOP_WORDS = new Set([
"the", "a", "an", "is", "are", "and", "or", "to", "of", "in", "for", "on", "at",
"by", "with", "as", "this", "that", "these", "those",
"我", "你", "他", "她", "们", "的", "了", "是", "有", "和", "或",
"帮", "想", "要", "需要", "做", "用", "把", "给", "让",
]);
/** v5.7.5: CJK 字符检测 — JS \w 不含中日韩,得专门判 */
const CJK_RE = /[㐀-鿿豈-]/;
/** 提取 query 里的"语义子串":中文 2-3 字组合 + 英文单词 + 别名样式短语 */
function extractSemanticTokens(query: string): string[] {
const result = new Set<string>();
const lower = query.toLowerCase();
// 1. 英文 token(按 \W 拆,过滤 stop words)
for (const t of lower.split(/[^a-z0-9]+/)) {
if (t.length > 1 && !STOP_WORDS.has(t)) result.add(t);
}
// 2. CJK 连续字符串(一段连续中文当一个 phrase)
const cjkRuns = lower.match(/[㐀-鿿豈-]+/g) ?? [];
for (const run of cjkRuns) {
if (run.length >= 2) result.add(run);
// 双字滑动窗口:避免长 query 漏匹配("代码简化" 同时生成 "代码"+"简化"+"码简"...过宽)
// 折中:只取 2-grams 跟 3-grams 跟整串
if (run.length >= 4) {
for (let i = 0; i + 2 <= run.length; i++) {
const bigram = run.slice(i, i + 2);
if (!STOP_WORDS.has(bigram)) result.add(bigram);
}
}
}
return [...result];
}
/** 简单关键词重叠 + 别名加权评分 */
function scoreSkill(skill: { description: string; aliases: string[]; slug: string }, query: string): number {
const tokens = extractSemanticTokens(query);
if (tokens.length === 0) return 0;
const haystack = (
skill.slug.toLowerCase() +
" " +
skill.description.toLowerCase() +
" " +
skill.aliases.join(" ").toLowerCase()
);
let keywordMatches = 0;
let aliasBonus = 0;
let exactAliasHit = false;
for (const t of tokens) {
if (haystack.includes(t)) keywordMatches++;
// alias 完整命中加分(中英对照场景:query "代码简化" → alias "simplify"/"简化")
for (const a of skill.aliases) {
const al = a.toLowerCase();
if (!al) continue;
// exact alias 等于 token / token 等于 exact alias —— 强信号("规划" 严格 = "规划" alias)
if (al === t) exactAliasHit = true;
if (al.includes(t) || (t.length >= 2 && t.includes(al))) aliasBonus += 0.15;
}
}
const tokenScore = tokens.length > 0 ? keywordMatches / tokens.length : 0;
// 整短语命中(整 query 直接出现在 description / aliases 里)— 强信号
let phraseBonus = 0;
const queryLower = query.toLowerCase().trim();
if (queryLower.length >= 2 && haystack.includes(queryLower)) phraseBonus = 0.3;
let score = tokenScore * 0.6 + aliasBonus + phraseBonus;
// exact alias 命中保底 0.7(典型场景:query "规划XX" → alias "规划" 严格命中)
if (exactAliasHit) score = Math.max(score, 0.7);
return Math.min(1, score);
}
/** 解析 SKILL.md 的 YAML frontmatter(轻量正则,不引入 yaml 依赖) */
function parseFrontmatter(content: string): { name?: string; description?: string; aliases?: string[] } {
const match = content.match(/^---\n([\s\S]*?)\n---/);
if (!match) return {};
const fm = match[1];
const result: { name?: string; description?: string; aliases?: string[] } = {};
const nameMatch = fm.match(/^name:\s*(.+)$/m);
if (nameMatch) result.name = nameMatch[1].trim().replace(/^["']|["']$/g, "");
// description 可能跨多行("..." 或 缩进续行),抓双引号包裹的或单行
const descMatch = fm.match(/^description:\s*"([^"]+)"|^description:\s*(.+)$/m);
if (descMatch) result.description = (descMatch[1] ?? descMatch[2] ?? "").trim();
// aliases 是 yaml list
const aliasesIdx = fm.indexOf("aliases:");
if (aliasesIdx >= 0) {
const tail = fm.slice(aliasesIdx);
const aliasLines = tail.match(/\n\s*-\s*(.+)/g) ?? [];
result.aliases = aliasLines.map((l) => l.replace(/^\n\s*-\s*/, "").trim().replace(/^["']|["']$/g, ""));
}
return result;
}
/** 扫一个目录下的所有 skill 子目录 */
function scanSkillDir(dir: string, source: string): SkillEntry[] {
if (!existsSync(dir)) return [];
const entries: SkillEntry[] = [];
let names: string[];
try {
names = readdirSync(dir);
} catch {
return [];
}
for (const name of names) {
const sp = join(dir, name);
let isDir = false;
try {
isDir = statSync(sp).isDirectory();
} catch {
continue;
}
if (!isDir) continue;
const skillMd = join(sp, "SKILL.md");
if (!existsSync(skillMd)) continue;
let content = "";
try {
content = readFileSync(skillMd, "utf-8");
} catch {
continue;
}
const fm = parseFrontmatter(content);
if (!fm.name) continue; // 不是有效 skill
entries.push({
slug: fm.name,
name: fm.name,
description: fm.description ?? "",
aliases: fm.aliases ?? [],
path: sp,
source,
});
}
return entries;
}
/** 扫描所有可能的 skill 路径,包括 WeCom 多 agent 动态 workspace */
export function discoverInstalledSkills(openclawDir: string): SkillEntry[] {
const seen = new Set<string>();
const result: SkillEntry[] = [];
const candidates: Array<[string, string]> = [
[join(openclawDir, "skills"), "openclaw"],
[join(openclawDir, "workspace", "skills"), "workspace"],
[join(process.cwd(), ".claude", "skills"), "project"],
[join(homedir(), ".claude", "skills"), "user"],
];
// v5.7.5: 扫 ~/.openclaw/workspace-*/skills 和 agents/*/skills(WeCom / DingTalk 动态 agent 隔离)
// 这些路径的 skills 跨 agent 共享但每个 agent workspace 独立,扫所有去重即可
if (existsSync(openclawDir)) {
try {
for (const entry of readdirSync(openclawDir)) {
if (!entry.startsWith("workspace-") && entry !== "agents") continue;
const sub = join(openclawDir, entry);
try {
if (!statSync(sub).isDirectory()) continue;
} catch {
continue;
}
if (entry === "agents") {
// ~/.openclaw/agents/<agentId>/skills
try {
for (const agentName of readdirSync(sub)) {
const skillsDir = join(sub, agentName, "skills");
candidates.push([skillsDir, `agent:agentName`]);
}
} catch {
/* 忽略权限错误 */
}
} else {
// ~/.openclaw/workspace-<id>/skills
candidates.push([join(sub, "skills"), `workspace:entry.slice(10, 30)`]);
}
}
} catch {
/* 静默 */
}
}
for (const [dir, source] of candidates) {
for (const e of scanSkillDir(dir, source)) {
if (seen.has(e.slug)) continue;
seen.add(e.slug);
result.push(e);
}
}
return result;
}
interface RecommendOptions {
installedSkills: SkillEntry[];
query: string;
limit: number;
installedThreshold: number;
includeUninstalled: boolean;
includePlanning: boolean;
openclawDir: string;
}
function recommend(opts: RecommendOptions): string {
const lines: string[] = [];
// ── 1. 已装命中 ──
const installedScored = opts.installedSkills
.map((s) => ({ skill: s, score: scoreSkill(s, opts.query) }))
.filter((x) => x.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, opts.limit);
const goodHits = installedScored.filter((x) => x.score >= opts.installedThreshold);
if (goodHits.length > 0) {
lines.push(`🎯 推荐已装 skill(按相关度):\n`);
for (const { skill, score } of goodHits) {
const aliasHint = skill.aliases.length > 0 ? `(别名:skill.aliases.slice(0, 3).join(" / "))` : "";
lines.push(` (score).toFixed(2) skill.slugaliasHint`);
if (skill.description) lines.push(` skill.description.slice(0, 200)`);
lines.push(` 召唤:在对话里说 "用 skill.aliases[0] ?? skill.slug trimQuery(opts.query, 40)"`);
lines.push("");
}
}
// ── 2. ClawHub 上未装的 huo15-* 候选 ──
if (opts.includeUninstalled) {
const installedSlugs = new Set(opts.installedSkills.map((s) => s.slug));
const uninstalled = CLAW_HUB_SKILLS.filter((slug) => !installedSlugs.has(slug)).map((slug) => ({
slug,
meta: KNOWN_HUO15_SKILLS[slug] ?? { description: "", aliases: [] },
}));
const matched = uninstalled
.map(({ slug, meta }) => ({
slug,
meta,
score: scoreSkill({ slug, description: meta.description, aliases: meta.aliases }, opts.query),
}))
.filter((x) => x.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, opts.limit);
if (matched.length > 0) {
lines.push(`📦 ClawHub 上可装但还没装的 huo15-* skill:\n`);
for (const { slug, meta, score } of matched) {
lines.push(` score.toFixed(2) slug`);
if (meta.description) lines.push(` meta.description`);
lines.push(` 安装:openclaw skills install slug`);
lines.push("");
}
}
}
// ── 3. 都没合适 → 自建规划 ──
const noGoodHit = goodHits.length === 0;
if (opts.includePlanning && noGoodHit) {
lines.push(`🛠️ 没找到完全匹配的 skill — 给你一份自建规划:\n`);
const proposedSlug = proposeSlug(opts.query);
const triggers = proposeTriggers(opts.query);
lines.push(`建议新 skill 名:proposedSlug`);
lines.push(`建议 SKILL.md frontmatter:`);
lines.push("```yaml");
lines.push("---");
lines.push(`name: proposedSlug`);
lines.push(`displayName: proposeDisplayName(opts.query)`);
lines.push(`description: "proposeDescription(opts.query)"`);
lines.push(`version: 1.0.0`);
if (triggers.length > 0) {
lines.push(`aliases:`);
for (const t of triggers.slice(0, 5)) lines.push(` - t`);
}
lines.push("---");
lines.push("```");
lines.push("");
lines.push("内容大纲建议(参考 huo15-openclaw-* 系列结构):");
lines.push(" 1. 简介:什么场景下召唤本 skill");
lines.push(" 2. 触发关键词 + 反触发词(避免误召唤)");
lines.push(" 3. 主流程:3-5 步骤化的执行框架");
lines.push(" 4. 硬红线(禁做清单)");
lines.push(" 5. 输出格式(用户能直接复制的格式)");
lines.push(" 6. 实例(1-2 个 before/after 对比)");
lines.push("");
lines.push("**⚠️ 红线 #3(用户硬约束)**:必须先在 huo15-skills 本地仓库写好 → clawhub publish 发到 ClawHub → 再让 enhance 的 skill-installer.ts CLAW_HUB_SKILLS 引用 slug。**插件代码绝不内嵌 skill 内容**。");
lines.push("");
lines.push("操作步骤:");
lines.push(` 1. cd ~/workspace/projects/openclaw/huo15-skills && mkdir proposedSlug`);
lines.push(` 2. 把上面的 frontmatter + 内容大纲写到 proposedSlug/SKILL.md`);
lines.push(` 3. CLAWHUB_TOKEN=clh_<TOKEN> clawhub publish ./proposedSlug --version 1.0.0`);
lines.push(` 4. 编辑 huo15-openclaw-enhance/src/modules/skill-installer.ts,把 "proposedSlug" 加到 CLAW_HUB_SKILLS`);
lines.push(` 5. enhance bump 版本 → 发 npm + ClawHub`);
}
if (lines.length === 0) {
lines.push(`未找到与 "trimQuery(opts.query, 60)" 相关的 skill。`);
lines.push(`可以试试:`);
lines.push(` - 改用更具体的关键词(如 "代码 review" / "Web UI 设计" / "安全审查")`);
lines.push(` - 用 enhance_skill_doctor 看完整已装清单`);
lines.push(` - 加 includePlanning=true 让我给你一份新 skill 规划`);
}
return lines.join("\n");
}
function trimQuery(q: string, max: number): string {
return q.length > max ? q.slice(0, max) + "…" : q;
}
function proposeSlug(query: string): string {
// 提取英文关键词;中文场景留空 placeholder
const en = query.toLowerCase().replace(/[^a-z0-9\s]/g, "").split(/\s+/).filter((w) => w && !STOP_WORDS.has(w)).slice(0, 3).join("-");
return en ? `huo15-openclaw-en` : `huo15-openclaw-<your-topic>`;
}
function proposeDisplayName(query: string): string {
const trimmed = trimQuery(query, 30).replace(/^["']|["']$/g, "");
return `火一五·trimmed 技能`;
}
function proposeDescription(query: string): string {
return `当用户说"trimQuery(query, 40)"时召唤本 skill — 提供对应场景的最佳实践框架`;
}
function proposeTriggers(query: string): string[] {
return query
.split(/[\s\W]+/)
.filter((w) => w.length > 1 && !STOP_WORDS.has(w.toLowerCase()))
.slice(0, 8);
}
export function registerSkillRecommender(api: OpenClawPluginApi, config?: SkillRecommenderConfig) {
const openclawDir = resolveOpenClawHome(api);
// 启动期扫一次(缓存到内存,每次工具调用刷新若文件变更)
let cache = discoverInstalledSkills(openclawDir);
let cacheTime = Date.now();
const cacheTtlMs = (config?.cacheTtlSec ?? 60) * 1000;
function getInstalled(): SkillEntry[] {
if (Date.now() - cacheTime > cacheTtlMs) {
cache = discoverInstalledSkills(openclawDir);
cacheTime = Date.now();
}
return cache;
}
api.logger.info(`[enhance-skill-recommender] 启动期扫到 cache.length 个已装 skill`);
api.registerTool(
((_ctx: OpenClawPluginToolContext) => ({
name: "enhance_skill_recommend",
description: "按用户需求挑已装 skill / 推荐未装 huo15-* / 给自建规划。结合三种结果按相关度排序",
parameters: Type.Object({
query: Type.String({ description: "用户需求文本,如 '帮我 review 这个 PR' / '设计一个 Web UI'" }),
limit: Type.Optional(Type.Number({ description: "每段最多返回几条,默认 5", minimum: 1, maximum: 20 })),
includeUninstalled: Type.Optional(
Type.Boolean({ description: "是否包含 ClawHub 上未装的 huo15-* 候选,默认 true" }),
),
includePlanning: Type.Optional(
Type.Boolean({ description: "命中 < 阈值时是否给自建 skill 规划,默认 true" }),
),
}),
async execute(_id: string, params: Record<string, unknown>) {
const query = String(params.query ?? "").trim();
if (!query) {
return { content: [{ type: "text" as const, text: "❌ query 必填" }] };
}
const text = recommend({
installedSkills: getInstalled(),
query,
limit: Math.max(1, Math.min(20, Number(params.limit ?? 5))),
installedThreshold: config?.installedThreshold ?? 0.25,
includeUninstalled: params.includeUninstalled !== false,
includePlanning: params.includePlanning !== false,
openclawDir,
});
return { content: [{ type: "text" as const, text }] };
},
})) as any,
{ name: "enhance_skill_recommend" },
);
}
FILE:src/modules/spawn-task.ts
/**
* 模块: enhance_spawn_task — Claude-Code 风格的"离线 TODO 孵化器"
*
* 与 Claude Code 的 spawn_task 相比:
* - Claude Code 的 spawn_task 能真正开一个新会话/worktree 去跑。
* - 龙虾没有等价原语(子 Agent 是同 session 并发执行),所以我们不假装能"真的孵化",
* 而是把提案写到 enhance 的"延期任务"记忆条目里(category: project, tags: spawn-task),
* 并通过通知提示用户手动派发(可以配合 cron-cli 或手动开新 session)。
* - 同时在 dashboard 展示最近孵化任务列表,方便用户一键拷贝提示词。
*
* 这样我们既对齐了 Claude Code 的语义("这件事值得做,但不应该污染当前上下文"),
* 又不虚假地向用户承诺龙虾没有的能力。
*/
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import type { OpenClawPluginToolContext } from "openclaw/plugin-sdk/core";
import { Type } from "@sinclair/typebox";
import { resolveOpenClawHome } from "../utils/resolve-home.js";
import { getDb, storeMemory } from "../utils/sqlite-store.js";
import { DEFAULT_AGENT_ID } from "../types.js";
function pickAgentId(ctx: { agentId?: string } | undefined): string {
return ((ctx?.agentId ?? DEFAULT_AGENT_ID).trim() || DEFAULT_AGENT_ID);
}
function pickSessionId(ctx: { sessionKey?: string; sessionId?: string } | undefined): string {
return ((ctx?.sessionKey ?? ctx?.sessionId ?? "") + "").trim();
}
/** POSIX 单引号 shell-escape,安全嵌入多行 prompt。 */
function shellEscape(s: string): string {
return `'s.replace(/'/g, "'\\''")'`;
}
/** 生成一键派发的 CLI 命令(可直接复制到终端粘贴运行)。 */
function buildCliCmd(agentId: string, prompt: string, thinking: "off" | "low" | "medium" | "high" = "low"): string {
return `openclaw agent --agent agentId --thinking thinking --message shellEscape(prompt)`;
}
export function registerSpawnTask(api: OpenClawPluginApi) {
const openclawDir = resolveOpenClawHome(api);
const db = getDb(openclawDir);
api.registerTool(
((ctx: OpenClawPluginToolContext) => ({
name: "enhance_spawn_task",
description: "孵化新 session 执行的子任务(只记录,由用户决定是否新开会话)",
parameters: Type.Object({
title: Type.String({ description: "动词短语标题(<60 字)" }),
prompt: Type.String({ description: "新 session 的完整自包含提示词" }),
tldr: Type.Optional(Type.String({ description: "1-2 句摘要" })),
tags: Type.Optional(Type.String({ description: "逗号分隔标签" })),
targetAgent: Type.Optional(
Type.String({ description: "目标 agent id" }),
),
thinking: Type.Optional(
Type.Union([Type.Literal("off"), Type.Literal("low"), Type.Literal("medium"), Type.Literal("high")], {
description: "思考档,默认 low",
}),
),
}),
async execute(_id: string, params: Record<string, unknown>) {
const currentAgentId = pickAgentId(ctx);
const sessionId = pickSessionId(ctx);
const title = String(params.title ?? "").trim();
const prompt = String(params.prompt ?? "").trim();
const tldr = String(params.tldr ?? "").trim();
const extraTags = String(params.tags ?? "").trim();
const targetAgent = String(params.targetAgent ?? "").trim() || currentAgentId;
const thinking = (params.thinking as "off" | "low" | "medium" | "high" | undefined) ?? "low";
if (!title || !prompt) {
return {
content: [{ type: "text" as const, text: "title 和 prompt 均必填。" }],
};
}
const tags = ["spawn-task", ...(extraTags ? extraTags.split(",").map((t) => t.trim()).filter(Boolean) : [])].join(",");
const cliCmd = buildCliCmd(targetAgent, prompt, thinking);
const content = [
`【孵化子任务】title`,
tldr ? `TL;DR: tldr` : "",
`目标 Agent: targetAgent · 思考档: thinking`,
`CLI: cliCmd`,
`Prompt:`,
prompt,
]
.filter(Boolean)
.join("\n");
const entry = storeMemory(db, currentAgentId, "project", content, tags, 7, sessionId, {
howToApply: `在终端运行 \`cliCmd\` 即可派发该子任务到 agent=targetAgent。`,
});
return {
content: [
{
type: "text" as const,
text: [
`✓ 已孵化子任务 #entry.id:title`,
tldr ? ` tldr` : "",
` 一键派发(复制到终端执行):`,
` cliCmd`,
` 或在仪表盘 "子任务" 标签查看;也可直接新开 session 粘贴 Prompt。`,
]
.filter(Boolean)
.join("\n"),
},
],
structuredContent: {
id: entry.id,
title,
tldr,
prompt,
targetAgent,
thinking,
cliCmd,
},
};
},
})) as any,
{ name: "enhance_spawn_task" },
);
api.logger.info("[enhance] 子任务派发模块已加载(enhance_spawn_task)");
}
FILE:src/modules/statusline.ts
/**
* 模块: 状态栏
*
* 龙虾的状态展示通常依赖 CLI/Control UI,缺少一个"一眼看全插件增强信号"的入口。
* 本模块做两件事:
* 1. 暴露 enhance_statusline 工具:模型/用户随时查询当前模式、任务、记忆命中、宠物。
* 2. 挂 HTTP 路由 /plugins/enhance/api/statusline,JSON 快照,方便 Control UI 嵌入。
*
* 纯只读,绝不影响龙虾运行状态。
*/
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import type { OpenClawPluginToolContext } from "openclaw/plugin-sdk/core";
import { Type } from "@sinclair/typebox";
import type Database from "better-sqlite3";
import {
getLatestTodos,
listChapters,
getMemoryStats,
getOrCreatePet,
listScheduledBindings,
} from "../utils/sqlite-store.js";
import type { NotificationQueue, TodoEntry } from "../types.js";
import { DEFAULT_AGENT_ID } from "../types.js";
import { getCurrentMode } from "./mode-gate.js";
/** Observability bits read from ctx / runtimeConfig at tool-call time. */
export interface ObservabilityContext {
model?: string;
thinking?: string;
fastMode?: boolean;
channel?: string;
sessionId?: string;
}
function pickObservability(ctx: unknown): ObservabilityContext {
const c = ctx as {
runtimeConfig?: { agents?: { defaults?: any; list?: any[] } };
agentId?: string;
sessionId?: string;
sessionKey?: string;
messageChannel?: string;
} | undefined;
const agentId = c?.agentId;
const agents = c?.runtimeConfig?.agents;
const defaults = agents?.defaults;
const cfg = agents?.list?.find((a: { id?: string }) => a?.id === agentId) ?? defaults ?? {};
const modelCfg = cfg?.model ?? defaults?.model;
const modelStr = typeof modelCfg === "string"
? modelCfg
: (modelCfg?.primary ?? undefined);
return {
model: modelStr,
thinking: cfg?.thinkingDefault ?? defaults?.thinkingDefault,
fastMode: cfg?.fastModeDefault ?? defaults?.fastModeDefault,
channel: c?.messageChannel,
sessionId: c?.sessionId ?? c?.sessionKey,
};
}
function pickAgentId(ctx: { agentId?: string } | undefined): string {
return ((ctx?.agentId ?? DEFAULT_AGENT_ID).trim() || DEFAULT_AGENT_ID);
}
function pickSessionId(ctx: { sessionKey?: string; sessionId?: string } | undefined): string {
return ((ctx?.sessionKey ?? ctx?.sessionId ?? "") + "").trim();
}
function summariseTodos(todos: TodoEntry[]): { done: number; total: number; active?: string } {
const done = todos.filter((t) => t.status === "completed").length;
const active = todos.find((t) => t.status === "in_progress")?.content;
return { done, total: todos.length, active };
}
interface Snapshot {
agentId: string;
mode: string;
todos: { done: number; total: number; active?: string };
chaptersToday: number;
memory: { total: number; byCategory: Record<string, number> };
unreadNotifications: number;
pet: { name: string; level: number; color: string; mood: string };
scheduledBindings: number;
/** Observability (runtime-only; undefined if not resolvable). */
observability?: ObservabilityContext;
}
export function buildSnapshot(
db: Database.Database,
agentId: string,
sessionId: string,
notifyQueue: NotificationQueue,
observability?: ObservabilityContext,
): Snapshot {
const todos = getLatestTodos(db, agentId);
const chapters = listChapters(db, agentId, undefined, 200).filter((c) => {
const created = new Date(c.created_at).getTime();
return Date.now() - created < 24 * 60 * 60 * 1000;
});
const memoryStats = getMemoryStats(db, agentId);
const totalMem = memoryStats.total ?? 0;
const byCat: Record<string, number> = {};
for (const [k, v] of Object.entries(memoryStats)) {
if (k !== "total") byCat[k] = v;
}
const pet = getOrCreatePet(db, agentId);
const scheduled = listScheduledBindings(db, agentId).filter((b) => b.enabled === 1);
return {
agentId,
mode: getCurrentMode(agentId, sessionId),
todos: summariseTodos(todos),
chaptersToday: chapters.length,
memory: { total: totalMem, byCategory: byCat },
unreadNotifications: notifyQueue.getUnreadCount(agentId),
pet: { name: pet.name, level: pet.level, color: pet.color, mood: pet.mood },
scheduledBindings: scheduled.length,
observability,
};
}
function renderLine(snap: Snapshot): string {
const parts = [
`agent=snap.agentId`,
`mode=snap.mode`,
`todos=snap.todos.done/snap.todos.total`,
];
if (snap.todos.active) parts.push(`▶ snap.todos.active`);
parts.push(`mem=snap.memory.total`);
parts.push(`chapters(24h)=snap.chaptersToday`);
if (snap.unreadNotifications > 0) parts.push(`notif=snap.unreadNotifications`);
parts.push(`🔥snap.pet.name·Lvsnap.pet.level`);
if (snap.scheduledBindings > 0) parts.push(`cron=snap.scheduledBindings`);
const obs = snap.observability;
if (obs?.model) parts.push(`model=obs.model`);
if (obs?.thinking && obs.thinking !== "off") parts.push(`think=obs.thinking`);
if (obs?.fastMode) parts.push(`fast`);
if (obs?.channel) parts.push(`ch=obs.channel`);
return parts.join(" · ");
}
export function registerStatusline(
api: OpenClawPluginApi,
db: Database.Database,
notifyQueue: NotificationQueue,
) {
// Tool
api.registerTool(
((ctx: OpenClawPluginToolContext) => ({
name: "enhance_statusline",
description: "查看 Agent/session 状态快照(模式、任务、记忆、宠物、通知)",
parameters: Type.Object({
format: Type.Optional(
Type.Union([Type.Literal("line"), Type.Literal("detail"), Type.Literal("json")], {
description: "line(默认)|detail|json",
}),
),
}),
async execute(_id: string, params: Record<string, unknown>) {
const agentId = pickAgentId(ctx);
const sessionId = pickSessionId(ctx);
const obs = pickObservability(ctx);
const snap = buildSnapshot(db, agentId, sessionId, notifyQueue, obs);
const format = (params.format as string | undefined) ?? "line";
if (format === "json") {
return { content: [{ type: "text" as const, text: JSON.stringify(snap, null, 2) }] };
}
if (format === "detail") {
const memLines = Object.entries(snap.memory.byCategory)
.map(([k, v]) => ` · k: v`)
.join("\n");
const obsLines: string[] = [];
if (snap.observability?.model) obsLines.push(`模型: snap.observability.model`);
if (snap.observability?.thinking) obsLines.push(`思考档: snap.observability.thinking`);
if (snap.observability?.fastMode) obsLines.push(`快速模式: 开`);
if (snap.observability?.channel) obsLines.push(`通道: snap.observability.channel`);
if (snap.observability?.sessionId) obsLines.push(`Session: snap.observability.sessionId.slice(0, 12)`);
const text = [
`📊 状态快照 (agent: snap.agentId)`,
`模式: snap.mode`,
`任务: snap.todos.done/snap.todos.totalsnap.todos.active ? ` · 进行中:${snap.todos.active` : ""}`,
`记忆: snap.memory.total 条`,
memLines,
`章节(24h): snap.chaptersToday`,
`未读通知: snap.unreadNotifications`,
`🔥 snap.pet.name(Lvsnap.pet.level·snap.pet.color·snap.pet.mood)`,
`定时任务: snap.scheduledBindings`,
...(obsLines.length > 0 ? [``, `— 可观测性 —`, ...obsLines] : []),
].join("\n");
return { content: [{ type: "text" as const, text }] };
}
return { content: [{ type: "text" as const, text: renderLine(snap) }] };
},
})) as any,
{ name: "enhance_statusline" },
);
api.logger.info("[enhance] 状态栏模块已加载(enhance_statusline;HTTP 端点由仪表盘路由 /plugins/enhance/api/statusline 提供)");
}
FILE:src/modules/structured-memory.ts
/**
* 模块1: 结构化记忆系统(多 Agent 隔离版)
*
* 每个动态 Agent(如 WeCom 用户/群组)拥有独立的记忆空间。
* 工具使用 OpenClawPluginToolFactory 模式,从 ctx.agentId 获取当前 Agent。
* 钩子从 ctx.agentId 获取当前 Agent。
*/
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import type { OpenClawPluginToolContext } from "openclaw/plugin-sdk/core";
import { Type } from "@sinclair/typebox";
import {
getDb,
storeMemory,
searchMemories,
getRecentMemories,
deleteMemory,
getMemoryStats,
purgeMemories,
} from "../utils/sqlite-store.js";
import { resolveOpenClawHome } from "../utils/resolve-home.js";
import { DEFAULT_AGENT_ID, type MemoryConfig, type MemoryCategory } from "../types.js";
const VALID_CATEGORIES: MemoryCategory[] = ["user", "project", "feedback", "reference", "decision"];
// v5.7.2: 跟 memory-integrator 的 TAG_BLACKLIST 保持一致 — store 时拒绝写入这些 tag
const RESERVED_TAGS = new Set([
"auto-compact",
"auto-checkpoint",
"audit",
"internal",
]);
function hasReservedTag(tags: string): string | null {
if (!tags) return null;
for (const t of tags.split(",")) {
const trimmed = t.trim().toLowerCase();
if (RESERVED_TAGS.has(trimmed)) return trimmed;
}
return null;
}
function resolveAgentId(ctx: OpenClawPluginToolContext): string {
return (ctx?.agentId ?? DEFAULT_AGENT_ID).trim();
}
export function registerStructuredMemory(api: OpenClawPluginApi, config?: MemoryConfig) {
const openclawDir = resolveOpenClawHome(api);
const db = getDb(openclawDir);
const maxCtx = config?.maxContextEntries ?? 5;
// ── Tool Factory: enhance_memory_store ──
api.registerTool( (
(ctx: OpenClawPluginToolContext) => ({
name: "enhance_memory_store",
description: "存储结构化记忆(按 Agent 隔离);规则/决策短条目,长文档走 kb-ingest",
parameters: Type.Object({
category: Type.Union(VALID_CATEGORIES.map((c) => Type.Literal(c)), {
description: "user|project|feedback|reference|decision",
}),
content: Type.String({ description: "记忆主体内容" }),
why: Type.Optional(
Type.String({ description: "为什么值得记住(背景/约束)" }),
),
howToApply: Type.Optional(
Type.String({ description: "何时/如何套用" }),
),
tags: Type.Optional(Type.String({ description: "逗号分隔标签" })),
importance: Type.Optional(
Type.Number({ description: "1-10,默认 5", minimum: 1, maximum: 10 }),
),
}),
async execute(_id: string, params: Record<string, unknown>) {
const agentId = resolveAgentId(ctx);
const tagsRaw = (params.tags as string) ?? "";
const reserved = hasReservedTag(tagsRaw);
if (reserved) {
return {
content: [
{
type: "text" as const,
text: `❌ 拒绝存储:tag "reserved" 是 enhance 保留的系统类标签([...RESERVED_TAGS].join(" / ")),不允许在用户决策记忆中使用。请改用业务相关 tag。`,
},
],
};
}
const entry = storeMemory(
db,
agentId,
params.category as MemoryCategory,
params.content as string,
tagsRaw,
(params.importance as number) ?? 5,
"",
{
why: params.why as string | undefined,
howToApply: params.howToApply as string | undefined,
},
);
return {
content: [
{
type: "text" as const,
text: `已存储记忆 #entry.id [entry.category] (agent: agentId): (params.content as string).slice(0, 80)...`,
},
],
};
},
}) as any),
{ name: "enhance_memory_store" },
);
// ── Tool Factory: enhance_memory_search ──
api.registerTool( (
(ctx: OpenClawPluginToolContext) => ({
name: "enhance_memory_search",
description: "搜索当前 Agent 的结构化记忆,可按分类/关键词筛选",
parameters: Type.Object({
category: Type.Optional(
Type.Union(VALID_CATEGORIES.map((c) => Type.Literal(c)), {
description: "分类筛选",
}),
),
keyword: Type.Optional(Type.String({ description: "关键词" })),
limit: Type.Optional(Type.Number({ description: "默认 10", default: 10 })),
}),
async execute(_id: string, params: Record<string, unknown>) {
const agentId = resolveAgentId(ctx);
const entries = searchMemories(db, agentId, {
category: params.category as MemoryCategory | undefined,
keyword: params.keyword as string | undefined,
limit: (params.limit as number) ?? 10,
});
if (entries.length === 0) {
return { content: [{ type: "text" as const, text: `未找到匹配的记忆 (agent: agentId)。` }] };
}
const lines = entries.map((e) => {
const body = [
`#e.id [e.category] (重要性:e.importance) e.created_at`,
` 内容: e.content`,
];
if (e.why && e.why.trim()) body.push(` 原因(Why): e.why.trim()`);
if (e.how_to_apply && e.how_to_apply.trim()) body.push(` 套用(How): e.how_to_apply.trim()`);
body.push(` 标签: e.tags || "无"`);
return body.join("\n");
});
return {
content: [{ type: "text" as const, text: `找到 entries.length 条记忆 (agent: agentId):\n\nlines.join("\n\n")` }],
};
},
}) as any),
{ name: "enhance_memory_search" },
);
// ── Tool Factory: enhance_memory_review ──
api.registerTool( (
(ctx: OpenClawPluginToolContext) => ({
name: "enhance_memory_review",
description: "查看 Agent 记忆统计/最近条目,或删除指定记忆",
parameters: Type.Object({
action: Type.Union([Type.Literal("stats"), Type.Literal("recent"), Type.Literal("delete")], {
description: "stats|recent|delete",
}),
id: Type.Optional(Type.Number({ description: "delete 必填的记忆 ID" })),
limit: Type.Optional(Type.Number({ description: "recent 条数,默认 10" })),
}),
async execute(_id: string, params: Record<string, unknown>) {
const agentId = resolveAgentId(ctx);
const action = params.action as string;
if (action === "stats") {
const stats = getMemoryStats(db, agentId);
const lines = Object.entries(stats).map(([k, v]) => `k: v`);
return { content: [{ type: "text" as const, text: `记忆统计 (agent: agentId):\nlines.join("\n")` }] };
}
if (action === "delete") {
const memId = params.id as number | undefined;
if (!memId) {
return { content: [{ type: "text" as const, text: "删除操作需要提供记忆 ID。" }] };
}
const ok = deleteMemory(db, agentId, memId);
return {
content: [{ type: "text" as const, text: ok ? `已删除记忆 #memId` : `未找到记忆 #memId(或不属于当前 Agent)` }],
};
}
// recent
const entries = getRecentMemories(db, agentId, (params.limit as number) ?? 10);
if (entries.length === 0) {
return { content: [{ type: "text" as const, text: `暂无记忆 (agent: agentId)。` }] };
}
const lines = entries.map((e) => {
const extras: string[] = [];
if (e.why && e.why.trim()) extras.push(`why="e.why.trim().slice(0, 60)"`);
if (e.how_to_apply && e.how_to_apply.trim()) extras.push(`how="e.how_to_apply.trim().slice(0, 60)"`);
const suffix = extras.length > 0 ? ` · extras.join(" · ")` : "";
return `#e.id [e.category] e.created_at: e.content.slice(0, 100)suffix`;
});
return {
content: [{ type: "text" as const, text: `最近 entries.length 条记忆 (agent: agentId):\nlines.join("\n")` }],
};
},
}) as any),
{ name: "enhance_memory_review" },
);
// ── 注意:不在 before_prompt_build 中注入记忆内容 ──
// openclaw 内置 memory-core / memory-lancedb 已通过 registerMemoryCapability
// 和 before_agent_start hook 注入记忆上下文。本插件不重复注入,
// 仅通过上面注册的 enhance_memory_* 工具提供结构化分类记忆的补充能力。
// ── Tool: enhance_memory_purge —— 按 tag/category 批量清理 ──
// 给 v5.7.1 hot-fix 使用:清掉之前 before_compaction hook 误存的 [auto-compact] 噪音
api.registerTool(
((ctx: OpenClawPluginToolContext) => ({
name: "enhance_memory_purge",
description: "按 tag 或 category 批量清理当前 Agent 的记忆(dry_run 默认 true,预览不删除)",
parameters: Type.Object({
tag: Type.Optional(Type.String({ description: "tag 子串匹配(LIKE %tag%)" })),
category: Type.Optional(
Type.Union(VALID_CATEGORIES.map((c) => Type.Literal(c)), {
description: "限定 category",
}),
),
contentLike: Type.Optional(
Type.String({ description: "content LIKE %?% 子串匹配" }),
),
dry_run: Type.Optional(Type.Boolean({ description: "默认 true,仅预览匹配条数" })),
}),
async execute(_id: string, params: Record<string, unknown>) {
const agentId = resolveAgentId(ctx);
const tag = params.tag as string | undefined;
const category = params.category as MemoryCategory | undefined;
const contentLike = params.contentLike as string | undefined;
const dryRun = params.dry_run !== false; // 默认 true 安全
if (!tag && !category && !contentLike) {
return {
content: [
{ type: "text", text: "❌ 必须至少传一个过滤条件:tag / category / contentLike" },
],
};
}
const result = purgeMemories(db, agentId, { tag, category, contentLike, dryRun });
const verb = dryRun ? "将匹配(未删除)" : "已删除";
return {
content: [
{
type: "text",
text: `verb result.matched 条记忆(agent=agentIdtag ? `, tag~"${tag"` : ""}category ? `, category=${category` : ""
}contentLike ? `, content~"${contentLike"` : ""})""`,
},
],
};
},
})) as any,
{ name: "enhance_memory_purge" },
);
api.logger.info("[enhance] 结构化记忆模块已加载(多 Agent 隔离,不干涉 openclaw 内置记忆)");
}
FILE:src/modules/task-planner.ts
/**
* 模块: 任务规划器(Task Planner)
*
* Tool: enhance_plan_task
* Hook: before_prompt_build
*
* 功能:
* - 将用户的复杂目标分解为可执行的子任务列表
* - 提供结构化的任务描述(目标、步骤、优先级、预计工时)
* - 在检测到"帮我做"/"规划"/"分析一下"等触发词时自动注入规划提示
*
* 对标 Claude Code "执行编排" 能力(Agent Harness 六层第3层)
*/
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import type { OpenClawPluginToolContext } from "openclaw/plugin-sdk/core";
import { Type } from "@sinclair/typebox";
// ── 类型 ──
export interface SubTask {
id: string;
title: string;
description: string;
priority: "high" | "medium" | "low";
estimatedMinutes?: number;
dependencies: string[]; // 依赖的子任务 id
status: "pending" | "in_progress" | "completed";
}
export interface TaskPlan {
goal: string;
tasks: SubTask[];
summary: string;
totalEstimatedMinutes?: number;
}
// ── 任务分解提示词 ──
const TASK_DECOMPOSITION_PROMPT = `你是一个专业的任务规划助手。请将用户的复杂目标分解为具体的可执行子任务。
分解原则:
1. 每个子任务应该是原子性的(单一职责)
2. 按执行顺序排列,考虑依赖关系
3. 为每个任务标注优先级:高(HIGH)/中(MEDIUM)/低(LOW)
4. 估计每个任务的耗时(分钟)
5. 识别跨agent协作机会(如需要写代码+测试+部署,拆分为独立任务)
输出格式(严格 JSON):
{
"goal": "用户目标的简短描述",
"tasks": [
{
"id": "task-1",
"title": "任务标题",
"description": "具体要做什么",
"priority": "HIGH|MEDIUM|LOW",
"estimatedMinutes": 30,
"dependencies": [],
"status": "pending"
}
],
"summary": "整体计划的一句话总结",
"totalEstimatedMinutes": 总分钟数
}`;
// ── 触发词 ──
const TRIGGER_PATTERNS = [
/帮我做/i,
/帮我处理/i,
/帮我完成/i,
/帮我规划/i,
/规划一下/i,
/计划一下/i,
/怎么实现/i,
/如何实现/i,
/如何做/i,
/分析一下/i,
/完整方案/i,
/具体步骤/i,
/执行方案/i,
/从零开始/i,
/整体方案/i,
/TODO/i,
/\(分解\)/i,
];
const REFLECTION_PATTERNS = [
/反思一下/i,
/回顾一下/i,
/总结一下/i,
/评估一下/i,
];
// ── 执行 ──
function extractGoalFromQuery(query: string): string {
// 去掉触发词,得到核心目标
let goal = query
.replace(/帮我做|帮我处理|帮我完成|帮我规划|规划一下|计划一下|怎么实现|如何实现|如何做|分析一下|完整方案|具体步骤|执行方案|从零开始|整体方案/g, "")
.trim();
return goal || query.slice(0, 100);
}
function generateTaskId(index: number): string {
return `task-String(index + 1).padStart(2, "0")`;
}
// ── 主模块 ──
export function registerTaskPlanner(api: OpenClawPluginApi) {
// ── Tool: enhance_plan_task ──
// 注意: 工厂函数类型在运行时通过 Jiti 解析,TS 类型仅为编译参考
// eslint-disable-next-line @typescript-eslint/no-explicit-any
api.registerTool( (
(ctx: OpenClawPluginToolContext) => ({
name: "enhance_plan_task",
description: "将复杂目标分解为可执行子任务列表(用于规划/分析/反思)",
parameters: Type.Object({
goal: Type.String({ description: "目标或问题描述" }),
constraints: Type.Optional(
Type.String({ description: "约束条件(时间/技术栈等)" }),
),
mode: Type.Optional(
Type.Union([Type.Literal("plan"), Type.Literal("analyze"), Type.Literal("reflect")], {
description: "plan|analyze|reflect",
default: "plan",
}),
),
}),
async execute(_id: string, params: Record<string, unknown>): Promise<any> {
const goal: string = (params.goal as string) ?? "";
const constraints: string = (params.constraints as string) ?? "";
const mode: string = (params.mode as string) ?? "plan";
if (!goal.trim()) {
return {
content: [
{
type: "text" as const,
text: "⚠️ 请提供目标描述。例如:`帮我完成 Odoo 数据库迁移`",
},
],
};
}
const agentId = (ctx as any)?.agentId ?? "main";
// 构建分解请求
const fullGoal = [goal, constraints].filter(Boolean).join("\n约束: ");
// 模拟任务分解(基于启发式规则,简单场景直接分解)
// 实际生产中可接入 LLM API 做真实分解
const tasks = decomposeGoal(fullGoal, mode);
// 渲染输出
const planText = renderTaskPlan(tasks, goal);
api.logger.info(`[enhance-taskplanner] 生成了 tasks.tasks.length 个子任务 (agent: agentId)`);
return {
content: [{ type: "text" as const, text: planText }],
};
},
}) as any),
{ name: "enhance_plan_task" },
);
// ── Hook: before_prompt_build — 自动触发规划 ──
api.on("before_prompt_build", (event: any, ctx: any) => {
const query: string = (event as any)?.prompt ?? "";
const agentId = (ctx?.agentId ?? "main") as string;
// 检测触发词
const isPlanningIntent = TRIGGER_PATTERNS.some((p) => p.test(query));
const isReflectionIntent = REFLECTION_PATTERNS.some((p) => p.test(query));
if (!isPlanningIntent && !isReflectionIntent) return {};
const mode = isReflectionIntent ? "reflect" : "plan";
const goal = extractGoalFromQuery(query);
// 注入任务分解提示
const injection = [
"\n\n<!-- enhance-taskplanner: 自动任务规划 -->",
isReflectionIntent
? "【反思模式】请先总结当前状态,然后识别改进机会:\n"
: "【任务规划】请将以下目标分解为具体可执行的子任务:\n",
`目标: goal`,
"\n分解要求:",
"1. 每个子任务原子化(单一职责)",
"2. 按执行顺序排列,标注依赖",
"3. 优先级:高(H)/中(M)/低(L)",
"4. 估计耗时(分钟)",
`\n输出格式:使用 enhance_plan_task 工具,mode="mode"`,
].join("\n");
return { prependContext: injection };
});
api.logger.info("[enhance] 任务规划模块已加载(enhance_plan_task tool + before_prompt_build 自动触发)");
}
// ── 启发式任务分解 ──
function decomposeGoal(goal: string, mode: string): TaskPlan {
const goalLower = goal.toLowerCase();
// 简单分解规则
if (mode === "reflect") {
return {
goal,
summary: "当前状态反思与改进计划",
totalEstimatedMinutes: 15,
tasks: [
{ id: "task-01", title: "总结当前进展", description: "列出已完成的关键里程碑和当前状态", priority: "high", estimatedMinutes: 5, dependencies: [], status: "pending" },
{ id: "task-02", title: "识别问题与风险", description: "列出当前面临的主要问题和潜在风险", priority: "high", estimatedMinutes: 5, dependencies: ["task-01"], status: "pending" },
{ id: "task-03", title: "制定改进计划", description: "针对识别出的问题,制定具体的改进措施", priority: "medium", estimatedMinutes: 5, dependencies: ["task-02"], status: "pending" },
],
};
}
// 代码开发类任务
if (goalLower.includes("开发") || goalLower.includes("实现") || goalLower.includes("写代码") || goalLower.includes("coding")) {
return {
goal,
summary: "代码开发完整流程",
totalEstimatedMinutes: 120,
tasks: [
{ id: "task-01", title: "需求分析", description: "明确功能需求、输入输出、边界条件", priority: "high", estimatedMinutes: 15, dependencies: [], status: "pending" },
{ id: "task-02", title: "技术方案设计", description: "选择技术栈、设计数据结构、定义接口", priority: "high", estimatedMinutes: 20, dependencies: ["task-01"], status: "pending" },
{ id: "task-03", title: "编写代码", description: "按设计方案实现功能代码", priority: "high", estimatedMinutes: 60, dependencies: ["task-02"], status: "pending" },
{ id: "task-04", title: "单元测试", description: "编写并运行单元测试,验证核心逻辑", priority: "high", estimatedMinutes: 15, dependencies: ["task-03"], status: "pending" },
{ id: "task-05", title: "集成测试", description: "将新代码与现有系统集成,验证端到端流程", priority: "medium", estimatedMinutes: 10, dependencies: ["task-04"], status: "pending" },
],
};
}
// Odoo 相关任务
if (goalLower.includes("odoo") || goalLower.includes("ERP")) {
return {
goal,
summary: "Odoo 系统任务执行计划",
totalEstimatedMinutes: 90,
tasks: [
{ id: "task-01", title: "环境检查", description: "确认 Odoo 版本、数据库连接、依赖状态", priority: "high", estimatedMinutes: 5, dependencies: [], status: "pending" },
{ id: "task-02", title: "数据备份", description: "在操作前备份数据库和附件文件", priority: "high", estimatedMinutes: 10, dependencies: ["task-01"], status: "pending" },
{ id: "task-03", title: "需求分析", description: "理解业务需求,确定改动范围", priority: "high", estimatedMinutes: 15, dependencies: ["task-01"], status: "pending" },
{ id: "task-04", title: "代码/配置修改", description: "按需求修改模型、视图、工作流", priority: "high", estimatedMinutes: 40, dependencies: ["task-02", "task-03"], status: "pending" },
{ id: "task-05", title: "功能验证", description: "在测试环境验证修改效果", priority: "medium", estimatedMinutes: 15, dependencies: ["task-04"], status: "pending" },
{ id: "task-06", title: "上线部署", description: "将修改部署到生产环境", priority: "medium", estimatedMinutes: 5, dependencies: ["task-05"], status: "pending" },
],
};
}
// 默认分解
return {
goal,
summary: `完成目标所需的关键步骤`,
totalEstimatedMinutes: 30,
tasks: [
{ id: "task-01", title: "收集信息", description: "收集与目标相关的所有信息", priority: "high", estimatedMinutes: 5, dependencies: [], status: "pending" },
{ id: "task-02", title: "分析现状", description: "分析当前状态,识别差距", priority: "high", estimatedMinutes: 10, dependencies: ["task-01"], status: "pending" },
{ id: "task-03", title: "制定方案", description: "制定具体的执行方案", priority: "high", estimatedMinutes: 10, dependencies: ["task-02"], status: "pending" },
{ id: "task-04", title: "执行与验证", description: "按方案执行并验证结果", priority: "medium", estimatedMinutes: 5, dependencies: ["task-03"], status: "pending" },
],
};
}
// ── 渲染 ──
function renderTaskPlan(plan: TaskPlan, originalGoal: string): string {
const lines = [
`📋 **任务计划**\n`,
`**目标**: originalGoal`,
`**总结**: plan.summary`,
plan.totalEstimatedMinutes ? `**预计总耗时**: ~plan.totalEstimatedMinutes 分钟\n` : "\n",
`---`,
`**子任务 (plan.tasks.length 个)**\n`,
];
for (const task of plan.tasks) {
const priorityEmoji = task.priority === "high" ? "🔴" : task.priority === "medium" ? "🟡" : "🟢";
const deps = task.dependencies.length > 0 ? ` ← task.dependencies.join(", ")` : "";
const time = task.estimatedMinutes ? ` (task.estimatedMinutesmin)` : "";
lines.push(
[
`**priorityEmoji task.id** task.titletime`,
` └─ task.description`,
` └─ 依赖: "无"deps`,
].join("\n"),
);
}
lines.push("\n---\n💡 **提示**: 使用 `enhance_plan_task` 工具获取更详细的分解");
return lines.join("\n");
}
FILE:src/modules/todo-tracker.ts
/**
* 模块: 任务追踪(Claude-Code 风格 TodoWrite)
*
* - enhance_todo_write(todos[]): 整组覆盖式写入(与 Claude Code 语义一致)
* - enhance_todo_update(position, status): 单项状态变更,不动顺序
* - enhance_todo_list(): 查看当前 session 的任务
*
* 与龙虾原生 task tool 的区分:
* - 龙虾的 "task" 指并发子 Agent(Agent tool)。
* - 本模块是轻量级的"清单/进度条",面向主 Agent 自身的心流管理,
* 仪表盘会实时显示。不启动子 Agent,不读龙虾 task 执行栈。
*
* 龙虾原生未提供 TodoWrite 等价物,因此本模块纯新增,不与龙虾功能重复。
*/
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import type { OpenClawPluginToolContext } from "openclaw/plugin-sdk/core";
import { Type } from "@sinclair/typebox";
import { resolveOpenClawHome } from "../utils/resolve-home.js";
import {
getDb,
replaceTodos,
listTodos,
updateTodoStatus,
type TodoInput,
} from "../utils/sqlite-store.js";
import type { NotificationQueue, TodoEntry } from "../types.js";
import { DEFAULT_AGENT_ID } from "../types.js";
function pickAgentId(ctx: { agentId?: string } | undefined): string {
return ((ctx?.agentId ?? DEFAULT_AGENT_ID).trim() || DEFAULT_AGENT_ID);
}
function pickSessionId(ctx: { sessionKey?: string; sessionId?: string } | undefined): string {
return ((ctx?.sessionKey ?? ctx?.sessionId ?? "") + "").trim();
}
function renderTodo(t: TodoEntry): string {
const marker = t.status === "completed" ? "[x]" : t.status === "in_progress" ? "[~]" : "[ ]";
const active = t.status === "in_progress" ? ` — t.active_form` : "";
return `marker t.contentactive`;
}
function summarize(rows: TodoEntry[]): string {
if (rows.length === 0) return "(无任务)";
const done = rows.filter((r) => r.status === "completed").length;
const active = rows.find((r) => r.status === "in_progress");
const head = `任务清单(done/rows.length 完成active ? `,进行中:${active.content` : ""}):`;
return [head, ...rows.map(renderTodo)].join("\n");
}
export function registerTodoTracker(api: OpenClawPluginApi, notifyQueue: NotificationQueue) {
const openclawDir = resolveOpenClawHome(api);
const db = getDb(openclawDir);
// enhance_todo_write — 覆盖式写入整组
api.registerTool(
((ctx: OpenClawPluginToolContext) => ({
name: "enhance_todo_write",
description: "维护任务清单(整组覆盖写入);任务≥3步用,同时只一项 in_progress",
parameters: Type.Object({
todos: Type.Array(
Type.Object({
content: Type.String({ description: "任务内容(祈使句)" }),
activeForm: Type.String({ description: "进行中展示形式" }),
status: Type.Optional(
Type.Union(
[Type.Literal("pending"), Type.Literal("in_progress"), Type.Literal("completed")],
{ description: "默认 pending" },
),
),
}),
{ description: "覆盖式写入;空数组=清空" },
),
}),
async execute(_id: string, params: Record<string, unknown>) {
const agentId = pickAgentId(ctx);
const sessionId = pickSessionId(ctx);
const todos = ((params.todos as TodoInput[]) ?? []).map((t) => ({
content: String(t.content ?? "").trim(),
activeForm: String(t.activeForm ?? "").trim() || String(t.content ?? "").trim(),
status: (t.status ?? "pending") as TodoInput["status"],
})).filter((t) => t.content.length > 0);
const stored = replaceTodos(db, agentId, sessionId, todos);
const inProgressCount = stored.filter((r) => r.status === "in_progress").length;
if (inProgressCount > 1) {
notifyQueue.emit(
agentId,
"warn",
"workflow",
"任务清单提醒",
`检测到 inProgressCount 个 in_progress 任务;建议同一时间只保留一个。`,
);
}
return {
content: [
{ type: "text" as const, text: summarize(stored) },
],
};
},
})) as any,
{ name: "enhance_todo_write" },
);
// enhance_todo_update — 单项状态变更
api.registerTool(
((ctx: OpenClawPluginToolContext) => ({
name: "enhance_todo_update",
description: "更新单条任务状态(按 position 索引,完成后立即调用)",
parameters: Type.Object({
position: Type.Integer({ description: "位置(从 0 开始)" }),
status: Type.Union(
[Type.Literal("pending"), Type.Literal("in_progress"), Type.Literal("completed")],
{ description: "目标状态" },
),
}),
async execute(_id: string, params: Record<string, unknown>) {
const agentId = pickAgentId(ctx);
const sessionId = pickSessionId(ctx);
const updated = updateTodoStatus(
db,
agentId,
sessionId,
Number(params.position ?? -1),
params.status as any,
);
if (!updated) {
return {
content: [{ type: "text" as const, text: `未找到 position=params.position 的任务。` }],
};
}
const all = listTodos(db, agentId, sessionId);
return {
content: [{ type: "text" as const, text: `已更新:renderTodo(updated)\n\nsummarize(all)` }],
};
},
})) as any,
{ name: "enhance_todo_update" },
);
// enhance_todo_list — 查询
api.registerTool(
((ctx: OpenClawPluginToolContext) => ({
name: "enhance_todo_list",
description: "查看当前 session 的任务清单。",
parameters: Type.Object({}),
async execute() {
const agentId = pickAgentId(ctx);
const sessionId = pickSessionId(ctx);
const rows = listTodos(db, agentId, sessionId);
return {
content: [{ type: "text" as const, text: summarize(rows) }],
};
},
})) as any,
{ name: "enhance_todo_list" },
);
api.logger.info("[enhance] 任务追踪模块已加载(enhance_todo_write / update / list)");
}
FILE:src/modules/tool-safety.ts
/**
* 模块: 工具安全补充(非侵入)
*
* 设计原则:
* - 龙虾自带 `tools.allow` / `tools.deny` / sandbox 权限模型。本模块**不**替代它。
* - 本模块只补充龙虾原生规则无法表达的颗粒度(例如 glob pathPattern、命令子串模式)。
* - 匹配到规则只走两种路径:
* hardblock → 直接拒绝(相当于龙虾 deny 的补充规则)
* block → 通过龙虾 requireApproval 弹窗由用户决定(非阻塞)
* 其它命中只写 safety_log 审计表,不阻塞,不复制龙虾已有的决定。
* - after_tool_call 只做错误分类 + 指数退避建议,**不伪造重试**(龙虾主循环才有重试权);
* 分类结果写 safety_log 供仪表盘/工具检索。
*/
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import type { OpenClawPluginToolContext } from "openclaw/plugin-sdk/core";
import { Type } from "@sinclair/typebox";
import { getDb, logSafetyEvent, getRecentSafetyEvents, getSafetyStats } from "../utils/sqlite-store.js";
import { resolveOpenClawHome } from "../utils/resolve-home.js";
import { DEFAULT_AGENT_ID, type SafetyConfig, type SafetyRule } from "../types.js";
// ─────────────────────── 错误分类 ───────────────────────
export type ErrorCategory =
| "rate_limit"
| "server_error"
| "network_error"
| "auth_error"
| "timeout_error"
| "unknown_error";
interface ClassifiedError {
category: ErrorCategory;
retryable: boolean;
statusCode?: number;
retryAfterMs?: number;
message: string;
}
function classifyError(errorMsg: string, statusCode?: number): ClassifiedError {
const msg = errorMsg.toLowerCase();
if (statusCode === 429 || msg.includes("429") || msg.includes("rate limit") || msg.includes("too many requests")) {
const retryAfterMatch =
errorMsg.match(/retry[- ]?after[:\s]*(\d+)/i) ||
errorMsg.match(/retryafterms[:\s]*(\d+)/i);
const retryAfterMs = retryAfterMatch ? parseInt(retryAfterMatch[1], 10) : undefined;
return { category: "rate_limit", retryable: true, statusCode: statusCode ?? 429, retryAfterMs, message: errorMsg };
}
if (
((statusCode ?? 0) >= 500 && (statusCode ?? 0) < 600) ||
msg.includes("500") || msg.includes("502") || msg.includes("503") ||
msg.includes("internal server error") || msg.includes("bad gateway") ||
msg.includes("service unavailable")
) {
return { category: "server_error", retryable: true, statusCode, message: errorMsg };
}
if (
statusCode === 401 || statusCode === 403 ||
msg.includes("401") || msg.includes("403") ||
msg.includes("unauthorized") || msg.includes("forbidden") ||
msg.includes("invalid api key") || msg.includes("authentication failed")
) {
return { category: "auth_error", retryable: false, statusCode, message: errorMsg };
}
if (
msg.includes("timeout") || msg.includes("etimedout") || msg.includes("econnreset") ||
msg.includes("econnrefused") || msg.includes("network error") ||
msg.includes("enotfound") || msg.includes("socket hang up") ||
msg.includes("getaddrinfo") || msg.includes("eai_again")
) {
return { category: "network_error", retryable: true, statusCode, message: errorMsg };
}
if (msg.includes("timed out") || msg.includes("deadline exceeded")) {
return { category: "timeout_error", retryable: true, statusCode, message: errorMsg };
}
return { category: "unknown_error", retryable: false, statusCode, message: errorMsg };
}
interface RetryAdvice {
maxAttempts: number;
baseDelayMs: number;
maxDelayMs: number;
backoffMultiplier: number;
}
const RETRY_ADVICE: Record<ErrorCategory, RetryAdvice> = {
rate_limit: { maxAttempts: 5, baseDelayMs: 1000, maxDelayMs: 60000, backoffMultiplier: 2 },
server_error: { maxAttempts: 3, baseDelayMs: 2000, maxDelayMs: 30000, backoffMultiplier: 2 },
network_error: { maxAttempts: 3, baseDelayMs: 1000, maxDelayMs: 15000, backoffMultiplier: 2 },
timeout_error: { maxAttempts: 2, baseDelayMs: 2000, maxDelayMs: 10000, backoffMultiplier: 2 },
auth_error: { maxAttempts: 1, baseDelayMs: 0, maxDelayMs: 0, backoffMultiplier: 1 },
unknown_error: { maxAttempts: 1, baseDelayMs: 0, maxDelayMs: 0, backoffMultiplier: 1 },
};
function computeBackoffMs(attempt: number, advice: RetryAdvice, retryAfterMs?: number): number {
if (retryAfterMs !== undefined) return Math.min(retryAfterMs, advice.maxDelayMs);
const delay = advice.baseDelayMs * Math.pow(advice.backoffMultiplier, Math.max(0, attempt - 1));
return Math.min(delay, advice.maxDelayMs);
}
// ─────────────────────── 规则匹配 ───────────────────────
function matchGlob(pattern: string, text: string): boolean {
const regex = new RegExp(
"^" + pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*") + "$",
"i",
);
return regex.test(text);
}
function extractMatchText(params: Record<string, unknown>): string {
for (const key of ["command", "input", "path", "file_path", "url", "query"]) {
if (typeof params[key] === "string") return params[key] as string;
}
try {
return JSON.stringify(params);
} catch {
return "";
}
}
function extractPathText(params: Record<string, unknown>): string {
for (const key of ["path", "file_path", "filePath", "filename"]) {
if (typeof params[key] === "string") return params[key] as string;
}
return "";
}
// ─────────────────────── 最近错误分类缓存(供 enhance_retry_status 查询) ───────────────────────
interface ErrorObservation {
agentId: string;
toolName: string;
classified: ClassifiedError;
advice: RetryAdvice;
suggestedDelayMs: number;
observedAt: number;
attemptHint: number;
}
const recentObservations = new Map<string, ErrorObservation>();
const OBSERVATION_TTL_MS = 60_000;
function observationKey(agentId: string, toolName: string): string {
return `agentId::toolName`;
}
function pruneObservations(now: number) {
for (const [k, v] of recentObservations.entries()) {
if (now - v.observedAt > OBSERVATION_TTL_MS) recentObservations.delete(k);
}
}
// ─────────────────────── 主入口 ───────────────────────
export function registerToolSafety(api: OpenClawPluginApi, config?: SafetyConfig) {
const openclawDir = resolveOpenClawHome(api);
const db = getDb(openclawDir);
const rules: SafetyRule[] = config?.rules ?? [];
const defaultAction = config?.defaultAction ?? "allow";
const enableRetry = config?.enableRetry ?? true;
// ── Hook: before_tool_call — 补充式规则匹配(不重写龙虾原生决策) ──
// v5.7.8: typed via openclaw 4.24 SDK (PluginHookBeforeToolCallEvent + PluginHookToolContext)
api.on("before_tool_call", (event, ctx) => {
const agentId = (ctx?.agentId ?? DEFAULT_AGENT_ID).trim();
const toolName = event?.toolName ?? "";
const params = event?.params ?? {};
const matchText = extractMatchText(params);
const pathText = extractPathText(params);
for (const rule of rules) {
if (!matchGlob(rule.tool, toolName)) continue;
if (rule.pattern && !matchGlob(rule.pattern, matchText)) continue;
if (rule.pathPattern && !matchGlob(rule.pathPattern, pathText)) continue;
logSafetyEvent(db, agentId, toolName, matchText.slice(0, 500), rule.action, JSON.stringify(rule), rule.reason ?? "");
if (rule.action === "hardblock") {
api.logger.warn(`[enhance-safety] hardblock toolName (agent: agentId)`);
return { block: true, blockReason: rule.reason ?? "匹配 enhance 安全规则(hardblock)" };
}
if (rule.action === "block") {
return {
requireApproval: {
title: `enhance 安全补充:工具「toolName」需确认`,
description: [rule.reason ?? "该操作匹配 enhance 的细粒度安全规则。", `工具: toolName`].join("\n"),
severity: "critical" as const,
timeoutMs: 30_000,
timeoutBehavior: "deny" as const,
},
};
}
// log / allow: 不干预龙虾原生决策
return {};
}
if (defaultAction === "log") {
logSafetyEvent(db, agentId, toolName, matchText.slice(0, 500), "log", "", "default-log");
}
return {};
}, { priority: 900 } as any);
// ── Hook: after_tool_call — 错误观察(不自动重试,只给建议) ──
// v5.7.8: typed via openclaw 4.24 SDK (PluginHookAfterToolCallEvent + PluginHookToolContext)
if (enableRetry) {
api.on("after_tool_call", (event, ctx) => {
const agentId = (ctx?.agentId ?? DEFAULT_AGENT_ID).trim();
const toolName = event?.toolName ?? "";
const rawError = event?.error ?? "";
const durationMs = event?.durationMs ?? 0;
if (!rawError) return;
const statusMatch = rawError.match(/\b(4\d{2}|5\d{2})\b/);
const statusCode = statusMatch ? parseInt(statusMatch[1], 10) : undefined;
const classified = classifyError(rawError, statusCode);
const advice = RETRY_ADVICE[classified.category];
const now = Date.now();
pruneObservations(now);
const key = observationKey(agentId, toolName);
const prior = recentObservations.get(key);
const attempt = prior ? Math.min(prior.attemptHint + 1, advice.maxAttempts) : 1;
const suggestedDelayMs = classified.retryable
? computeBackoffMs(attempt, advice, classified.retryAfterMs)
: 0;
recentObservations.set(key, {
agentId,
toolName,
classified,
advice,
suggestedDelayMs,
observedAt: now,
attemptHint: attempt,
});
logSafetyEvent(
db,
agentId,
toolName,
rawError.slice(0, 500),
classified.category.toUpperCase(),
JSON.stringify({
category: classified.category,
retryable: classified.retryable,
statusCode,
suggestedDelayMs,
attemptHint: attempt,
durationMs,
}),
classified.category,
);
if (classified.retryable) {
api.logger.info(
`[enhance-safety] toolName → classified.category (attempt≈attempt/advice.maxAttempts, 建议退避 suggestedDelayMsms)`,
);
} else {
api.logger.warn(`[enhance-safety] toolName → classified.category (不建议重试)`);
}
});
}
// ── Tool: enhance_safety_log ──
api.registerTool(
((ctx: OpenClawPluginToolContext) => ({
name: "enhance_safety_log",
description: "查看 enhance 安全补充规则的审计日志",
parameters: Type.Object({
action: Type.Union([Type.Literal("recent"), Type.Literal("stats")], {
description: "recent|stats",
}),
limit: Type.Optional(Type.Number({ description: "recent 条数", default: 15 })),
}),
async execute(_id: string, params: Record<string, unknown>) {
const agentId = (((ctx as any)?.agentId as string | undefined) ?? DEFAULT_AGENT_ID).trim();
if (params.action === "stats") {
const stats = getSafetyStats(db, agentId);
return {
content: [
{
type: "text" as const,
text: `enhance 安全补充统计 (agent: agentId):\n总事件: stats.total\n已拦截: stats.blocked\n已记录: stats.logged\n\n注:这些是 enhance 规则命中数,不包含龙虾原生 tools.allow/deny 拦截。`,
},
],
};
}
const events = getRecentSafetyEvents(db, agentId, (params.limit as number) ?? 15);
if (events.length === 0) {
return { content: [{ type: "text" as const, text: `暂无 enhance 安全事件 (agent: agentId)。` }] };
}
const lines = events.map(
(e) => `[e.action] e.created_at | e.tool: (e.params ?? "").slice(0, 60)e.reason ? ` (${e.reason)` : ""}`,
);
return {
content: [{ type: "text" as const, text: `最近 enhance 安全事件 (agent: agentId):\nlines.join("\n")` }],
};
},
})) as any,
{ name: "enhance_safety_log" },
);
// ── Tool: enhance_retry_status ──
api.registerTool(
((ctx: OpenClawPluginToolContext) => ({
name: "enhance_retry_status",
description: "查询最近一分钟失败工具调用的错误分类和建议退避(不自动重试)",
parameters: Type.Object({}),
async execute() {
pruneObservations(Date.now());
const agentId = (((ctx as any)?.agentId as string | undefined) ?? DEFAULT_AGENT_ID).trim();
const entries = Array.from(recentObservations.values()).filter((e) => e.agentId === agentId);
if (entries.length === 0) {
return { content: [{ type: "text" as const, text: `当前 Agent (agentId) 无可观测的失败工具调用。` }] };
}
const lines = entries.map((e) => {
const ageSec = Math.round((Date.now() - e.observedAt) / 1000);
const suggestion = e.classified.retryable
? `建议退避 Math.round(e.suggestedDelayMs / 100) / 10s(最多 e.advice.maxAttempts 次)`
: "不建议重试";
return `[e.classified.category] e.toolName\n 错误: e.classified.message.slice(0, 100)\n ageSecs 前 · attempt≈e.attemptHint/e.advice.maxAttempts · suggestion`;
});
return {
content: [{ type: "text" as const, text: `最近失败观测 (entries.length):\n\nlines.join("\n\n")` }],
};
},
})) as any,
{ name: "enhance_retry_status" },
);
// ── Tool: enhance_safety_rules ──
api.registerTool(
((_ctx: OpenClawPluginToolContext) => ({
name: "enhance_safety_rules",
description: "查看 enhance 补充安全规则清单(不含龙虾原生 tools.allow/deny)",
parameters: Type.Object({}),
async execute() {
if (rules.length === 0) {
return {
content: [
{
type: "text" as const,
text: `当前无 enhance 补充规则(默认策略 defaultAction)。\n\n龙虾原生权限请查看 openclaw.json → tools.allow / tools.deny。\nenhance 细粒度规则配置:openclaw.json → plugins.entries.enhance.config.safety.rules。`,
},
],
};
}
const lines = rules.map(
(r, i) =>
`i + 1. [r.action] tool=r.toolr.pattern ? ` pattern="${r.pattern"` : ""}r.pathPattern ? ` pathPattern="${r.pathPattern"` : ""}r.reason ? ` — ${r.reason` : ""}`,
);
return {
content: [
{
type: "text" as const,
text: `enhance 补充规则 (rules.length 条):\nlines.join("\n")\n\n默认策略: defaultAction\n(龙虾原生 tools.allow/deny 独立生效,不经本表)`,
},
],
};
},
})) as any,
{ name: "enhance_safety_rules" },
);
api.logger.info(
`[enhance] 工具安全补充模块已加载(rules.length 条规则;仅补充龙虾原生 tools.allow/deny 未覆盖的颗粒度;错误分类"已停用")`,
);
}
FILE:src/modules/transcript-search.ts
/**
* 模块: enhance_transcript_search — 全文搜索历史会话 JSONL
*
* 来源 / 设计参考:
* /Applications/Claude.app/Contents/Resources/app.asar 里的
* .vite/build/transcript-search-worker/transcriptSearchWorker.js
*
* 那是 Claude Desktop 的官方实现,做法极简:
* - 不建二级索引,直接流式读 JSONL
* - 行级 JSON.parse,filter 出 message 类型
* - extractText 兼容 string / [{type:"text", text}] 数组
* - indexOf 子串匹配 + ±80 字符 snippet
*
* 我们把这套搬到 openclaw 的 session 目录上 (~/.openclaw/agents/<agentId>/sessions/*.jsonl):
* - 完全只读 — 不动龙虾任何东西
* - 不建表 — 不与 enhance 的 SQLite 库重叠
* - 不建 FTS — 用户每次搜临时扫,简单可靠(Claude Desktop 都没用 FTS)
*
* 性能:单 session 通常 < 1 MB,几十个 session 全扫一次 < 100ms(SSD),完全可接受。
*/
import { promises as fs } from "node:fs";
import { createReadStream } from "node:fs";
import { createInterface } from "node:readline";
import { join } from "node:path";
import { Type } from "@sinclair/typebox";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import type { OpenClawPluginToolContext } from "openclaw/plugin-sdk/core";
import { resolveOpenClawHome } from "../utils/resolve-home.js";
import { DEFAULT_AGENT_ID } from "../types.js";
const SNIPPET_RADIUS = 80;
const DEFAULT_LIMIT = 10;
const HARD_LIMIT_FILES = 50; // 即使没 hit 满 limit,最多扫这么多文件
interface SearchHit {
sessionId: string;
fileName: string;
role: string;
snippet: string;
matchedAt: string;
lastModifiedMs: number;
}
interface SessionFile {
sessionId: string;
filePath: string;
fileName: string;
lastModifiedMs: number;
isReset: boolean;
}
function pickAgentId(ctx: unknown): string {
return (((ctx as any)?.agentId as string | undefined) ?? DEFAULT_AGENT_ID).trim() || DEFAULT_AGENT_ID;
}
/**
* 列出某个 agent 下所有可读的 session jsonl 文件,按 mtime 倒序。
* - 默认跳过 .reset. / .deleted. / .checkpoint. / .trajectory.(这些是历史快照)
* - includeReset=true 时把 .reset. 也算进来(用户可能想搜被压缩前的内容)
*/
async function listSessionFiles(
openclawHome: string,
agentId: string,
includeReset: boolean,
): Promise<SessionFile[]> {
const sessionsDir = join(openclawHome, "agents", agentId, "sessions");
let entries: string[];
try {
entries = await fs.readdir(sessionsDir);
} catch {
return [];
}
const out: SessionFile[] = [];
for (const fname of entries) {
if (!fname.endsWith(".jsonl")) continue;
const isDeleted = fname.includes(".deleted.");
const isReset = fname.includes(".reset.");
const isCheckpoint = fname.includes(".checkpoint.");
const isTrajectory = fname.includes(".trajectory.");
if (isDeleted || isCheckpoint || isTrajectory) continue;
if (isReset && !includeReset) continue;
// 提取 sessionId(取第一段 UUID 形式)
const sessionId = fname.split(".")[0];
const filePath = join(sessionsDir, fname);
let lastModifiedMs = 0;
try {
const stat = await fs.stat(filePath);
lastModifiedMs = stat.mtimeMs;
} catch {
continue;
}
out.push({ sessionId, filePath, fileName: fname, lastModifiedMs, isReset });
}
out.sort((a, b) => b.lastModifiedMs - a.lastModifiedMs);
return out;
}
/**
* 从 message.content 提取纯文本。
* 兼容三种形态:
* - string (早期版本)
* - [{type:"text", text:"…"}, …] (Anthropic block 标准)
* - {type:"text", text:"…"} (单 block,不规范但偶见)
*/
function extractText(content: unknown): string {
if (typeof content === "string") return content;
if (Array.isArray(content)) {
let out = "";
for (const block of content) {
if (block && typeof block === "object") {
const b = block as { type?: unknown; text?: unknown };
if (b.type === "text" && typeof b.text === "string") out += b.text + " ";
}
}
return out;
}
if (content && typeof content === "object") {
const b = content as { type?: unknown; text?: unknown };
if (b.type === "text" && typeof b.text === "string") return b.text;
}
return "";
}
function makeSnippet(text: string, idx: number, qLen: number): string {
const start = Math.max(0, idx - SNIPPET_RADIUS);
const end = Math.min(text.length, idx + qLen + SNIPPET_RADIUS);
const slice = text.slice(start, end).trim();
return (start > 0 ? "…" : "") + slice + (end < text.length ? "…" : "");
}
/**
* 流式扫单个文件,找到第一条匹配就返回。
* 与 Claude Desktop 的实现策略一致:每个 session 只贡献一个 hit(first match),
* 这样 limit=10 就是"找到 10 个不同 session",比"全文返回所有匹配"更可读。
*/
async function scanFile(
session: SessionFile,
needle: string,
caseSensitive: boolean,
): Promise<SearchHit | null> {
const stream = createReadStream(session.filePath, { encoding: "utf8" });
const rl = createInterface({ input: stream, crlfDelay: Infinity });
try {
for await (const line of rl) {
if (!line) continue;
let parsed: { type?: unknown; message?: { role?: unknown; content?: unknown }; timestamp?: unknown };
try {
parsed = JSON.parse(line);
} catch {
continue;
}
if (parsed.type !== "message") continue;
const role = typeof parsed.message?.role === "string" ? parsed.message.role : "?";
const text = extractText(parsed.message?.content);
if (!text) continue;
const normalized = text.replace(/\s+/g, " ");
const haystack = caseSensitive ? normalized : normalized.toLowerCase();
const idx = haystack.indexOf(needle);
if (idx === -1) continue;
const matchedAtRaw = parsed.timestamp;
const matchedAt =
typeof matchedAtRaw === "string"
? matchedAtRaw
: typeof matchedAtRaw === "number"
? new Date(matchedAtRaw).toISOString()
: new Date(session.lastModifiedMs).toISOString();
return {
sessionId: session.sessionId,
fileName: session.fileName,
role,
snippet: makeSnippet(normalized, idx, needle.length),
matchedAt,
lastModifiedMs: session.lastModifiedMs,
};
}
} finally {
rl.close();
stream.destroy();
}
return null;
}
export function registerTranscriptSearch(api: OpenClawPluginApi) {
const openclawHome = resolveOpenClawHome(api);
api.registerTool(
((ctx: OpenClawPluginToolContext) => ({
name: "enhance_transcript_search",
description: "搜索当前 Agent 历史会话 jsonl(流式扫,无索引);找『我上次怎么做的』",
parameters: Type.Object({
query: Type.String({ description: "搜索词,不区分大小写" }),
agentId: Type.Optional(Type.String({ description: "目标 agent,默认当前" })),
limit: Type.Optional(Type.Number({ description: "最多返回 hit 数,默认 10", minimum: 1, maximum: 50 })),
includeReset: Type.Optional(Type.Boolean({ description: "是否包含 .reset. 历史,默认 false" })),
caseSensitive: Type.Optional(Type.Boolean({ description: "区分大小写,默认 false" })),
}),
async execute(_id: string, params: Record<string, unknown>) {
const rawQuery = String(params.query ?? "").trim();
if (!rawQuery) {
return { content: [{ type: "text" as const, text: "query 不能为空。" }] };
}
const agentId = (String(params.agentId ?? "").trim() || pickAgentId(ctx)) || DEFAULT_AGENT_ID;
const limit = Math.min(Math.max(Number(params.limit ?? DEFAULT_LIMIT), 1), 50);
const includeReset = Boolean(params.includeReset);
const caseSensitive = Boolean(params.caseSensitive);
const needle = caseSensitive
? rawQuery.replace(/\s+/g, " ")
: rawQuery.replace(/\s+/g, " ").toLowerCase();
const files = await listSessionFiles(openclawHome, agentId, includeReset);
if (files.length === 0) {
return {
content: [
{
type: "text" as const,
text: `没找到 agent=agentId 的任何 session 文件(路径:join(openclawHome, "agents", agentId, "sessions"))。`,
},
],
structuredContent: { agentId, hits: [], filesScanned: 0 },
};
}
const hits: SearchHit[] = [];
let filesScanned = 0;
for (const f of files.slice(0, HARD_LIMIT_FILES)) {
filesScanned++;
try {
const hit = await scanFile(f, needle, caseSensitive);
if (hit) hits.push(hit);
} catch {
// 单文件失败不影响整体
}
if (hits.length >= limit) break;
}
if (hits.length === 0) {
return {
content: [
{
type: "text" as const,
text:
`agent=agentId 扫描了 filesScanned 个 session,未找到匹配「rawQuery」的内容。\n` +
(includeReset
? "已包含 .reset. 历史。"
: "提示:加 includeReset=true 可搜被压缩重置过的旧 session。"),
},
],
structuredContent: { agentId, hits: [], filesScanned },
};
}
const lines: string[] = [
`🔍 命中 hits.length 条(扫了 filesScanned 个 session,agent=agentId)`,
"",
];
for (const h of hits) {
const ts = h.matchedAt.replace("T", " ").replace(/\.\d+Z$/, "Z");
lines.push(`【h.role@ts】 session=h.sessionId.slice(0, 8)…`);
lines.push(` h.snippet`);
lines.push("");
}
return {
content: [{ type: "text" as const, text: lines.join("\n").trim() }],
structuredContent: { agentId, hits, filesScanned },
};
},
})) as any,
{ name: "enhance_transcript_search" },
);
api.logger.info("[enhance] 历史会话搜索模块已加载(enhance_transcript_search)");
}
FILE:src/modules/workflow-hooks.ts
/**
* 模块4: 工作流自动化 — 增强版(多 Agent 隔离 + 条件分支 + 任务状态)
*
* v5.6: 4 个工具合并为 1 个 dispatcher(enhance_workflow),仅保留任务管理
* 工具(enhance_task)独立。tool schema 数从 5 减到 2,per-turn 成本砍 ~60%。
*
* P2 增强:
* - 正则触发词(不只是 plain string includes)
* - 时间条件(cron 风格:每天9点、周一上班等)
* - 任务状态持久化(跨 session 追踪 TODO 进度)
* - 条件分支(if/then/else 逻辑)
*/
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import type { OpenClawPluginToolContext } from "openclaw/plugin-sdk/core";
import { Type } from "@sinclair/typebox";
import { join } from "node:path";
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { resolveOpenClawHome } from "../utils/resolve-home.js";
import { DEFAULT_AGENT_ID, type Workflow, type WorkflowConfig } from "../types.js";
// ──────────────────────────────────────────────
// 常量
// ──────────────────────────────────────────────
const WORKFLOWS_DIR = "workflows";
const TASKS_FILE = "workflow-tasks.json";
// ──────────────────────────────────────────────
// 任务状态
// ──────────────────────────────────────────────
export interface WorkflowTask {
id: string;
workflowName: string;
agentId: string;
description: string;
status: "pending" | "in_progress" | "completed" | "cancelled";
createdAt: string;
updatedAt: string;
completedAt?: string;
priority: "high" | "medium" | "low";
tags: string[];
}
function getTasksPath(openclawDir: string): string {
return join(openclawDir, WORKFLOWS_DIR, TASKS_FILE);
}
function ensureTasksDir(openclawDir: string): void {
const dir = join(openclawDir, WORKFLOWS_DIR);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
}
function loadAllTasks(openclawDir: string): WorkflowTask[] {
const path = getTasksPath(openclawDir);
if (!existsSync(path)) return [];
try {
return JSON.parse(readFileSync(path, "utf-8")) as WorkflowTask[];
} catch {
return [];
}
}
function saveTasks(openclawDir: string, tasks: WorkflowTask[]): void {
ensureTasksDir(openclawDir);
writeFileSync(getTasksPath(openclawDir), JSON.stringify(tasks, null, 2), "utf-8");
}
// ──────────────────────────────────────────────
// 条件评估
// ──────────────────────────────────────────────
export type ConditionType =
| "always"
| "keyword"
| "regex"
| "time_range"
| "day_of_week"
| "agent_state";
export interface WorkflowCondition {
type: ConditionType;
/** keyword / regex: 匹配文本 */
pattern?: string;
/** time_range: "09:00-17:30" */
timeRange?: string;
/** day_of_week: ["monday","tuesday"] 或 ["weekday","weekend"] */
daysOfWeek?: string[];
/** agent_state: 触发的工作流名称 */
afterWorkflow?: string;
}
function evaluateCondition(
cond: WorkflowCondition,
userMessage: string,
now: Date,
): boolean {
switch (cond.type) {
case "always":
return true;
case "keyword":
return !!(cond.pattern && userMessage.includes(cond.pattern));
case "regex":
try {
return !!(cond.pattern && new RegExp(cond.pattern, "i").test(userMessage));
} catch {
return false;
}
case "time_range":
return evaluateTimeRange(cond.timeRange ?? "", now);
case "day_of_week":
return evaluateDayOfWeek(cond.daysOfWeek ?? [], now);
case "agent_state":
// 由调用方在 hook 中处理
return false;
default:
return false;
}
}
function evaluateTimeRange(range: string, now: Date): boolean {
if (!range) return false;
const match = range.match(/^(\d{1,2}):(\d{2})-(\d{1,2}):(\d{2})$/);
if (!match) return false;
const [_, sh, sm, eh, em] = match.map(Number);
const currentMins = now.getHours() * 60 + now.getMinutes();
const startMins = sh * 60 + sm;
const endMins = eh * 60 + em;
if (startMins <= endMins) {
return currentMins >= startMins && currentMins <= endMins;
} else {
// 跨天(如 22:00-02:00)
return currentMins >= startMins || currentMins <= endMins;
}
}
function evaluateDayOfWeek(days: string[], now: Date): boolean {
if (days.length === 0) return false;
const dayNames = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"];
const today = dayNames[now.getDay()];
if (days.includes("weekday")) {
return today !== "saturday" && today !== "sunday";
}
if (days.includes("weekend")) {
return today === "saturday" || today === "sunday";
}
return days.map((d) => d.toLowerCase()).includes(today);
}
// ──────────────────────────────────────────────
// 主模块
// ──────────────────────────────────────────────
function getWorkflowsDir(openclawDir: string): string {
return join(openclawDir, WORKFLOWS_DIR);
}
function getWorkflowsPath(openclawDir: string): string {
return join(getWorkflowsDir(openclawDir), "enhance-workflows.json");
}
function loadAllWorkflows(openclawDir: string): Workflow[] {
const path = getWorkflowsPath(openclawDir);
if (!existsSync(path)) return [];
try {
const data = JSON.parse(readFileSync(path, "utf-8"));
return (data as Workflow[]).map((w) => ({
...w,
agent_id: w.agent_id || DEFAULT_AGENT_ID,
}));
} catch {
return [];
}
}
function loadWorkflows(openclawDir: string, agentId: string): Workflow[] {
return loadAllWorkflows(openclawDir).filter((w) => w.agent_id === agentId);
}
function saveWorkflows(openclawDir: string, workflows: Workflow[]): void {
ensureDir(openclawDir);
writeFileSync(getWorkflowsPath(openclawDir), JSON.stringify(workflows, null, 2), "utf-8");
}
function ensureDir(openclawDir: string): void {
const dir = getWorkflowsDir(openclawDir);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
}
export function registerWorkflowHooks(api: OpenClawPluginApi, _config?: WorkflowConfig) {
const openclawDir = resolveOpenClawHome(api);
// ── Tool: enhance_workflow(v5.6 合并:define/list/delete/tasks 4合1)──
api.registerTool(((ctx: any) => ({
name: "enhance_workflow",
description: "工作流 CRUD 与任务看板;action=define/list/delete/tasks",
parameters: Type.Object({
action: Type.Union([
Type.Literal("define"),
Type.Literal("list"),
Type.Literal("delete"),
Type.Literal("tasks"),
], { description: "操作:define 创建/更新;list 列出;delete 删除;tasks 看板" }),
name: Type.Optional(Type.String({ description: "工作流名(define/delete 必填)" })),
trigger: Type.Optional(Type.String({ description: "触发词,/regex/ 为正则" })),
instructions: Type.Optional(Type.String({ description: "触发后注入的指令" })),
condition: Type.Optional(
Type.Object({
type: Type.Union([
Type.Literal("always"),
Type.Literal("keyword"),
Type.Literal("regex"),
Type.Literal("time_range"),
Type.Literal("day_of_week"),
]),
pattern: Type.Optional(Type.String()),
timeRange: Type.Optional(Type.String()),
daysOfWeek: Type.Optional(Type.Array(Type.String())),
}),
),
priority: Type.Optional(
Type.Union([Type.Literal("high"), Type.Literal("medium"), Type.Literal("low")]),
),
}),
async execute(_id: string, params: Record<string, unknown>): Promise<any> {
const agentId = (ctx?.agentId ?? DEFAULT_AGENT_ID).trim();
const action = String(params.action ?? "list");
if (action === "define") {
if (!params.name || !params.trigger || !params.instructions) {
return {
content: [{ type: "text" as const, text: "define 需要 name + trigger + instructions" }],
};
}
const allWorkflows = loadAllWorkflows(openclawDir);
const existing = allWorkflows.findIndex(
(w) => w.name === params.name && w.agent_id === agentId,
);
const workflow: Workflow = {
id: existing >= 0 ? allWorkflows[existing].id : `wf_Date.now()`,
agent_id: agentId,
name: params.name as string,
trigger: params.trigger as string,
instructions: params.instructions as string,
enabled: true,
created_at:
existing >= 0 ? allWorkflows[existing].created_at : new Date().toISOString(),
};
if (existing >= 0) allWorkflows[existing] = workflow;
else allWorkflows.push(workflow);
saveWorkflows(openclawDir, allWorkflows);
return {
content: [
{
type: "text" as const,
text: `已"创建"工作流「params.name」(agent: agentId)\n触发: "params.trigger"`,
},
],
};
}
if (action === "list") {
const workflows = loadWorkflows(openclawDir, agentId);
if (workflows.length === 0) {
return {
content: [{ type: "text" as const, text: `暂无工作流 (agent: agentId)。` }],
};
}
const lines = workflows.map(
(w) => `"⏸️" w.name (触发: "w.trigger")`,
);
return {
content: [
{
type: "text" as const,
text: `工作流 (workflows.length 个):\n\nlines.join("\n\n")`,
},
],
};
}
if (action === "delete") {
if (!params.name) {
return { content: [{ type: "text" as const, text: "delete 需要 name" }] };
}
const allWorkflows = loadAllWorkflows(openclawDir);
const idx = allWorkflows.findIndex(
(w) => w.name === params.name && w.agent_id === agentId,
);
if (idx < 0) {
return { content: [{ type: "text" as const, text: `未找到「params.name」` }] };
}
allWorkflows.splice(idx, 1);
saveWorkflows(openclawDir, allWorkflows);
return { content: [{ type: "text" as const, text: `已删除「params.name」` }] };
}
if (action === "tasks") {
const allTasks = loadAllTasks(openclawDir).filter((t) => t.agentId === agentId);
const pending = allTasks.filter((t) => t.status === "pending");
const inProgress = allTasks.filter((t) => t.status === "in_progress");
const completed = allTasks.filter(
(t) => t.status === "completed" || t.status === "cancelled",
);
const lines = [
`📋 任务看板 (agent: agentId)`,
`🔴 进行中: inProgress.length 个`,
`🟡 待处理: pending.length 个`,
`✅ 已完成: completed.length 个`,
"",
...inProgress
.slice(0, 5)
.map((t) => ` 🔴 t.id: t.description (t.priority)`),
...pending
.slice(0, 5)
.map((t) => ` 🟡 t.id: t.description (t.priority)`),
];
return { content: [{ type: "text" as const, text: lines.filter(Boolean).join("\n") }] };
}
return { content: [{ type: "text" as const, text: `未知 action: action` }] };
},
})) as any, { name: "enhance_workflow" });
// ── Tool: enhance_task ── (独立保留,task CRUD 与 workflow 是两个域)
api.registerTool(((ctx: any) => ({
name: "enhance_task",
description: "跨 session 任务 CRUD;action=create/update/list/get/delete",
parameters: Type.Object({
action: Type.Union([
Type.Literal("create"),
Type.Literal("update"),
Type.Literal("list"),
Type.Literal("get"),
Type.Literal("delete"),
]),
id: Type.Optional(Type.String()),
description: Type.Optional(Type.String()),
status: Type.Optional(
Type.Union([
Type.Literal("pending"),
Type.Literal("in_progress"),
Type.Literal("completed"),
Type.Literal("cancelled"),
]),
),
priority: Type.Optional(
Type.Union([Type.Literal("high"), Type.Literal("medium"), Type.Literal("low")]),
),
}),
async execute(_id: string, params: Record<string, unknown>): Promise<any> {
const agentId = (ctx?.agentId ?? DEFAULT_AGENT_ID).trim();
const allTasks = loadAllTasks(openclawDir);
const now = new Date().toISOString();
switch (params.action) {
case "create": {
const task: WorkflowTask = {
id: `task_Date.now()`,
workflowName: (params as any).workflowName ?? "manual",
agentId,
description: (params.description as string) ?? "",
status: "pending",
createdAt: now,
updatedAt: now,
priority: (params.priority as any) ?? "medium",
tags: [],
};
allTasks.push(task);
saveTasks(openclawDir, allTasks);
return {
content: [
{
type: "text" as const,
text: `已创建任务 task.id (agent: agentId)\n描述: task.description\n优先级: task.priority`,
},
],
};
}
case "update": {
const taskId = params.id as string;
const idx = allTasks.findIndex((t) => t.id === taskId && t.agentId === agentId);
if (idx < 0) {
return { content: [{ type: "text" as const, text: `未找到任务 taskId` }] };
}
const task = allTasks[idx];
if (params.status) task.status = params.status as any;
if (params.description) task.description = params.description as string;
if (params.priority) task.priority = params.priority as any;
task.updatedAt = now;
if (task.status === "completed" || task.status === "cancelled") {
task.completedAt = now;
}
saveTasks(openclawDir, allTasks);
return {
content: [
{
type: "text" as const,
text: `已更新任务 taskId: task.status${task.description` : ""}`,
},
],
};
}
case "list": {
const mine = allTasks.filter(
(t) =>
t.agentId === agentId && t.status !== "completed" && t.status !== "cancelled",
);
if (mine.length === 0) {
return {
content: [{ type: "text" as const, text: `暂无进行中任务 (agent: agentId)` }],
};
}
const lines = mine.map(
(t) =>
`[t.status] t.id: t.description (t.priority, 创建于 t.createdAt.slice(0, 10))`,
);
return {
content: [
{
type: "text" as const,
text: `进行中任务 (mine.length 个):\n\nlines.join("\n\n")`,
},
],
};
}
case "get": {
const taskId = params.id as string;
const task = allTasks.find((t) => t.id === taskId && t.agentId === agentId);
if (!task) {
return { content: [{ type: "text" as const, text: `未找到任务 taskId` }] };
}
return {
content: [
{
type: "text" as const,
text: `任务: task.id\n状态: task.status\n描述: task.description\n优先级: task.priority\n创建: task.createdAt\n更新: task.updatedAt${task.completedAt` : ""}`,
},
],
};
}
case "delete": {
const taskId = params.id as string;
const before = allTasks.length;
const remaining = allTasks.filter(
(t) => !(t.id === taskId && t.agentId === agentId),
);
saveTasks(openclawDir, remaining);
return {
content: [
{
type: "text" as const,
text: `已删除 before - remaining.length 个任务`,
},
],
};
}
default:
return { content: [{ type: "text" as const, text: "未知 action" }] };
}
},
})) as any, { name: "enhance_task" });
// ── Hook: before_prompt_build — 增强版触发评估 ──
api.on("before_prompt_build", (event: any, ctx: any) => {
const agentId = (ctx?.agentId ?? DEFAULT_AGENT_ID).trim();
const userMessage: string = (event as any)?.prompt ?? "";
if (!userMessage) return {};
const now = new Date();
const workflows = loadWorkflows(openclawDir, agentId);
const triggered = workflows.filter((w) => {
if (!w.enabled) return false;
// 评估触发词(支持 /regex/flags 语法)
let triggered = false;
const t = w.trigger.trim();
if (t.startsWith("/") && t.endsWith("/")) {
// 正则触发
try {
triggered = new RegExp(t.slice(1, -1), "i").test(userMessage);
} catch {
triggered = userMessage.includes(t);
}
} else {
triggered = userMessage.includes(t);
}
return triggered;
});
if (triggered.length === 0) return {};
// 按优先级排序(high > medium > low)
const priorityOrder = { high: 0, medium: 1, low: 2 };
triggered.sort((a, b) => {
const ap = ((a as any)?.priority as keyof typeof priorityOrder) ?? "medium";
const bp = ((b as any)?.priority as keyof typeof priorityOrder) ?? "medium";
return (priorityOrder[ap] ?? 1) - (priorityOrder[bp] ?? 1);
});
const instructions = triggered
.map((w) => `### 工作流「w.name」已触发\nw.instructions`)
.join("\n\n");
return {
appendSystemContext: [
"\n\n## 工作流自动化(增强包 v5.6)",
`Agent: agentId`,
`时间: now.toISOString().slice(0, 16) ("long")})`,
"触发的工作流:",
instructions,
].join("\n"),
};
});
api.logger.info("[enhance] 工作流自动化 v5.6 已加载(5→2 工具,条件分支 + 任务状态 + 正则触发)");
}
FILE:src/types.ts
/**
* 龙虾增强包 — 类型定义
*/
// ── 默认 Agent ID ──
export const DEFAULT_AGENT_ID = "main";
// ── 结构化记忆 ──
export type MemoryCategory = "user" | "project" | "feedback" | "reference" | "decision";
export interface MemoryEntry {
id: number;
agent_id: string;
category: MemoryCategory;
content: string;
/** 该条记忆为什么值得记 — 通常是背景、约束、踩过的坑 */
why?: string;
/** 记忆适用场景 — 未来会话何时/如何套用这条 */
how_to_apply?: string;
tags: string;
importance: number;
session_id: string;
created_at: string;
updated_at: string;
}
export interface MemoryConfig {
enabled?: boolean;
autoCapture?: boolean;
maxContextEntries?: number;
}
// ── 工具安全 ──
/** block: 弹出用户确认对话框(可超时拒绝);hardblock: 无条件拦截;log: 只记录;allow: 放行 */
export type SafetyAction = "block" | "hardblock" | "log" | "allow";
export interface SafetyRule {
tool: string;
pattern?: string;
pathPattern?: string;
action: SafetyAction;
reason?: string;
}
export interface SafetyConfig {
enabled?: boolean;
rules?: SafetyRule[];
defaultAction?: SafetyAction;
/** 是否启用自动重试(429指数退避/5xx重试/网络超时重试),默认 true */
enableRetry?: boolean;
}
// ── 提示词增强 ──
// 注意:taskClassification / safetyAwareness / memoryInstructions 已移除
// 因为 openclaw 内置系统提示词已包含:
// - "## Execution Bias" 覆盖了任务分类
// - "## Safety" 覆盖了安全意识
// - "## Memory Recall" 覆盖了记忆工具说明
// 仅保留 qualityGuidelines(openclaw 无对应内置内容)
export type PromptSection = "qualityGuidelines";
export interface PromptConfig {
enabled?: boolean;
sections?: PromptSection[];
}
// ── 工作流 ──
export interface Workflow {
id: string;
agent_id: string;
name: string;
trigger: string;
instructions: string;
enabled: boolean;
created_at: string;
}
export interface WorkflowConfig {
enabled?: boolean;
}
// ── 仪表盘 ──
export interface DashboardConfig {
enabled?: boolean;
}
// ── 小火苗宠物 ──
export type FlameColor = "orange" | "blue" | "purple" | "green" | "white";
export type FlameSize = "tiny" | "small" | "medium" | "large";
export type FlameMood = "idle" | "busy" | "error" | "success" | "sleep";
export interface FlameStats {
warmth: number;
brightness: number;
stability: number;
spark: number;
endurance: number;
}
export interface FlamePet {
agent_id: string;
name: string;
color: FlameColor;
size: FlameSize;
level: number;
xp: number;
total_xp: number;
mood: FlameMood;
stats: FlameStats;
personality: string;
created_at: string;
updated_at: string;
}
export interface PetConfig {
enabled?: boolean;
name?: string;
color?: FlameColor;
}
// ── 智能贴士 ──
export type TipCategory = "shortcuts" | "memory" | "workflow" | "safety" | "general";
export interface Tip {
id: string;
category: TipCategory;
text: string;
weight?: number;
}
export interface TipsConfig {
enabled?: boolean;
injectInPrompt?: boolean;
cooldownMinutes?: number;
}
// ── 通知中心 ──
export type NotificationLevel = "info" | "warn" | "success";
export type NotificationSource = "safety" | "memory" | "pet" | "tips" | "workflow" | "config-doctor";
export interface Notification {
id: number;
agent_id: string;
level: NotificationLevel;
source: NotificationSource;
title: string;
detail: string;
read: boolean;
created_at: string;
}
export interface NotificationConfig {
enabled?: boolean;
maxRetained?: number;
}
export interface NotificationQueue {
emit(agentId: string, level: NotificationLevel, source: NotificationSource, title: string, detail?: string): void;
getRecent(agentId?: string, limit?: number): Notification[];
getUnreadCount(agentId?: string): number;
markRead(id: number): void;
prune(maxRetained: number): void;
}
// ── 输出自检 ──
export interface SelfCheckConfig {
enabled?: boolean;
checkEmpty?: boolean;
checkNoReply?: boolean;
checkErrorKeywords?: boolean;
checkExcessiveLength?: boolean;
maxLength?: number;
errorKeywords?: string[];
blockOnEmpty?: boolean;
}
// ── Context 裁剪 ──
export interface ContextPrunerConfig {
enabled?: boolean;
/** 相关性阈值(0-1),低于此分数的记忆被过滤,默认 0.5 */
threshold?: number;
/** 最多注入多少条记忆,默认 5 */
maxEntries?: number;
debug?: boolean;
}
// ── KB Corpus(共享知识库 → 龙虾 memory corpus 桥接) ──
export interface KbCorpusConfigType {
enabled?: boolean;
/** 共享 KB 根目录,默认 ~/.openclaw/kb/shared */
sharedKbPath?: string;
/** 相关性阈值(0-1),默认 0.3 */
threshold?: number;
/** 单次 search 最多返回几条,默认 5 */
maxResults?: number;
debug?: boolean;
}
// ── Todos ──
export type TodoStatus = "pending" | "in_progress" | "completed";
export interface TodoEntry {
id: number;
agent_id: string;
session_id: string;
content: string;
active_form: string;
status: TodoStatus;
position: number;
created_at: string;
updated_at: string;
}
export interface TodoConfig {
enabled?: boolean;
}
// ── Chapter marks ──
export interface ChapterMark {
id: number;
agent_id: string;
session_id: string;
title: string;
summary?: string;
created_at: string;
}
export interface ChapterConfig {
enabled?: boolean;
}
// ── Mode gate ──
export type AgentMode = "normal" | "plan" | "explore";
export interface ModeConfig {
enabled?: boolean;
defaultMode?: AgentMode;
}
// ── Statusline / Scheduled tasks ──
export interface StatuslineConfig {
enabled?: boolean;
}
export interface ScheduledTasksConfig {
enabled?: boolean;
}
// ── Transcript search (v5.7) ──
/**
* 历史会话全文搜索(流式扫 ~/.openclaw/agents/<agentId>/sessions/*.jsonl)。
* 算法照搬 Claude Desktop 的 transcriptSearchWorker:行级 JSON.parse + indexOf + ±80 字符 snippet。
* 完全只读 openclaw session 目录,不建表、不建索引。
*/
export interface TranscriptSearchConfig {
enabled?: boolean;
}
// ── Session Lifecycle (v5.7.7) ──
/**
* 接入 openclaw 4.22 的 session_start / session_end / before_reset /
* subagent_spawned / subagent_ended 五个 hook,闭环 session 生命周期。
* 写入用专用 tag `lifecycle-flush` 避免被 corpus pruner 当成决策记忆召回(v5.7.2 黑名单逻辑)。
*/
export interface SessionLifecycleConfig {
enabled?: boolean;
/** 接 session_start hook:新会话起点加章节占位(仅 idle > 30min 时) */
enableSessionStart?: boolean;
/** 接 session_end hook:会话结束自动 mark_chapter + flush in_progress todo 到 project memory */
enableSessionEnd?: boolean;
/** 接 before_reset hook:reset 前最后机会抢救 in_progress + 最近章节到 decision memory */
enableBeforeReset?: boolean;
/** 接 subagent_spawned/ended hook:spawn 链路自动落 chapter */
enableSubagent?: boolean;
debug?: boolean;
}
// ── Skill Recommender (v5.7.5) ──
/**
* 按用户需求挑已装 skill / 推荐未装 huo15-* / 给自建规划。
* 算法灵感:反编译 Claude Desktop loadSkills + "Available skills: list." prompt 注入;
* enhance 改成按需工具避免每轮 prompt 占 schema。
*/
export interface SkillRecommenderConfig {
enabled?: boolean;
/** 已装 skill 命中相关度的阈值(< 阈值视为"没找到",触发未装/自建建议),默认 0.25 */
installedThreshold?: number;
/** 启动期扫描结果缓存 TTL(秒),默认 60 */
cacheTtlSec?: number;
}
// ── Config doctor (v5.7.3) ──
/**
* 启动期诊断 ~/.openclaw/openclaw.json 的常见配置陷阱:
* - 缺失 agents.defaults.compaction.reserveTokensFloor(4.22 默认值过小)
* - reserveTokensFloor < 5000 或 > 100000
* - 各 model maxTokens ≥ contextWindow/2 且 > 32000(吃掉太多输出预算导致 'Context limit exceeded')
* 工具 enhance_config_doctor 让用户主动诊断并拿到 fix 命令。
* 完全只读 openclaw.json,不修改用户配置。
*/
export interface ConfigDoctorConfig {
enabled?: boolean;
/** 推荐的 reserveTokensFloor 下限阈值,默认 5000 */
minReserveTokensFloor?: number;
/** 推荐的 reserveTokensFloor 上限阈值,默认 100000 */
maxReserveTokensFloor?: number;
/** 推荐 model maxTokens 上限,默认 32000;超过会警告 */
maxModelMaxTokens?: number;
}
// ── 工具分层(v5.6 新增) ──
/**
* 工具分层:
* - minimal: 仅 L1 常驻层(~10 工具)— 仅核心功能(记忆 / 状态栏 / spawn / 模式 / 章节 / installer)
* - balanced: L1 + L2(~18 工具)— 默认值,加上 todo / chapter / 定时任务桥
* - full: 全部(~26 工具,workflow 合并后)— 完整功能
*
* 目的:降低每轮 prompt 里的工具 schema 总量,缓解 context 填满压力。
* 现象:所有工具 schema 每轮都会全量发给模型,工具越多,单轮固定底座越高。
* 修改 toolTier 后需重启 openclaw 生效。
*/
export type ToolTier = "minimal" | "balanced" | "full";
// ── 插件总配置 ──
export interface EnhancePluginConfig {
/** 工具分层预设,v5.6 新增。默认 "balanced"。 */
toolTier?: ToolTier;
memory?: MemoryConfig;
safety?: SafetyConfig;
prompt?: PromptConfig;
workflows?: WorkflowConfig;
dashboard?: DashboardConfig;
pet?: PetConfig;
tips?: TipsConfig;
notifications?: NotificationConfig;
selfCheck?: SelfCheckConfig;
contextPruner?: ContextPrunerConfig;
todos?: TodoConfig;
chapters?: ChapterConfig;
mode?: ModeConfig;
statusline?: StatuslineConfig;
scheduledTasks?: ScheduledTasksConfig;
kbCorpus?: KbCorpusConfigType;
sessionRecap?: SessionRecapConfigType;
transcriptSearch?: TranscriptSearchConfig;
/** v5.7.3: 启动期诊断 openclaw.json 陷阱配置 */
configDoctor?: ConfigDoctorConfig;
/** v5.7.5: 按用户需求挑已装 skill / 推荐未装 / 给自建规划 */
skillRecommender?: SkillRecommenderConfig;
/** v5.7.7: 接入 openclaw 4.22 的 session_start/end/before_reset/subagent_* hook 闭环 session 生命周期 */
sessionLifecycle?: SessionLifecycleConfig;
}
export interface SessionRecapConfigType {
enabled?: boolean;
/** idle 判定阈值(分钟),默认 75 */
recapIdleMinutes?: number;
/** 两次 recap 最小间隔(分钟),防抖,默认 30 */
recapMinIntervalMinutes?: number;
/** recap 里最多展示章节数,默认 1 */
maxChapters?: number;
/** recap 里最多展示 todo 数,默认 3 */
maxTodos?: number;
/** recap 里最多展示 decision 记忆数,默认 2 */
maxDecisions?: number;
}
FILE:src/utils/channel-detect.ts
/**
* 渠道检测工具 - 跨渠道统一抽象
* 支持: wecom(企微), dingtalk(钉钉), terminal
*/
// sessionKey → channelId 的缓存
const channelCache = new Map<string, string>();
/**
* 从 message_received 事件中检测渠道并缓存
* 调用时机: api.on("message_received", ...)
*/
export function detectChannel(
event: { channel?: string; originatingChannel?: string },
sessionKey: string
): string {
// OpenClaw 可能在不同字段传渠道
const ch =
event.originatingChannel ??
event.channel ??
"terminal";
const resolved = ch.toLowerCase().trim();
channelCache.set(sessionKey, resolved);
return resolved;
}
/**
* 根据 sessionKey 查询渠道
*/
export function getChannel(sessionKey: string): string {
if (!sessionKey) return "terminal";
return channelCache.get(sessionKey) ?? "terminal";
}
/**
* 是否企微渠道
*/
export function isWecom(sessionKey: string): boolean {
return getChannel(sessionKey) === "wecom";
}
/**
* 是否钉钉渠道
*/
export function isDingtalk(sessionKey: string): boolean {
const ch = getChannel(sessionKey);
return ch === "dingtalk" || ch === "dingding";
}
/**
* 是否终端渠道(有 TTY)
*/
export function isTerminal(sessionKey: string): boolean {
const ch = getChannel(sessionKey);
return ch === "terminal" || ch === "" || ch === "cli";
}
/**
* 获取渠道偏好的输出格式
* wecom/dingtalk → emoji纯文本
* terminal → ASCII艺术
*/
export type OutputFormat = "emoji" | "ascii" | "markdown";
export function getOutputFormat(sessionKey: string): OutputFormat {
if (isTerminal(sessionKey)) return "ascii";
if (isWecom(sessionKey)) return "markdown";
if (isDingtalk(sessionKey)) return "emoji";
return "emoji";
}
FILE:src/utils/resolve-home.ts
/**
* 安全地获取 OpenClaw 主目录
*
* 优先级:
* 1. api.runtime.state.resolveStateDir()(SDK 官方方式)
* 2. OPENCLAW_STATE_DIR 环境变量
* 3. os.homedir() + "/.openclaw"
*
* 避免直接使用 process.env.HOME,防止安全扫描误报。
*/
import { homedir } from "node:os";
import { join } from "node:path";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
export function resolveOpenClawHome(api: OpenClawPluginApi): string {
// 优先: SDK 官方方法
try {
const stateDir = api.runtime?.state?.resolveStateDir?.();
if (stateDir) return stateDir;
} catch {
// fallback
}
// 次选: 环境变量(与 WeCom 插件一致)
const envDir = (globalThis as any).process?.env?.OPENCLAW_STATE_DIR?.trim();
if (envDir) return envDir;
// 兜底: os.homedir()
return join(homedir(), ".openclaw");
}
FILE:src/utils/sqlite-store.ts
/**
* SQLite 辅助工具 — 结构化记忆存储
*
* 所有表都包含 agent_id 列以支持动态 Agent 隔离。
* 每个 WeCom 用户/群组对应一个独立的 agentId,数据完全隔离。
*/
import Database from "better-sqlite3";
import { join } from "node:path";
import { existsSync, mkdirSync } from "node:fs";
import type {
MemoryEntry,
MemoryCategory,
FlamePet,
FlameColor,
FlameStats,
Notification,
NotificationLevel,
NotificationSource,
TodoEntry,
TodoStatus,
ChapterMark,
} from "../types.js";
const SCHEMA_V2 = `
CREATE TABLE IF NOT EXISTS memories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
agent_id TEXT NOT NULL DEFAULT 'main',
category TEXT NOT NULL CHECK(category IN ('user','project','feedback','reference','decision')),
content TEXT NOT NULL,
tags TEXT DEFAULT '',
importance INTEGER DEFAULT 5 CHECK(importance BETWEEN 1 AND 10),
session_id TEXT DEFAULT '',
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_memories_agent ON memories(agent_id);
CREATE INDEX IF NOT EXISTS idx_memories_agent_category ON memories(agent_id, category);
CREATE INDEX IF NOT EXISTS idx_memories_agent_created ON memories(agent_id, created_at DESC);
CREATE TABLE IF NOT EXISTS safety_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
agent_id TEXT NOT NULL DEFAULT 'main',
tool TEXT NOT NULL,
params TEXT,
action TEXT NOT NULL,
rule TEXT,
reason TEXT,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_safety_agent ON safety_log(agent_id);
CREATE INDEX IF NOT EXISTS idx_safety_agent_created ON safety_log(agent_id, created_at DESC);
`;
/**
* v1 → v2 迁移:为已有表添加 agent_id 列
*/
function migrateV1ToV2(db: Database.Database): void {
const memoryCols = db.prepare("PRAGMA table_info(memories)").all() as Array<{ name: string }>;
if (memoryCols.length > 0 && !memoryCols.some((c) => c.name === "agent_id")) {
db.exec("ALTER TABLE memories ADD COLUMN agent_id TEXT NOT NULL DEFAULT 'main'");
db.exec("CREATE INDEX IF NOT EXISTS idx_memories_agent ON memories(agent_id)");
db.exec("CREATE INDEX IF NOT EXISTS idx_memories_agent_category ON memories(agent_id, category)");
db.exec("CREATE INDEX IF NOT EXISTS idx_memories_agent_created ON memories(agent_id, created_at DESC)");
}
const safetyCols = db.prepare("PRAGMA table_info(safety_log)").all() as Array<{ name: string }>;
if (safetyCols.length > 0 && !safetyCols.some((c) => c.name === "agent_id")) {
db.exec("ALTER TABLE safety_log ADD COLUMN agent_id TEXT NOT NULL DEFAULT 'main'");
db.exec("CREATE INDEX IF NOT EXISTS idx_safety_agent ON safety_log(agent_id)");
db.exec("CREATE INDEX IF NOT EXISTS idx_safety_agent_created ON safety_log(agent_id, created_at DESC)");
}
}
/**
* v2 → v3 迁移:新增 flame_pets 和 notifications 表
*/
function migrateV2ToV3(db: Database.Database): void {
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() as Array<{ name: string }>;
const tableNames = new Set(tables.map((t) => t.name));
if (!tableNames.has("flame_pets")) {
db.exec(`
CREATE TABLE flame_pets (
agent_id TEXT PRIMARY KEY,
name TEXT NOT NULL DEFAULT '小火苗',
color TEXT NOT NULL DEFAULT 'orange',
level INTEGER NOT NULL DEFAULT 1,
xp INTEGER NOT NULL DEFAULT 0,
total_xp INTEGER NOT NULL DEFAULT 0,
stat_warmth INTEGER NOT NULL DEFAULT 10,
stat_brightness INTEGER NOT NULL DEFAULT 10,
stat_stability INTEGER NOT NULL DEFAULT 10,
stat_spark INTEGER NOT NULL DEFAULT 10,
stat_endurance INTEGER NOT NULL DEFAULT 10,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
`);
}
if (!tableNames.has("notifications")) {
db.exec(`
CREATE TABLE notifications (
id INTEGER PRIMARY KEY AUTOINCREMENT,
agent_id TEXT NOT NULL DEFAULT 'main',
level TEXT NOT NULL DEFAULT 'info',
source TEXT NOT NULL,
title TEXT NOT NULL,
detail TEXT DEFAULT '',
read INTEGER NOT NULL DEFAULT 0,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX idx_notif_agent ON notifications(agent_id);
CREATE INDEX idx_notif_created ON notifications(created_at DESC);
`);
}
}
/**
* v4 → v5 迁移:memories 表新增 why / how_to_apply 结构化正文字段
* (对齐 Claude Code feedback/project 记忆的 Why + How-to-apply 两段式)
*/
function migrateV4ToV5(db: Database.Database): void {
const memoryCols = db.prepare("PRAGMA table_info(memories)").all() as Array<{ name: string }>;
const hasCol = (n: string) => memoryCols.some((c) => c.name === n);
if (memoryCols.length > 0 && !hasCol("why")) {
db.exec("ALTER TABLE memories ADD COLUMN why TEXT DEFAULT NULL");
}
if (memoryCols.length > 0 && !hasCol("how_to_apply")) {
db.exec("ALTER TABLE memories ADD COLUMN how_to_apply TEXT DEFAULT NULL");
}
}
/**
* v3 → v4 迁移:todos / chapters / scheduled_task_bindings
*/
function migrateV3ToV4(db: Database.Database): void {
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() as Array<{ name: string }>;
const tableNames = new Set(tables.map((t) => t.name));
if (!tableNames.has("todos")) {
db.exec(`
CREATE TABLE todos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
agent_id TEXT NOT NULL DEFAULT 'main',
session_id TEXT NOT NULL DEFAULT '',
content TEXT NOT NULL,
active_form TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','in_progress','completed')),
position INTEGER NOT NULL DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX idx_todos_agent_session ON todos(agent_id, session_id);
CREATE INDEX idx_todos_agent_status ON todos(agent_id, status);
`);
}
if (!tableNames.has("chapters")) {
db.exec(`
CREATE TABLE chapters (
id INTEGER PRIMARY KEY AUTOINCREMENT,
agent_id TEXT NOT NULL DEFAULT 'main',
session_id TEXT NOT NULL DEFAULT '',
title TEXT NOT NULL,
summary TEXT DEFAULT '',
created_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX idx_chapters_agent_session ON chapters(agent_id, session_id, created_at DESC);
`);
}
if (!tableNames.has("scheduled_task_bindings")) {
db.exec(`
CREATE TABLE scheduled_task_bindings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
agent_id TEXT NOT NULL DEFAULT 'main',
name TEXT NOT NULL,
cron_ref TEXT NOT NULL,
instructions TEXT NOT NULL DEFAULT '',
enabled INTEGER NOT NULL DEFAULT 1,
last_fired_at TEXT,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX idx_scheduled_agent ON scheduled_task_bindings(agent_id);
`);
}
}
let _db: Database.Database | null = null;
export function getDb(openclawDir: string): Database.Database {
if (_db) return _db;
const memoryDir = join(openclawDir, "memory");
if (!existsSync(memoryDir)) mkdirSync(memoryDir, { recursive: true });
const dbPath = join(memoryDir, "enhance-memory.sqlite");
_db = new Database(dbPath);
_db.pragma("journal_mode = WAL");
// 检测是否需要迁移 v1 表
const tables = _db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() as Array<{ name: string }>;
if (tables.some((t) => t.name === "memories")) {
migrateV1ToV2(_db);
} else {
_db.exec(SCHEMA_V2);
}
// v2 → v3: 新增宠物和通知表
migrateV2ToV3(_db);
// v3 → v4: todos / chapters / scheduled_task_bindings
migrateV3ToV4(_db);
// v4 → v5: memories 加 why / how_to_apply
migrateV4ToV5(_db);
// v5.7.2: 启动期清理 safety_log / notifications 90 天前的旧记录,避免无限增长
// 异步 fire-and-forget,不阻塞插件加载
try {
_db.exec(`
DELETE FROM safety_log WHERE created_at < datetime('now', '-90 days');
DELETE FROM notifications WHERE created_at < datetime('now', '-90 days');
`);
} catch {
// 静默失败 — 旧表不存在或权限错误不影响插件正常工作
}
return _db;
}
/** v5.7.2: 暴露给运维 / enhance_memory_purge 调用的清理函数 */
export function purgeOldSafetyLogs(db: Database.Database, retentionDays: number = 90): { deleted: number } {
const result = db
.prepare(`DELETE FROM safety_log WHERE created_at < datetime('now', ?)`)
.run(`-retentionDays days`);
return { deleted: result.changes };
}
// ── 记忆操作(全部按 agentId 隔离)──
export function storeMemory(
db: Database.Database,
agentId: string,
category: MemoryCategory,
content: string,
tags: string = "",
importance: number = 5,
sessionId: string = "",
extras: { why?: string; howToApply?: string } = {},
): MemoryEntry {
const stmt = db.prepare(
`INSERT INTO memories (agent_id, category, content, tags, importance, session_id, why, how_to_apply)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
);
const result = stmt.run(
agentId,
category,
content,
tags,
Math.min(10, Math.max(1, importance)),
sessionId,
extras.why ?? null,
extras.howToApply ?? null,
);
return db.prepare("SELECT * FROM memories WHERE id = ?").get(result.lastInsertRowid) as MemoryEntry;
}
export function searchMemories(
db: Database.Database,
agentId: string,
opts: {
category?: MemoryCategory;
keyword?: string;
limit?: number;
since?: string;
} = {},
): MemoryEntry[] {
const conditions: string[] = ["agent_id = ?"];
const params: unknown[] = [agentId];
if (opts.category) {
conditions.push("category = ?");
params.push(opts.category);
}
if (opts.keyword) {
conditions.push("(content LIKE ? OR tags LIKE ?)");
params.push(`%opts.keyword%`, `%opts.keyword%`);
}
if (opts.since) {
conditions.push("created_at >= ?");
params.push(opts.since);
}
const where = `WHERE conditions.join(" AND ")`;
const limit = opts.limit ?? 20;
return db
.prepare(`SELECT * FROM memories where ORDER BY importance DESC, created_at DESC, id DESC LIMIT ?`)
.all(...params, limit) as MemoryEntry[];
}
export function getRecentMemories(db: Database.Database, agentId: string, limit: number = 5): MemoryEntry[] {
return db
.prepare("SELECT * FROM memories WHERE agent_id = ? ORDER BY created_at DESC LIMIT ?")
.all(agentId, limit) as MemoryEntry[];
}
export function deleteMemory(db: Database.Database, agentId: string, id: number): boolean {
const result = db.prepare("DELETE FROM memories WHERE id = ? AND agent_id = ?").run(id, agentId);
return result.changes > 0;
}
/**
* 按 tag / category / contentLike 批量清理记忆(agentId 隔离)。
* dry_run=true 时只 SELECT 不 DELETE,返回匹配条数(v5.7.1 hot-fix 用)。
*/
export function purgeMemories(
db: Database.Database,
agentId: string,
opts: {
tag?: string;
category?: MemoryCategory;
contentLike?: string;
dryRun?: boolean;
},
): { matched: number } {
const conditions: string[] = ["agent_id = ?"];
const params: unknown[] = [agentId];
if (opts.tag) {
conditions.push("tags LIKE ?");
params.push(`%opts.tag%`);
}
if (opts.category) {
conditions.push("category = ?");
params.push(opts.category);
}
if (opts.contentLike) {
conditions.push("content LIKE ?");
params.push(`%opts.contentLike%`);
}
const where = `WHERE conditions.join(" AND ")`;
const matched = (db.prepare(`SELECT COUNT(*) AS c FROM memories where`).get(...params) as {
c: number;
}).c;
if (!opts.dryRun) {
db.prepare(`DELETE FROM memories where`).run(...params);
}
return { matched };
}
export function getMemoryStats(db: Database.Database, agentId?: string): Record<string, number> {
const query = agentId
? "SELECT category, COUNT(*) as count FROM memories WHERE agent_id = ? GROUP BY category"
: "SELECT category, COUNT(*) as count FROM memories GROUP BY category";
const rows = (agentId
? db.prepare(query).all(agentId)
: db.prepare(query).all()
) as Array<{ category: string; count: number }>;
const stats: Record<string, number> = { total: 0 };
for (const row of rows) {
stats[row.category] = row.count;
stats.total += row.count;
}
return stats;
}
/** 获取所有已知的 agentId 列表 */
export function getAllAgentIds(db: Database.Database): string[] {
const rows = db
.prepare("SELECT DISTINCT agent_id FROM memories UNION SELECT DISTINCT agent_id FROM safety_log")
.all() as Array<{ agent_id: string }>;
return rows.map((r) => r.agent_id);
}
// ── 安全日志操作(全部按 agentId 隔离)──
export function logSafetyEvent(
db: Database.Database,
agentId: string,
tool: string,
params: string,
action: string,
rule: string = "",
reason: string = "",
): void {
db.prepare(
`INSERT INTO safety_log (agent_id, tool, params, action, rule, reason) VALUES (?, ?, ?, ?, ?, ?)`,
).run(agentId, tool, params, action, rule, reason);
}
export function getRecentSafetyEvents(
db: Database.Database,
agentId?: string,
limit: number = 20,
): Array<{ id: number; agent_id: string; tool: string; params: string; action: string; rule: string; reason: string; created_at: string }> {
if (agentId) {
return db
.prepare("SELECT * FROM safety_log WHERE agent_id = ? ORDER BY created_at DESC LIMIT ?")
.all(agentId, limit) as any[];
}
return db
.prepare("SELECT * FROM safety_log ORDER BY created_at DESC LIMIT ?")
.all(limit) as any[];
}
export function getSafetyStats(
db: Database.Database,
agentId?: string,
): { total: number; blocked: number; logged: number } {
const where = agentId ? "WHERE agent_id = ?" : "";
const row = (agentId
? db.prepare(
`SELECT
COUNT(*) as total,
SUM(CASE WHEN action = 'block' THEN 1 ELSE 0 END) as blocked,
SUM(CASE WHEN action = 'log' THEN 1 ELSE 0 END) as logged
FROM safety_log where`,
).get(agentId)
: db.prepare(
`SELECT
COUNT(*) as total,
SUM(CASE WHEN action = 'block' THEN 1 ELSE 0 END) as blocked,
SUM(CASE WHEN action = 'log' THEN 1 ELSE 0 END) as logged
FROM safety_log`,
).get()
) as any;
return { total: row.total ?? 0, blocked: row.blocked ?? 0, logged: row.logged ?? 0 };
}
// ── 火苗宠物操作 ──
const XP_PER_LEVEL = (level: number) => 50 + level * 30;
const SIZE_FOR_LEVEL = (level: number): "tiny" | "small" | "medium" | "large" =>
level <= 5 ? "tiny" : level <= 15 ? "small" : level <= 30 ? "medium" : "large";
const COLOR_FOR_STATS = (stats: FlameStats): FlameColor => {
const entries: [keyof FlameStats, FlameColor][] = [
["warmth", "orange"], ["brightness", "white"], ["stability", "blue"],
["spark", "purple"], ["endurance", "green"],
];
let max = 0; let color: FlameColor = "orange";
for (const [key, c] of entries) {
if (stats[key] > max) { max = stats[key]; color = c; }
}
return color;
};
const PERSONALITY_FOR_STATS = (stats: FlameStats): string => {
const entries: [keyof FlameStats, string][] = [
["warmth", "温暖守护者"], ["brightness", "明亮引路人"], ["stability", "沉稳磐石"],
["spark", "灵感火花"], ["endurance", "不灭之焰"],
];
let max = 0; let p = "温暖守护者";
for (const [key, label] of entries) {
if (stats[key] > max) { max = stats[key]; p = label; }
}
return p;
};
function rowToPet(row: any): FlamePet {
const stats: FlameStats = {
warmth: row.stat_warmth, brightness: row.stat_brightness,
stability: row.stat_stability, spark: row.stat_spark, endurance: row.stat_endurance,
};
return {
agent_id: row.agent_id, name: row.name, color: row.color as FlameColor,
size: SIZE_FOR_LEVEL(row.level), level: row.level, xp: row.xp,
total_xp: row.total_xp, mood: "idle", stats,
personality: PERSONALITY_FOR_STATS(stats),
created_at: row.created_at, updated_at: row.updated_at,
};
}
export function getOrCreatePet(db: Database.Database, agentId: string, defaultName?: string, defaultColor?: FlameColor): FlamePet {
let row = db.prepare("SELECT * FROM flame_pets WHERE agent_id = ?").get(agentId) as any;
if (!row) {
db.prepare("INSERT INTO flame_pets (agent_id, name, color) VALUES (?, ?, ?)").run(
agentId, defaultName ?? "小火苗", defaultColor ?? "orange",
);
row = db.prepare("SELECT * FROM flame_pets WHERE agent_id = ?").get(agentId);
}
return rowToPet(row);
}
export function addPetXp(
db: Database.Database, agentId: string,
xpGain: number, statBoosts?: Partial<FlameStats>,
): { pet: FlamePet; leveledUp: boolean; oldLevel: number } {
const pet = getOrCreatePet(db, agentId);
const oldLevel = pet.level;
let xp = pet.xp + xpGain;
let level = pet.level;
let totalXp = pet.total_xp + xpGain;
// 升级循环
while (xp >= XP_PER_LEVEL(level) && level < 50) {
xp -= XP_PER_LEVEL(level);
level++;
}
if (level >= 50) xp = Math.min(xp, XP_PER_LEVEL(50));
// 属性加成
const w = Math.min(100, pet.stats.warmth + (statBoosts?.warmth ?? 0));
const b = Math.min(100, pet.stats.brightness + (statBoosts?.brightness ?? 0));
const st = Math.min(100, pet.stats.stability + (statBoosts?.stability ?? 0));
const sp = Math.min(100, pet.stats.spark + (statBoosts?.spark ?? 0));
const e = Math.min(100, pet.stats.endurance + (statBoosts?.endurance ?? 0));
// 升级时自动推导颜色
const stats: FlameStats = { warmth: w, brightness: b, stability: st, spark: sp, endurance: e };
const color = level !== oldLevel ? COLOR_FOR_STATS(stats) : pet.color;
db.prepare(`
UPDATE flame_pets SET level=?, xp=?, total_xp=?, color=?,
stat_warmth=?, stat_brightness=?, stat_stability=?, stat_spark=?, stat_endurance=?,
updated_at=datetime('now')
WHERE agent_id=?
`).run(level, xp, totalXp, color, w, b, st, sp, e, agentId);
const updated = getOrCreatePet(db, agentId);
return { pet: updated, leveledUp: level > oldLevel, oldLevel };
}
export function renamePet(db: Database.Database, agentId: string, name: string): void {
getOrCreatePet(db, agentId);
db.prepare("UPDATE flame_pets SET name=?, updated_at=datetime('now') WHERE agent_id=?").run(name, agentId);
}
export function setPetColor(db: Database.Database, agentId: string, color: FlameColor): void {
getOrCreatePet(db, agentId);
db.prepare("UPDATE flame_pets SET color=?, updated_at=datetime('now') WHERE agent_id=?").run(color, agentId);
}
// ── 通知操作 ──
export function emitNotification(
db: Database.Database, agentId: string,
level: NotificationLevel, source: NotificationSource,
title: string, detail: string = "",
): void {
db.prepare(
"INSERT INTO notifications (agent_id, level, source, title, detail) VALUES (?, ?, ?, ?, ?)",
).run(agentId, level, source, title, detail);
}
export function getRecentNotifications(db: Database.Database, agentId?: string, limit: number = 20): Notification[] {
if (agentId) {
return db.prepare(
"SELECT * FROM notifications WHERE agent_id = ? ORDER BY created_at DESC LIMIT ?",
).all(agentId, limit) as any[];
}
return db.prepare(
"SELECT * FROM notifications ORDER BY created_at DESC LIMIT ?",
).all(limit) as any[];
}
export function getUnreadNotificationCount(db: Database.Database, agentId?: string): number {
const row = agentId
? db.prepare("SELECT COUNT(*) as c FROM notifications WHERE agent_id=? AND read=0").get(agentId) as any
: db.prepare("SELECT COUNT(*) as c FROM notifications WHERE read=0").get() as any;
return row?.c ?? 0;
}
export function markNotificationRead(db: Database.Database, id: number): void {
db.prepare("UPDATE notifications SET read=1 WHERE id=?").run(id);
}
export function pruneNotifications(db: Database.Database, maxRetained: number): void {
const count = (db.prepare("SELECT COUNT(*) as c FROM notifications").get() as any)?.c ?? 0;
if (count > maxRetained * 1.5) {
db.prepare(`
DELETE FROM notifications WHERE id NOT IN (
SELECT id FROM notifications ORDER BY created_at DESC LIMIT ?
)
`).run(maxRetained);
}
}
// ── Todo 操作 ──
export interface TodoInput {
content: string;
activeForm: string;
status?: TodoStatus;
}
export function replaceTodos(
db: Database.Database,
agentId: string,
sessionId: string,
todos: TodoInput[],
): TodoEntry[] {
const tx = db.transaction(() => {
db.prepare("DELETE FROM todos WHERE agent_id = ? AND session_id = ?").run(agentId, sessionId);
const insert = db.prepare(
`INSERT INTO todos (agent_id, session_id, content, active_form, status, position)
VALUES (?, ?, ?, ?, ?, ?)`,
);
todos.forEach((t, i) => {
insert.run(agentId, sessionId, t.content, t.activeForm, t.status ?? "pending", i);
});
});
tx();
return listTodos(db, agentId, sessionId);
}
export function listTodos(db: Database.Database, agentId: string, sessionId?: string): TodoEntry[] {
if (sessionId) {
return db
.prepare(
`SELECT * FROM todos WHERE agent_id = ? AND session_id = ? ORDER BY position ASC, id ASC`,
)
.all(agentId, sessionId) as TodoEntry[];
}
return db
.prepare(
`SELECT * FROM todos WHERE agent_id = ? ORDER BY updated_at DESC, position ASC LIMIT 200`,
)
.all(agentId) as TodoEntry[];
}
export function updateTodoStatus(
db: Database.Database,
agentId: string,
sessionId: string,
position: number,
status: TodoStatus,
): TodoEntry | null {
db.prepare(
`UPDATE todos SET status = ?, updated_at = datetime('now')
WHERE agent_id = ? AND session_id = ? AND position = ?`,
).run(status, agentId, sessionId, position);
return (
(db
.prepare(`SELECT * FROM todos WHERE agent_id = ? AND session_id = ? AND position = ?`)
.get(agentId, sessionId, position) as TodoEntry | undefined) ?? null
);
}
export function getLatestTodos(db: Database.Database, agentId: string): TodoEntry[] {
const sessionRow = db
.prepare(
`SELECT session_id FROM todos WHERE agent_id = ? ORDER BY updated_at DESC LIMIT 1`,
)
.get(agentId) as { session_id?: string } | undefined;
if (!sessionRow?.session_id) return [];
return listTodos(db, agentId, sessionRow.session_id);
}
// ── Chapter 操作 ──
export function addChapter(
db: Database.Database,
agentId: string,
sessionId: string,
title: string,
summary = "",
): ChapterMark {
const r = db
.prepare(
`INSERT INTO chapters (agent_id, session_id, title, summary) VALUES (?, ?, ?, ?)`,
)
.run(agentId, sessionId, title, summary);
return db
.prepare(`SELECT * FROM chapters WHERE id = ?`)
.get(r.lastInsertRowid) as ChapterMark;
}
export function listChapters(
db: Database.Database,
agentId: string,
sessionId?: string,
limit = 50,
): ChapterMark[] {
if (sessionId) {
return db
.prepare(
`SELECT * FROM chapters WHERE agent_id = ? AND session_id = ? ORDER BY created_at ASC LIMIT ?`,
)
.all(agentId, sessionId, limit) as ChapterMark[];
}
return db
.prepare(
`SELECT * FROM chapters WHERE agent_id = ? ORDER BY created_at DESC LIMIT ?`,
)
.all(agentId, limit) as ChapterMark[];
}
// ── Scheduled task binding 操作 ──
export interface ScheduledTaskBinding {
id: number;
agent_id: string;
name: string;
cron_ref: string;
instructions: string;
enabled: number;
last_fired_at: string | null;
created_at: string;
}
export function upsertScheduledBinding(
db: Database.Database,
agentId: string,
name: string,
cronRef: string,
instructions: string,
): ScheduledTaskBinding {
const existing = db
.prepare(`SELECT * FROM scheduled_task_bindings WHERE agent_id = ? AND name = ?`)
.get(agentId, name) as ScheduledTaskBinding | undefined;
if (existing) {
db.prepare(
`UPDATE scheduled_task_bindings SET cron_ref = ?, instructions = ?, enabled = 1 WHERE id = ?`,
).run(cronRef, instructions, existing.id);
return {
...existing,
cron_ref: cronRef,
instructions,
enabled: 1,
};
}
const r = db
.prepare(
`INSERT INTO scheduled_task_bindings (agent_id, name, cron_ref, instructions) VALUES (?, ?, ?, ?)`,
)
.run(agentId, name, cronRef, instructions);
return db
.prepare(`SELECT * FROM scheduled_task_bindings WHERE id = ?`)
.get(r.lastInsertRowid) as ScheduledTaskBinding;
}
export function listScheduledBindings(
db: Database.Database,
agentId?: string,
): ScheduledTaskBinding[] {
if (agentId) {
return db
.prepare(
`SELECT * FROM scheduled_task_bindings WHERE agent_id = ? ORDER BY created_at DESC`,
)
.all(agentId) as ScheduledTaskBinding[];
}
return db
.prepare(`SELECT * FROM scheduled_task_bindings ORDER BY created_at DESC`)
.all() as ScheduledTaskBinding[];
}
export function disableScheduledBinding(
db: Database.Database,
agentId: string,
name: string,
): boolean {
const r = db
.prepare(
`UPDATE scheduled_task_bindings SET enabled = 0 WHERE agent_id = ? AND name = ?`,
)
.run(agentId, name);
return r.changes > 0;
}
export function touchScheduledBindingFired(
db: Database.Database,
id: number,
): void {
db.prepare(
`UPDATE scheduled_task_bindings SET last_fired_at = datetime('now') WHERE id = ?`,
).run(id);
}
FILE:templates/AGENTS.enhance-patch.md
<!-- ═══ 龙虾增强包补丁 开始 ═══ -->
## 增强行为准则(龙虾增强包)
### 决策框架
在执行多步骤任务时,遵循「理解 → 设计 → 执行 → 验证」的流程:
1. **理解**: 先读相关代码和文档,不要凭空假设
2. **设计**: 复杂任务先规划(可使用 plan-mode 技能),简单任务直接执行
3. **执行**: 最小变更原则,一次只改一件事
4. **验证**: 改完后确认结果正确(可使用 verify-mode 技能)
### 响应模式
根据请求类型自动调整:
- **编程任务**: 简洁精准,展示最小 diff,解释 why 而非 what
- **研究调查**: 系统全面,给出文件路径和行号,区分事实和推测
- **日常对话**: 自然简短,不过度解释
### 记忆管理
- 当了解到重要的用户偏好、项目决策、反馈信息时,使用 `enhance_memory_store` 存储
- 回答问题前,考虑使用 `enhance_memory_search` 查找相关历史记忆
- 定期使用 memory-curator 技能整理记忆
### 安全意识
- 执行破坏性操作(删除、覆盖、force push)前先确认
- 不要提交敏感文件(.env、密钥、凭据)
- 如果发现安全问题立即指出并修复
<!-- ═══ 龙虾增强包补丁 结束 ═══ -->
FILE:templates/SOUL.enhance-patch.md
<!-- ═══ 龙虾增强包补丁 开始 ═══ -->
## 增强行为特质(龙虾增强包)
### 工作风格
- 直奔主题,一句话能说清的不用三句话
- 修 bug 不顺手重构,加功能不过度设计
- 三行相似代码好过一个过早的抽象
- 失败时先诊断原因再重试,不盲目循环
### 增强能力
你拥有以下增强工具,在合适的时机使用:
- `enhance_memory_store/search/review` — 结构化记忆管理
- `enhance_safety_log/rules` — 安全审计
- `enhance_workflow_define/list/delete` — 工作流自动化
- `plan-mode` / `explore-mode` / `verify-mode` / `memory-curator` — 增强技能
### 记忆习惯
当以下情况发生时,主动存储记忆:
- 用户纠正你的做法(feedback 类)
- 用户表达偏好(user 类)
- 做出重要决策或发现关键信息(decision/project 类)
- 提到有用的外部资源(reference 类)
<!-- ═══ 龙虾增强包补丁 结束 ═══ -->
FILE:tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "dist",
"declaration": true,
"resolveJsonModule": true
},
"include": ["index.ts", "src/**/*.ts"],
"exclude": ["node_modules", "dist"]
}