Initial import: zcbot personal task agent

DESIGN.md / PROGRESS.md 落地 Phase 1-3:
- core/: LiteLLM 封装 + ReAct loop + 会话持久化 + Anthropic skill registry
- tools/: read/write/edit/glob/grep/shell/run_python/load_skill (Hybrid 范式)
- skills/coding | proposal: WHY+WHAT 风格 SKILL.md
- skills/ppt: 完整渐进披露 (SKILL + 4 references + 3 scripts)
  · 借鉴 hugohe3/ppt-master 的两阶段 + spec lock 思路
  · MSO_SHAPE 图标体系 + 安全区 + 越界检测
  · 默认商务红主题 (#C00000 / #E15554 / #FFC107)
- config/models/: DeepSeek V4 flash/pro 档案

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-05-06 11:02:59 +08:00
commit 3a66849953
31 changed files with 4000 additions and 0 deletions

47
.gitignore vendored Normal file
View File

@ -0,0 +1,47 @@
# Secrets
.env
.env.*
*.key
*.pem
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
*.egg-info/
.pytest_cache/
.ruff_cache/
.mypy_cache/
# Virtualenv
.venv/
venv/
env/
# 用户运行产物 / 临时文件
workspace/
slides/
*.log
# Claude Code 本地状态
.claude/
# IDE / OS
.vscode/
.idea/
*.swp
.DS_Store
Thumbs.db
desktop.ini
# 可执行产物 (PPT skill 测试输出)
*.tmp.pptx
output.pptx
untitled*.pptx
# 用户本地工具脚本 / 规划文件 (不入库)
规划.docx
cl.ps1
col.bat

1140
DESIGN.md Normal file

File diff suppressed because it is too large Load Diff

154
PROGRESS.md Normal file
View File

@ -0,0 +1,154 @@
# 实施进度
> 配合 `DESIGN.md` 阅读。本文件记录已完成的事、关键决策、与原设计的偏差。
最后更新: 2026-05-06 (PPT skill 完善:references + scripts;v2 加图标系统 + 安全区 + 越界检测 + 默认红色主题)
---
## 总体状态
| Phase | 标题 | 状态 | 备注 |
|------|-----|-----|------|
| 1 | 最小可用骨架 | ✅ 完成 | 全部验收点过 |
| 2 | Skill 系统 + 三个 skill | ✅ 完成 | Anthropic 格式;coding/ppt/proposal |
| 3 | Hybrid 范式 (run_python) | ✅ 完成 | subprocess + 敏感 env 过滤 |
| 4 | 演化性能力 | 🟡 部分 | Model Profile 已就位;capability probing 未做;版本化 prompts 未做 |
| 5 | Eval Suite | ❌ 未开始 | |
| 6 | 长任务工程化 | 🟡 部分 | session 中断恢复已完成;context 压缩、双层记忆未做 |
| 7 | 打磨 | ❌ 未开始 | Docker 沙盒 / 更多 skill / Web UI |
---
## 已完成清单
### 1. 项目骨架
- 目录: `core/ tools/ skills/ prompts/ config/ workspace/`
- 入口: `cli.py` (REPL) + `main.py` (装配)
- 依赖: `requirements.txt` (litellm / pyyaml / click / rich / python-pptx / python-docx / matplotlib)
- 本地虚拟环境: `.venv/`(Python 3.10.9)
### 2. 模型层
- `core/capabilities.py`: `ModelCapabilities` 数据类,从 `config/models/<family>.yaml` 加载
- `core/llm.py`: LiteLLM 封装,自动按 capabilities 启用 parallel_tools / reasoning_effort / prompt_caching / thinking_mode;指数退避重试
- `config/models/deepseek_v4.yaml`: flash 和 pro 两档
- 缺 `DEEPSEEK_API_KEY` 时报清晰错误,不崩
### 3. 会话与持久化
- `core/session.py`: 内存消息列表 + 元数据 + 落盘 JSON,文件格式
```json
{"meta": {"id","created_at","cwd","model","model_profile"}, "messages": [...]}
```
老格式(纯 list)向后兼容
- 每次 `cli.py chat` 启动一个新 session,文件名 `workspace/sessions/<YYYYMMDD_HHMMSS>.json`
- 支持: `--resume last` / `--resume <id>`;resume 时若当前 cwd 与记录不同会警告
- REPL 命令: `/exit /reset /new /id`
- `cli.py sessions` 列表显示 id / msgs / cwd / 第一条用户消息预览
### 4. ReAct 主循环
- `core/loop.py`: LLM ↔ tool 循环,无 tool_calls 即返回
- LLM 调用包了 `console.status("thinking...", spinner="dots")` 转圈点
- 工具结果对模型截断到 16K 字符,对用户预览 400 字符
- 所有日志走 `rich.Console`,彩色
### 5. 通用工具
- `tools/base.py`: `Tool` 基类 + `_resolve` 路径解析
- `tools/fs.py`:
- `read` —— 带行号,支持 offset/limit
- `write` —— 自动建父目录,覆写
- `edit` —— old_str **唯一匹配**约束(CoreCoder 风格)
- `glob` —— `**/*.py` 等模式
- `grep` —— Python 正则,自动跳过 `.git node_modules __pycache__ .venv venv dist build`
- `tools/shell.py`: subprocess 执行,黑名单拦 `rm -rf /` 等;默认 60s 超时
- `tools/run_python.py`: subprocess 跑临时 .py 文件,过滤 `*API_KEY *TOKEN *SECRET *PASSWORD *PRIVATE_KEY` 环境变量
### 6. Skill 系统(Anthropic 渐进披露标准)
- `core/skills.py`: `SkillRegistry` 扫描 `skills/<name>/`,只读 SKILL.md frontmatter 做 discovery
- `tools/skill_tool.py`: `load_skill(name)` 工具返回完整 SKILL.md 给模型
- 三个 skill,均按 WHY+WHAT 风格写,不写 Step 1/2/3:
- `skills/coding/SKILL.md`
- `skills/ppt/` —— 完整渐进披露结构(借鉴 hugohe3/ppt-master 的两阶段 + spec lock 思路):
- `SKILL.md`(两阶段工作流 + 八条对齐 + 默认红色主题 + 反模式)
- `references/design_principles.md`(字号/配色/留白/图表 + §4.1 **字数预算表**)
- `references/canvas_presets.md`(16:9 / 4:3 / 9:16 等画布表)
- `references/layouts.md`(9 种轻量版式 + **safe area 起手** + assert_inside / TEXT_TO_FIT_SHAPE 兜底)
- `references/icons.md`(MSO_SHAPE 图标目录 + unicode 字形表 + 5 个标准图标 helper)
- `scripts/quality_check.py`(页数/标题/bullet/字号/配色 + **shape 越界 + 文本溢出估算**)
- `scripts/source_to_md.py`(PDF/DOCX/PPTX/URL → Markdown,策略阶段输入)
- `scripts/render_icon.py`(unicode 字形 → 透明 PNG,MSO_SHAPE 兜底)
- **默认配色**:商务红 PRIMARY `#C00000` / SECONDARY `#E15554` / ACCENT `#FFC107`
- `skills/proposal/SKILL.md`(含工作目录约定 + 字数表 + python-docx 合并模板)
### 7. System Prompt
- `prompts/system/general_v1.md`(无版本化软链接,直接引用 v1)
- 启动时拼接顺序: 通用指引 → discovery 块(skill 列表) → 当前工作目录
---
## 关键决策与偏差
| 项 | 决策 | 与设计差异 |
|---|------|-----------|
| 工具基目录 | 用户当前 cwd,不是 workspace/ | 设计未明说;选 cwd 是因为 agent 该操作用户的项目 |
| Workspace 用途 | 只存 sessions/(暂时) | 设计含 `tasks/ memory/ logs/`,后续 Phase 6 再加 |
| Session 粒度 | 一个文件一个 session,无 task 概念 | 设计有 task_id / state.json,Phase 6 再加 |
| 版本化 prompt | 直接 general_v1.md,无 active.md 软链接 | Windows 软链接麻烦;后续要切版本时再做 |
| run_python 沙盒 | subprocess + env 过滤 | 设计阶段 1 就是这套,未升级 Docker |
| 工具数 | 8 个 (read/write/edit/glob/grep/shell/run_python/load_skill) | 设计上限 ≤10 同时可见,目前刚好 |
---
## 验收过的测试
- 全项目 `ast.parse` 语法 OK
- yaml 配置可解析
- 所有 import 链路在 venv 中跑通
- `cli.py --help` / `cli.py chat --help` / `cli.py sessions --help` 正常
- `SkillRegistry` 识别出 3 个 skill,discovery 块拼装正确
- 缺 `DEEPSEEK_API_KEY` 时报清晰错误
- 实测 DeepSeek API 接通(`deepseek-v4-flash` 模型 ID 被认),仅因账户余额不足而返回 InsufficientBalance —— **接入路径已通**
---
## 已知遗留 / 下一步候选
按性价比排序:
1. **Phase 4 capability probing**(~半天)—— 启动时跑 needle-in-haystack / 并行 tool 探测,把 yaml 声称的能力对账
2. **Phase 5 Eval Suite**(~2 天)—— 模型升级决策的依据。每类任务 3-5 个 case,客观 + LLM judge 双评分
3. **Phase 6 task 概念 + state.json**(~1 天)—— 让 session 升级为任务,workspace 加 `tasks/<task_id>/`
4. **Phase 6 context 三层压缩**(~1 天)—— 兜底用,V4 长上下文一般用不到
5. **Phase 6 双层记忆**(~半天)—— `workspace/memory/core.md` 注 prompt + `extended/` 按需读
6. **Phase 7 Docker 沙盒**(~1 天)—— 替换 subprocess,run_python 安全升级
7. **Phase 7 更多 skill / 模型档案**(持续)
---
## 文件清单(代码量)
```
core/capabilities.py 71 行
core/llm.py 89 行
core/loop.py 99 行
core/session.py 77 行
core/skills.py 81 行
tools/base.py 34 行
tools/fs.py 182 行
tools/shell.py 63 行
tools/run_python.py 84 行
tools/skill_tool.py 45 行
main.py 120 行
cli.py 138 行
─────────────────────────────────
合计 Python 1083 行
prompts/system/general_v1.md
skills/coding/SKILL.md
skills/ppt/SKILL.md
skills/proposal/SKILL.md
config/agent.yaml
config/models/deepseek_v4.yaml
requirements.txt
```
设计预估 Phase 1-3 大约 800-1000 行,实际 1083 行,略多但仍在可读范围。

138
cli.py Normal file
View File

@ -0,0 +1,138 @@
"""CLI 入口: 简单 REPL。
用法:
python cli.py chat # 新建一个 session
python cli.py chat --resume last # 恢复最近一个
python cli.py chat --resume 20260506_141523
python cli.py chat --model deepseek_v4.pro
python cli.py sessions # 列出历史 session
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
import click
from rich.console import Console
from rich.prompt import Prompt
from main import build_agent, load_config, resolve_workspace, sessions_dir
@click.group()
def cli() -> None:
"""zcbot - 个人任务 agent"""
@cli.command()
@click.option("--model", default=None, help="模型档案,如 deepseek_v4.flash 或 deepseek_v4.pro")
@click.option("--workspace", default=None, help="工作目录(存 sessions/)")
@click.option("--resume", default=None, help="恢复某个 session: 'last' 或 session_id")
def chat(model: str, workspace: str, resume: str) -> None:
"""启动交互式 REPL。每次启动默认开新 session,用 --resume 接老的。"""
console = Console()
try:
agent, session, sid = build_agent(
model_name=model,
workspace=workspace,
console=console,
session_id=resume,
resume=bool(resume),
)
except Exception as e:
console.print(f"[red]启动失败:[/red] {type(e).__name__}: {e}")
sys.exit(1)
if resume:
console.print(
f"[green]恢复 session[/green] [bold]{sid}[/bold] ({len(session.messages)} 条消息) "
f"model: [bold]{agent.caps.model_id}[/bold]"
)
else:
console.print(
f"[green]新 session[/green] [bold]{sid}[/bold] "
f"model: [bold]{agent.caps.model_id}[/bold]"
)
console.print("[dim]/exit 退出 /reset 清空当前对话 /new 开一个新 session /id 显示 session id[/dim]\n")
while True:
try:
user_input = Prompt.ask("[bold blue]you[/bold blue]")
except (EOFError, KeyboardInterrupt):
console.print("\n[dim]bye[/dim]")
break
cmd = user_input.strip()
if cmd in ("/exit", "/quit"):
break
if cmd == "/reset":
session.reset(keep_system=True)
console.print("[dim]当前 session 已重置(保留 system)[/dim]")
continue
if cmd == "/new":
try:
agent, session, sid = build_agent(
model_name=model, workspace=workspace, console=console
)
except Exception as e:
console.print(f"[red]新建失败:[/red] {type(e).__name__}: {e}")
continue
console.print(f"[green]新 session[/green] [bold]{sid}[/bold]")
continue
if cmd == "/id":
cwd_disp = session.meta.get("cwd", "?")
model_disp = session.meta.get("model", agent.caps.model_id)
console.print(f"[dim]session: {sid} model: {model_disp} cwd: {cwd_disp}[/dim]")
continue
if not cmd:
continue
try:
agent.run(user_input)
except KeyboardInterrupt:
console.print("\n[yellow]已中断本轮。下一条输入会继续这个 session。[/yellow]")
except Exception as e:
console.print(f"[red]运行错误:[/red] {type(e).__name__}: {e}")
@cli.command()
@click.option("--workspace", default=None, help="工作目录")
@click.option("--limit", default=20, help="显示最近 N 个")
def sessions(workspace: str, limit: int) -> None:
"""列出已有 session。"""
cfg = load_config()
ws = resolve_workspace(workspace, cfg)
sdir = sessions_dir(ws)
items = sorted(sdir.glob("*.json"), reverse=True)[:limit]
if not items:
click.echo(f"(no sessions in {sdir})")
return
click.echo(f"{'session id':<18} {'msgs':>4} {'cwd':<32} preview")
click.echo("-" * 100)
for p in items:
try:
data = json.loads(p.read_text(encoding="utf-8"))
if isinstance(data, list):
messages, meta = data, {}
else:
messages = data.get("messages", []) or []
meta = data.get("meta", {}) or {}
n = len(messages)
preview = ""
for m in messages:
if isinstance(m, dict) and m.get("role") == "user":
preview = (m.get("content") or "")[:50].replace("\n", " ")
break
cwd = meta.get("cwd") or "?"
if len(cwd) > 32:
cwd = "..." + cwd[-29:]
except Exception as e:
n, preview, cwd = -1, f"[parse error: {e}]", "?"
click.echo(f"{p.stem:<18} {n:>4} {cwd:<32} {preview}")
if __name__ == "__main__":
cli()

7
config/agent.yaml Normal file
View File

@ -0,0 +1,7 @@
# 默认模型档案: <family>.<variant>,对应 config/models/<family>.yaml
default_model: deepseek_v4.flash
models_dir: config/models
skills_dir: skills
workspace_dir: workspace
system_prompt: prompts/system/general_v1.md

View File

@ -0,0 +1,42 @@
# DeepSeek V4 模型档案
# 如你的账号还没有 V4 访问权限,可把 model_id 改成 deepseek/deepseek-chat
family: deepseek_v4
variants:
flash:
model_id: deepseek/deepseek-v4-flash
api_base: https://api.deepseek.com/v1
api_key_env: DEEPSEEK_API_KEY
max_context: 1048576
reliable_context: 262144
max_output: 8192
parallel_tools: false
tool_calling_quality: good
thinking_mode: false
reasoning_effort_levels: []
default_reasoning_effort: ""
code_quality: good
enable_run_python: true
max_iterations: 50
optimal_temperature: 0.3
prompt_caching: false
extended_thinking: false
pro:
model_id: deepseek/deepseek-v4-pro
api_base: https://api.deepseek.com/v1
api_key_env: DEEPSEEK_API_KEY
max_context: 1048576
reliable_context: 524288
max_output: 8192
parallel_tools: true
tool_calling_quality: excellent
thinking_mode: true
reasoning_effort_levels: [low, medium, high, max]
default_reasoning_effort: medium
code_quality: excellent
enable_run_python: true
max_iterations: 100
optimal_temperature: 0.2
prompt_caching: false
extended_thinking: false

0
core/__init__.py Normal file
View File

71
core/capabilities.py Normal file
View File

@ -0,0 +1,71 @@
"""模型能力档案: 不同模型的参数差异都收敛到 yaml,加新模型不用改代码。"""
from __future__ import annotations
from dataclasses import dataclass, field, fields
from pathlib import Path
from typing import List
import yaml
@dataclass
class ModelCapabilities:
model_id: str = ""
family: str = ""
variant: str = ""
# 上下文
max_context: int = 128_000
reliable_context: int = 64_000
max_output: int = 4096
# Tool calling
parallel_tools: bool = False
tool_calling_quality: str = "good"
# 思考模式
thinking_mode: bool = False
reasoning_effort_levels: List[str] = field(default_factory=list)
default_reasoning_effort: str = ""
# 代码 / 沙盒
code_quality: str = "good"
enable_run_python: bool = False
# 工程参数
max_iterations: int = 50
optimal_temperature: float = 0.3
# provider 特性
prompt_caching: bool = False
extended_thinking: bool = False
# API 接入
api_base: str = ""
api_key_env: str = ""
@classmethod
def load(cls, name: str, models_dir: Path) -> "ModelCapabilities":
"""name: '<family>.<variant>',如 'deepseek_v4.flash'"""
if "." in name:
family, variant = name.split(".", 1)
else:
family, variant = name, "default"
path = Path(models_dir) / f"{family}.yaml"
if not path.exists():
raise FileNotFoundError(f"模型档案不存在: {path}")
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
variants = data.get("variants", {})
if variant not in variants:
raise ValueError(
f"档案 {path} 没有 variant={variant};可选: {list(variants)}"
)
var = variants[variant]
valid_keys = {f.name for f in fields(cls)}
kwargs = {k: v for k, v in var.items() if k in valid_keys}
kwargs["family"] = data.get("family", family)
kwargs["variant"] = variant
return cls(**kwargs)

89
core/llm.py Normal file
View File

@ -0,0 +1,89 @@
"""LiteLLM 封装: capabilities 决定调用参数,自动重试。"""
from __future__ import annotations
import os
import time
from typing import Any, List, Optional
import litellm
from litellm.exceptions import (
APIConnectionError,
APIError,
RateLimitError,
ServiceUnavailableError,
Timeout,
)
from .capabilities import ModelCapabilities
class TokenCounter:
def __init__(self) -> None:
self.prompt_tokens = 0
self.completion_tokens = 0
def add(self, usage: Any) -> None:
if not usage:
return
if hasattr(usage, "model_dump"):
usage = usage.model_dump()
elif hasattr(usage, "dict"):
usage = usage.dict()
if isinstance(usage, dict):
self.prompt_tokens += int(usage.get("prompt_tokens") or 0)
self.completion_tokens += int(usage.get("completion_tokens") or 0)
@property
def total(self) -> int:
return self.prompt_tokens + self.completion_tokens
class LLM:
def __init__(self, capabilities: ModelCapabilities) -> None:
self.caps = capabilities
env_name = capabilities.api_key_env or "DEEPSEEK_API_KEY"
self.api_key = os.environ.get(env_name)
self.api_base = capabilities.api_base or None
self.token_counter = TokenCounter()
if not self.api_key:
raise RuntimeError(
f"环境变量 {env_name} 未设置,无法调用 {capabilities.model_id}"
)
def chat(
self,
messages: List[dict],
tools: Optional[list] = None,
parallel_tool_calls: Optional[bool] = None,
reasoning_effort: Optional[str] = None,
max_retries: int = 3,
) -> Any:
kwargs: dict = {
"model": self.caps.model_id,
"messages": messages,
"temperature": self.caps.optimal_temperature,
"api_key": self.api_key,
}
if self.api_base:
kwargs["api_base"] = self.api_base
if tools:
kwargs["tools"] = tools
if self.caps.parallel_tools and parallel_tool_calls is not False:
kwargs["parallel_tool_calls"] = True
if self.caps.thinking_mode and reasoning_effort:
kwargs["reasoning_effort"] = reasoning_effort
if self.caps.prompt_caching:
kwargs["extra_headers"] = {"anthropic-beta": "prompt-caching-2024-07-31"}
last_err: Optional[Exception] = None
for attempt in range(max_retries):
try:
response = litellm.completion(**kwargs)
self.token_counter.add(getattr(response, "usage", None))
return response
except (RateLimitError, APIConnectionError, ServiceUnavailableError, Timeout, APIError) as e:
last_err = e
if attempt == max_retries - 1:
break
time.sleep(2 ** attempt)
raise last_err # type: ignore[misc]

99
core/loop.py Normal file
View File

@ -0,0 +1,99 @@
"""主 agent loop: ReAct 风格,LLM ↔ Tool 反复直到无 tool_call。"""
from __future__ import annotations
import json
from typing import Any, Dict, Optional
from rich.console import Console
from .capabilities import ModelCapabilities
from .llm import LLM
from .session import Session
class AgentLoop:
def __init__(
self,
llm: LLM,
tools: Dict[str, Any],
session: Session,
capabilities: ModelCapabilities,
console: Optional[Console] = None,
max_iterations: Optional[int] = None,
) -> None:
self.llm = llm
self.tools = tools
self.session = session
self.caps = capabilities
self.max_iterations = max_iterations or capabilities.max_iterations
self.console = console or Console()
def run(self, user_message: str) -> str:
self.session.append({"role": "user", "content": user_message})
for _ in range(self.max_iterations):
with self.console.status("[dim]thinking...[/dim]", spinner="dots"):
response = self.llm.chat(
messages=self.session.messages,
tools=[t.schema for t in self.tools.values()],
reasoning_effort=self.caps.default_reasoning_effort or None,
)
msg = response.choices[0].message
self.session.append(msg)
tool_calls = getattr(msg, "tool_calls", None) or []
content = getattr(msg, "content", None)
if content:
self.console.print(f"[cyan]assistant>[/cyan] {content}")
if not tool_calls:
return content or ""
for tc in tool_calls:
result = self._execute_tool_call(tc)
self.session.append(
{
"role": "tool",
"tool_call_id": tc.id,
"content": result,
}
)
return "[reached max iterations]"
def _execute_tool_call(self, tc: Any) -> str:
name = tc.function.name
raw_args = tc.function.arguments or "{}"
try:
args = json.loads(raw_args)
except json.JSONDecodeError as e:
return f"[Error] invalid JSON arguments for {name}: {e}"
preview = json.dumps(args, ensure_ascii=False)
if len(preview) > 200:
preview = preview[:200] + "..."
self.console.print(f"[yellow]tool>[/yellow] {name}({preview})")
tool = self.tools.get(name)
if tool is None:
return f"[Error] unknown tool: {name}"
try:
result = tool.execute(**args)
except TypeError as e:
return f"[Error] bad arguments to {name}: {e}"
except Exception as e:
return f"[Error executing {name}] {type(e).__name__}: {e}"
if not isinstance(result, str):
result = str(result)
# 控制返回给模型的 tool 结果体量,避免炸 context
MAX_LEN = 16_000
if len(result) > MAX_LEN:
result = result[:MAX_LEN] + f"\n[... truncated, {len(result) - MAX_LEN} chars ...]"
# 给用户预览(截短)
preview = result if len(result) < 400 else result[:400] + "..."
self.console.print(f"[dim]{preview}[/dim]")
return result

77
core/session.py Normal file
View File

@ -0,0 +1,77 @@
"""会话: 内存中的消息列表 + meta(cwd / model / created_at) + 落盘 json。
文件格式:
{
"meta": {"id": "...", "created_at": "...", "cwd": "...", "model": "..."},
"messages": [...]
}
兼容老格式: 如果文件根是 list,就当 messages 处理,meta 为空
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any, Dict, List, Optional
def _to_dict(msg: Any) -> Any:
if isinstance(msg, dict):
return msg
if hasattr(msg, "model_dump"):
return msg.model_dump(exclude_none=True)
if hasattr(msg, "dict"):
return msg.dict(exclude_none=True)
return msg
class Session:
def __init__(
self,
system_prompt: str = "",
path: Optional[Path] = None,
meta: Optional[dict] = None,
) -> None:
self.messages: List[dict] = []
self.path = path
self.meta: Dict[str, Any] = dict(meta or {})
if system_prompt:
self.messages.append({"role": "system", "content": system_prompt})
def append(self, msg: Any) -> None:
self.messages.append(_to_dict(msg))
if self.path is not None:
self.save()
def reset(self, keep_system: bool = True) -> None:
if keep_system and self.messages and self.messages[0].get("role") == "system":
self.messages = [self.messages[0]]
else:
self.messages = []
if self.path is not None:
self.save()
def save(self) -> None:
if self.path is None:
return
self.path.parent.mkdir(parents=True, exist_ok=True)
payload = {"meta": self.meta, "messages": self.messages}
self.path.write_text(
json.dumps(payload, ensure_ascii=False, indent=2),
encoding="utf-8",
)
@classmethod
def load(cls, path: Path) -> "Session":
s = cls(path=path)
if not path.exists():
return s
data = json.loads(path.read_text(encoding="utf-8"))
if isinstance(data, list):
# 老格式: 纯消息列表
s.messages = data
s.meta = {}
elif isinstance(data, dict):
s.messages = data.get("messages", []) or []
s.meta = data.get("meta", {}) or {}
return s

81
core/skills.py Normal file
View File

@ -0,0 +1,81 @@
"""Skill 注册表 (Anthropic 标准格式)。
每个 skill skills/<name>/ 目录,内含 SKILL.md( frontmatter)+ 可选的
references/scripts/assets/启动时只读 frontmatter discovery,完整 SKILL.md
references agent 按需加载(渐进披露)
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, Optional, Tuple
import yaml
_FRONTMATTER_RE = re.compile(r"^---\n(.*?)\n---\n?", re.DOTALL)
def parse_frontmatter(text: str) -> Tuple[dict, str]:
"""解析 markdown 顶部的 YAML frontmatter。返回 (meta, body)。"""
m = _FRONTMATTER_RE.match(text)
if not m:
return {}, text
meta = yaml.safe_load(m.group(1)) or {}
if not isinstance(meta, dict):
meta = {}
return meta, text[m.end():]
@dataclass
class Skill:
name: str
description: str
skill_dir: Path
@property
def skill_md(self) -> Path:
return self.skill_dir / "SKILL.md"
def full_content(self) -> str:
return self.skill_md.read_text(encoding="utf-8")
@classmethod
def from_dir(cls, skill_dir: Path) -> Optional["Skill"]:
md = skill_dir / "SKILL.md"
if not md.exists():
return None
meta, _ = parse_frontmatter(md.read_text(encoding="utf-8"))
name = meta.get("name") or skill_dir.name
desc = meta.get("description") or ""
if not desc:
return None # description 是 discovery 的关键,缺了不收
return cls(name=name, description=desc, skill_dir=skill_dir)
class SkillRegistry:
def __init__(self, skills_dir: Path) -> None:
self.skills_dir = Path(skills_dir)
self.skills: Dict[str, Skill] = {}
self._scan()
def _scan(self) -> None:
if not self.skills_dir.exists():
return
for child in sorted(self.skills_dir.iterdir()):
if not child.is_dir():
continue
skill = Skill.from_dir(child)
if skill is not None:
self.skills[skill.name] = skill
def discovery_block(self) -> str:
"""启动时注入 system prompt 的 skill 列表(name + description)。"""
if not self.skills:
return ""
lines = [f"- **{s.name}**: {s.description}" for s in self.skills.values()]
return "\n".join(lines)
def get(self, name: str) -> Optional[Skill]:
return self.skills.get(name)

120
main.py Normal file
View File

@ -0,0 +1,120 @@
"""装配入口: 读 config → 加载 capabilities/skills → 构造 LLM/tools/session/loop。"""
from __future__ import annotations
from datetime import datetime
from pathlib import Path
from typing import Optional, Tuple
import yaml
from rich.console import Console
from core.capabilities import ModelCapabilities
from core.llm import LLM
from core.loop import AgentLoop
from core.session import Session
from core.skills import SkillRegistry
from tools.fs import EditTool, GlobTool, GrepTool, ReadTool, WriteTool
from tools.run_python import RunPythonTool
from tools.shell import ShellTool
from tools.skill_tool import LoadSkillTool
ROOT = Path(__file__).resolve().parent
def load_config() -> dict:
return yaml.safe_load((ROOT / "config" / "agent.yaml").read_text(encoding="utf-8")) or {}
def resolve_workspace(workspace: Optional[str], cfg: Optional[dict] = None) -> Path:
cfg = cfg or load_config()
p = Path(workspace) if workspace else ROOT / cfg.get("workspace_dir", "workspace")
p.mkdir(parents=True, exist_ok=True)
return p
def sessions_dir(workspace_dir: Path) -> Path:
d = workspace_dir / "sessions"
d.mkdir(parents=True, exist_ok=True)
return d
def resolve_session_path(workspace_dir: Path, session_id: Optional[str], resume: bool) -> Tuple[Path, str]:
"""返回 (path, session_id)。resume=True 时找现有文件,否则新建一个时间戳 id。"""
sdir = sessions_dir(workspace_dir)
if resume:
if session_id in (None, "", "last"):
existing = sorted(sdir.glob("*.json"))
if not existing:
raise FileNotFoundError(f"{sdir} 下没有任何 session 可恢复")
path = existing[-1]
return path, path.stem
path = sdir / f"{session_id}.json"
if not path.exists():
raise FileNotFoundError(f"session 不存在: {path}")
return path, session_id # type: ignore[return-value]
sid = session_id or datetime.now().strftime("%Y%m%d_%H%M%S")
return sdir / f"{sid}.json", sid
def build_agent(
model_name: Optional[str] = None,
workspace: Optional[str] = None,
console: Optional[Console] = None,
session_id: Optional[str] = None,
resume: bool = False,
) -> Tuple[AgentLoop, Session, str]:
cfg = load_config()
model = model_name or cfg["default_model"]
caps = ModelCapabilities.load(model, ROOT / cfg["models_dir"])
llm = LLM(caps)
workspace_dir = resolve_workspace(workspace, cfg)
session_path, sid = resolve_session_path(workspace_dir, session_id, resume)
# 工具基目录: 用户当前 cwd —— agent 操作的是用户项目,不是 zcbot 仓库本身
tool_base = Path.cwd()
skills = SkillRegistry(ROOT / cfg.get("skills_dir", "skills"))
if resume:
# 恢复: 直接加载老 session,不再注入新的 system prompt
session = Session.load(session_path)
saved_cwd = session.meta.get("cwd")
if saved_cwd and console is not None and saved_cwd != str(tool_base):
console.print(
f"[yellow]提示:[/yellow] 当前 cwd 与 session 记录不同 —— "
f"工具基于 current cwd,不会自动切回。\n"
f" session cwd: [dim]{saved_cwd}[/dim]\n"
f" current cwd: [dim]{tool_base}[/dim]"
)
else:
system_prompt = (ROOT / cfg["system_prompt"]).read_text(encoding="utf-8")
if skills.skills:
system_prompt += f"\n\n## 可用 skill (用 load_skill 加载完整指引)\n{skills.discovery_block()}"
system_prompt += f"\n\n## 当前工作目录\n{tool_base}"
meta = {
"id": sid,
"created_at": datetime.now().isoformat(timespec="seconds"),
"cwd": str(tool_base),
"model": caps.model_id,
"model_profile": model,
}
session = Session(system_prompt=system_prompt, path=session_path, meta=meta)
session.save() # 立刻落盘,占住文件名
tools = {}
for cls in (ReadTool, WriteTool, EditTool, GlobTool, GrepTool, ShellTool):
t = cls(base_dir=tool_base)
tools[t.name] = t
if skills.skills:
ls = LoadSkillTool(registry=skills, base_dir=tool_base)
tools[ls.name] = ls
if caps.enable_run_python:
rp = RunPythonTool(base_dir=tool_base)
tools[rp.name] = rp
agent = AgentLoop(llm, tools, session, caps, console=console)
return agent, session, sid

View File

@ -0,0 +1,25 @@
你是一个本地任务 agent,帮用户完成软件工程、文档撰写、内容生成类任务。
## 通用工具
- `read` / `write` / `edit` —— 文件操作
- `glob` / `grep` —— 文件搜索
- `shell` —— 执行命令(默认 60s 超时)
- `run_python` —— 在子进程里跑 Python (数据处理、生成 .pptx/.docx、画图等)
- `load_skill` —— 加载某个 skill 的完整指引
## Skill 机制
你启动时只看到下方 skill 的"名字 + 描述"。当用户的任务匹配某个 skill 的领域,
**先 `load_skill(name)`** 拿到完整指引(工作流、模板、原则),再开始干活。
不要凭印象推测一个 skill 怎么用 —— 永远 load 一下。skill 数有限,加载成本很低。
## 工作原则
- 动手前先看: 用 read/grep/glob 摸清现状,再 edit
- 改动最小化: edit 工具的 old_str 必须唯一匹配,不够唯一就多带上下文
- 有测试就跑测试验证;没有就用 run_python 写一段最小复现验证
- 输出简洁: 不复述 diff,只说做了什么、下一步要不要继续
- 工具结果带 `[Error ...]` 时,先想清楚原因再重试,不要盲目重复同一调用
- 不臆造 API、文献、数据 —— 不知道就 read 源码 / 让用户提供 / 明说不知道
## 路径
默认工作目录在系统消息末尾,所有相对路径基于该目录。

9
requirements.txt Normal file
View File

@ -0,0 +1,9 @@
litellm>=1.50.0
pyyaml>=6.0
click>=8.1.0
rich>=13.7.0
# 文档生成 (run_python 在 ppt / proposal skill 里会用到)
python-pptx>=0.6.21
python-docx>=1.1.0
matplotlib>=3.8.0

26
skills/coding/SKILL.md Normal file
View File

@ -0,0 +1,26 @@
---
name: coding
description: 修改、调试、实现代码相关任务。当用户要求修 bug、写函数、重构、读懂代码库、跑测试时使用。
---
# Coding
## 资源
- 通用工具就够: read / grep / glob / edit / write / shell / run_python
- 没有专属 scripts 或 templates,因为代码任务的多样性来自代码本身
## 原则
- **先看后改**: 用 grep/glob 定位,read 读出修改点的上下文,再 edit。盲改会出错。
- **改动最小**: 只动必要行,不顺手重构、不改无关空白
- **edit 唯一匹配**: old_str 必须在文件里出现且仅出现一次,不够唯一就多带上下文
- **验证优先**: 项目有测试就 `shell pytest` / `npm test`;没有就写最小复现脚本验证
- **不臆造 API**: 看到没用过的库,先 read 它的源码或文档,不要凭直觉拼方法名
## 输出
- 改完后一两句话说清: 改了什么、为什么、怎么验证
- 不复述 diff —— 用户会自己看
## 反模式
- 一次性 write 整个大文件 (改 3 行就别 write 200 行)
- 没读过文件直接 edit (大概率 old_str 匹配不上)
- 跑测试失败就立刻改测试 (先看是测试错还是代码错)

86
skills/ppt/SKILL.md Normal file
View File

@ -0,0 +1,86 @@
---
name: ppt
description: 生成 PowerPoint 演示文稿 (.pptx)。当用户要求做汇报 PPT、把材料/会议纪要/方案转为幻灯片、生成演示稿时使用。
---
# PPT
把材料变成可演示的 .pptx。**先定调,再出稿,再验收** —— 不要一口气把整份 deck 丢出去。
## 资源
- `references/design_principles.md` —— 字号/颜色/层级/留白/字数预算等硬规则,出稿前先翻一遍
- `references/canvas_presets.md` —— 16:9 / 4:3 / 9:16 / A4 等画布尺寸表
- `references/layouts.md` —— 9 种常用版式的 python-pptx 起手代码 + safe area 辅助 (封面/目录/分章/要点/双栏/图表/图片/金句/结尾)
- `references/icons.md` —— MSO_SHAPE 图标目录 + unicode 字形表 (替代大色块的轻量装饰)
- `scripts/source_to_md.py` —— 可执行,把 PDF/DOCX/PPTX/URL 转成干净 Markdown 再做素材
- `scripts/render_icon.py` —— 可执行,unicode 字形 → 透明 PNG (MSO_SHAPE 覆盖不到时兜底)
- `scripts/quality_check.py` —— 可执行,产物 .pptx 出来后跑一遍验收 (含越界 / 文本溢出检测)
## 默认主题
**商务红** —— PRIMARY `#C00000` / SECONDARY `#E15554` / ACCENT `#FFC107`。除非 spec_lock 指定其它配色,layouts.md 起手代码就用这套。其它备选见 `design_principles.md` §2。
## 两阶段工作流
### 阶段一: 策略 (Strategist)
产物:`spec_lock.md` —— 整个 deck 的"宪法",执行阶段每生成一页前都要重读。
**八条对齐**(不全部确认完,不开工):
1. **画布**: 16:9 / 4:3 / 9:16 (默认 16:9,见 canvas_presets.md)
2. **页数**: 默认 5-8 页;长报告再加,但每超 1 页就要问一次"这页非加不可吗"
3. **受众**: 领导汇报 / 同行评审 / 大众科普 / 客户 pitch —— 决定信息密度和措辞
4. **风格**: 商务正式 / 学术严谨 / 现代简约 / 极简留白 (默认现代简约)
5. **配色**: 主色 + 辅色 + 强调色,三色封顶。给具体 hex,不要"蓝色系"这种话
6. **字体**: 中文标题/正文,英文标题/正文。Win 默认 微软雅黑 + Arial
7. **图标/插图**: 是否要、风格 (线性/扁平/拟物)、来源 (用户提供 / 不用)
8. **图表**: 数据 ≥ 3 个点的页面默认配图;明确哪几页要图
写入 `spec_lock.md` 后给用户看一眼再继续。**spec_lock 写定后不要再改**,有冲突回头跟用户重新对齐。
### 阶段二: 执行 (Executor)
**逐页生成**,不是一次性 dump 全 deck。每页前先读一次 `spec_lock.md`,然后:
1. 写一个 `run_python` block,用 python-pptx 添加这一页 (载入已有 .pptx,append slide,save)
2. 跑完报这一页的:版式、标题、要点条数、是否含图
3. 用户确认 / 微调后再下一页
**为什么逐页?** 一次性出全 deck 很容易越到后面越糊。逐页能让用户在第 2 页就发现风格不对,而不是看完 8 页才推翻重来。
**例外**: 用户明确说 "你别问,直接全做了" —— 那就一次跑完,但跑完后必须用 `quality_check.py` 验收。
### 阶段三: 验收
- `python scripts/quality_check.py <output.pptx>` —— 检页数/标题/bullet 条数/文件大小
- 不通过的项,回头 edit 对应页
## 设计原则 (硬规则)
- **每页一个核心信息**: 一页讲一件事,塞两件就拆页
- **bullet ≤ 5 条**: 超过就拆页或改成图表/双栏
- **正文不写完整段落**: 列要点;长句留给演讲者口述
- **数据 ≥ 3 个点应有图表**: 用 matplotlib 生成 .png 嵌入
- **中文标题 ≤ 30 字** / **英文标题 ≤ 12 词**
- **配色三色封顶**: 主色 + 辅色 + 强调色,其他都用灰阶
- **少用大色块,多用细线 + 图标 + 留白**: 满铺色块只在封面/分章/结尾克制使用
- **图标走 MSO_SHAPE**: 原生形状可编辑、可缩放;复杂图标走 `render_icon.py`
- **Shape 不能越界**: `layouts.md` 的起手代码用 `assert_inside` 在生成时即报错;最终必跑 `quality_check.py`
- **字数按预算来**: 写 bullet 前查 `design_principles.md §4.1` 的字数预算表,溢出靠拆条不靠收缩字号
- 详细规则见 `references/design_principles.md`
## 工作目录约定
```
<task_dir>/
├── source.md # 阶段一: source_to_md.py 转出的素材
├── spec_lock.md # 阶段一: 八条对齐落定
├── slides/
│ └── chart_p3.png # 各页用到的图片素材
└── <topic>.pptx # 最终产物 (文件名按主题命名,不要 untitled.pptx)
```
## 反模式
- 用户没给材料就开始硬编内容
- 八条没对齐就跑 python-pptx
- 一个 `run_python` 出整 deck (中途改方向就要全推翻)
- 跑完不做 `quality_check.py` 就交付
- 起名 `output.pptx` / `untitled.pptx` —— 务必按主题给文件名
- 文字塞满整张幻灯片 —— 留白本身是设计
## 输出
完成后告诉用户:文件路径、页数、用到的版式列表、是否有未满足的 spec 项。问一句要不要再改。

View File

@ -0,0 +1,55 @@
# 画布尺寸预设
> 阶段一选画布时查这张表。**画布定了之后所有版式按这个尺寸算坐标**,不要中途改。
## 标准尺寸表
| 用途 | 比例 | 宽×高 (英寸) | python-pptx | 说明 |
|-----|------|------------|------------|------|
| **现代商务汇报** | 16:9 | 13.33 × 7.5 | `Inches(13.33), Inches(7.5)` | **默认选这个** |
| 老投影仪 | 4:3 | 10 × 7.5 | `Inches(10), Inches(7.5)` | 老会议室、教学场景 |
| 竖屏手机 / 朋友圈 | 9:16 | 7.5 × 13.33 | `Inches(7.5), Inches(13.33)` | 移动端阅读、视频号封面 |
| 小红书 | 3:4 | 7.5 × 10 | `Inches(7.5), Inches(10)` | 单图阅读 |
| 微信公众号长图 | 1:n | 7.5 × 7.5 起 | `Inches(7.5), Inches(7.5)` | 单页或拼接 |
| 海报 (A4 横) | √2:1 | 11.69 × 8.27 | `Inches(11.69), Inches(8.27)` | 打印 |
| 海报 (A4 竖) | 1:√2 | 8.27 × 11.69 | `Inches(8.27), Inches(11.69)` | 打印 |
| 大屏宣讲 | 16:9 高 dpi | 同 16:9 | 同上 | 字号上调 4-6pt |
## 选画布的几条经验
- 不知道选哪个 —— **16:9**,99% 场合通吃
- 用户在投影仪墙上看 —— 16:9
- 用户在电脑屏幕上看 —— 16:9 或 4:3
- 用户在手机上看 —— 9:16
- 用户要打印散发 —— A4 横或 A4 竖
- 用户说"做个图发朋友圈" —— 3:4 或 1:1,不是 PPT 范畴但 python-pptx 也能干
## python-pptx 画布初始化
```python
from pptx import Presentation
from pptx.util import Inches, Pt
prs = Presentation()
# 16:9 默认
prs.slide_width = Inches(13.33)
prs.slide_height = Inches(7.5)
# 4:3 改这两行
# prs.slide_width = Inches(10)
# prs.slide_height = Inches(7.5)
# 9:16 改这两行
# prs.slide_width = Inches(7.5)
# prs.slide_height = Inches(13.33)
```
## 安全边距 (各画布通用)
- 左右边距: **画布宽 × 0.05** (16:9 即 0.67 寸)
- 上下边距: **画布高 × 0.07** (16:9 即 0.5 寸)
- 内容区域: 画布尺寸减去四周边距,所有元素都摆在这个矩形内
## 字号随画布缩放
如果画布超过 16:9 默认尺寸 (比如做 4K 大屏),**所有字号 × (实际宽 / 13.33)**。模型自己换算,不要硬抄默认表。

View File

@ -0,0 +1,148 @@
# PPT 设计硬规则
> 出稿前过一遍。**这些不是建议,是工程约束** —— 模型生成 PPT 最常见的失败模式都是违反这些规则。
## 1. 字号 (16:9 标准)
| 元素 | 字号 (Pt) | 备注 |
|-----|----------|------|
| 主标题 (封面) | 44-54 | 单行不换行 |
| 标题 (内页) | 28-36 | 中文常用 32 |
| 副标题 / 章节小标题 | 20-24 | |
| 正文 / bullet | 18-22 | 低于 18 投影看不清 |
| 注释 / 数据来源 | 12-14 | 灰色,弱化 |
| 页脚页码 | 10-12 | 弱化处理 |
**底线**: 投影到 100 寸大屏,后排看得清最小字号是 18pt。**绝不能小于 14pt**,除非是数据来源等弱化信息。
## 2. 配色
### 三色制
- **主色 (Primary)** —— 标题、强调、关键数据。占视觉权重 60%
- **辅色 (Secondary)** —— 副标题、次要图形元素。占 30%
- **强调色 (Accent)** —— 关键数据点、CTA、警告。占 10%,不要泛滥
- 其他全部用灰阶 (#1F1F1F / #555 / #888 / #CCC / #F5F5F5)
### 推荐配色对照 (红色主题为默认)
| 风格 | 主色 | 辅色 | 强调色 | 备注 |
|-----|------|------|-------|------|
| **商务红** ⭐ 默认 | #C00000 | #E15554 | #FFC107 | 党政/年终/路演通用 |
| 中国红 | #8B0000 | #B22222 | #FFD700 | 民族/国货/红色文化主题 |
| 现代红 | #B91C1C | #DC2626 | #F59E0B | 新消费/科技产品发布 |
| 暖朱红 | #C73E1D | #E76F51 | #F4A261 | 学术汇报/行业会议 |
| 商务蓝 | #1F4E79 | #2E75B6 | #FFC000 | 金融/保险/政企 |
| 学术灰 | #2F2F2F | #595959 | #C00000 | 严肃论文/答辩 |
| 现代简约 | #2D3748 | #4A5568 | #38B2AC | 互联网/SaaS |
| 科技深色 | #0A192F | #112240 | #64FFDA | 黑客松/技术大会 |
### 禁忌
- 红配绿、紫配黄等高对比互补色不要直接用
- 渐变只用在 accent 上,正文/标题不要渐变
- 一份 deck 主色不要换。封面是 A 色、内页变 B 色 —— 这是大忌
## 3. 留白
- 标题与上边距 ≥ 0.4 英寸
- bullet 之间行距 1.3-1.5 倍
- 一页内容占满 70% 即可,**不要塞到边缘**
- 边距统一 (左右 0.7 寸,上下 0.5 寸常用值)
## 4. 信息密度
| 页类型 | 字数上限 | 图表 |
|-------|---------|-----|
| 封面 | 30 字 | 可选装饰图 |
| 目录 | 每条 ≤ 15 字 | 不要图 |
| 分章页 | ≤ 20 字 | 大号数字 + 章节名 |
| 要点页 | bullet ≤ 5 条,每条 ≤ 25 字 | 可选小图标 |
| 数据页 | 标题 + 一句结论 | **必须有图表** |
| 图片页 | ≤ 15 字标题 + 1-2 行说明 | 主体是图 |
## 4.1 字数预算 (避免溢出)
> 这是**布局超界的根因表**。bullet 写超了会顶到下一页元素;标题写超了会换行顶下来。开写前查这张表,而不是写完看 quality_check 报错。
公式: `每行字数 ≈ 框宽(in) × 72 / 字号(pt)`
| 字号 | 框宽 11.93 in (整宽) | 框宽 5.5 in (双栏单边) | 框宽 4.6 in (图片页文字区) |
|-----|--------------------|----------------------|--------------------------|
| 44 pt (主标题) | ≤ 19 字 | — | — |
| 36 pt (大标题) | ≤ 23 字 | — | — |
| 32 pt (内页标题) | ≤ 26 字 | — | — |
| 22 pt (要点) | ≤ 39 字 | ≤ 18 字 | ≤ 15 字 |
| 18 pt (正文) | ≤ 47 字 | ≤ 22 字 | ≤ 18 字 |
| 14 pt (注释) | ≤ 61 字 | ≤ 28 字 | ≤ 23 字 |
**英文字符按中文 0.5 个换算** (即英文每行约 2× 中文字数)。
### 行高估算
每行高度 ≈ `字号 × 1.4 / 72` (英寸)
| 字号 | 单行高 | 1 行框高 | 2 行框高 | 3 行框高 |
|-----|-------|---------|---------|---------|
| 32 pt | 0.62 in | 0.7 in | 1.3 in | 1.9 in |
| 22 pt | 0.43 in | 0.5 in | 0.9 in | 1.3 in |
| 18 pt | 0.35 in | 0.4 in | 0.8 in | 1.1 in |
| 14 pt | 0.27 in | 0.3 in | 0.6 in | 0.9 in |
**用法**: bullet 字数预计超表上限就拆条,不要试图靠 `auto_size` 收缩字号兜底 —— 会出现一页里字号大小不一,反而难看。
## 5. 文字层级
- 一页最多 3 级层级 (标题 / 正文 / 子项)
- 子项缩进 0.3-0.5 英寸
- 子项字号比父级小 2-4pt
- 不要四级以上嵌套
## 6. 图片规则
- **分辨率**: 投影建议 150 dpi 以上,印刷 300 dpi
- **占位**: 图片占满指定区域,不要拉伸变形 —— 用 `width=``height=` 单一参数让 python-pptx 等比缩放
- **背景**: 透明 PNG 优先;白底 JPG 在深色页上要做底色匹配
- **数量**: 一页最多 2 张图,3 张以上是网格图,按九宫格摆
## 7. 图表规则 (matplotlib)
- 颜色用 spec_lock 里定的主/辅/强调三色,**不要用 matplotlib 默认色板**
- 字号: 标题 16,坐标轴 12,刻度 10
- 去掉上方和右方边框 (`ax.spines['top'/'right'].set_visible(False)`)
- 数据标签直接标在柱子/点上,优先于看坐标
- 中文字体: `plt.rcParams['font.sans-serif'] = ['Microsoft YaHei', 'SimHei']`
- 负号: `plt.rcParams['axes.unicode_minus'] = False`
```python
# 示例:符合规则的柱状图 (默认红色主题)
import matplotlib.pyplot as plt
plt.rcParams['font.sans-serif'] = ['Microsoft YaHei', 'SimHei']
plt.rcParams['axes.unicode_minus'] = False
fig, ax = plt.subplots(figsize=(10, 5), dpi=150)
bars = ax.bar(["Q1","Q2","Q3","Q4"], [12,18,25,31],
color=["#C00000","#C00000","#C00000","#FFC107"]) # 末尾突出
for bar, v in zip(bars, [12,18,25,31]):
ax.text(bar.get_x()+bar.get_width()/2, v+0.5, str(v),
ha='center', fontsize=11)
ax.set_title("季度营收 (亿元)", fontsize=16)
ax.spines['top'].set_visible(False)
ax.spines['right'].set_visible(False)
fig.savefig("chart.png", bbox_inches="tight", dpi=150)
```
## 8. 一致性 (跨页)
- 标题位置不要跳来跳去 —— 所有内页标题都在同一像素位置
- 页脚 (页码 / logo / 标题) 在所有内页位置一致
- 字体在同 deck 内不要换 —— 中文一种字体,英文一种,够了
- 配色不变,字号梯度不变
## 9. 反模式速查
| 症状 | 原因 | 修法 |
|-----|------|-----|
| 一页字密密麻麻 | 没拆页 | 拆 2-3 页或转图表 |
| 投影看不清 | 字号 < 18 | 加大字号或拆页 |
| 颜色花 | 用了超过 5 种色 | 退回三色制 |
| bullet 是完整段落 | 把演讲稿当 bullet 写 | 提炼关键词,完整句留给口述 |
| 图表默认配色 | 没改 matplotlib 色板 | 用 spec_lock 主色 |
| 图标/图片随意找的 | 没统一风格 | 同一来源 / 同一风格 |
| 标题在每页位置都不一样 | 没用统一版式 | 见 layouts.md,固定模板 |

View File

@ -0,0 +1,189 @@
# 图标系统
> **首选 `MSO_SHAPE.*` —— PowerPoint 原生形状,矢量、可编辑、配色随主题。** 复杂图标(齿轮、放大镜、文件夹等无对应 MSO_SHAPE)再走 `render_icon.py` 用 unicode 字形栅格化为 PNG。
## A. MSO_SHAPE 图标目录
```python
from pptx.enum.shapes import MSO_SHAPE
```
### 标记类 (放在 bullet 前 / 标题旁)
| 用途 | MSO_SHAPE | 说明 |
|-----|-----------|------|
| 圆点 bullet | `OVAL` | 0.18×0.18 in,实心填充 |
| 方点 bullet | `RECTANGLE` | 0.16×0.16 in,实心 |
| 钻石点 | `DIAMOND` | 0.2×0.2 in |
| 对号 ✓ | `CHEVRON` 旋转 / 或用字形 | MSO 没有专门"check"形;用字形更清晰 |
| 加号 + | `MATH_PLUS` | 强调"新增"语境 |
| 星 ★ | `STAR_5_POINT` | 重点项;不要每页都用 |
| 心 | `HEART` | 用户向 / 软话题 |
### 箭头类 (流程 / 趋势)
| 用途 | MSO_SHAPE | 说明 |
|-----|-----------|------|
| 右箭头 → | `RIGHT_ARROW` | 流程下一步 |
| 上箭头 ↑ | `UP_ARROW` | 增长 |
| 下箭头 ↓ | `DOWN_ARROW` | 下降 |
| 双向箭头 ↔ | `LEFT_RIGHT_ARROW` | 对比 / 关联 |
| 折线右箭头 | `BENT_ARROW` / `CURVED_RIGHT_ARROW` | 转折 |
| 五边形流程 | `PENTAGON` | 流程节点(横排) |
| V 形 | `CHEVRON` | 流程节点(空间紧) |
### 几何/装饰
| 用途 | MSO_SHAPE | 说明 |
|-----|-----------|------|
| 圆形头像底 | `OVAL` | 头像/数字徽章 |
| 圆角矩形 | `ROUNDED_RECTANGLE` | 标签 / 按钮态 |
| 标注气泡 | `ROUNDED_RECTANGULAR_CALLOUT` | 引述 |
| 雷电 | `LIGHTNING_BOLT` | 突破 / 创新 |
| 太阳 | `SUN` | 机会 / 启示 |
| 月亮 | `MOON` | 夜晚 / 安静主题 |
| 云 | `CLOUD` | SaaS / 网络主题 |
| 禁止 | `NO_SYMBOL` | 反模式 / 禁止 |
| 笑脸 | `SMILEY_FACE` | 用户满意 |
### 引用/装饰
| 用途 | MSO_SHAPE | 说明 |
|-----|-----------|------|
| 大引号 | 字形 `"``LEFT_BRACE` | 金句页常用 |
| 横线分隔 | `RECTANGLE` 高 0.04 in | 标题下装饰线 |
| 竖线分隔 | `RECTANGLE` 宽 0.04 in | 双栏中线 |
| 三点 ⋯ | `OVAL` × 3 | 加载 / 进行中 |
## B. 标准用法
### B1. 圆点 bullet
```python
from pptx.enum.shapes import MSO_SHAPE
from pptx.util import Inches
def add_dot(slide, x, y, size=0.18, color=ACCENT):
dot = slide.shapes.add_shape(MSO_SHAPE.OVAL,
Inches(x), Inches(y),
Inches(size), Inches(size))
dot.fill.solid(); dot.fill.fore_color.rgb = color
dot.line.fill.background()
return dot
```
### B2. 编号徽章 (圆 + 数字)
```python
def add_badge(slide, x, y, num, diameter=0.7,
fill=PRIMARY, fg=RGBColor(255,255,255)):
circle = slide.shapes.add_shape(MSO_SHAPE.OVAL,
Inches(x), Inches(y),
Inches(diameter), Inches(diameter))
circle.fill.solid(); circle.fill.fore_color.rgb = fill
circle.line.fill.background()
tf = circle.text_frame
tf.text = str(num)
p = tf.paragraphs[0]
p.alignment = PP_ALIGN.CENTER
r = p.runs[0]
r.font.bold = True
r.font.size = Pt(20)
r.font.color.rgb = fg
r.font.name = "Arial"
return circle
```
### B3. 流程节点 (五边形)
```python
def add_pentagon(slide, x, y, w, h, text, fill=PRIMARY):
shp = slide.shapes.add_shape(MSO_SHAPE.PENTAGON,
Inches(x), Inches(y),
Inches(w), Inches(h))
shp.fill.solid(); shp.fill.fore_color.rgb = fill
shp.line.fill.background()
tf = shp.text_frame
tf.text = text
p = tf.paragraphs[0]; p.alignment = PP_ALIGN.CENTER
r = p.runs[0]; r.font.size = Pt(14); r.font.bold = True
r.font.color.rgb = RGBColor(255,255,255); r.font.name = "微软雅黑"
return shp
# 用法:水平排五个节点
for i, label in enumerate(["调研","设计","开发","测试","上线"]):
add_pentagon(slide, 0.7 + i*2.4, 3.5, 2.2, 0.8, label)
```
### B4. 强调箭头 (右箭头)
```python
def add_arrow_right(slide, x, y, w, h, fill=ACCENT):
a = slide.shapes.add_shape(MSO_SHAPE.RIGHT_ARROW,
Inches(x), Inches(y),
Inches(w), Inches(h))
a.fill.solid(); a.fill.fore_color.rgb = fill
a.line.fill.background()
return a
```
### B5. 标题装饰线
```python
def add_accent_line(slide, x, y, length=1.0, thickness=0.05, color=ACCENT):
"""标题下面那条 1 寸长的强调横线 (替代大色块的轻量做法)"""
bar = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE,
Inches(x), Inches(y),
Inches(length), Inches(thickness))
bar.fill.solid(); bar.fill.fore_color.rgb = color
bar.line.fill.background()
return bar
```
## C. Unicode 字形 (MSO_SHAPE 没有的图形)
某些图标 MSO_SHAPE 没有对应,用 unicode 字形渲染成 PNG 嵌入。Win/Mac 默认字体覆盖良好。
### 推荐字形 (避开 emoji,用单色符号)
```
✓ ✔ ✗ ✘ 对号 / 错号
✦ ✧ ✪ ★ 星
→ ← ↑ ↓ ↔ 箭头
⬛ ⬜ ◆ ◇ 方块菱形
● ○ ◉ ◎ 圆
※ ◇ ⬢ ⬡ 装饰
☰ ☱ ☲ ☳ 汉字六十四卦类(简洁)
∴ ∵ ⇒ ⇔ 数学
№ ¶ § † 文档符号
↗ ↘ ↙ ↖ 斜箭头
⌘ ⌥ ⌃ ⏎ 键盘
```
### 用 render_icon.py 生成
```bash
# 生成对号 PNG (强调色,96px)
python scripts/render_icon.py "✓" --color "#38B2AC" --size 96 -o slides/check.png
# 然后嵌入幻灯片
slide.shapes.add_picture("slides/check.png", Inches(1), Inches(2),
width=Inches(0.5))
```
## D. 用图标的几条原则
1. **同一 deck 风格统一** —— 全用 MSO_SHAPE 或全用字形 PNG,不要混
2. **颜色限定** —— 只用 PRIMARY / SECONDARY / ACCENT / GREY,不要每个图标独立配色
3. **大小克制** —— bullet 前的 dot 0.15-0.2 in;独立装饰图标 0.5-1.5 in;不要超过 2 in
4. **间距统一** —— 图标右侧到文字的间距固定,通常 0.2-0.3 in
5. **不替换文字** —— 图标是辅助,不是表意主体;一个 ★ 不能代替"重点"两字
6. **避免 emoji** —— emoji 在不同系统渲染差异大,且自带颜色与你的配色冲突
## E. 不要做什么
- ❌ 在每页都堆图标
- ❌ 用网上随便下载的彩色图标 (主题不统一)
- ❌ 用 emoji (🚀💡⚡) 当严肃汇报的图标
- ❌ 图标尺寸大于标题字号高度的 2 倍
- ❌ 用 STAR / HEART 装饰严肃议题 (融资额、合规)

View File

@ -0,0 +1,366 @@
# 9 种常用版式 (16:9, 13.33×7.5 in)
> **2.0 版本要点**:大幅减少满铺色块,引入 MSO_SHAPE 图标点缀,所有元素经 safe_area 校验不会越出画布。
复制 → 改文案 → 跑。配色用 `spec_lock.md` 里的实际 hex 替换占位。
## 通用起手 + 安全辅助
```python
from pptx import Presentation
from pptx.util import Inches, Pt, Emu
from pptx.dml.color import RGBColor
from pptx.enum.text import PP_ALIGN, MSO_ANCHOR, MSO_AUTO_SIZE
from pptx.enum.shapes import MSO_SHAPE
# ---- 配色 (默认红色主题; spec_lock 里有覆盖以 spec_lock 为准) ----
PRIMARY = RGBColor(0xC0, 0x00, 0x00) # 深红 - 标题/强调/关键数据
SECONDARY = RGBColor(0xE1, 0x55, 0x54) # 砖红 - 次要图形
ACCENT = RGBColor(0xFF, 0xC1, 0x07) # 金黄 - 关键数据点/CTA
INK = RGBColor(0x1F, 0x1F, 0x1F)
GREY = RGBColor(0x59, 0x59, 0x59)
GREY_LIGHT = RGBColor(0x88, 0x88, 0x88)
BG = RGBColor(0xFA, 0xFA, 0xFA) # 背景近白
WHITE = RGBColor(255, 255, 255)
CN_FONT = "微软雅黑"
EN_FONT = "Arial"
# ---- 画布与安全区 ----
prs = Presentation()
prs.slide_width = Inches(13.33)
prs.slide_height = Inches(7.5)
SLIDE_W = 13.33
SLIDE_H = 7.5
MARGIN_X = 0.7 # 左右
MARGIN_Y = 0.5 # 上下
SAFE_LEFT = MARGIN_X
SAFE_TOP = MARGIN_Y
SAFE_RIGHT = SLIDE_W - MARGIN_X
SAFE_BOTTOM = SLIDE_H - MARGIN_Y
SAFE_W = SAFE_RIGHT - SAFE_LEFT # 11.93
SAFE_H = SAFE_BOTTOM - SAFE_TOP # 6.5
BLANK = prs.slide_layouts[6]
def assert_inside(left, top, width, height, name=""):
"""放置前调一次。越界直接报错而不是悄悄超出。"""
if left < 0 or top < 0:
raise ValueError(f"[{name}] 左/上为负: ({left}, {top})")
if left + width > SLIDE_W + 1e-3:
raise ValueError(f"[{name}] 右越界: {left}+{width} > {SLIDE_W}")
if top + height > SLIDE_H + 1e-3:
raise ValueError(f"[{name}] 下越界: {top}+{height} > {SLIDE_H}")
# ---- 文本辅助 (默认 word_wrap, shrink-to-fit 兜底) ----
def set_text(tf, text, size, bold=False, color=INK, align=PP_ALIGN.LEFT,
font=CN_FONT):
tf.text = text
p = tf.paragraphs[0]; p.alignment = align
r = p.runs[0]
r.font.name = font; r.font.size = Pt(size); r.font.bold = bold
r.font.color.rgb = color
def add_textbox(slide, left, top, width, height, text, size,
bold=False, color=INK, align=PP_ALIGN.LEFT,
anchor=MSO_ANCHOR.TOP, font=CN_FONT, shrink=True,
name="textbox"):
assert_inside(left, top, width, height, name)
tb = slide.shapes.add_textbox(Inches(left), Inches(top),
Inches(width), Inches(height))
tf = tb.text_frame
tf.vertical_anchor = anchor
tf.word_wrap = True
if shrink:
# 文字超出框高时自动收缩字号 (兜底,不替代字数预算)
tf.auto_size = MSO_AUTO_SIZE.TEXT_TO_FIT_SHAPE
set_text(tf, text, size, bold, color, align, font)
return tb
# ---- 形状辅助 (无边线实心填充) ----
def add_rect(slide, left, top, width, height, fill, name="rect"):
assert_inside(left, top, width, height, name)
s = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, Inches(left), Inches(top),
Inches(width), Inches(height))
s.fill.solid(); s.fill.fore_color.rgb = fill
s.line.fill.background()
return s
def add_shape(slide, kind, left, top, width, height, fill, name="shape"):
assert_inside(left, top, width, height, name)
s = slide.shapes.add_shape(kind, Inches(left), Inches(top),
Inches(width), Inches(height))
s.fill.solid(); s.fill.fore_color.rgb = fill
s.line.fill.background()
return s
def add_dot(slide, x, y, size=0.18, color=ACCENT):
return add_shape(slide, MSO_SHAPE.OVAL, x, y, size, size, color, "dot")
def add_accent_line(slide, x, y, length=1.0, thickness=0.05, color=ACCENT):
"""标题下面那条强调线,替代大色块"""
return add_rect(slide, x, y, length, thickness, color, "accent_line")
def add_badge(slide, x, y, num, diameter=0.7, fill=PRIMARY, fg=WHITE):
"""编号徽章 (圆 + 数字)"""
c = add_shape(slide, MSO_SHAPE.OVAL, x, y, diameter, diameter, fill, "badge")
tf = c.text_frame; tf.text = str(num)
p = tf.paragraphs[0]; p.alignment = PP_ALIGN.CENTER
r = p.runs[0]
r.font.bold = True; r.font.size = Pt(int(diameter * 28))
r.font.color.rgb = fg; r.font.name = EN_FONT
return c
# ---- 标题套件 (内页通用) ----
def page_title(slide, text, page_num=None, total=None, footer="项目汇报"):
add_textbox(slide, SAFE_LEFT, SAFE_TOP, SAFE_W, 0.7, text,
32, bold=True, color=PRIMARY, name="title")
add_accent_line(slide, SAFE_LEFT, SAFE_TOP + 0.85,
length=0.8, color=ACCENT)
if page_num is not None and total is not None:
add_textbox(slide, SAFE_LEFT, 7.0, 6, 0.4, footer,
11, color=GREY_LIGHT, shrink=False, name="footer")
add_textbox(slide, 12.0, 7.0, 1.2, 0.4, f"{page_num} / {total}",
11, color=GREY_LIGHT, align=PP_ALIGN.RIGHT,
shrink=False, name="page_num")
```
> **要点**:
> - `assert_inside` 阻止任何越界。元素超出画布会立刻报 `ValueError`,而不是悄悄裁剪
> - `add_textbox` 默认 `word_wrap=True` + `auto_size=TEXT_TO_SHAPE_AND_FIT_TEXT` —— 文字溢出自动缩字号
> - `page_title` 用细线代替大块色填,所有内页统一调用
---
## L1 · 封面 (Cover) —— 极简,无大色块
```python
slide = prs.slides.add_slide(BLANK)
# 左上角小色块 + 标题左侧细色条
add_rect(slide, 0.7, 0.7, 0.6, 0.06, PRIMARY) # 顶部短线
add_rect(slide, 0.7, 1.05, 0.06, 1.5, ACCENT) # 左侧竖线 (装饰)
# 主标题
add_textbox(slide, 0.7, 2.6, 11.9, 1.4, "项目名称 / 演示主题",
44, bold=True, color=INK, name="cover_title")
# 副标题 (灰色,弱化)
add_textbox(slide, 0.7, 4.1, 11.9, 0.6, "一句话副标题或定位",
22, color=GREY, name="cover_sub")
# 汇报人 / 日期
add_textbox(slide, 0.7, 6.4, 11.9, 0.4,
"汇报人 · 部门 · 2026-05-06", 14, color=GREY_LIGHT,
name="cover_meta")
# 右下角小图标点缀 (五角星,可选)
add_shape(slide, MSO_SHAPE.STAR_5_POINT, 12.2, 6.3, 0.5, 0.5, ACCENT,
"deco_star")
```
---
## L2 · 目录 (Agenda) —— 编号徽章 + 文字
```python
slide = prs.slides.add_slide(BLANK)
page_title(slide, "目录")
items = ["背景与现状", "核心问题", "解决方案", "实施计划", "预期成果"]
for i, item in enumerate(items):
y = 1.9 + i * 0.95
add_badge(slide, SAFE_LEFT, y, i + 1, diameter=0.65)
add_textbox(slide, SAFE_LEFT + 1.0, y, SAFE_W - 1.0, 0.65,
item, 22, color=INK, anchor=MSO_ANCHOR.MIDDLE,
name=f"agenda_{i}")
```
---
## L3 · 章节分隔 (Section Divider) —— 浅色背景 + 大字编号
```python
slide = prs.slides.add_slide(BLANK)
# 整页极浅灰 (替代深色满铺)
add_rect(slide, 0, 0, SLIDE_W, SLIDE_H, BG)
# 左侧装饰竖条
add_rect(slide, 0.7, 2.5, 0.08, 2.5, ACCENT)
# 大编号 (主色,描边视觉感)
add_textbox(slide, 1.1, 2.0, 4, 2.5, "01", 160, bold=True,
color=PRIMARY, font=EN_FONT, name="sec_num")
# 章节名
add_textbox(slide, 5.5, 2.8, 7, 1.0, "背景与现状",
44, bold=True, color=INK, anchor=MSO_ANCHOR.MIDDLE,
name="sec_title")
# 引言
add_textbox(slide, 5.5, 4.0, 7, 0.6,
"本章讨论行业现状与机会窗口", 18, color=GREY,
name="sec_lead")
# 装饰小图标
add_shape(slide, MSO_SHAPE.RIGHT_ARROW, 5.5, 5.0, 0.6, 0.3, ACCENT,
"sec_arrow")
```
---
## L4 · 要点 (Bullets) —— 圆点 + 文字,无大块色
```python
slide = prs.slides.add_slide(BLANK)
page_title(slide, "核心结论")
bullets = [
"结论一:用一句话讲清楚",
"结论二:具体数据支撑,如增长 27%",
"结论三:对未来的判断,简洁有力",
"结论四:可选第四条,不要超过 5 条",
]
for i, b in enumerate(bullets):
y = 2.0 + i * 0.95
add_dot(slide, SAFE_LEFT + 0.05, y + 0.22, size=0.18, color=ACCENT)
add_textbox(slide, SAFE_LEFT + 0.45, y, SAFE_W - 0.45, 0.6,
b, 22, color=INK, anchor=MSO_ANCHOR.MIDDLE,
name=f"bullet_{i}")
```
---
## L5 · 双栏对比 (Two-Column) —— 中线分隔,小色块标签
```python
slide = prs.slides.add_slide(BLANK)
page_title(slide, "现状 vs 改进后")
mid_x = SLIDE_W / 2
# 中间细分隔线 (替代两块大矩形)
add_rect(slide, mid_x - 0.02, 2.0, 0.04, 4.8, RGBColor(0xDD, 0xDD, 0xDD),
"divider")
# 左栏小标签 (色块只占小区域)
add_rect(slide, SAFE_LEFT, 2.0, 0.8, 0.35, GREY, "left_tag")
add_textbox(slide, SAFE_LEFT, 2.0, 0.8, 0.35, "现状", 14, bold=True,
color=WHITE, align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE,
shrink=False, name="left_label")
left_pts = ["问题 A: 描述", "问题 B: 描述", "问题 C: 描述"]
for i, p in enumerate(left_pts):
add_dot(slide, SAFE_LEFT + 0.05, 2.7 + i * 0.7 + 0.18, color=GREY)
add_textbox(slide, SAFE_LEFT + 0.45, 2.7 + i * 0.7,
mid_x - SAFE_LEFT - 0.7, 0.55, p, 18, color=INK,
anchor=MSO_ANCHOR.MIDDLE, name=f"l_pt_{i}")
# 右栏小标签
add_rect(slide, mid_x + 0.3, 2.0, 0.8, 0.35, PRIMARY, "right_tag")
add_textbox(slide, mid_x + 0.3, 2.0, 0.8, 0.35, "改进后", 14, bold=True,
color=WHITE, align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE,
shrink=False, name="right_label")
right_pts = ["改善 A: 描述", "改善 B: 描述", "改善 C: 描述"]
for i, p in enumerate(right_pts):
add_dot(slide, mid_x + 0.35, 2.7 + i * 0.7 + 0.18, color=ACCENT)
add_textbox(slide, mid_x + 0.75, 2.7 + i * 0.7,
SAFE_RIGHT - mid_x - 0.75, 0.55, p, 18, color=INK,
anchor=MSO_ANCHOR.MIDDLE, name=f"r_pt_{i}")
```
---
## L6 · 图表为主 (Chart-focus) —— 标题 + 一句结论 + 大图
```python
# chart.png 已用 matplotlib 生成 (见 design_principles.md §7)
slide = prs.slides.add_slide(BLANK)
page_title(slide, "季度营收持续增长")
# 一句话结论
add_textbox(slide, SAFE_LEFT, SAFE_TOP + 1.1, SAFE_W, 0.5,
"Q4 同比增长 158%,创历史新高", 18, color=GREY,
name="lead")
# 图表 (居中,占 9 寸宽,高度自适应)
slide.shapes.add_picture("chart.png", Inches(2.2), Inches(2.4),
width=Inches(8.9))
# 数据来源 (右下角弱化)
add_textbox(slide, SAFE_LEFT, 6.95, SAFE_W, 0.4,
"数据来源: 公司年报 2025", 11, color=GREY_LIGHT,
align=PP_ALIGN.RIGHT, shrink=False, name="source")
```
---
## L7 · 图片为主 (Image-focus) —— 文字在图旁,不压图
> 之前用满铺图 + 半透明遮罩,效果不稳定。改成"图占 60% + 文字独立区"。
```python
slide = prs.slides.add_slide(BLANK)
# 左侧图占 60% 宽
slide.shapes.add_picture("hero.jpg", Inches(0), Inches(0),
width=Inches(8), height=Inches(7.5))
# 右侧浅灰背景区放文字
add_rect(slide, 8, 0, 5.33, 7.5, BG, "text_panel")
add_rect(slide, 8.4, 1.0, 0.06, 0.8, ACCENT, "deco_bar") # 装饰短线
add_textbox(slide, 8.4, 2.0, 4.6, 1.6, "走进未来", 36,
bold=True, color=INK, name="img_title")
add_textbox(slide, 8.4, 3.8, 4.6, 1.5,
"用一两句话点出主旨,不要把演讲稿搬上来。",
18, color=GREY, name="img_caption")
# 图标:右下角的箭头,引导视线
add_shape(slide, MSO_SHAPE.RIGHT_ARROW, 8.4, 6.5, 0.7, 0.35, ACCENT,
"img_cta")
```
---
## L8 · 金句 / 大字 (Quote) —— 留白主导,装饰极简
```python
slide = prs.slides.add_slide(BLANK)
# 左上大引号 (用 STAR 不合适;用字形)
add_textbox(slide, 0.8, 0.6, 1.5, 1.5, '"', 200, bold=True,
color=ACCENT, font=EN_FONT, shrink=False, name="quote_mark")
# 金句 (深色,留白多)
add_textbox(slide, 1.5, 2.7, 10.5, 2.0,
"把复杂留给我们,把简单留给用户。", 36, bold=True,
color=INK, anchor=MSO_ANCHOR.MIDDLE, name="quote_text")
# 装饰短线
add_accent_line(slide, 1.5, 5.0, length=0.5, color=ACCENT)
# 出处
add_textbox(slide, 1.5, 5.2, 10.5, 0.5, "—— 公司价值观 2025",
16, color=GREY, name="quote_attr")
```
---
## L9 · 结尾 / Q&A —— 浅色 + 大字,不再满铺深色
```python
slide = prs.slides.add_slide(BLANK)
# 顶部 + 底部装饰短线 (代替整页色块)
add_rect(slide, SAFE_LEFT, 0.6, 0.8, 0.06, ACCENT, "top_line")
add_rect(slide, SAFE_RIGHT - 0.8, 6.85, 0.8, 0.06, ACCENT, "bottom_line")
add_textbox(slide, 0, 2.5, SLIDE_W, 1.6, "Thank You", 80, bold=True,
color=PRIMARY, align=PP_ALIGN.CENTER, font=EN_FONT,
name="thanks")
add_textbox(slide, 0, 4.3, SLIDE_W, 0.6, "欢迎提问与讨论",
22, color=ACCENT, align=PP_ALIGN.CENTER, name="qa")
add_textbox(slide, 0, 6.2, SLIDE_W, 0.5,
"联系方式 / 邮箱 / 公众号", 14, color=GREY_LIGHT,
align=PP_ALIGN.CENTER, name="contact")
```
---
## 选版式速查
```
有数据 ≥ 3 点 → L6 (Chart-focus)
对比类 (前/后, A/B) → L5 (Two-Column)
要点 ≤ 5 条 → L4 (Bullets)
转场 / 换章 → L3 (Section Divider)
首页 → L1 (Cover)
末页 → L9 (Q&A)
有大图 / 视觉优先 → L7 (Image-focus)
观点强调 / 名言 → L8 (Quote)
```
## 三个常犯的越界场景
1. **bullet 字数超额** —— 22pt 在 11.5 寸宽下每行约 50 个中文字。超过 1 行就溢出 0.7 in 高的框。**用 `assert_inside` + `auto_size=TEXT_TO_SHAPE_AND_FIT_TEXT` 兜底**;但根本解法是**字数压缩**(见 design_principles.md §字数预算)
2. **标题占两行** —— 标题在 0.7 in 高的框里,32pt 单行高约 0.45 in,**两行就溢出**。中文标题 ≤ 30 字
3. **图片不等比拉伸** —— `add_picture(width=, height=)` 同时给会变形;**只给 width 或 height 一项**

View File

@ -0,0 +1,258 @@
"""quality_check.py: 验收 .pptx,产出问题清单。
用法:
python quality_check.py <output.pptx> [--spec spec_lock.md]
检查项:
- 文件存在且 > 10KB
- 总页数与 spec 一致 (如提供 spec_lock.md)
- 每页有标题
- 每页 bullet 5
- 文字字号 14pt (除页脚)
- 颜色集合 5 (粗略统计)
- 没有 untitled / output / placeholder 等占位文件名
- **形状不越出画布边界** (left+width / top+height 超界即报)
- **textbox 文本估算行数 > 框高度** 推断溢出
退出码:
0 = 全通过
1 = warning
2 = 致命问题 (文件缺失等)
"""
from __future__ import annotations
import argparse
import re
import sys
from pathlib import Path
try:
from pptx import Presentation
from pptx.util import Pt
except ImportError:
print("[fatal] pip install python-pptx", file=sys.stderr)
sys.exit(2)
# ---- spec 解析 (松散 markdown 解析,够用就行) ----
def parse_spec(spec_path: Path) -> dict:
if not spec_path or not spec_path.exists():
return {}
text = spec_path.read_text(encoding="utf-8")
spec: dict = {}
m = re.search(r"页数[:\s]*(\d+)", text)
if m:
spec["page_count"] = int(m.group(1))
m = re.search(r"画布[:\s]*(16:9|4:3|9:16|1:1|3:4)", text)
if m:
spec["canvas"] = m.group(1)
hexes = re.findall(r"#([0-9A-Fa-f]{6})", text)
if hexes:
spec["colors"] = [h.upper() for h in hexes[:5]]
return spec
# ---- 检查 ----
def check_pptx(path: Path, spec: dict) -> tuple[list, list]:
"""returns (errors, warnings)"""
errors, warnings = [], []
if not path.exists():
errors.append(f"文件不存在: {path}")
return errors, warnings
size_kb = path.stat().st_size / 1024
if size_kb < 10:
errors.append(f"文件太小 ({size_kb:.1f}KB),python-pptx 可能没写完")
name = path.stem.lower()
if name in ("untitled", "output", "presentation", "untitled1", "new", "test"):
warnings.append(
f"文件名 '{path.name}' 太通用,建议按主题命名"
)
prs = Presentation(path)
n_slides = len(prs.slides)
slide_w_in = prs.slide_width / 914400 # EMU → inch
slide_h_in = prs.slide_height / 914400
print(
f"[info] 文件: {path.name} 大小: {size_kb:.1f}KB "
f"页数: {n_slides} 画布: {slide_w_in:.2f}×{slide_h_in:.2f} in"
)
expected = spec.get("page_count")
if expected and n_slides != expected:
warnings.append(f"页数 {n_slides} 与 spec 期望 {expected} 不符")
spec_colors = set(spec.get("colors", []))
seen_colors: set[str] = set()
for idx, slide in enumerate(prs.slides, 1):
title_text = None
bullet_count = 0
small_font_count = 0
for s_i, shape in enumerate(slide.shapes):
# ---- 形状越界检查 (任何 shape) ----
try:
left_in = shape.left / 914400 if shape.left is not None else 0
top_in = shape.top / 914400 if shape.top is not None else 0
w_in = shape.width / 914400 if shape.width is not None else 0
h_in = shape.height / 914400 if shape.height is not None else 0
except (AttributeError, TypeError):
left_in = top_in = w_in = h_in = 0
tol = 0.02 # 0.02 in 容忍 (约 0.5mm)
shape_label = (
shape.name if hasattr(shape, "name") and shape.name
else f"shape#{s_i}"
)
if left_in < -tol or top_in < -tol:
warnings.append(
f"{idx}{shape_label} 起点为负: "
f"({left_in:.2f}, {top_in:.2f})"
)
if left_in + w_in > slide_w_in + tol:
overflow = left_in + w_in - slide_w_in
warnings.append(
f"{idx}{shape_label} 右越界 {overflow:.2f}in "
f"(画布 {slide_w_in:.2f},shape 右 {left_in + w_in:.2f})"
)
if top_in + h_in > slide_h_in + tol:
overflow = top_in + h_in - slide_h_in
warnings.append(
f"{idx}{shape_label} 下越界 {overflow:.2f}in "
f"(画布 {slide_h_in:.2f},shape 底 {top_in + h_in:.2f})"
)
if not shape.has_text_frame:
continue
tf = shape.text_frame
text = (tf.text or "").strip()
if not text:
continue
if title_text is None and len(text) <= 40 and "\n" not in text:
title_text = text
# ---- 文本溢出估算 ----
# 估算:中文字号 N pt 在框宽 W in 下,每行约 W*72/N 个中文字
# 非空段落数 + 长段落折行数 ≈ 实际行数
# 行数 × (size_pt * 1.4 / 72) > 框高 → 溢出
try:
first_size_pt = None
for para in tf.paragraphs:
for run in para.runs:
if run.font.size:
first_size_pt = run.font.size.pt
break
if first_size_pt:
break
if first_size_pt and w_in > 0.5 and h_in > 0.2:
chars_per_line = max(1, int(w_in * 72 / first_size_pt))
est_lines = 0
for para in tf.paragraphs:
ptxt = (para.text or "").strip()
if not ptxt:
continue
est_lines += max(
1,
(len(ptxt) + chars_per_line - 1) // chars_per_line
)
line_height_in = first_size_pt * 1.4 / 72
needed_h = est_lines * line_height_in
if needed_h > h_in + 0.1:
warnings.append(
f"{idx}{shape_label} 文本可能溢出 "
f"(估 {est_lines} 行,需 {needed_h:.2f}in,"
f"框高 {h_in:.2f}in): {text[:25]}..."
)
except (AttributeError, TypeError, ValueError):
pass
for para in tf.paragraphs:
ptxt = (para.text or "").strip()
if not ptxt:
continue
if len(ptxt) > 1 and ptxt != title_text:
bullet_count += 1
for run in para.runs:
if run.font.size:
if run.font.size < Pt(14):
small_font_count += 1
if run.font.color and run.font.color.type:
try:
rgb = run.font.color.rgb
if rgb is not None:
seen_colors.add(str(rgb))
except (AttributeError, KeyError, ValueError):
pass
if title_text is None:
warnings.append(f"{idx} 页缺标题")
elif len(title_text) > 30:
warnings.append(
f"{idx} 页标题过长 ({len(title_text)} 字): {title_text[:20]}..."
)
if bullet_count > 5:
warnings.append(
f"{idx} 页 bullet {bullet_count} 条 (上限 5),建议拆页或转图表"
)
if small_font_count > 0:
warnings.append(
f"{idx} 页有 {small_font_count} 处字号 < 14pt,投影看不清"
)
if len(seen_colors) > 6:
warnings.append(
f"颜色 {len(seen_colors)} 种 (含不同灰阶),理想 ≤ 5;考虑收敛到三色制"
)
if spec_colors and seen_colors:
unmatched = seen_colors - spec_colors
if len(unmatched) > 3:
warnings.append(
f"出现 {len(unmatched)} 个 spec_lock 之外的颜色,可能用了 matplotlib 默认色板"
)
return errors, warnings
def main():
ap = argparse.ArgumentParser()
ap.add_argument("pptx", type=Path)
ap.add_argument("--spec", type=Path, default=None,
help="spec_lock.md 路径")
args = ap.parse_args()
spec = parse_spec(args.spec) if args.spec else {}
if spec:
print(f"[info] spec 已加载: {spec}")
errors, warnings = check_pptx(args.pptx, spec)
if errors:
print("\n[errors]")
for e in errors:
print(f"{e}")
if warnings:
print("\n[warnings]")
for w in warnings:
print(f" ! {w}")
if not errors and not warnings:
print("\n[ok] 全部通过")
sys.exit(0)
sys.exit(2 if errors else 1)
if __name__ == "__main__":
main()

View File

@ -0,0 +1,129 @@
"""render_icon.py: unicode 字形 → 透明背景 PNG。
MSO_SHAPE 覆盖不到的图标 (齿轮放大镜文件夹等),用字形渲染兜底
首选 MSO_SHAPE, references/icons.md
用法:
python render_icon.py "" --color "#38B2AC" --size 96 -o check.png
python render_icon.py "" --color "#FFC000" --size 128 -o star.png
python render_icon.py "" --color "#1F4E79" --size 64 -o arrow.png
退出码:
0 = 成功
1 = Pillow 缺失
2 = 字体找不到
"""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
def find_font(preferred: list[str]) -> str | None:
"""按顺序找系统字体。返回字体路径或 None。"""
candidates = []
# Windows
candidates += [
rf"C:\Windows\Fonts\{name}" for name in [
"seguisym.ttf", # Segoe UI Symbol
"seguiemj.ttf", # Segoe UI Emoji (彩色,慎用)
"msyh.ttc", "msyh.ttf", # 微软雅黑
"simsun.ttc", # 宋体
"arial.ttf",
]
]
# macOS
candidates += [
"/System/Library/Fonts/Apple Symbols.ttf",
"/System/Library/Fonts/PingFang.ttc",
"/Library/Fonts/Arial Unicode.ttf",
]
# Linux
candidates += [
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/truetype/wqy/wqy-microhei.ttc",
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
]
if preferred:
candidates = preferred + candidates
for c in candidates:
if Path(c).exists():
return c
return None
def hex_to_rgba(hex_str: str) -> tuple[int, int, int, int]:
h = hex_str.lstrip("#")
if len(h) == 6:
return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16), 255
if len(h) == 8:
return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16), int(h[6:8], 16)
raise ValueError(f"bad hex color: {hex_str}")
def render(glyph: str, color: str, size_px: int, output: Path,
font_path: str | None, padding: int) -> None:
try:
from PIL import Image, ImageDraw, ImageFont
except ImportError:
print("[fatal] pip install Pillow", file=sys.stderr)
sys.exit(1)
font_path = font_path or find_font([])
if not font_path:
print("[fatal] no symbol font found; pass --font /path/to/font.ttf",
file=sys.stderr)
sys.exit(2)
rgba = hex_to_rgba(color)
# 字体载入,用 size_px 的 0.85 做实际字号让字形不顶格
font_size = int(size_px * 0.85)
font = ImageFont.truetype(font_path, font_size)
# 测量字形真实包围盒
tmp = Image.new("RGBA", (size_px * 2, size_px * 2), (0, 0, 0, 0))
draw = ImageDraw.Draw(tmp)
bbox = draw.textbbox((0, 0), glyph, font=font)
tw = bbox[2] - bbox[0]
th = bbox[3] - bbox[1]
# 输出画布:正方形,边长 = size_px,加 padding
canvas_size = size_px + 2 * padding
img = Image.new("RGBA", (canvas_size, canvas_size), (0, 0, 0, 0))
draw = ImageDraw.Draw(img)
# 居中绘制 (考虑 bbox 偏移)
x = (canvas_size - tw) // 2 - bbox[0]
y = (canvas_size - th) // 2 - bbox[1]
draw.text((x, y), glyph, font=font, fill=rgba)
output.parent.mkdir(parents=True, exist_ok=True)
img.save(output, "PNG")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("glyph", help="unicode 字符,如 ✓ ★ →")
ap.add_argument("--color", default="#1F4E79", help="hex,默认 #1F4E79")
ap.add_argument("--size", type=int, default=96,
help="像素边长 (字形主体),默认 96")
ap.add_argument("--padding", type=int, default=8,
help="周围透明边距像素,默认 8")
ap.add_argument("--font", default=None,
help="自定义字体路径 (.ttf/.ttc/.otf)")
ap.add_argument("-o", "--output", type=Path, required=True,
help="输出 PNG 路径")
args = ap.parse_args()
render(args.glyph, args.color, args.size, args.output,
args.font, args.padding)
size_kb = args.output.stat().st_size / 1024
print(f"[ok] {args.output} ({size_kb:.1f} KB)")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,157 @@
"""source_to_md.py: 把素材转成干净 Markdown,作为后续策略阶段的输入。
用法:
python source_to_md.py <input> # 自动按扩展名识别
python source_to_md.py <url> # http/https 走 web 抓
python source_to_md.py file.pdf -o source.md
支持:
.pdf pypdf 提取文本
.docx python-docx 段落
.pptx python-pptx 提取每页文字
.txt/.md 直读
URL requests + 简易 HTML 剥离
设计原则:模型在策略阶段只看 Markdown,不读二进制 / 不爬复杂排版
"""
from __future__ import annotations
import argparse
import re
import sys
from pathlib import Path
from urllib.parse import urlparse
def from_pdf(path: Path) -> str:
try:
from pypdf import PdfReader
except ImportError:
return "[error] pip install pypdf"
reader = PdfReader(str(path))
parts = [f"# {path.stem}\n"]
for i, page in enumerate(reader.pages, 1):
text = (page.extract_text() or "").strip()
if text:
parts.append(f"\n## Page {i}\n\n{text}\n")
return "\n".join(parts)
def from_docx(path: Path) -> str:
try:
from docx import Document
except ImportError:
return "[error] pip install python-docx"
doc = Document(str(path))
parts = [f"# {path.stem}\n"]
for para in doc.paragraphs:
text = para.text.strip()
if not text:
continue
style = (para.style.name or "").lower() if para.style else ""
if "heading 1" in style:
parts.append(f"\n## {text}\n")
elif "heading 2" in style:
parts.append(f"\n### {text}\n")
elif "heading 3" in style:
parts.append(f"\n#### {text}\n")
else:
parts.append(f"\n{text}\n")
return "".join(parts)
def from_pptx(path: Path) -> str:
try:
from pptx import Presentation
except ImportError:
return "[error] pip install python-pptx"
prs = Presentation(str(path))
parts = [f"# {path.stem}\n"]
for i, slide in enumerate(prs.slides, 1):
parts.append(f"\n## Slide {i}\n")
for shape in slide.shapes:
if shape.has_text_frame:
txt = shape.text_frame.text.strip()
if txt:
parts.append(f"\n{txt}\n")
return "".join(parts)
def from_text(path: Path) -> str:
return path.read_text(encoding="utf-8", errors="replace")
_TAG_RE = re.compile(r"<[^>]+>")
_WS_RE = re.compile(r"\n{3,}")
def from_url(url: str) -> str:
try:
import requests
except ImportError:
return "[error] pip install requests"
r = requests.get(url, timeout=30, headers={
"User-Agent": "Mozilla/5.0 (compatible; ppt-source-to-md/1.0)"
})
r.raise_for_status()
html = r.text
# 极简剥离:script/style 删,标签去除
html = re.sub(r"<script[\s\S]*?</script>", "", html, flags=re.I)
html = re.sub(r"<style[\s\S]*?</style>", "", html, flags=re.I)
title_m = re.search(r"<title[^>]*>([^<]+)</title>", html, re.I)
title = title_m.group(1).strip() if title_m else url
# 块级标签转换行
html = re.sub(r"</?(p|div|br|li|h[1-6]|tr)[^>]*>", "\n", html, flags=re.I)
text = _TAG_RE.sub("", html)
text = re.sub(r"&nbsp;", " ", text)
text = re.sub(r"&amp;", "&", text)
text = re.sub(r"&lt;", "<", text)
text = re.sub(r"&gt;", ">", text)
text = re.sub(r"&quot;", '"', text)
text = "\n".join(line.strip() for line in text.splitlines())
text = _WS_RE.sub("\n\n", text).strip()
return f"# {title}\n\nSource: {url}\n\n{text}\n"
def dispatch(src: str) -> str:
parsed = urlparse(src)
if parsed.scheme in ("http", "https"):
return from_url(src)
path = Path(src)
if not path.exists():
return f"[error] not found: {src}"
ext = path.suffix.lower()
if ext == ".pdf":
return from_pdf(path)
if ext == ".docx":
return from_docx(path)
if ext == ".pptx":
return from_pptx(path)
if ext in (".txt", ".md"):
return from_text(path)
return f"[error] unsupported extension: {ext}"
def main():
ap = argparse.ArgumentParser()
ap.add_argument("src", help="文件路径或 http(s) URL")
ap.add_argument("-o", "--output", type=Path, default=None,
help="写到文件;默认打印到 stdout")
args = ap.parse_args()
md = dispatch(args.src)
if args.output:
args.output.write_text(md, encoding="utf-8")
print(f"[ok] {args.output} ({len(md)} chars)")
else:
sys.stdout.write(md)
if __name__ == "__main__":
main()

79
skills/proposal/SKILL.md Normal file
View File

@ -0,0 +1,79 @@
---
name: proposal
description: 撰写科研申报书/课题立项书 (国自然、省基金、横向项目、校级课题)。当用户要写课题申请、立项依据、研究计划、技术路线、本子时使用。
---
# 科研申报书
## 工作流
1. **对齐课题信息**: 不要急着写正文。先与用户确认并落到 `project.md`:
- 研究方向 / 拟解决的关键科学问题
- 创新点 (3 条以内,要"小而尖")
- 技术路线骨架
- 应用场景与受众
2. **分章节起草**: 每章一个 .md 文件,不要一次性出全文 —— 单章写完先给用户看,定调后再下一章
3. **合并定稿**: 用 `run_python` + python-docx,把各章节 md 套模板渲染成 .docx
## 工作目录约定
```
proposal/
├── project.md # 课题信息卡片
├── sections/
│ ├── 01_background.md # 立项依据
│ ├── 02_objectives.md # 研究内容与目标
│ ├── 03_method.md # 拟采取的研究方案
│ ├── 04_innovation.md # 特色与创新
│ ├── 05_basis.md # 研究基础
│ └── 06_cv.md # 申请人简介
└── proposal.docx # 最终输出
```
## 字数(国自然青年示例,其他基金按实际套)
| 章节 | 推荐字数 |
|-----|---------|
| 立项依据 | 5000-8000 |
| 研究内容与目标 | 2000-3000 |
| 研究方案 | 3000-5000 |
| 特色与创新 | 800-1500 |
| 研究基础 | 1500-2500 |
| 申请人简介 | 1000-2000 |
超出/不足都不专业,严格控字数。
## 硬规则
- **文献必须真实**: 不可编造作者、年份、DOI、期刊。需要引用先告诉用户来源,让用户提供文献清单
- **GB/T 7714 顺序编码制**: 引文 [1][2][3]...,文末参考文献顺序对应
- **不堆形容词**: "首次提出""填补空白""国际领先" 一律不用,除非用户明确要这种话术
- **逻辑先行**: 立项依据按 "现状 → 问题 → 本课题切入点" 三段式;研究方案按 "目标 → 任务分解 → 技术路线 → 可行性"
## 合并 docx 模板
```python
from docx import Document
from docx.shared import Pt
from pathlib import Path
doc = Document()
style = doc.styles['Normal']
style.font.name = '宋体'
style.font.size = Pt(12)
sections = sorted(Path("proposal/sections").glob("*.md"))
for sec in sections:
text = sec.read_text(encoding="utf-8")
for para in text.split("\n\n"):
para = para.strip()
if not para:
continue
if para.startswith("# "):
doc.add_heading(para[2:], level=1)
elif para.startswith("## "):
doc.add_heading(para[3:], level=2)
else:
doc.add_paragraph(para)
doc.save("proposal/proposal.docx")
```
## 反模式
- 用户没给课题就开始硬编内容
- 一次性出全文 (用户没法迭代)
- 引文里写 "[Smith et al., 2023]" 但其实根本没这篇文献

0
tools/__init__.py Normal file
View File

34
tools/base.py Normal file
View File

@ -0,0 +1,34 @@
"""Tool 基类: 子类只需声明 name/description/parameters 和 execute。"""
from __future__ import annotations
from abc import ABC, abstractmethod
from pathlib import Path
from typing import Optional
class Tool(ABC):
name: str = ""
description: str = ""
parameters: dict = {}
def __init__(self, base_dir: Optional[Path] = None) -> None:
self.base_dir: Path = Path(base_dir) if base_dir else Path.cwd()
@abstractmethod
def execute(self, **kwargs) -> str:
...
@property
def schema(self) -> dict:
return {
"type": "function",
"function": {
"name": self.name,
"description": self.description,
"parameters": self.parameters,
},
}
def _resolve(self, path: str) -> Path:
p = Path(path)
return p if p.is_absolute() else (self.base_dir / p)

182
tools/fs.py Normal file
View File

@ -0,0 +1,182 @@
"""文件系统工具: read / write / edit / glob / grep。
edit 工具采用 CoreCoder "唯一匹配"约束: old_str 必须在文件中出现且仅出现一次,
否则报错这是防止 LLM 改错地方的业界最佳实践
"""
from __future__ import annotations
import re
from pathlib import Path
from .base import Tool
class ReadTool(Tool):
name = "read"
description = (
"Read a text file. Returns content with 1-indexed line numbers. "
"Use offset/limit for large files."
)
parameters = {
"type": "object",
"properties": {
"path": {"type": "string", "description": "Absolute or relative file path"},
"offset": {"type": "integer", "description": "Start line (1-indexed)", "default": 1},
"limit": {"type": "integer", "description": "Max lines", "default": 2000},
},
"required": ["path"],
}
def execute(self, path: str, offset: int = 1, limit: int = 2000) -> str:
p = self._resolve(path)
if not p.exists():
return f"[Error] file not found: {p}"
if not p.is_file():
return f"[Error] not a file: {p}"
try:
text = p.read_text(encoding="utf-8")
except UnicodeDecodeError:
return f"[Error] not a UTF-8 text file: {p}"
lines = text.split("\n")
start = max(1, offset)
end = min(len(lines), start + limit - 1)
out = [f"{i+1:6d}\t{lines[i]}" for i in range(start - 1, end)]
header = f"[{p}] lines {start}-{end} of {len(lines)}\n"
return header + "\n".join(out)
class WriteTool(Tool):
name = "write"
description = (
"Write content to a file (creates parent dirs, overwrites if exists). "
"Prefer 'edit' for modifying existing files."
)
parameters = {
"type": "object",
"properties": {
"path": {"type": "string"},
"content": {"type": "string"},
},
"required": ["path", "content"],
}
def execute(self, path: str, content: str) -> str:
p = self._resolve(path)
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(content, encoding="utf-8")
return f"[wrote {len(content)} chars to {p}]"
class EditTool(Tool):
name = "edit"
description = (
"Replace a unique string in a file. old_str MUST occur exactly once in the file, "
"otherwise the call fails. Include enough surrounding context to make it unique."
)
parameters = {
"type": "object",
"properties": {
"path": {"type": "string"},
"old_str": {"type": "string", "description": "Exact substring to replace, must be unique"},
"new_str": {"type": "string", "description": "Replacement string"},
},
"required": ["path", "old_str", "new_str"],
}
def execute(self, path: str, old_str: str, new_str: str) -> str:
p = self._resolve(path)
if not p.exists():
return f"[Error] file not found: {p}"
content = p.read_text(encoding="utf-8")
count = content.count(old_str)
if count == 0:
return f"[Error] old_str not found in {p}"
if count > 1:
return f"[Error] old_str appears {count} times in {p}, must be unique — add more context"
p.write_text(content.replace(old_str, new_str), encoding="utf-8")
return f"[edited {p}: 1 replacement]"
class GlobTool(Tool):
name = "glob"
description = "Find files by glob pattern (e.g. '**/*.py', 'src/*.md'). Returns up to 200 paths."
parameters = {
"type": "object",
"properties": {
"pattern": {"type": "string"},
"path": {"type": "string", "description": "Base directory (default: cwd)", "default": "."},
},
"required": ["pattern"],
}
def execute(self, pattern: str, path: str = ".") -> str:
base = self._resolve(path)
if not base.exists():
return f"[Error] base path not found: {base}"
# 把 '**/' 前缀的递归交给 rglob,其他用 glob
if "**" in pattern:
matches = sorted(str(p) for p in base.glob(pattern))
else:
matches = sorted(str(p) for p in base.glob(pattern))
if not matches:
return f"[no matches for '{pattern}' under {base}]"
return "\n".join(matches[:200])
class GrepTool(Tool):
name = "grep"
description = "Search a regex in files. Returns up to 200 'path:line:content' lines."
parameters = {
"type": "object",
"properties": {
"pattern": {"type": "string", "description": "Python regex"},
"path": {"type": "string", "default": "."},
"glob": {
"type": "string",
"description": "File glob filter, e.g. '*.py' or '**/*.md'",
"default": "",
},
"ignore_case": {"type": "boolean", "default": False},
},
"required": ["pattern"],
}
SKIP_DIRS = {".git", "node_modules", "__pycache__", ".venv", "venv", "dist", "build"}
def execute(self, pattern: str, path: str = ".", glob: str = "", ignore_case: bool = False) -> str:
base = self._resolve(path)
if not base.exists():
return f"[Error] base path not found: {base}"
flags = re.IGNORECASE if ignore_case else 0
try:
regex = re.compile(pattern, flags)
except re.error as e:
return f"[Error] invalid regex: {e}"
if glob:
files = list(base.glob(glob)) if "**" in glob else list(base.rglob(glob))
else:
files = list(base.rglob("*"))
matches: list[str] = []
for f in files:
if not f.is_file():
continue
if any(part in self.SKIP_DIRS for part in f.parts):
continue
try:
text = f.read_text(encoding="utf-8")
except (UnicodeDecodeError, OSError):
continue
for i, line in enumerate(text.split("\n"), 1):
if regex.search(line):
matches.append(f"{f}:{i}:{line}")
if len(matches) >= 200:
break
if len(matches) >= 200:
break
if not matches:
return f"[no matches for /{pattern}/ in {base}]"
return "\n".join(matches)

84
tools/run_python.py Normal file
View File

@ -0,0 +1,84 @@
"""run_python: 在 subprocess 里执行 Python 代码 (Hybrid 范式的关键)。
JSON tool call 处理离散操作 (read/edit/shell),run_python 处理连续逻辑
(数据计算批处理生成 .pptx/.docx)让模型自己挑工具
阶段 1 (本地): subprocess + 工作目录限制 + 敏感环境变量过滤
阶段 2 (后期): Docker / E2B 替换执行后端
"""
from __future__ import annotations
import os
import subprocess
import sys
import tempfile
from pathlib import Path
from .base import Tool
_SENSITIVE_PATTERNS = ("API_KEY", "TOKEN", "SECRET", "PASSWORD", "PRIVATE_KEY")
class RunPythonTool(Tool):
name = "run_python"
description = (
"Execute Python code in a subprocess. Returns stdout/stderr/exit_code.\n"
"Use for: data analysis, batch file ops, document generation (.pptx/.docx), "
"matplotlib charts, or any task where Python is more natural than chaining tools.\n"
"Working directory is the agent's base dir. Files you create persist there.\n"
"Available libs (install with shell pip if missing): "
"pandas, numpy, matplotlib, python-pptx, python-docx, requests, pypdf."
)
parameters = {
"type": "object",
"properties": {
"code": {
"type": "string",
"description": "Python source. Anything written to stdout is returned.",
},
"timeout": {"type": "integer", "default": 120, "description": "Seconds before kill"},
},
"required": ["code"],
}
def execute(self, code: str, timeout: int = 120) -> str:
# 写到临时文件,避免 -c 转义问题
with tempfile.NamedTemporaryFile(
suffix=".py", mode="w", delete=False, encoding="utf-8"
) as f:
f.write(code)
script_path = f.name
try:
env = os.environ.copy()
for k in list(env):
u = k.upper()
if any(p in u for p in _SENSITIVE_PATTERNS):
del env[k]
env["PYTHONIOENCODING"] = "utf-8"
result = subprocess.run(
[sys.executable, script_path],
cwd=str(self.base_dir),
capture_output=True,
timeout=timeout,
text=True,
encoding="utf-8",
errors="replace",
env=env,
)
except subprocess.TimeoutExpired:
return f"[Error] python script timed out after {timeout}s"
finally:
try:
Path(script_path).unlink()
except OSError:
pass
parts = []
if result.stdout:
parts.append(f"[stdout]\n{result.stdout.rstrip()}")
if result.stderr:
parts.append(f"[stderr]\n{result.stderr.rstrip()}")
parts.append(f"[exit {result.returncode}]")
return "\n".join(parts)

63
tools/shell.py Normal file
View File

@ -0,0 +1,63 @@
"""Shell 执行: subprocess 跑命令,有黑名单拦明显危险操作。"""
from __future__ import annotations
import subprocess
from .base import Tool
class ShellTool(Tool):
name = "shell"
description = (
"Execute a shell command and return stdout/stderr/exit_code. "
"Default 60s timeout. Working directory is the agent's base dir."
)
parameters = {
"type": "object",
"properties": {
"command": {"type": "string"},
"timeout": {"type": "integer", "default": 60, "description": "Seconds before kill"},
},
"required": ["command"],
}
BLOCKED_PATTERNS = (
"rm -rf /",
"rm -rf ~",
"rm -rf $HOME",
":(){ :|:& };:",
"mkfs",
"dd if=/dev/zero",
"> /dev/sda",
"format c:",
)
def execute(self, command: str, timeout: int = 60) -> str:
normalized = command.lower()
for pat in self.BLOCKED_PATTERNS:
if pat in normalized:
return f"[Error] blocked dangerous command pattern: {pat!r}"
try:
result = subprocess.run(
command,
shell=True,
cwd=str(self.base_dir),
capture_output=True,
timeout=timeout,
text=True,
encoding="utf-8",
errors="replace",
)
except subprocess.TimeoutExpired:
return f"[Error] command timed out after {timeout}s"
except FileNotFoundError as e:
return f"[Error] {e}"
parts = []
if result.stdout:
parts.append(f"[stdout]\n{result.stdout.rstrip()}")
if result.stderr:
parts.append(f"[stderr]\n{result.stderr.rstrip()}")
parts.append(f"[exit {result.returncode}]")
return "\n".join(parts)

45
tools/skill_tool.py Normal file
View File

@ -0,0 +1,45 @@
"""load_skill 工具: agent 主动加载某个 skill 的完整 SKILL.md。
这是渐进披露的中间层 (Activation): 启动时只看到 name+description,
认为自己要做某类任务时, load_skill 拿到完整指引
"""
from __future__ import annotations
from pathlib import Path
from typing import Optional
from core.skills import SkillRegistry
from .base import Tool
class LoadSkillTool(Tool):
name = "load_skill"
description = (
"Load full instructions for a skill. Call this when the current task matches "
"a skill's domain (e.g. writing PPT, coding, research proposal). "
"Returns the SKILL.md body, which may reference further files in skills/<name>/references."
)
parameters = {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Skill name as listed in the system prompt's discovery block",
}
},
"required": ["name"],
}
def __init__(self, registry: SkillRegistry, base_dir: Optional[Path] = None) -> None:
super().__init__(base_dir)
self.registry = registry
def execute(self, name: str) -> str:
skill = self.registry.get(name)
if skill is None:
available = ", ".join(self.registry.skills.keys()) or "(none)"
return f"[Error] skill '{name}' not found. Available: {available}"
body = skill.full_content()
header = f"[skill={skill.name}, dir={skill.skill_dir}]\n"
return header + body