feat: 重组为标准 skill 包结构 + 修复 verify.sh bash 3.2 兼容

- 新增 .claude/skills/new-project/SKILL.md(标准 skill 格式)
- install-skill.sh 安装时组装 SKILL.md + verify.sh,单一源无重复
- verify.sh 改用索引数组替代 declare -A,兼容 macOS bash 3.2
- log_pass/log_skip 显式 return 0,避免 set -e 下非 verbose 误退出
- README 改为 skill 优先;补全 copier.yml/scripts/template 入库
This commit is contained in:
baozaotumao
2026-06-01 21:19:45 -07:00
parent 167fa10dde
commit 39fe248f9f
53 changed files with 3744 additions and 55 deletions
-38
View File
@@ -1,38 +0,0 @@
帮用户用这个 scaffold 生成一个新项目。
用户输入:$ARGUMENTS
## 步骤
1. 如果用户没有提供项目名,先问清楚以下信息(一次性问完,不要逐个问):
- 项目目录名(my-project
- 是否包含 backendFastAPI
- 是否包含 frontendReact + Vite
- 是否包含 agentLLM 服务)
- 数据库:sqlite 还是 postgresql
- 如果 include_agent=trueLLM providergemini-cli / openai-api / anthropic-api
2. 在用户指定的目录(或当前目录)运行 copier 命令:
```bash
copier copy git+https://github.com/baozaotumao2025/scaffold.git <项目目录> \
--data project_name="<显示名称>" \
--data include_backend=<true/false> \
--data include_agent=<true/false> \
--data include_frontend=<true/false> \
--data database=<sqlite/postgresql> \
--data llm_provider=<provider> # 仅 include_agent=true 时加此项
```
3. 生成完成后,运行验证:
```bash
./scripts/verify.sh -v <项目目录>
```
4. 汇报生成结果和验证输出。如有失败项,给出修复建议。
## 注意
- 如果用户已在命令后直接提供了参数(如 `/new-project my-app backend+frontend`),直接解析并执行,不要再问一遍。
- 生成目录默认放在当前工作目录下。
- 不要修改 scaffold 模板本身。
+69
View File
@@ -0,0 +1,69 @@
---
name: "new-project"
description: "使用 scaffold copier 模板一键生成含完整质量门禁的 FastAPI + React 项目。当用户需要从零搭建新项目、初始化工程脚手架、创建新服务、bootstrap 新代码库,或需要带 pre-commit/CI/Docker/Claude 规则的标准化项目骨架时使用。支持按需选择 backend(FastAPI) / frontend(React+Vite) / agent(LLM) 服务、sqlite 或 postgresql 数据库、以及 LLM provider,并自动裁剪 Claude Code 规则文件(原理层 + 落地层)。不适用于:已有项目的局部改造、单文件重构、或非新建场景。"
dependencies: ["copier"]
---
# new-project
## Overview
一条命令生成带有完整质量门禁(pre-commit、CI、Docker、Claude Code 规则)的 FastAPI + React 项目,并随选型自动裁剪 Claude Code 落地层规则。模板从 GitHub 拉取,无需本地克隆 scaffold 仓库。
## Instructions
### 第一步:收集参数
若用户未提供足够信息,**一次性**询问以下内容(不要逐个问):
| 参数 | 说明 | 默认值 |
|------|------|--------|
| 项目目录名 | 生成到哪个文件夹 | my-project |
| 是否包含 backend | FastAPI 服务 | true |
| 是否包含 frontend | React + Vite | true |
| 是否包含 agent | LLM Agent 服务 | false |
| 数据库 | sqlite / postgresql | sqlite |
| LLM provider | 仅 agent=true 时问 | gemini-cli |
若用户输入包含 `--preset`,直接套用以下预设,跳过问卷:
| Preset | 等价配置 |
|--------|---------|
| `principles` | 三服务全 false — 仅复制原理层规则,无服务代码 |
| `fullstack` | backend+frontendsqlite |
| `fullstack-ai` | backend+frontend+agentpostgresqlopenai-api |
| `api-only` | 仅 backendsqlite |
### 第二步:执行生成
```bash
copier copy git+https://github.com/baozaotumao2025/scaffold.git <项目目录> \
--data project_name="<显示名称>" \
--data include_backend=<true/false> \
--data include_agent=<true/false> \
--data include_frontend=<true/false> \
--data database=<sqlite/postgresql> \
--data llm_provider=<provider>
```
> `llm_provider` 仅在 `include_agent=true` 时添加。
### 第三步:验证
使用本 skill 自带的验证脚本(自包含,无需依赖生成项目内的工具):
```bash
bash ${CLAUDE_SKILL_DIR}/scripts/verify.sh -v <项目目录>
```
验证失败时,逐条输出修复建议,不要静默跳过。
### 第四步:汇报
输出生成的服务组合、规则文件列表,以及 verify.sh 汇总结果。
## Resources
- 模板仓库:`git+https://github.com/baozaotumao2025/scaffold.git`
- 验证脚本:`${CLAUDE_SKILL_DIR}/scripts/verify.sh`(只读幂等,可重复运行)
- 规则说明:见 scaffold 仓库 `README.md` — "两层规则体系" 一节
+46 -17
View File
@@ -1,6 +1,32 @@
# scaffold
# scaffold · new-project skill
基于 [Copier](https://copier.readthedocs.io/) 的工程脚手架,一键生成含完整质量门禁的 FastAPI + React 项目,并随选型自动定制 Claude Code 约束规则
> 一个 **Claude Code Skill**:在任意项目里对 Claude 说一句"帮我新建项目",即可生成含完整质量门禁(pre-commit、CI、Docker、Claude 规则)的 FastAPI + React 工程骨架,并随选型自动裁剪规则文件
## 🚀 安装(推荐)
一条命令装到全局,**任意项目可用**,无需克隆本仓库:
```bash
curl -fsSL https://raw.githubusercontent.com/baozaotumao2025/scaffold/main/scripts/install-skill.sh | bash
```
## 用法
安装后,在 Claude Code 中任选一种方式:
```
/new-project # 交互式问卷,逐步选服务/数据库
/new-project my-app # 指定项目名,其余交互确认
/new-project my-rules --preset principles # 仅生成 10 个原理层规则,无服务代码
```
或者**直接用自然语言**——Claude 会自动识别并触发 skill
> 「帮我新建一个全栈项目,要 FastAPI + React,数据库用 postgresql」
可用 preset`principles`(仅规则)· `fullstack` · `fullstack-ai` · `api-only`
> 模板由 copier 从 GitHub 实时拉取。`verify.sh` 在仓库根 `scripts/` 单一维护,安装时由 `install-skill.sh` 组装进 skill,使安装后的 skill 自包含。
---
@@ -45,8 +71,11 @@ include_frontend=false → 所有 frontend*.md 排除
scaffold/
├── copier.yml ← 问卷变量 + 条件排除逻辑
├── README.md
├── .claude/skills/new-project/
│ └── SKILL.md ← skill 元数据 + 生成指令(verify.sh 安装时组装)
├── scripts/
│ ├── verify.sh ← 工具链合规检查(幂等只读)
│ ├── verify.sh ← 工具链合规检查(幂等只读,单一源
│ ├── install-skill.sh ← 把 skill 安装到 ~/.claude/skills/
│ └── retrofit.sh ← 补装工具链到已有项目
└── template/
├── .pre-commit-config.yaml.jinja ← 按服务条件生成 hooks
@@ -77,20 +106,20 @@ scaffold/
---
## 前置依赖
## 进阶:不装 skill,直接用 copier
> 以下面向 CI、脚本,或不使用 Claude Code 的场景。日常使用推荐上面的 skill 方式。
前置依赖:
```bash
pip install copier # 或 uv tool install copier
```
---
## 生成新项目
### 交互式问卷(推荐首次使用)
### 交互式问卷
```bash
copier copy git+https://github.com/your-org/scaffold.git my-new-project
copier copy git+https://github.com/baozaotumao2025/scaffold.git my-new-project
```
问卷包含以下选择:
@@ -118,7 +147,7 @@ copier copy git+https://github.com/your-org/scaffold.git my-new-project
```bash
# 全栈 + OpenAI API + PostgreSQL
copier copy git+https://github.com/your-org/scaffold.git my-project \
copier copy git+https://github.com/baozaotumao2025/scaffold.git my-project \
--data project_name="My Service" \
--data include_backend=true \
--data include_agent=true \
@@ -127,7 +156,7 @@ copier copy git+https://github.com/your-org/scaffold.git my-project \
--data llm_provider=openai-api
# 仅 APIbackend onlySQLite
copier copy git+https://github.com/your-org/scaffold.git my-api \
copier copy git+https://github.com/baozaotumao2025/scaffold.git my-api \
--data include_backend=true \
--data include_agent=false \
--data include_frontend=false
@@ -234,7 +263,7 @@ Claude 规则文件 18 0 0
--scaffold URL 指定 scaffold 来源(覆盖 $SCAFFOLD_URL
环境变量:
SCAFFOLD_URL scaffold 来源(默认:git+https://github.com/your-org/scaffold.git
SCAFFOLD_URL scaffold 来源(默认:git+https://github.com/baozaotumao2025/scaffold.git
```
```bash
@@ -248,7 +277,7 @@ Claude 规则文件 18 0 0
SCAFFOLD_URL=./scaffold ./scripts/retrofit.sh /path/to/project
# 使用远程 scaffold,仅运行 copier(跳过 uv/pnpm
./scripts/retrofit.sh --scaffold git+https://github.com/your-org/scaffold.git --no-deps .
./scripts/retrofit.sh --scaffold git+https://github.com/baozaotumao2025/scaffold.git --no-deps .
# 补装后立即验证
./scripts/retrofit.sh . && ./scripts/verify.sh .
@@ -310,10 +339,10 @@ copier copy ./scaffold /tmp/test-api \
## 发布到团队
```bash
cd /Users/maxta/Downloads/scaffold
cd my-scaffold
git init && git add . && git commit -m "chore: initial scaffold"
git remote add origin git@github.com:your-org/scaffold.git
git remote add origin git@github.com:baozaotumao2025/scaffold.git
git push -u origin main
# 打版本 tagcopier update 按 tag 追踪)
@@ -324,7 +353,7 @@ git tag v1.0.0 && git push --tags
```bash
pip install copier
copier copy git+https://github.com/your-org/scaffold.git my-project
copier copy git+https://github.com/baozaotumao2025/scaffold.git my-project
```
---
+156
View File
@@ -0,0 +1,156 @@
_templates_suffix: .jinja
_skip_if_exists:
- "backend/src/**"
- "agent/src/**"
- "frontend/src/**"
- "**/.env"
_exclude:
- copier.yml
# ── 服务目录 ──────────────────────────────────────────────────────────────
- "{% if not include_backend %}backend{% endif %}"
- "{% if not include_agent %}agent{% endif %}"
- "{% if not include_frontend %}frontend{% endif %}"
# ── Backend 规则(仅 include_backend=true 时复制)──────────────────────
- "{% if not include_backend %}.claude/rules/backend.md{% endif %}"
- "{% if not include_backend %}.claude/rules/backend-db.md.jinja{% endif %}"
- "{% if not include_backend %}.claude/rules/backend-concurrency.md{% endif %}"
# ── Agent 规则(仅 include_agent=true 时复制)────────────────────────────
- "{% if not include_agent %}.claude/rules/agent.md.jinja{% endif %}"
- "{% if not include_agent %}.claude/rules/agent-dag.md{% endif %}"
- "{% if not include_agent %}.claude/rules/agent-concurrency.md.jinja{% endif %}"
- "{% if not include_agent %}.claude/rules/agent-extension-points.md{% endif %}"
# LLM 驱动层:按 llm_provider 只保留对应变体
- "{% if not include_agent or llm_provider != 'gemini-cli' %}.claude/rules/agent-llm-pty.md{% endif %}"
- "{% if not include_agent or llm_provider == 'gemini-cli' %}.claude/rules/agent-llm-api.md{% endif %}"
# ── Frontend 规则(仅 include_frontend=true 时复制)──────────────────────
- "{% if not include_frontend %}.claude/rules/frontend.md{% endif %}"
- "{% if not include_frontend %}.claude/rules/frontend-runtime.md{% endif %}"
# ── 跨服务规则 ────────────────────────────────────────────────────────────
# data-lifecycle: agent 产生文件,backend 清理 DB,两者都有才有意义
- "{% if not (include_backend and include_agent) %}.claude/rules/data-lifecycle.md{% endif %}"
# communication-catalog: 描述服务间 WS 消息,单服务时无跨层通信
- "{% if not (include_backend or include_agent) or not (include_agent or include_frontend) %}.claude/rules/communication-catalog.md{% endif %}"
_tasks:
- command: git init
- command: git add .
- command: "git commit -m 'chore: initialize project from scaffold'"
- command: uv sync
working_directory: backend
when: "{{ include_backend }}"
- command: uv sync
working_directory: agent
when: "{{ include_agent }}"
- command: pnpm install
working_directory: frontend
when: "{{ include_frontend }}"
- command: "pre-commit install && pre-commit install --hook-type commit-msg"
# ── 项目基本信息 ────────────────────────────────────────────────────────
project_name:
type: str
help: 项目显示名称(用于文档、README
default: My Awesome Service
project_slug:
type: str
help: 项目 slugsnake_case,用于包名、目录名)
default: "{{ project_name | lower | replace(' ', '_') | replace('-', '_') }}"
validator: >-
{% if not (project_slug | regex_search('^[a-z][a-z0-9_]*$')) %}
project_slug 只能包含小写字母、数字和下划线,且以字母开头
{% endif %}
org_name:
type: str
help: GitHub 组织 / 用户名(用于仓库 URL)
default: my-org
description:
type: str
help: 项目一行描述
default: "{{ project_name }}"
# ── 服务选择(条件包含) ────────────────────────────────────────────────
include_backend:
type: bool
help: 包含 Python FastAPI backend 服务?
default: true
include_agent:
type: bool
help: 包含 Python LLM Agent 服务?
default: false
include_frontend:
type: bool
help: 包含 React + Vite + TypeScript 前端?
default: true
# ── 端口配置 ────────────────────────────────────────────────────────────
backend_port:
type: int
help: Backend 服务端口
default: 8000
when: "{{ include_backend }}"
agent_port:
type: int
help: Agent 服务端口
default: 8001
when: "{{ include_agent }}"
frontend_port:
type: int
help: 前端开发服务器 / 容器暴露端口
default: 5173
when: "{{ include_frontend }}"
# ── Python 配置 ──────────────────────────────────────────────────────────
python_version:
type: str
help: Python 版本
default: "3.12"
choices:
- "3.11"
- "3.12"
- "3.13"
database:
type: str
help: 数据库类型
default: sqlite
choices:
- sqlite
- postgresql
when: "{{ include_backend }}"
# ── 前端配置 ─────────────────────────────────────────────────────────────
node_version:
type: str
help: Node.js 版本(CI 使用)
default: "22"
choices:
- "20"
- "22"
when: "{{ include_frontend }}"
# ── LLM 配置(Agent 专属) ───────────────────────────────────────────────
llm_provider:
type: str
help: LLM 提供商
default: gemini-cli
choices:
- gemini-cli
- openai-api
- anthropic-api
- custom
when: "{{ include_agent }}"
llm_cmd:
type: str
help: LLM CLI 命令(留空则按 provider 默认)
default: "{% if llm_provider == 'gemini-cli' %}gemini{% elif llm_provider == 'openai-api' %}openai{% else %}llm{% endif %}"
when: "{{ include_agent }}"
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env bash
# 把 new-project skill 安装到 ~/.claude/skills/(全局,任意项目可用)。
#
# 单一源原则:verify.sh 只在仓库根 scripts/ 维护一份,
# 本脚本在安装时把它和 SKILL.md 组装进 ~/.claude/skills/new-project/
# 使安装后的 skill 自包含(无需仓库即可运行验证)。
#
# 用法:
# curl -fsSL https://raw.githubusercontent.com/baozaotumao2025/scaffold/main/scripts/install-skill.sh | bash
# 或在已克隆的仓库内:
# ./scripts/install-skill.sh
set -euo pipefail
SKILL_NAME="new-project"
DEST="$HOME/.claude/skills/$SKILL_NAME"
RAW_BASE="https://raw.githubusercontent.com/baozaotumao2025/scaffold/main"
SKILL_MD_URL="$RAW_BASE/.claude/skills/$SKILL_NAME/SKILL.md"
VERIFY_URL="$RAW_BASE/scripts/verify.sh"
# 在仓库内运行时优先用本地文件,否则从 GitHub 拉取
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LOCAL_SKILL_MD="$SCRIPT_DIR/../.claude/skills/$SKILL_NAME/SKILL.md"
LOCAL_VERIFY="$SCRIPT_DIR/verify.sh"
fetch() { # fetch <url> <dest>
if command -v curl &>/dev/null; then
curl -fsSL "$1" -o "$2"
elif command -v wget &>/dev/null; then
wget -qO "$2" "$1"
else
echo "错误:需要 curl 或 wget" >&2
exit 1
fi
}
mkdir -p "$DEST/scripts"
if [[ -f "$LOCAL_SKILL_MD" && -f "$LOCAL_VERIFY" ]]; then
echo "→ 从本地仓库组装安装"
cp "$LOCAL_SKILL_MD" "$DEST/SKILL.md"
cp "$LOCAL_VERIFY" "$DEST/scripts/verify.sh"
else
echo "→ 从 GitHub 组装安装"
fetch "$SKILL_MD_URL" "$DEST/SKILL.md"
fetch "$VERIFY_URL" "$DEST/scripts/verify.sh"
fi
chmod +x "$DEST/scripts/verify.sh"
echo "✓ 已安装 skill 到 $DEST"
echo " 在任意项目中打开 Claude Code,输入 /$SKILL_NAME 或描述\"新建项目\"即可触发"
+330
View File
@@ -0,0 +1,330 @@
#!/usr/bin/env bash
# retrofit.sh — 对未通过 copier 创建的已有项目补装工具链
#
# 工作方式:用 copier copy 将 scaffold 模版叠加到已有项目。
# - _skip_if_exists 保护已有 src/ 代码,不会覆盖
# - 工具安装步骤全部幂等,重复运行安全
#
# 用法:
# ./scripts/retrofit.sh [OPTIONS] [PROJECT_PATH]
#
# 前置依赖:
# pip install copier # 或 uv tool install copier
set -euo pipefail
# ── 默认值 ────────────────────────────────────────────────────────────────────
SCAFFOLD_URL="${SCAFFOLD_URL:-git+https://github.com/your-org/scaffold.git}"
TARGET="."
DRY_RUN=0
FORCE=0
NO_DEPS=0
# ── 步骤状态跟踪 ──────────────────────────────────────────────────────────────
declare -a STEP_NAMES=()
declare -a STEP_STATUS=() # ok / skip / warn / fail / dry
# ── 颜色 ──────────────────────────────────────────────────────────────────────
if [ -t 1 ]; then
C_GREEN='\033[32m'; C_YELLOW='\033[33m'; C_RED='\033[31m'
C_CYAN='\033[36m'; C_BOLD='\033[1m'; C_RESET='\033[0m'
C_DIM='\033[2m'
else
C_GREEN=''; C_YELLOW=''; C_RED=''; C_CYAN=''; C_BOLD=''; C_RESET=''; C_DIM=''
fi
# ── 帮助 ──────────────────────────────────────────────────────────────────────
usage() {
cat <<EOF
${C_BOLD}用法:${C_RESET} $(basename "$0") [OPTIONS] [PROJECT_PATH]
将 scaffold 工具链叠加到已有项目,幂等安全,可重复运行。
已有的 src/ 代码不会被覆盖(copier _skip_if_exists 保护)。
${C_BOLD}选项:${C_RESET}
-h, --help 显示此帮助信息
-n, --dry-run 预览所有操作,不实际执行任何命令
-f, --force 强制覆盖(移除 _skip_if_exists 保护,慎用)
--no-deps 跳过依赖安装(uv sync / pnpm install),仅运行 copier
--scaffold URL 指定 scaffold 来源(覆盖 \$SCAFFOLD_URL
${C_BOLD}环境变量:${C_RESET}
SCAFFOLD_URL scaffold 来源(默认:${SCAFFOLD_URL}
${C_BOLD}参数:${C_RESET}
PROJECT_PATH 目标项目目录(默认:当前目录)
${C_BOLD}示例:${C_RESET}
# 补装工具链到当前目录
$(basename "$0")
# 预览将执行哪些步骤(不实际运行)
$(basename "$0") --dry-run /path/to/project
# 使用本地 scaffold 补装到指定目录
SCAFFOLD_URL=./scaffold $(basename "$0") /path/to/project
# 补装后用 verify.sh 验证
$(basename "$0") . && ./scripts/verify.sh .
${C_BOLD}幂等性:${C_RESET}
所有步骤均已做存在检查,重复运行不会造成副作用:
- copier copy 使用 _skip_if_exists 保护已有代码
- uv sync / pnpm install 天然幂等
- pre-commit install 在已装时输出 "already installed"
EOF
}
# ── 参数解析 ──────────────────────────────────────────────────────────────────
while [[ $# -gt 0 ]]; do
case "$1" in
-h|--help) usage; exit 0 ;;
-n|--dry-run) DRY_RUN=1; shift ;;
-f|--force) FORCE=1; shift ;;
--no-deps) NO_DEPS=1; shift ;;
--scaffold) SCAFFOLD_URL="$2"; shift 2 ;;
--scaffold=*) SCAFFOLD_URL="${1#--scaffold=}"; shift ;;
-*) printf "未知选项: %s\n" "$1" >&2; usage >&2; exit 2 ;;
*) TARGET="$1"; shift ;;
esac
done
TARGET="$(cd "$TARGET" 2>/dev/null && pwd)" \
|| { printf "${C_RED}错误:目标路径不存在: %s${C_RESET}\n" "$TARGET" >&2; exit 2; }
# ── 日志辅助 ──────────────────────────────────────────────────────────────────
TS() { date '+%H:%M:%S'; }
log_info() { printf "${C_DIM}[%s]${C_RESET} ${C_CYAN}[INFO]${C_RESET} %s\n" "$(TS)" "$*"; }
log_ok() { printf "${C_DIM}[%s]${C_RESET} ${C_GREEN}[OK]${C_RESET} %s\n" "$(TS)" "$*"; }
log_skip() { printf "${C_DIM}[%s]${C_RESET} ${C_YELLOW}[SKIP]${C_RESET} %s\n" "$(TS)" "$*"; }
log_warn() { printf "${C_DIM}[%s]${C_RESET} ${C_YELLOW}[WARN]${C_RESET} %s\n" "$(TS)" "$*"; }
log_error() { printf "${C_DIM}[%s]${C_RESET} ${C_RED}[ERROR]${C_RESET} %s\n" "$(TS)" "$*" >&2; }
log_dry() { printf "${C_DIM}[%s]${C_RESET} ${C_CYAN}[DRY]${C_RESET} %s\n" "$(TS)" "$*"; }
record_step() {
STEP_NAMES+=("$1")
STEP_STATUS+=("$2")
}
# 执行命令(dry-run 时只打印)
run_cmd() {
local desc="$1"; shift
if [ "$DRY_RUN" -eq 1 ]; then
log_dry "$desc: $*"
else
log_info "$desc: $*"
if ! "$@"; then
log_error "$desc 失败(退出码 $?"
return 1
fi
fi
}
# 检查命令是否存在,不存在时返回 1(不会因 set -e 退出)
require_cmd() {
local cmd="$1" install_hint="${2:-}"
if command -v "$cmd" &>/dev/null; then
local ver
ver=$("$cmd" --version 2>/dev/null | head -1 || echo "版本未知")
log_info "$cmd 已安装: $ver"
return 0
else
if [ -n "$install_hint" ]; then
log_warn "$cmd 未安装。安装方式: $install_hint"
else
log_warn "$cmd 未安装,将跳过相关步骤"
fi
return 1
fi
}
# ── 前置检查 ──────────────────────────────────────────────────────────────────
printf "\n${C_BOLD}═══ Retrofit: %s ═══${C_RESET}\n" "$TARGET"
[ "$DRY_RUN" -eq 1 ] && printf "${C_YELLOW}${C_BOLD}⚠ DRY-RUN 模式:不执行任何实际操作${C_RESET}\n"
[ "$FORCE" -eq 1 ] && printf "${C_YELLOW}${C_BOLD}⚠ FORCE 模式:将覆盖已有配置文件${C_RESET}\n"
printf "\n"
log_info "scaffold 来源: $SCAFFOLD_URL"
log_info "目标目录: $TARGET"
printf "\n"
printf "${C_BOLD}[ 前置依赖检查 ]${C_RESET}\n"
HAS_COPIER=0; require_cmd copier "pip install copier 或 uv tool install copier" && HAS_COPIER=1 || true
HAS_UV=0; require_cmd uv "curl -LsSf https://astral.sh/uv/install.sh | sh" && HAS_UV=1 || true
HAS_PNPM=0; require_cmd pnpm "npm install -g pnpm" && HAS_PNPM=1 || true
HAS_PC=0; require_cmd pre-commit "pip install pre-commit" && HAS_PC=1 || true
if [ "$HAS_COPIER" -eq 0 ] && [ "$DRY_RUN" -eq 0 ]; then
log_error "copier 是必须依赖,无法继续。请先安装:pip install copier"
exit 1
fi
printf "\n"
# ── 步骤 1:Copier 模版叠加 ───────────────────────────────────────────────────
printf "${C_BOLD}[ 步骤 1/5 ] Copier 模版叠加${C_RESET}\n"
COPIER_ARGS=(--trust)
[ "$FORCE" -eq 0 ] && COPIER_ARGS+=(--overwrite) || COPIER_ARGS+=(--overwrite --skip-if-exists)
if [ "$DRY_RUN" -eq 1 ]; then
log_dry "copier copy ${COPIER_ARGS[*]} \"$SCAFFOLD_URL\" \"$TARGET\""
record_step "Copier 模版叠加" "dry"
else
log_info "运行 copier copysrc/ 代码不会被覆盖..."
if copier copy "${COPIER_ARGS[@]}" "$SCAFFOLD_URL" "$TARGET"; then
log_ok "Copier 模版叠加完成"
record_step "Copier 模版叠加" "ok"
else
log_error "Copier 模版叠加失败"
record_step "Copier 模版叠加" "fail"
printf "\n${C_RED}致命错误,终止后续步骤${C_RESET}\n"
exit 1
fi
fi
printf "\n"
# ── 步骤 2Backend uv sync ───────────────────────────────────────────────────
printf "${C_BOLD}[ 步骤 2/5 ] Backend 依赖 (uv sync)${C_RESET}\n"
if [ "$NO_DEPS" -eq 1 ]; then
log_skip "--no-deps 已设置,跳过"
record_step "Backend uv sync" "skip"
elif [ ! -f "$TARGET/backend/pyproject.toml" ]; then
log_skip "backend/ 不存在,跳过"
record_step "Backend uv sync" "skip"
elif [ "$HAS_UV" -eq 0 ]; then
log_warn "uv 未安装,跳过 backend uv sync"
record_step "Backend uv sync" "warn"
else
if run_cmd "backend uv sync" uv sync --frozen --directory "$TARGET/backend"; then
log_ok "backend 依赖安装完成(--frozen 保证锁文件一致)"
record_step "Backend uv sync" "ok"
else
log_warn "backend uv sync 失败,继续其余步骤"
record_step "Backend uv sync" "warn"
fi
fi
printf "\n"
# ── 步骤 3Agent uv sync ─────────────────────────────────────────────────────
printf "${C_BOLD}[ 步骤 3/5 ] Agent 依赖 (uv sync)${C_RESET}\n"
if [ "$NO_DEPS" -eq 1 ]; then
log_skip "--no-deps 已设置,跳过"
record_step "Agent uv sync" "skip"
elif [ ! -f "$TARGET/agent/pyproject.toml" ]; then
log_skip "agent/ 不存在,跳过"
record_step "Agent uv sync" "skip"
elif [ "$HAS_UV" -eq 0 ]; then
log_warn "uv 未安装,跳过 agent uv sync"
record_step "Agent uv sync" "warn"
else
if run_cmd "agent uv sync" uv sync --frozen --directory "$TARGET/agent"; then
log_ok "agent 依赖安装完成"
record_step "Agent uv sync" "ok"
else
log_warn "agent uv sync 失败,继续其余步骤"
record_step "Agent uv sync" "warn"
fi
fi
printf "\n"
# ── 步骤 4Frontend pnpm install ────────────────────────────────────────────
printf "${C_BOLD}[ 步骤 4/5 ] Frontend 依赖 (pnpm install)${C_RESET}\n"
if [ "$NO_DEPS" -eq 1 ]; then
log_skip "--no-deps 已设置,跳过"
record_step "Frontend pnpm install" "skip"
elif [ ! -f "$TARGET/frontend/package.json" ]; then
log_skip "frontend/ 不存在,跳过"
record_step "Frontend pnpm install" "skip"
elif [ "$HAS_PNPM" -eq 0 ]; then
log_warn "pnpm 未安装,跳过 frontend pnpm install"
record_step "Frontend pnpm install" "warn"
else
if run_cmd "pnpm install" pnpm install --frozen-lockfile --dir "$TARGET/frontend"; then
log_ok "frontend 依赖安装完成(--frozen-lockfile 保证锁文件一致)"
record_step "Frontend pnpm install" "ok"
else
log_warn "pnpm install 失败,继续其余步骤"
record_step "Frontend pnpm install" "warn"
fi
fi
printf "\n"
# ── 步骤 5:Pre-commit 钩子安装 ───────────────────────────────────────────────
printf "${C_BOLD}[ 步骤 5/5 ] Pre-commit 钩子安装${C_RESET}\n"
if [ ! -f "$TARGET/.pre-commit-config.yaml" ]; then
log_skip ".pre-commit-config.yaml 不存在,跳过"
record_step "Pre-commit 钩子" "skip"
elif [ "$HAS_PC" -eq 0 ]; then
log_warn "pre-commit 未安装,跳过钩子安装"
record_step "Pre-commit 钩子" "warn"
elif [ ! -d "$TARGET/.git" ]; then
log_warn "非 Git 仓库(.git 不存在),跳过钩子安装"
record_step "Pre-commit 钩子" "warn"
else
# 幂等检查:钩子是否已安装
if [ "$DRY_RUN" -eq 0 ] && [ -f "$TARGET/.git/hooks/pre-commit" ]; then
log_skip "pre-commit 钩子已存在,幂等跳过(使用 --force 强制重装)"
record_step "Pre-commit 钩子" "skip"
else
PC_OK=1
run_cmd "pre-commit install" pre-commit install --allow-missing-config \
-C "$TARGET" || PC_OK=0
run_cmd "pre-commit install commit-msg" pre-commit install \
--hook-type commit-msg --allow-missing-config -C "$TARGET" || PC_OK=0
if [ "$PC_OK" -eq 1 ]; then
log_ok "pre-commit 钩子安装完成(pre-commit + commit-msg"
record_step "Pre-commit 钩子" "ok"
else
log_warn "pre-commit 钩子安装部分失败"
record_step "Pre-commit 钩子" "warn"
fi
fi
fi
printf "\n"
# ── 汇总 ──────────────────────────────────────────────────────────────────────
SUMMARY_OK=0; SUMMARY_SKIP=0; SUMMARY_WARN=0; SUMMARY_FAIL=0; SUMMARY_DRY=0
for s in "${STEP_STATUS[@]}"; do
case "$s" in
ok) SUMMARY_OK=$((SUMMARY_OK+1)) ;;
skip) SUMMARY_SKIP=$((SUMMARY_SKIP+1)) ;;
warn) SUMMARY_WARN=$((SUMMARY_WARN+1)) ;;
fail) SUMMARY_FAIL=$((SUMMARY_FAIL+1)) ;;
dry) SUMMARY_DRY=$((SUMMARY_DRY+1)) ;;
esac
done
printf "${C_BOLD}═══════════════════════════════════════════════════════════════${C_RESET}\n"
printf "${C_BOLD}Retrofit 步骤汇总: %s${C_RESET}\n" "$TARGET"
printf "───────────────────────────────────────────────────────────────\n"
printf "${C_BOLD}%-30s %s${C_RESET}\n" "步骤" "状态"
printf "───────────────────────────────────────────────────────────────\n"
for i in "${!STEP_NAMES[@]}"; do
name="${STEP_NAMES[$i]}"; status="${STEP_STATUS[$i]}"
case "$status" in
ok) icon="${C_GREEN}✓ 完成${C_RESET}" ;;
skip) icon="${C_CYAN} 跳过${C_RESET}" ;;
warn) icon="${C_YELLOW}△ 警告${C_RESET}" ;;
fail) icon="${C_RED}✗ 失败${C_RESET}" ;;
dry) icon="${C_CYAN}◌ 预览${C_RESET}" ;;
esac
printf "%-30s %b\n" "$name" "$icon"
done
printf "───────────────────────────────────────────────────────────────\n"
printf "完成: ${C_GREEN}%d${C_RESET} 跳过: ${C_CYAN}%d${C_RESET} 警告: ${C_YELLOW}%d${C_RESET} 失败: ${C_RED}%d${C_RESET}\n" \
"$SUMMARY_OK" "$SUMMARY_SKIP" "$SUMMARY_WARN" "$SUMMARY_FAIL"
printf "${C_BOLD}═══════════════════════════════════════════════════════════════${C_RESET}\n"
# ── 后续建议 ──────────────────────────────────────────────────────────────────
printf "\n${C_BOLD}后续步骤:${C_RESET}\n"
[ "$DRY_RUN" -eq 1 ] && printf " 1. 移除 --dry-run 后重新运行以实际执行\n"
[ "$SUMMARY_WARN" -gt 0 ] && printf " • 检查上方警告项,手动安装缺失工具后重新运行\n"
printf " • 运行 ${C_CYAN}./scripts/verify.sh %s${C_RESET} 验证最终结果\n" "$TARGET"
[ "$DRY_RUN" -eq 0 ] && [ -d "$TARGET/.git" ] && \
printf " • 运行 ${C_CYAN}(cd %s && pre-commit run --all-files)${C_RESET} 验证 hooks\n" "$TARGET"
printf "\n"
# ── 退出码 ────────────────────────────────────────────────────────────────────
[ "$SUMMARY_FAIL" -gt 0 ] && exit 1 || exit 0
+354
View File
@@ -0,0 +1,354 @@
#!/usr/bin/env bash
# verify.sh — 检查项目工具链配置是否完整
#
# 用法:
# ./scripts/verify.sh [OPTIONS] [PROJECT_PATH]
#
# 选项:
# -h, --help 显示帮助信息
# -v, --verbose 详细输出(显示所有通过项和跳过项)
# -s, --strict 严格模式:警告也视为失败
#
# 退出码:
# 0 全部通过(严格模式下无警告且无失败)
# 1 存在失败项
# 2 命令行参数错误
set -euo pipefail
# ── 默认值 ────────────────────────────────────────────────────────────────────
VERBOSE=0
STRICT=0
ROOT="."
# ── 全局计数(按分类) ────────────────────────────────────────────────────────
# 用索引数组而非关联数组(declare -A),以兼容 macOS 自带的 bash 3.2。
CATEGORIES=("根目录" "Backend" "Agent" "Frontend" "Pre-commit 钩子" "Claude 规则文件" "工具链可用性")
CAT_PASS=(); CAT_WARN=(); CAT_FAIL=()
for _i in "${!CATEGORIES[@]}"; do CAT_PASS[$_i]=0; CAT_WARN[$_i]=0; CAT_FAIL[$_i]=0; done
TOTAL_PASS=0
TOTAL_WARN=0
TOTAL_FAIL=0
CURRENT_CAT=""
CURRENT_IDX=-1
# ── 颜色 ──────────────────────────────────────────────────────────────────────
if [ -t 1 ]; then
C_GREEN='\033[32m'; C_YELLOW='\033[33m'; C_RED='\033[31m'
C_CYAN='\033[36m'; C_BOLD='\033[1m'; C_RESET='\033[0m'
else
C_GREEN=''; C_YELLOW=''; C_RED=''; C_CYAN=''; C_BOLD=''; C_RESET=''
fi
# ── 帮助 ──────────────────────────────────────────────────────────────────────
usage() {
cat <<EOF
${C_BOLD}用法:${C_RESET} $(basename "$0") [OPTIONS] [PROJECT_PATH]
检查项目工具链配置是否符合 scaffold 规范。
不修改任何文件,安全幂等,可重复运行。
${C_BOLD}选项:${C_RESET}
-h, --help 显示此帮助信息
-v, --verbose 详细输出(含通过项、跳过项的具体路径)
-s, --strict 严格模式:警告(WARN)也视为失败,退出码 1
${C_BOLD}参数:${C_RESET}
PROJECT_PATH 要检查的项目根目录(默认:当前目录)
${C_BOLD}示例:${C_RESET}
$(basename "$0") # 检查当前目录
$(basename "$0") /path/to/project # 检查指定目录
$(basename "$0") -v -s . # 详细 + 严格模式
${C_BOLD}退出码:${C_RESET}
0 全部通过(严格模式下无警告)
1 存在失败项(或严格模式下存在警告)
2 参数错误
EOF
}
# ── 参数解析 ──────────────────────────────────────────────────────────────────
while [[ $# -gt 0 ]]; do
case "$1" in
-h|--help) usage; exit 0 ;;
-v|--verbose) VERBOSE=1; shift ;;
-s|--strict) STRICT=1; shift ;;
-vs|-sv) VERBOSE=1; STRICT=1; shift ;;
-*) echo "未知选项: $1" >&2; usage >&2; exit 2 ;;
*) ROOT="$1"; shift ;;
esac
done
ROOT="$(cd "$ROOT" 2>/dev/null && pwd)" || { echo "路径不存在: $ROOT" >&2; exit 2; }
# ── 输出辅助 ──────────────────────────────────────────────────────────────────
log_pass() {
TOTAL_PASS=$((TOTAL_PASS + 1))
[ "$CURRENT_IDX" -ge 0 ] && CAT_PASS[$CURRENT_IDX]=$(( ${CAT_PASS[$CURRENT_IDX]:-0} + 1 ))
[ "$VERBOSE" -eq 1 ] && printf " ${C_GREEN}${C_RESET} %s\n" "$*"
return 0
}
log_warn() {
TOTAL_WARN=$((TOTAL_WARN + 1))
[ "$CURRENT_IDX" -ge 0 ] && CAT_WARN[$CURRENT_IDX]=$(( ${CAT_WARN[$CURRENT_IDX]:-0} + 1 ))
printf " ${C_YELLOW}${C_RESET} %s\n" "$*"
}
log_fail() {
TOTAL_FAIL=$((TOTAL_FAIL + 1))
[ "$CURRENT_IDX" -ge 0 ] && CAT_FAIL[$CURRENT_IDX]=$(( ${CAT_FAIL[$CURRENT_IDX]:-0} + 1 ))
printf " ${C_RED}${C_RESET} %s\n" "$*"
}
log_skip() {
[ "$VERBOSE" -eq 1 ] && printf " ${C_CYAN}${C_RESET} %s\n" "$*"
return 0
}
section() {
CURRENT_CAT="$1"
CURRENT_IDX=-1
local i
for i in "${!CATEGORIES[@]}"; do
[ "${CATEGORIES[$i]}" = "$1" ] && { CURRENT_IDX=$i; break; }
done
printf "\n${C_BOLD}[ %s ]${C_RESET}\n" "$1"
}
# ── 检查函数 ──────────────────────────────────────────────────────────────────
# 检查文件或目录是否存在
check() {
local label="$1" path="$2" severity="${3:-fail}"
if [ -e "$ROOT/$path" ]; then
log_pass "$label"
else
[ "$severity" = "warn" ] && log_warn "$label — 缺失: $path" || log_fail "$label — 缺失: $path"
fi
}
# 检查文件中是否包含指定内容
check_contains() {
local label="$1" path="$2" pattern="$3" severity="${4:-fail}"
if [ ! -e "$ROOT/$path" ]; then
log_skip "$label — 文件不存在,跳过内容检查: $path"
return
fi
if grep -q "$pattern" "$ROOT/$path" 2>/dev/null; then
log_pass "$label"
else
[ "$severity" = "warn" ] \
&& log_warn "$label — 未找到 '$pattern' 在 $path" \
|| log_fail "$label — 未找到 '$pattern' 在 $path"
fi
}
# 检查命令是否可用
check_cmd() {
local label="$1" cmd="$2" severity="${3:-warn}"
if command -v "$cmd" &>/dev/null; then
local ver
ver=$(${cmd} --version 2>/dev/null | head -1 || echo "版本未知")
log_pass "$label ($ver)"
else
[ "$severity" = "warn" ] && log_warn "$label — 未安装: $cmd" || log_fail "$label — 未安装: $cmd"
fi
}
# ── 读取 copier 配置(可选)────────────────────────────────────────────────────
COPIER_ANSWERS="$ROOT/.copier-answers.yml"
LLM_PROVIDER=""
DB_TYPE=""
if [ -f "$COPIER_ANSWERS" ]; then
LLM_PROVIDER=$(grep "^llm_provider:" "$COPIER_ANSWERS" 2>/dev/null | awk '{print $2}' | tr -d "\"'" || true)
DB_TYPE=$(grep "^database:" "$COPIER_ANSWERS" 2>/dev/null | awk '{print $2}' | tr -d "\"'" || true)
fi
# ── 开始检查 ──────────────────────────────────────────────────────────────────
printf "${C_BOLD}═══ 工具链合规检查: %s ═══${C_RESET}\n" "$ROOT"
[ -n "$LLM_PROVIDER" ] && printf "${C_CYAN}配置来源:.copier-answers.yml llm_provider=%s database=%s${C_RESET}\n" \
"${LLM_PROVIDER:-?}" "${DB_TYPE:-?}"
# ── 根目录 ────────────────────────────────────────────────────────────────────
section "根目录"
check "Git 仓库" ".git"
check "Pre-commit 配置" ".pre-commit-config.yaml"
check "Gitignore" ".gitignore"
check "CLAUDE.md" "CLAUDE.md"
check "Docker Compose" "docker-compose.yml" "warn"
check "Copier 答案文件" ".copier-answers.yml" "warn"
# ── Backend ───────────────────────────────────────────────────────────────────
if [ -d "$ROOT/backend" ]; then
section "Backend"
check "pyproject.toml" "backend/pyproject.toml"
check "uv.lock" "backend/uv.lock"
check ".env.example" "backend/.env.example"
check "Dockerfile" "backend/Dockerfile" "warn"
check_contains "ruff 命名规则 N" "backend/pyproject.toml" '"N"'
check_contains "ruff 禁 print T20" "backend/pyproject.toml" '"T20"'
check_contains "mypy strict" "backend/pyproject.toml" "strict = true"
check_contains "pytest-asyncio 配置" "backend/pyproject.toml" "asyncio_mode"
if [ -n "$DB_TYPE" ]; then
local_driver="aiosqlite"
[ "$DB_TYPE" = "postgresql" ] && local_driver="asyncpg"
check_contains "DB driver ($DB_TYPE)" "backend/pyproject.toml" "$local_driver"
fi
else
log_skip "Backend 目录不存在,跳过 Backend 检查"
fi
# ── Agent ─────────────────────────────────────────────────────────────────────
if [ -d "$ROOT/agent" ]; then
section "Agent"
check "pyproject.toml" "agent/pyproject.toml"
check "uv.lock" "agent/uv.lock"
check ".env.example" "agent/.env.example"
check "Dockerfile" "agent/Dockerfile" "warn"
check_contains "ruff 命名规则 N" "agent/pyproject.toml" '"N"'
check_contains "ruff 禁 print T20" "agent/pyproject.toml" '"T20"'
check_contains "mypy strict" "agent/pyproject.toml" "strict = true"
check_contains "pytest-asyncio 配置" "agent/pyproject.toml" "asyncio_mode"
if [ -n "$LLM_PROVIDER" ]; then
case "$LLM_PROVIDER" in
gemini-cli) : ;; # pty 是标准库,无需额外包
openai-api) check_contains "openai SDK" "agent/pyproject.toml" "openai" ;;
anthropic-api) check_contains "anthropic SDK" "agent/pyproject.toml" "anthropic" ;;
esac
fi
else
log_skip "Agent 目录不存在,跳过 Agent 检查"
fi
# ── Frontend ──────────────────────────────────────────────────────────────────
if [ -d "$ROOT/frontend" ]; then
section "Frontend"
check "package.json" "frontend/package.json"
check "pnpm-lock.yaml" "frontend/pnpm-lock.yaml"
check ".env.example" "frontend/.env.example"
check "tsconfig.json" "frontend/tsconfig.json"
check "vite.config.ts" "frontend/vite.config.ts"
check "vitest.config.ts" "frontend/vitest.config.ts" "warn"
check "eslint.config.js" "frontend/eslint.config.js"
check_contains "禁 console.log" "frontend/eslint.config.js" "no-console"
check_contains "命名约定规则" "frontend/eslint.config.js" "naming-convention"
check_contains "TypeScript strict" "frontend/tsconfig.json" '"strict": true'
else
log_skip "Frontend 目录不存在,跳过 Frontend 检查"
fi
# ── Pre-commit 钩子 ───────────────────────────────────────────────────────────
if [ -f "$ROOT/.pre-commit-config.yaml" ]; then
section "Pre-commit 钩子"
check_contains "Conventional Commits" ".pre-commit-config.yaml" "conventional-pre-commit"
check_contains "ruff lint/format" ".pre-commit-config.yaml" "ruff"
check_contains "防密钥泄漏" ".pre-commit-config.yaml" "detect-private-key"
check_contains "gitleaks" ".pre-commit-config.yaml" "gitleaks" "warn"
check "Git hooks 已安装" ".git/hooks/pre-commit" "warn"
fi
# ── Claude 规则文件 ───────────────────────────────────────────────────────────
if [ -d "$ROOT/.claude/rules" ]; then
section "Claude 规则文件"
RULES_DIR="$ROOT/.claude/rules"
# 全局规则(原理层,始终期望存在)
GLOBAL_RULES=(architecture.md ddd.md design-discipline.md communication-protocol.md
testing-tdd.md quality-gates.md security.md config.md
git-workflow.md conventions.md)
for r in "${GLOBAL_RULES[@]}"; do
check "全局规则: $r" ".claude/rules/$r"
done
# Backend 落地层规则
if [ -d "$ROOT/backend" ]; then
check "Backend 规则: backend.md" ".claude/rules/backend.md"
check "Backend 规则: backend-db.md" ".claude/rules/backend-db.md"
check "Backend 规则: backend-concurrency.md" ".claude/rules/backend-concurrency.md"
fi
# Agent 落地层规则
if [ -d "$ROOT/agent" ]; then
check "Agent 规则: agent.md" ".claude/rules/agent.md"
check "Agent 规则: agent-dag.md" ".claude/rules/agent-dag.md"
check "Agent 规则: agent-concurrency.md" ".claude/rules/agent-concurrency.md"
check "Agent 规则: agent-extension-points.md" ".claude/rules/agent-extension-points.md"
# LLM 驱动层规则:按 llm_provider 检查正确的变体
if [ -z "$LLM_PROVIDER" ] || [ "$LLM_PROVIDER" = "gemini-cli" ]; then
check "Agent LLM 驱动规则: agent-llm-pty.md" ".claude/rules/agent-llm-pty.md"
if [ -f "$RULES_DIR/agent-llm-api.md" ]; then
log_warn "Agent LLM 驱动规则冲突 — agent-llm-api.md 不应与 gemini-cli 共存"
fi
else
check "Agent LLM 驱动规则: agent-llm-api.md" ".claude/rules/agent-llm-api.md"
if [ -f "$RULES_DIR/agent-llm-pty.md" ]; then
log_warn "Agent LLM 驱动规则冲突 — agent-llm-pty.md 不应与 $LLM_PROVIDER 共存"
fi
fi
fi
# Frontend 落地层规则
if [ -d "$ROOT/frontend" ]; then
check "Frontend 规则: frontend.md" ".claude/rules/frontend.md"
check "Frontend 规则: frontend-runtime.md" ".claude/rules/frontend-runtime.md"
fi
# 跨服务规则
if [ -d "$ROOT/backend" ] && [ -d "$ROOT/agent" ]; then
check "跨服务规则: data-lifecycle.md" ".claude/rules/data-lifecycle.md"
check "跨服务规则: communication-catalog.md" ".claude/rules/communication-catalog.md" "warn"
check "跨服务规则: error-codes.md" ".claude/rules/error-codes.md" "warn"
fi
else
log_warn ".claude/rules/ 目录不存在 — Claude 规则文件未部署"
fi
# ── 工具链可用性(信息性,不影响退出码)─────────────────────────────────────
section "工具链可用性"
check_cmd "uv" "uv" "warn"
check_cmd "pnpm" "pnpm" "warn"
check_cmd "pre-commit" "pre-commit" "warn"
check_cmd "copier" "copier" "warn"
if [ -d "$ROOT/agent" ]; then
check_cmd "playwright" "playwright" "warn"
fi
# ── 汇总 ──────────────────────────────────────────────────────────────────────
printf "\n${C_BOLD}═══════════════════════════════════════════════════════════════${C_RESET}\n"
printf "${C_BOLD}%-22s %6s %6s %6s${C_RESET}\n" "分类" "通过" "警告" "失败"
printf "───────────────────────────────────────────────────────────────\n"
for i in "${!CATEGORIES[@]}"; do
cat="${CATEGORIES[$i]}"
p=${CAT_PASS[$i]:-0}; w=${CAT_WARN[$i]:-0}; f=${CAT_FAIL[$i]:-0}
[ $((p + w + f)) -eq 0 ] && continue
wc="${C_RESET}"; fc="${C_RESET}"
[ "$w" -gt 0 ] && wc="${C_YELLOW}"
[ "$f" -gt 0 ] && fc="${C_RED}"
printf "%-22s ${C_GREEN}%6d${C_RESET} ${wc}%6d${C_RESET} ${fc}%6d${C_RESET}\n" \
"$cat" "$p" "$w" "$f"
done
printf "───────────────────────────────────────────────────────────────\n"
wc="${C_RESET}"; fc="${C_RESET}"
[ "$TOTAL_WARN" -gt 0 ] && wc="${C_YELLOW}"
[ "$TOTAL_FAIL" -gt 0 ] && fc="${C_RED}"
printf "${C_BOLD}%-22s ${C_GREEN}%6d${C_RESET} ${wc}%6d${C_RESET} ${fc}%6d${C_RESET}${C_BOLD}${C_RESET}\n" \
"合计" "$TOTAL_PASS" "$TOTAL_WARN" "$TOTAL_FAIL"
printf "${C_BOLD}═══════════════════════════════════════════════════════════════${C_RESET}\n"
# 最终判定
if [ "$TOTAL_FAIL" -gt 0 ]; then
printf "\n${C_RED}${C_BOLD}✗ %d 项失败,项目尚不合规${C_RESET}\n" "$TOTAL_FAIL"
[ "$TOTAL_WARN" -gt 0 ] && printf "${C_YELLOW}△ %d 项警告(非阻塞)${C_RESET}\n" "$TOTAL_WARN"
exit 1
elif [ "$STRICT" -eq 1 ] && [ "$TOTAL_WARN" -gt 0 ]; then
printf "\n${C_YELLOW}${C_BOLD}△ 严格模式:%d 项警告视为失败${C_RESET}\n" "$TOTAL_WARN"
exit 1
elif [ "$TOTAL_WARN" -gt 0 ]; then
printf "\n${C_GREEN}${C_BOLD}✓ 核心检查全部通过${C_RESET} ${C_YELLOW}△ %d 项警告(建议修复)${C_RESET}\n" "$TOTAL_WARN"
exit 0
else
printf "\n${C_GREEN}${C_BOLD}✓ 全部 %d 项通过,项目完全合规${C_RESET}\n" "$TOTAL_PASS"
exit 0
fi
@@ -0,0 +1,136 @@
---
paths:
- "agent/**"
---
# Agent 并发模型与资源管理
事件驱动的异步 I/O、进程池下放 CPU 密集任务、并发上限背压、浏览器复用、生命周期取消、工作目录清理与日志规范。
## 并发模型与资源管理
Agent 是最重的并发场景:单进程 FastAPI(单事件循环)要同时服务多个会话,每个会话持有{% if llm_provider == 'gemini-cli' %}一个 LLM CLI 子进程 + {% endif %}一个 Chromium 上下文 + 阶段性 CPU 密集合成。**核心铁律:绝不阻塞事件循环。**
### 工作类型与处理方式
| 工作类型 | 例子 | 处理方式 |
|---------|------|---------|
| 异步 I/O | asyncio 子进程、网络、WebSocket | 直接 `await` |
{% if llm_provider == 'gemini-cli' -%}
| 阻塞 fd 读 | PTY master_fd 读取 | **非阻塞 fd + `loop.add_reader`**,不要 thread-per-session |
{% endif -%}
| CPU 密集 | python-pptx 合成、图像处理 | `ProcessPoolExecutor` via `run_in_executor` |
{% if llm_provider != 'gemini-cli' -%}
| LLM 流式 | SDK `async for token in stream` | 直接 `await`,事件驱动 |
{% endif %}
{% if llm_provider == 'gemini-cli' %}
### 1. PTY 读取:事件驱动,零线程
**不要**为每个会话开一个线程跑阻塞 `os.read`(线程池会被会话数打爆)。正确做法是把 master fd 设为非阻塞,注册到事件循环:
```python
os.set_blocking(master_fd, False)
loop.add_reader(master_fd, on_pty_readable) # 可读时回调,零额外线程
def on_pty_readable():
try:
data = os.read(master_fd, 4096)
except BlockingIOError:
return
queue.put_nowait(strip_ansi(data.decode(errors="replace")))
```
清理时务必 `loop.remove_reader(master_fd)`。
### 2. CPU 密集:进程池
{% else %}
### 1. CPU 密集:进程池
{% endif %}
`synthesize` 节点(python-pptx 合成)是纯 CPU + 持 GIL,会卡住整个事件循环 1–3 秒,影响所有其他会话。必须下放到进程池:
```python
process_pool = ProcessPoolExecutor(max_workers=os.cpu_count())
await loop.run_in_executor(process_pool, build_pptx, screenshots_dir, pptx_path)
```
进程池函数必须是**纯函数**(参数 + 返回值可序列化),禁止访问共享状态。
### {% if llm_provider == 'gemini-cli' %}3{% else %}2{% endif %}. 并发上限与背压
每个会话 = {% if llm_provider == 'gemini-cli' %}1 个 CLI 进程 + {% endif %}1 个 Chromium context,内存/CPU 开销大。用全局信号量封顶,超出排队并向前端反馈位置:
```python
session_semaphore = asyncio.Semaphore(settings.max_concurrent_sessions)
async with session_semaphore:
await run_session(...)
# 排队期间向前端推 {"type": "queued", "position": N}
```
### {% if llm_provider == 'gemini-cli' %}4{% else %}3{% endif %}. Playwright 浏览器复用
启动时拉起**一个** Chromium,每个会话开独立 `browser_context`(廉价),用完关 context,不要每次 `launch()`
```python
# lifespan 启动
app.state.browser = await playwright.chromium.launch()
# 截图节点
context = await app.state.browser.new_context(viewport={"width": 1920, "height": 1080})
try:
page = await context.new_page()
...
finally:
await context.close()
```
### {% if llm_provider == 'gemini-cli' %}5{% else %}4{% endif %}. 资源生命周期与取消
每个会话是一个 asyncio Task。WebSocket 断开 → 取消 Task → `finally` 块彻底清理:
```python
finally:
{% if llm_provider == 'gemini-cli' %}
loop.remove_reader(master_fd)
proc.kill()
await proc.wait() # 必须 wait,回收僵尸进程
os.close(master_fd)
{% else %}
await llm_session.close() # 释放 HTTP 连接
{% endif %}
await context.close()
```
### 共享状态线程安全
所有会话状态活在事件循环里——纯 async 访问无需锁。下放到进程池的函数**严禁触碰共享 dict**,只能通过参数传入、返回值传出。
## 工作目录清理(后台任务)
`lifespan` 启动定时任务(每小时),按 TTL + 磁盘预算清理 `~{{ '/' }}{{ project_slug }}/{uuid}/`
- 删除超 `SESSION_TTL_HOURS`(默认 24h)的会话目录
- 总占用超 `MAX_DISK_GB`(默认 5GB)时按 LRU 删到阈值以下
- 删除前校验路径在 work_dir 内(防穿越)
## 日志规范(core/logging.py
使用 loguru,两个 sink
| Sink | 格式 | 条件 |
|------|------|------|
| stderr | 人类可读彩色格式 | 开发环境(`ENV=dev` |
| `~{{ '/' }}{{ project_slug }}/logs/agent.log` | JSON(每行一条) | 所有环境 |
文件 sink 配轮转与保留:`rotation="100 MB"`、`retention="14 days"`、`compression="zip"`。
每条日志必须携带 `session_id`(通过 loguru contextualize 注入):
```python
with logger.contextualize(session_id=state.session_id):
logger.info("session started")
```
**禁止使用 `print()`,禁止使用 stdlib `logging`。统一用 `from loguru import logger`。**
+168
View File
@@ -0,0 +1,168 @@
---
paths:
- "agent/**"
---
# Agent DAG 编排与异常体系
PresentationState 聚合根、DAG 节点表、runner 协调器(含 checkpoint 恢复与写入点)以及异常体系与传播规则。
## DAG 编排(dag/
### PresentationStatedomain/state.py,聚合根状态)
> 属领域层:是会话聚合根的状态载体,封装状态机不变量(如合法的 status 迁移),纯逻辑零 IO。
```python
@dataclass
class PresentationState:
session_id: str
work_dir: Path
html_path: Path | None = None
pptx_path: Path | None = None
preview_paths: list[Path] = field(default_factory=list)
image_source: str = ""
image_api_key: str = ""
status: Literal[
"idle", "dialog", "previewing", "generating",
"screenshotting", "synthesizing", "done", "refining", "error"
] = "idle"
error_message: str | None = None
```
### DAG 节点(dag/nodes.py
节点签名统一:
```python
async def node_xxx(state: PresentationState, send: Callable, recv_queue: asyncio.Queue) -> PresentationState
```
| 节点 | 触发条件 | 行为 |
|------|---------|------|
| `session_start` | 收到 `start_session` | 创建 work_dir,启动 PTYskill 检查 |
| `phase1_dialog` | PTY 启动后 | 透传对话消息;用户 `user_input` → 写 stdin |
| `phase2_preview` | 检测到首个预览 HTML | 拦截 3 个 HTML → 推 `style_preview``style_pick` → 写 stdin |
| `phase3_generate` | 用户选风格 | 监听 PTY 直到完整 HTML;保存 `slides.html` |
| `screenshot` | `html_ready` 后自动 | Playwright 截图,逐页推 `log` 进度 |
| `synthesize` | `screenshot` 完成 | python-pptx 合成,推 `pptx_ready` |
| `refine_loop` | `done` 状态下收到 `user_input` | 写 PTY stdin → 重进 `phase3_generate` |
### DAG 协调器(dag/runner.py
`runner.py` 有两个职责:**恢复入口**(连接建立时检查 checkpoint)和**正常流程**(串联所有节点)。
```python
async def run_session(state: PresentationState, send, recv_queue: asyncio.Queue,
checkpoint: dict | None = None):
try:
# ── 断点恢复入口 ──────────────────────────────────────────────
if checkpoint:
resume_status = checkpoint.get("status")
if resume_status == "done":
# 完全恢复:直接推送已有结果,无需任何 Gemini 调用
state.html_path = Path(checkpoint["html_path"])
state.pptx_path = Path(checkpoint["pptx_path"])
await send({"type": "html_ready", "session_id": state.session_id})
await send({"type": "pptx_ready", "session_id": state.session_id})
await send({"type": "done"})
# 进入 refine 循环等待用户继续
await _refine_loop(state, send, recv_queue)
return
if resume_status in ("screenshotting", "synthesizing"):
# slides.html 已在磁盘,跳过 Gemini,直接从截图节点恢复
state.html_path = Path(checkpoint["html_path"])
state.status = "screenshotting"
logger.info("Resuming from checkpoint | status={}", resume_status)
state = await screenshot(state, send) # ← checkpoint 写入点 A
state = await synthesize(state, send) # ← checkpoint 写入点 B
await send({"type": "done"})
await _refine_loop(state, send, recv_queue)
return
# dialog / generating / error:无法恢复,PTY 已死
await send({"type": "checkpoint_lost",
"message": "上次会话在生成过程中中断,需要重新开始"})
# ── 正常流程 ──────────────────────────────────────────────────
state = await session_start(state, send, recv_queue)
state = await phase1_dialog(state, send, recv_queue)
state = await phase2_preview(state, send, recv_queue)
state = await phase3_generate(state, send, recv_queue)
state = await screenshot(state, send) # ← checkpoint 写入点 A
state = await synthesize(state, send) # ← checkpoint 写入点 B
await send({"type": "done"})
await _refine_loop(state, send, recv_queue)
except AgentException as e:
logger.error("DAG error | code={} msg={}", e.code, e.message)
await send({"type": "error", "code": e.code, "message": e.message})
except Exception as e:
logger.exception("Unexpected DAG error")
await send({"type": "error", "code": "INTERNAL_ERROR", "message": str(e)})
finally:
cleanup_pty(state)
async def _refine_loop(state, send, recv_queue):
while True:
msg = await recv_queue.get()
if msg["type"] == "user_input":
state = await refine_loop(state, send, recv_queue, msg["text"])
state = await screenshot(state, send) # ← checkpoint 写入点 A
state = await synthesize(state, send) # ← checkpoint 写入点 B
await send({"type": "done"})
```
### Checkpoint 写入点
`screenshot``synthesize` 节点完成后,通过推送特定消息触发 Backend 写入 DB
| 写入点 | 节点 | 推送消息(Backend 监听后写 DB |
|--------|------|---------------------------------|
| A | `screenshot` 完成 | `{"type": "html_ready", "html_path": "..."}` |
| B | `synthesize` 完成 | `{"type": "pptx_ready", "pptx_path": "..."}` |
| — | `done` | `{"type": "done"}` → Backend 将 status 更新为 `"done"` |
**checkpoint 写入由 Backend 负责(监听 Agent 推送的消息),Agent 只负责推送正确的消息。**
## 异常体系(domain/exceptions.py
异常类型是**领域错误词汇**(统一语言的一部分,且 `code` 对应错误码契约 `error-codes.md`),故归领域层 `domain/exceptions.py`,由 domain 拥有。框架级的 `exception_handler` 注册属接口/基础设施关注点,放 `middleware/error_handler.py`,不污染 domain 的纯净(零框架/IO 依赖)。
### domain/exceptions.py
```python
class AgentException(Exception):
def __init__(self, code: str, message: str, status_code: int = 500):
self.code = code
self.message = message
self.status_code = status_code
class GeminiStartupError(AgentException):
def __init__(self):
super().__init__("GEMINI_STARTUP_ERROR", "Gemini CLI 启动失败")
class GeminiTimeoutError(AgentException):
def __init__(self, phase: str):
super().__init__("GEMINI_TIMEOUT", f"Gemini 在 {phase} 阶段超时")
class SkillNotInstalledError(AgentException):
def __init__(self):
super().__init__("SKILL_NOT_INSTALLED", "skill 未安装")
class PlaywrightError(AgentException):
def __init__(self, detail: str):
super().__init__("PLAYWRIGHT_ERROR", f"截图失败: {detail}")
class HtmlExtractionError(AgentException):
def __init__(self):
super().__init__("HTML_EXTRACTION_FAILED", "无法从 Gemini 输出中提取有效 HTML")
```
### 异常传播规则
- DAG 节点内捕获异常 → 更新 `state.status = "error"` → 通过 `send({"type": "error", "message": ...})` 推送给前端 → 抛给 runner
- Runner 捕获 → 记录日志 → 关闭 PTY → 清理资源
- **禁止在节点内静默 swallow 异常**
@@ -0,0 +1,43 @@
---
paths:
- "agent/**"
---
# Agent 扩展点规范
providers / export / storage / llm 四个扩展点的统一模式:端口(Protocol)+ 适配器多实现 + registry 工厂。
## 扩展点规范(providers / export / storage
四个扩展点(含 llm)都遵循同一模式:**端口(Protocol,统一声明于 `domain/ports.py`+ 适配器多实现 + registry 工厂**。调用方只依赖端口,按配置选实现,新增能力不改调用方。
```python
# domain/ports.py —— 所有端口在此声明(六边形架构:端口属领域核心)
class Exporter(Protocol):
async def export(self, screenshots: list[Path], out_path: Path) -> Path: ...
class StorageBackend(Protocol):
async def save(self, key: str, data: bytes) -> None: ...
async def read(self, key: str) -> bytes: ...
def url_for(self, key: str) -> str: ...
class ImageProvider(Protocol):
def build_prompt_context(self) -> str: ... # 注入 gemini prompt 的图片来源说明
```
**节点必须通过接口调用,禁止硬编码具体实现:**
- `synthesize` 节点调用 `exporter.export(...)`,不直接调 python-pptx
- 所有文件读写经 `StorageBackend`,不直接碰 `Path.write_bytes`(本地实现内部才碰 FS
- 加 PDF 导出 = 新增 `pdf_exporter.py` 注册到 registry`synthesize` 一行不改
## registry 工厂
每个扩展点目录提供 `registry.py`,按 `Settings` 配置选择具体实现:
- `llm/`:按 `gemini_cmd` / 厂商配置选 `GeminiCliSession``LLMSession` 实现
- `providers/`:按 `image_source``unsplash` / `pexels` / `none``ImageProvider` 实现
- `export/`:按导出格式选 `pptx_exporter`(未来 `pdf_exporter`)等 `Exporter` 实现
- `storage/`:按存储后端选 `local_storage`(未来 `s3_storage`)等 `StorageBackend` 实现
新增能力 = 新增一个实现文件 + 在对应 `registry.py` 注册,调用方(DAG 节点)零改动。
+110
View File
@@ -0,0 +1,110 @@
---
paths:
- "agent/**"
---
# Agent LLM 驱动层与 API 桥接
厂商无关的 LLM API 抽象、HTTP SDK 集成机制、超时保护与流式响应处理。
## 重要:LLMSession 端口是协议,不是机制
- `LLMSession` Protocol 只定义"能力"(发消息、收推送流、关闭),不规定底层是哪家 SDK
- 本文件描述 API SDK 适配器实现模式(OpenAI / Anthropic 等)
- PTY CLI 路径见 `agent-llm-pty.md`(当前配置未启用)
## LLMSession 协议(domain/ports.py
API SDK 场景使用**推送式流**`AsyncIterator`),而非拉取式 `stream_until`
```python
from typing import Protocol, AsyncIterator
class LLMSession(Protocol):
async def generate(
self,
messages: list[dict], # [{"role": "user", "content": "..."}]
) -> AsyncIterator[str]: ... # 逐 token 推送
async def close(self) -> None: ... # 释放 HTTP 连接池
```
## 适配器结构(llm/
| 文件 | 职责 |
|------|------|
| `domain/ports.py` | `LLMSession` 协议(端口,接口定义) |
| `llm/<provider>_api.py` | SDK 适配器实现(实现 `LLMSession` |
| `llm/registry.py` | 按 `Settings.llm_provider` 选择实现 |
`pty_bridge.py`,无 `skills/installer.py`——API 模式不需要 PTY 或 skill 安装。
## 适配器实现模式
```python
from typing import AsyncIterator, override
from domain.ports import LLMSession
class OpenAISession(LLMSession):
def __init__(self, settings) -> None:
from openai import AsyncOpenAI
self._client = AsyncOpenAI(
api_key=settings.llm_api_key,
base_url=settings.llm_base_url or None,
)
self._model = settings.llm_model
@override
async def generate(self, messages: list[dict]) -> AsyncIterator[str]:
stream = await self._client.chat.completions.create(
model=self._model,
messages=messages,
stream=True,
)
async for chunk in stream:
delta = chunk.choices[0].delta.content
if delta:
yield delta
@override
async def close(self) -> None:
await self._client.close()
```
Anthropic 适配器同理,将 `AsyncOpenAI` 换为 `AsyncAnthropic`,流式 API 换为 `client.messages.stream`
## DAG 节点中的调用模式
```python
async def phase3_generate(state, send, recv_queue):
try:
async with asyncio.timeout(settings.generate_timeout_seconds):
chunks: list[str] = []
async for token in llm_session.generate(state.messages):
chunks.append(token)
await send({"type": "llm_token", "text": token})
html = extract_html("".join(chunks))
if not html:
raise HtmlExtractionError()
except TimeoutError:
raise LLMTimeoutError(phase="generate")
```
不需要 ANSI 过滤——API 响应是纯文本,无终端转义码。
## 超时保护
每个 DAG 阶段用 `asyncio.timeout()` 包裹 LLM 调用:
| 阶段 | 配置项 |
|------|--------|
| phase1_dialog(单轮对话) | `dialog_timeout_seconds`(默认 60s |
| phase2_preview3 张预览) | `preview_timeout_seconds`(默认 180s |
| phase3_generate(完整 HTML | `generate_timeout_seconds`(默认 300s |
超时后抛 `LLMTimeoutError``domain/exceptions.py`),不静默挂起。
## 连接管理
`LLMSession` 实例在 DAG 节点开始时创建,在 `runner.py``finally` 块中通过 `await llm_session.close()` 确定性释放 HTTP 连接池,防止连接泄漏。
禁止在节点内手动调用 `close()`——由 runner 统一回收。
+98
View File
@@ -0,0 +1,98 @@
---
paths:
- "agent/**"
---
# Agent LLM 驱动层与 PTY 桥接
厂商无关的 LLM CLI 抽象、PTY 伪终端桥接机制、超时保护与 HTML 拦截检测。
## LLM CLI 驱动层(llm/
厂商无关的抽象。`LLMSession` 端口定义在 `domain/ports.py`,本目录只放适配器实现。DAG 节点只依赖端口,不感知具体厂商。
| 文件 | 职责 |
|------|------|
| `domain/ports.py` | `LLMSession` 协议(端口,接口定义) |
| `pty_bridge.py` | 共享 PTY 机制(所有 CLI 厂商复用) |
| `gemini_cli.py` | `GeminiCliSession`gemini 命令 + prompt 约定 + HTML 提取 |
**扩展新厂商**:新增 `claude_cli.py` 等实现 `LLMSession` 端口即可,PTY 机制无需重写。
> 注意:当前只支持 **PTY 驱动的交互式 CLI**。若未来要接入直接 API 调用(SDK),那是不同机制,应另开 `llm/api/` 子目录,不要塞进 PTY 层。
## LLMSession 协议(domain/ports.py
```python
class LLMSession(Protocol):
async def start(self) -> None: ... # 启动 CLI 进程
async def send(self, text: str) -> None: ... # 写入用户输入
async def stream_until(self, predicate) -> str: ... # 流式读取直到满足条件
async def close(self) -> None: ... # 关闭进程,清理资源
```
## PTY 桥接(pty_bridge.py
### 原理
```
master_fd ──读──► 解析输出 ──► WebSocket 推送
master_fd ──写◄── WebSocket 收到用户输入
slave_fd ──────► CLI 进程 stdin/stdout(认为自己在真实终端)
```
### 关键实现
**启动进程:**
```python
master_fd, slave_fd = pty.openpty()
proc = await asyncio.create_subprocess_exec(
'gemini',
stdin=slave_fd, stdout=slave_fd, stderr=slave_fd,
env={**os.environ, 'TERM': 'xterm-256color'},
)
os.close(slave_fd) # 父进程关闭 slave,只用 master
```
**异步读取(事件驱动,非阻塞 fd + add_reader,零线程):**
```python
os.set_blocking(master_fd, False)
loop.add_reader(master_fd, on_pty_readable) # 详见「并发模型」章节
```
> 不要用 thread-per-session 的阻塞 `os.read`,会话多时线程池会被打爆。
**ANSI 转义码过滤(推送前必须清洗,实现在 `utils/ansi.py`):**
```python
ANSI_ESCAPE = re.compile(r'\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])')
clean_text = ANSI_ESCAPE.sub('', raw_text)
```
**写入用户输入:**
```python
os.write(master_fd, (user_text + '\n').encode('utf-8'))
```
### 超时保护
每个 DAG 阶段设置独立超时(`asyncio.wait_for`):
| 阶段 | 超时 |
|------|------|
| phase1_dialog(单轮回答) | 60s |
| phase2_preview3 张预览) | 180s |
| phase3_generate(完整 HTML | 300s |
| screenshot | 180s |
超时后抛 `GeminiTimeoutError`
## HTML 拦截检测
```python
def detect_html(text: str) -> str | None:
m = re.search(r'```html\s*([\s\S]+?)```|(<html[\s\S]+?</html>)', text, re.IGNORECASE)
return (m.group(1) or m.group(2)).strip() if m else None
```
根据 `state.status` 决定处理:
- `"previewing"` → 保存 `preview_{n}.html`,推送 `style_preview` 事件
- `"generating"` → 保存 `slides.html`,推送 `html_ready`,触发后续节点
+185
View File
@@ -0,0 +1,185 @@
---
paths:
- "agent/**"
---
# Agent 服务总览
Agent 服务的职责、技术栈、目录结构、配置{% if llm_provider == 'gemini-cli' %}、Skill 安装{% endif %}与测试规范。
## 概览(职责)
FastAPI 服务(port {{ agent_port }})。核心职责:{% if llm_provider == 'gemini-cli' %}PTY 桥接 LLM CLI 交互式会话{% else %}通过 SDK 调用 LLM API{% endif %} + DAG 编排(对话 → 风格预览 → 生成 → 截图 → 合成)+ 事件流式推送给 Backend。
## 技术栈
| 库 | 用途 |
|----|------|
| FastAPI + uvicorn | WebSocket 服务端 |
{% if llm_provider == 'gemini-cli' -%}
| pty(标准库) | 伪终端,让 LLM CLI 认为自己在真实终端 |
| asyncio | 异步 I/O,协调 PTY 读写与 WebSocket |
{% elif llm_provider == 'openai-api' -%}
| openai | OpenAI SDK,流式生成(`AsyncOpenAI` |
| asyncio | 异步 I/O,协调 SDK 调用与 WebSocket |
{% elif llm_provider == 'anthropic-api' -%}
| anthropic | Anthropic SDK,流式生成(`AsyncAnthropic` |
| asyncio | 异步 I/O,协调 SDK 调用与 WebSocket |
{% else -%}
| asyncio | 异步 I/O,协调 LLM 调用与 WebSocket |
{% endif -%}
| playwrightasync | 截取每页幻灯片截图 |
| python-pptx | 合成 PPTX |
| loguru | 结构化日志 |
| pytest + pytest-asyncio | 测试 |
## 包管理(uv
```bash
# 添加依赖
{% if llm_provider == 'gemini-cli' -%}
uv add fastapi uvicorn playwright python-pptx loguru pydantic-settings pytest pytest-asyncio
{% elif llm_provider == 'openai-api' -%}
uv add fastapi uvicorn openai playwright python-pptx loguru pydantic-settings pytest pytest-asyncio
{% elif llm_provider == 'anthropic-api' -%}
uv add fastapi uvicorn anthropic playwright python-pptx loguru pydantic-settings pytest pytest-asyncio
{% else -%}
uv add fastapi uvicorn playwright python-pptx loguru pydantic-settings pytest pytest-asyncio
{% endif %}
# 安装 Playwright 浏览器(首次)
uv run playwright install chromium
# 运行服务
uv run python -m src.main
# 运行测试
uv run pytest
```
关键文件:`pyproject.toml`(依赖声明)、`uv.lock`(锁文件,必须提交 git
## 数据库
**Agent 不操作数据库,不引入 SQLAlchemy / Alembic。**
Agent 是无状态处理服务。DB 由 Backend 单一持有。checkpoint 信息由 Backend 在 WebSocket 建连时作为参数传入,Agent 只读取,不写入任何持久化存储。
## 目录结构(clean-arch
```
agent/
├── src/
│ ├── main.py # 接口适配器:FastAPI appWebSocket 端点
│ │
│ ├── domain/ # 领域层:纯逻辑零 IO
│ │ ├── models.py
│ │ ├── state.py # 聚合根状态
│ │ ├── ports.py # 端口定义(LLMSession、Exporter 等)
│ │ └── exceptions.py
│ │
│ ├── dag/ # 应用层:用例编排
│ │ ├── nodes.py
│ │ └── runner.py
│ │
│ ├── llm/ # 扩展点①:LLMSession 适配器
{% if llm_provider == 'gemini-cli' -%}
│ │ ├── pty_bridge.py # 共享 PTY 机制
│ │ └── gemini_cli.py # GeminiCliSession 实现
{% elif llm_provider == 'openai-api' -%}
│ │ └── openai_api.py # OpenAISession 实现
{% elif llm_provider == 'anthropic-api' -%}
│ │ └── anthropic_api.py # AnthropicSession 实现
{% else -%}
│ │ └── custom_llm.py # 自定义 LLMSession 实现
{% endif -%}
│ │ └── registry.py
│ ├── providers/ # 扩展点②:ImageProvider 适配器
│ │ └── registry.py
│ ├── export/ # 扩展点③:Exporter 适配器
│ │ └── pptx_exporter.py
│ ├── storage/ # 扩展点④:StorageBackend 适配器
│ │ └── local_storage.py
{% if llm_provider == 'gemini-cli' -%}
│ ├── skills/
│ │ └── installer.py # CLI skill 自动安装
{% endif -%}
│ ├── utils/
{% if llm_provider == 'gemini-cli' -%}
│ │ ├── ansi.py # ANSI 转义码过滤
{% endif -%}
│ │ ├── concurrency.py
│ │ ├── files.py
│ │ └── ids.py
│ ├── middleware/
│ └── core/
│ ├── config.py
│ └── logging.py
└── tests/
├── conftest.py
{% if llm_provider == 'gemini-cli' -%}
├── test_pty_bridge.py
├── test_gemini_cli.py
├── test_installer.py
{% else -%}
├── test_llm_session.py
{% endif -%}
├── test_nodes.py
└── test_runner.py
```
## 配置(core/config.py
```python
class Settings(BaseSettings):
env: Literal["dev", "prod"] = "dev"
work_dir: Path = Path.home() / "{{ project_slug }}"
port: int = {{ agent_port }}
log_level: str = "DEBUG"
{% if llm_provider == 'gemini-cli' %}
llm_cmd: str = "gemini"
skill_name: str = "frontend-slides"
skill_repo: str = "" # 从 .env 注入
pty_read_buffer_size: int = 4096
{% elif llm_provider == 'openai-api' %}
llm_api_key: str = "" # 从 .env 注入,不硬编码
llm_model: str = "gpt-4o"
llm_base_url: str = "" # 非空时覆盖 SDK 默认 endpoint
{% elif llm_provider == 'anthropic-api' %}
llm_api_key: str = "" # 从 .env 注入,不硬编码
llm_model: str = "claude-sonnet-4-6"
llm_base_url: str = ""
{% else %}
llm_api_key: str = ""
llm_model: str = ""
llm_base_url: str = ""
{% endif %}
generate_timeout_seconds: int = 300
preview_timeout_seconds: int = 180
dialog_timeout_seconds: int = 60
max_concurrent_sessions: int = 4
process_pool_workers: int = os.cpu_count() or 4
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
```
{% if llm_provider == 'gemini-cli' %}
## Skill 安装(skills/installer.py
Agent 启动时自动确保配置指定的 skill 已安装。`skill_name` 与 `skill_repo` 均来自 `Settings`,换 skill 只改 `.env`,不改代码。
> 注意:安装目的地路径(`~/.gemini/skills/`)是 Gemini CLI 约定;换 CLI 时 `installer.py` 需同步修改。
{% endif %}
## 测试规范
{% if llm_provider == 'gemini-cli' -%}
- PTY 测试:mock `pty.openpty`、`os.read/write`,验证 ANSI 过滤和 HTML 检测
- 超时测试:注入假 PTY 不输出,验证 `LLMTimeoutError` 被抛出
- skill 安装测试:mock `git clone`,验证成功/失败分支
{% else -%}
- LLM session 测试:mock `LLMSession.generate` 返回 `AsyncIterator[str]`,验证流式处理
- 超时测试:`generate` mock 为永不完成的迭代器,验证 `LLMTimeoutError` 被抛出
{% endif -%}
- DAG 节点测试:mock `send` 回调,验证状态转换和推送消息类型
- checkpoint 恢复测试:
- 注入 `checkpoint={status: "done"}` → 验证直接推送结果,不启动 LLM
- 注入 `checkpoint={status: "screenshotting"}` → 验证跳过 LLM 直接进截图节点
- 注入 `checkpoint={status: "dialog"}` → 验证推送 `checkpoint_lost`
+65
View File
@@ -0,0 +1,65 @@
# 架构总览
> 三层架构、通信链路、限界上下文、六边形分层与依赖方向。
## 三层架构
```
frontend/ React + Vite + TypeScript + Tailwind
backend/ FastAPIport 8000)—— WebSocket 代理 + 会话持久化
agent/ FastAPIport 8001)—— LLM CLI PTY 桥接 + DAG 编排
```
Backend 是纯代理层,不持有 AI 业务逻辑。所有交互编排在 Agent。
## 通信链路概览
```
Browser
↕ WebSocket ws://localhost:8000/ws/{session_id}
Backend (port 8000)
↕ WebSocket ws://localhost:8001/ws/{session_id}
Agent (port 8001)
↕ pty.openpty()
LLM CLI(加载所配置的幻灯片生成 skill)
```
- LLM CLI(当前适配器为 Gemini CLI)与所加载的 skill 都是**可替换的外部依赖**,不在领域内硬编码。CLI 命令、skill 来源(仓库/路径)与安装路径均经配置注入(见 `config.md``agent.md`),换 CLI 或换 skill 不触动领域逻辑。
## 限界上下文(Bounded Context
| 上下文 | 载体 | 子域 |
|--------|------|------|
| 生成编排 Generation | agent | 核心域(Core |
| 会话管理 SessionMgmt | backend | 支撑域(Supporting |
| 交互表现 Presentation | frontend | 通用域(Generic |
- 上下文间**仅经消息契约集成**(即 Shared Kernel,见「通信协议与交互规约」)
- LLM CLI 为外部系统,`agent/llm/` 适配器充当**防腐层(Anti-Corruption Layer**,把终端协议翻译为领域语言,隔离外部模型对领域的侵蚀
## 分层(六边形,依赖恒向内)
领域层不依赖任何框架/IO
```
infrastructure 适配器:pty / playwright / pptx / db / ws
│ 实现端口(依赖倒置)
application 用例编排:DAG runner+nodes、backend services
│ 依赖
domain 实体 / 值对象 / 领域服务 / 端口定义;纯逻辑,零 IO
```
## 依赖方向(低耦合的硬规则)
依赖只能单向流动,**禁止反向或跨层依赖**:
```
Backend: routers → services → domain.ports ← db.repositories # 依赖倒置:服务依赖端口,db 适配器实现端口;domain 不依赖任何框架
Agent: main(接口) → dag(application) → domain # 适配器(llm/providers/export/storage)实现 domain 端口
依赖恒向内指向 domain;domain 不依赖任何外层与框架
Frontend: pages → hooks → services/store # components 不碰 store/网络
```
- 上层依赖下层,下层**绝不**反向 import 上层
- `utils/` 是叶子层,谁都可依赖它,它不依赖任何业务模块
- 依赖方向由 import-linterPython/ dependency-cruiserTS)在 CI 强制
@@ -0,0 +1,47 @@
---
paths: ["backend/**"]
---
# backend-concurrency
Backend 并发模型:WebSocket 双向转发的 Task 管理 + SQLite 并发处理。
Backend 是纯异步 I/O 代理,无 CPU 密集工作,无需进程池。关注点在两处:双向转发的任务管理、SQLite 并发。
## 1. WebSocket 双向转发
每个连接 = 两个 Task(上行 / 下行),并联运行;任一侧断开必须取消另一侧,避免 Task 泄漏:
```python
@router.websocket("/ws/{session_id}")
async def ws_proxy(ws: WebSocket, session_id: str, repo: SessionRepository = Depends(get_session_repo)):
await ws.accept()
await session_service.get_or_create(repo, session_id)
checkpoint = await session_service.load_checkpoint(repo, session_id)
async with agent_client.connect(session_id, checkpoint=checkpoint) as agent_ws:
to_agent = asyncio.create_task(forward_to_agent(ws, agent_ws))
to_browser = asyncio.create_task(forward_to_browser(agent_ws, ws, repo)) # 转发同时写 checkpoint
done, pending = await asyncio.wait(
{to_agent, to_browser}, return_when=asyncio.FIRST_COMPLETED
)
for task in pending: # 一侧结束,取消另一侧
task.cancel()
```
## 2. SQLite 并发
SQLite 默认串行写,并发下易触发 `database is locked`。引擎连接时必须开 WAL + busy_timeout
```python
# db/base.py —— connect 时执行
@event.listens_for(engine.sync_engine, "connect")
def set_sqlite_pragma(dbapi_conn, _):
cursor = dbapi_conn.cursor()
cursor.execute("PRAGMA journal_mode=WAL") # 读写不互斥
cursor.execute("PRAGMA busy_timeout=5000") # 锁等待 5s 而非立即报错
cursor.execute("PRAGMA foreign_keys=ON")
cursor.close()
```
- 一个请求/连接一个 `AsyncSession``get_db` 作用域),**禁止跨 Task 共享 Session**
- 本工作负载写入量低,WAL 足够;若未来高并发,迁移到 Postgres(仅换 `database_url` 与 driverORM 不变)
+245
View File
@@ -0,0 +1,245 @@
---
paths: ["backend/**"]
---
# backend-db
数据库({% if database == 'sqlite' %}SQLite{% else %}PostgreSQL{% endif %} + SQLAlchemy async)、Session 模型、Alembic 迁移、Checkpoint 断点恢复与会话保留。
## 数据库配置
{% if database == 'sqlite' %}
### 文件位置
```
~/{{ project_slug }}/{{ project_slug }}.db # 生产
:memory: # 测试(conftest.py 中覆盖)
```
### 引擎配置(db/engine.py
```python
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
engine = create_async_engine(
f"sqlite+aiosqlite:///{settings.db_path}",
echo=settings.env == "dev",
connect_args={"check_same_thread": False},
)
# WAL 模式:提升并发读性能
@event.listens_for(engine.sync_engine, "connect")
def set_wal_mode(dbapi_conn, _):
dbapi_conn.execute("PRAGMA journal_mode=WAL")
AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False)
```
SQLite 无需连接池配置——`aiosqlite` 内部以串行化方式处理并发写。
{% else %}
### 连接字符串
```
# .env
DATABASE_URL=postgresql+asyncpg://user:password@localhost:5432/{{ project_slug }}
```
### 引擎配置(db/engine.py
```python
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
engine = create_async_engine(
settings.database_url,
echo=settings.env == "dev",
pool_size=10,
max_overflow=20,
pool_pre_ping=True, # 连接健康检查,防止使用断开的连接
pool_recycle=3600, # 1 小时回收连接,防止 idle timeout
)
AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False)
```
### PostgreSQL 迁移注意事项
- 大表加列(NOT NULL)需提供服务端默认值,避免全表 lock
- 建索引用 `CREATE INDEX CONCURRENTLY`(非阻塞)
- 枚举类型变更需显式 `ALTER TYPE`Alembic autogenerate 不自动处理
{% endif %}
## Session 聚合根(domain/entities.py
`Session` 是领域核心实体,**纯 Python,零框架/IO 依赖**。业务规则(状态迁移、恢复判断)封装在实体方法内,不外泄到 service 层。
```python
from dataclasses import dataclass
from .value_objects import SessionStatus
@dataclass
class Session:
id: str
topic: str
language: str = "中文"
style: str | None = None
image_source: str | None = None
work_dir: str = ""
status: SessionStatus = SessionStatus.IDLE
html_path: str | None = None
pptx_path: str | None = None
error_message: str | None = None
def mark_screenshotting(self, html_path: str) -> None:
if self.status != SessionStatus.GENERATING:
raise InvalidTransitionError(self.status, SessionStatus.SCREENSHOTTING)
self.html_path = html_path
self.status = SessionStatus.SCREENSHOTTING
def mark_synthesizing(self, pptx_path: str) -> None:
self.pptx_path = pptx_path
self.status = SessionStatus.SYNTHESIZING
def mark_done(self) -> None:
self.status = SessionStatus.DONE
def mark_error(self, message: str) -> None:
self.error_message = message
self.status = SessionStatus.ERROR
def can_recover(self) -> bool:
return self.status.can_recover()
```
`SessionStatus` 值对象(`domain/value_objects.py`):
```python
from enum import StrEnum
class SessionStatus(StrEnum):
IDLE = "idle"
DIALOG = "dialog"
GENERATING = "generating"
SCREENSHOTTING = "screenshotting"
SYNTHESIZING = "synthesizing"
DONE = "done"
ERROR = "error"
def can_recover(self) -> bool:
return self in (self.SCREENSHOTTING, self.SYNTHESIZING, self.DONE)
```
## SessionRecord ORM 映射(db/models.py
ORM 类仅做持久化映射,**不含任何业务逻辑**。
```python
class SessionRecord(Base):
__tablename__ = "sessions"
id: Mapped[str] = mapped_column(String(36), primary_key=True)
topic: Mapped[str] = mapped_column(String(500))
style: Mapped[str | None] = mapped_column(String(100))
language: Mapped[str] = mapped_column(String(20), default="中文")
image_source: Mapped[str | None] = mapped_column(String(50))
work_dir: Mapped[str] = mapped_column(String(500))
status: Mapped[str] = mapped_column(String(30), default="idle")
error_message: Mapped[str | None] = mapped_column(Text)
html_path: Mapped[str | None] = mapped_column(String(500))
pptx_path: Mapped[str | None] = mapped_column(String(500))
checkpoint_at: Mapped[datetime | None] = mapped_column(DateTime)
created_at: Mapped[datetime] = mapped_column(DateTime, default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime, default=func.now(), onupdate=func.now())
completed_at: Mapped[datetime | None] = mapped_column(DateTime)
```
## SessionRepository 适配器(db/repositories.py
`SQLAlchemySessionRepository` 实现 `domain/ports.py` 的 `SessionRepository` 端口,负责 ORM ↔ 领域实体的互转。调用方(services)对 SQLAlchemy 零感知。
```python
from typing import override
from domain.ports import SessionRepository
from domain.entities import Session
from .models import SessionRecord
class SQLAlchemySessionRepository(SessionRepository):
def __init__(self, db: AsyncSession) -> None:
self._db = db
@override
async def get(self, session_id: str) -> Session | None:
record = await self._db.get(SessionRecord, session_id)
return self._to_entity(record) if record else None
@override
async def save(self, session: Session) -> None:
record = await self._db.get(SessionRecord, session.id)
record.status = session.status
record.html_path = session.html_path
record.pptx_path = session.pptx_path
record.error_message = session.error_message
record.checkpoint_at = func.now()
await self._db.flush()
@override
async def create(self, session: Session) -> None:
record = SessionRecord(
id=session.id, topic=session.topic, language=session.language,
style=session.style, image_source=session.image_source,
work_dir=session.work_dir, status=session.status,
)
self._db.add(record)
await self._db.flush()
def _to_entity(self, r: SessionRecord) -> Session:
return Session(
id=r.id, topic=r.topic, language=r.language,
style=r.style, image_source=r.image_source, work_dir=r.work_dir,
status=SessionStatus(r.status),
html_path=r.html_path, pptx_path=r.pptx_path,
error_message=r.error_message,
)
```
## 迁移工作流(Alembic
```bash
uv run alembic revision --autogenerate -m "描述变更"
uv run alembic upgrade head
uv run alembic downgrade -1
```
**规则:禁止直接修改已提交的迁移脚本。每次 model 变更必须生成新迁移脚本。**
## Checkpoint / 断点恢复
`slides.html` 成功落盘是 checkpoint 分界线:
| 断连时状态 | 能否恢复 | 恢复行为 |
|-----------|---------|---------|
| `dialog` / `generating` | ❌ | 推送 `checkpoint_lost`,提示用户重新开始 |
| `screenshotting` | ✅ | 跳过 LLM,重跑截图 |
| `synthesizing` | ✅ | 截图已在磁盘,重跑合成 |
| `done` | ✅ | 直接推送 `html_ready` + `pptx_ready` |
## DB 依赖注入(core/deps.py
路由层通过 `get_session_repo` 获得 `SessionRepository` 实例,**不直接依赖 `get_db`**
```python
async def get_db() -> AsyncGenerator[AsyncSession, None]:
async with AsyncSessionLocal() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
async def get_session_repo(db: AsyncSession = Depends(get_db)) -> SessionRepository:
return SQLAlchemySessionRepository(db)
```
## 会话保留(DB_RETENTION_DAYS
会话记录保留 `DB_RETENTION_DAYS`(默认 30 天),`lifespan` 定时任务软删→硬删过期记录。
+201
View File
@@ -0,0 +1,201 @@
---
paths: ["backend/**"]
---
# backend
FastAPI 服务(port 8000)的技术栈、目录、分层、路由、中间件与配置规范。
## 概览
职责:WebSocket 代理层 + 文件服务 + 会话持久化。
**Backend 不连接任何 LLM**——所有大模型交互都在 Agent。Backend 只做 WebSocket 代理 + 数据持久化。
## 技术栈
| 库 | 用途 |
|----|------|
| FastAPI + uvicorn | Web 框架 + ASGI 服务器 |
| SQLAlchemy 2.x (async) | ORM |
| SQLite | 轻量数据库(单文件,无需独立服务) |
| Alembic | 数据库迁移 |
| loguru | 结构化日志 |
| pydantic-settings | 环境变量管理 |
| websockets | Agent WebSocket 客户端 |
| pytest + httpx + pytest-asyncio | 测试 |
## 包管理(uv
```bash
uv add fastapi uvicorn "sqlalchemy[asyncio]" aiosqlite alembic loguru pydantic-settings websockets pytest httpx pytest-asyncio
uv run python -m src.main # 运行服务
uv run pytest # 运行测试
```
关键文件:`pyproject.toml`(依赖声明)、`uv.lock`(锁文件,必须提交 git)、`alembic.ini`(迁移配置)
## 目录结构
```
backend/
├── alembic.ini # Alembic 入口配置
├── alembic/
│ ├── env.py # 迁移环境(导入所有 Model)
│ └── versions/ # 自动生成的迁移脚本
├── src/
│ ├── main.py # FastAPI app 实例、启动/关闭事件
│ ├── domain/ # 领域层,纯逻辑,零框架/IO 依赖
│ │ ├── entities.py # Session 聚合根(纯 Python dataclass,含状态机方法)
│ │ ├── value_objects.py # SessionStatusStrEnum,含 can_recover 规则)
│ │ └── ports.py # SessionRepository Protocol(仓储端口)
│ ├── routers/
│ │ ├── ws.py # WebSocket 代理 /ws/{session_id}
│ │ └── files.py # 文件下载 + 预览路由
│ ├── services/
│ │ ├── agent_client.py # 连接 Agent WebSocket
│ │ └── session_service.py # 会话用例编排(依赖 SessionRepository 端口)
│ ├── schemas/
│ │ ├── messages.py # Pydantic WebSocket 消息模型
│ │ └── session.py # Pydantic 会话读写 schema(与 ORM 解耦)
│ ├── middleware/
│ │ ├── request_id.py # 每个请求注入唯一 X-Request-Id
│ │ ├── access_log.py # 请求/响应结构化日志
│ │ └── error_handler.py # 全局未捕获异常 → 标准错误响应
│ ├── exceptions/
│ │ ├── base.py # AppException 基类
│ │ └── handlers.py # FastAPI exception_handler 注册
│ ├── db/
│ │ ├── base.py # async_engine、AsyncSessionLocal、Base 声明基类
│ │ ├── models.py # SessionRecord ORM 映射(纯持久化,无业务逻辑)
│ │ └── repositories.py # SQLAlchemySessionRepository(实现 SessionRepository 端口)
│ ├── utils/ # 横切纯函数工具(无业务逻辑、无状态)
│ │ ├── ids.py # request_id / uuid 生成
│ │ └── timing.py # 计时、超时上下文管理器
│ └── core/
│ ├── config.py # Settingspydantic-settings
│ ├── deps.py # FastAPI 依赖注入(get_db、get_session_repo、get_settings
│ └── logging.py # loguru 初始化、sink 配置
└── tests/
├── conftest.py # 测试 DBin-memory SQLite)、app fixture
├── test_session_entity.py # Session 领域实体单元测试(classicist,不 mock
├── test_ws.py
├── test_files.py
└── test_session_service.py
```
## 分层映射(六边形架构)
backend 是支撑域,遵循六边形架构,依赖恒向内:
| 层 | 目录 | 职责 |
|----|------|------|
| 接口适配器 | `routers/` | WS/HTTP 入站适配,仅转译协议 |
| 应用(用例) | `services/` | 会话用例编排,依赖 `SessionRepository` 端口 |
| 领域 | `domain/` | `Session` 聚合根、`SessionStatus` 值对象、`SessionRepository` 端口;纯逻辑零 IO |
| 基础设施 | `db/repositories.py``agent_client` | 实现 `SessionRepository` 端口、出站适配 |
## 路由层规范
- 路由函数只做:参数校验 + 调用 service + 返回响应
- 禁止在路由函数中写业务 if/else
- 所有 DB 操作必须通过 `session_service`,禁止在路由层直接操作 DB
- REST 路由统一挂 `/api/v1` 前缀(下载/预览等);WS 消息带 `v` 字段(见根「协议版本化」)
- 会话记录保留 `DB_RETENTION_DAYS`(默认 30 天),`lifespan` 定时任务软删→硬删过期记录
### RESTful 资源命名规范
| 项 | 规范 |
|----|------|
| 资源 | 名词复数:`/api/v1/sessions``/api/v1/sessions/{session_id}/pptx` |
| 路径段 | 小写 + kebab-case,不放动词(动作用 HTTP method |
| 方法 | GET 读 / POST 建 / PUT 全量改 / PATCH 局部改 / DELETE 删 |
| JSON 字段 | snake_case |
| 版本 | `/api/v1` 前缀 |
| 状态码 | 语义化 200/201/204/4xx/5xx |
例:`GET /api/v1/sessions/{session_id}/pptx`。路径/字段命名靠 review + 可选 spectralOpenAPI lint);HTTP 语义靠 review。
### Swagger / OpenAPI 文档
- FastAPI 自动生成 `/docs`Swagger UI)、`/redoc``/openapi.json`
-`.env``ENABLE_API_DOCS` 控制:dev 默认开,prod 关(`docs_url=None` 等)
- Settings 字段 `enable_api_docs: bool = True`,映射环境变量 `ENABLE_API_DOCS`
## 中间件(middleware/
### request_id.py
每个请求(含 WebSocket 握手)注入 `X-Request-Id`,传入 loguru 上下文,在所有日志条目中携带,便于全链路追踪。
### access_log.py
记录每个 HTTP 请求的结构化日志(method、path、status_code、duration_ms、request_id)。WebSocket 连接记录 connect / disconnect 事件。
### error_handler.py
捕获所有未处理异常,返回统一错误结构:
```json
{
"error": {
"code": "INTERNAL_ERROR",
"message": "服务器内部错误",
"request_id": "xxx"
}
}
```
生产环境不暴露堆栈信息;开发环境在日志中完整记录。
异常体系(exceptions/):业务错误必须抛 `AppException` 子类(携带 `code`/`message`/`status_code`),禁止在路由层直接 raise HTTPException。在 `main.py` 注册 `app.add_exception_handler(AppException, app_exception_handler)``app.add_exception_handler(Exception, global_exception_handler)`
## 配置(core/config.py
配置项见 `.env.example`(提交 git 的模板);真实配置放 `.env`gitignore)。`.env` 用 SCREAMING_SNAKESettings 字段用 snake_caseenv 映射(`agent_ws_url``AGENT_WS_URL`)为 pydantic-settings 默认行为,无需 alias_generator。
```python
class Settings(BaseSettings):
env: Literal["dev", "prod"] = "dev"
agent_ws_url: str = "ws://localhost:8001"
work_dir: Path = Path.home() / "ppt-gen"
database_url: str = f"sqlite+aiosqlite:///{Path.home()}/ppt-gen/ppt_gen.db"
port: int = 8000
log_level: str = "DEBUG"
enable_api_docs: bool = True
model_config = SettingsConfigDict(
env_file=".env", env_file_encoding="utf-8",
)
```
- 缺失/类型错误在启动时即报错(fail-fast)
- 优先级:环境变量 > `.env` > 默认值
- 敏感配置只从环境读取,`.env` 不提交 git
## WebSocket 代理核心
重连入口(routers/ws.py):
```python
@router.websocket("/ws/{session_id}")
async def ws_proxy(ws: WebSocket, session_id: str, repo: SessionRepository = Depends(get_session_repo)):
await ws.accept()
checkpoint = await session_service.load_checkpoint(repo, session_id)
# 将 checkpoint 信息附在 start_session 消息中转发给 Agent
async with agent_client.connect(session_id, checkpoint=checkpoint) as agent_ws:
await asyncio.gather(forward_to_agent(ws, agent_ws), forward_to_browser(agent_ws, ws))
```
详见 `backend-concurrency.md`(双向转发 + Task 管理)与 `backend-db.md`checkpoint)。
## 日志规范(core/logging.py
loguru,两个 sinkstderrdev 人类可读彩色)+ `~/ppt-gen/logs/backend.log`(JSON 每行一条,所有环境)。文件 sink 配 `rotation="100 MB"``retention="14 days"``compression="zip"`。级别 `DEBUG`(dev)/`INFO`(prod)。每条日志必须携带 `request_id`loguru contextualize 注入)。禁止 `print()` 与 stdlib `logging`,统一 `from loguru import logger`
## 测试规范
- **领域实体(classicist**`test_session_entity.py` 直接实例化 `Session`,验证状态机方法与不变量,零 mock,零 IO
- **service 层(mockist**`test_session_service.py` mock `SessionRepository` 端口,验证用例编排(状态流转调用顺序)
- **集成(真实 DB**`conftest.py` 提供 in-memory SQLite + `SQLAlchemySessionRepository``test_ws.py` mock Agent WS,验证消息原样转发
- checkpoint 测试:验证不同 status 下重连时返回正确的 `Session` 数据,`can_recover()` 结果符合预期
- 每次 CI 前自动运行 `alembic upgrade head`(针对测试 DB
@@ -0,0 +1,27 @@
---
paths: ["backend/**", "agent/**", "frontend/**"]
---
# 消息目录(payload 形状)
> 查阅型:各消息 type 的 payload 形状,外层由信封包裹(见 `communication-protocol.md`)。字段一律 snake_case。
命令(Browser → Backend → Agent):
```json
{"type": "start_session", "language": "中文", "image_source": "unsplash", "image_api_key": "xxx"}
{"type": "user_input", "text": "深圳科技政策解读"}
{"type": "style_pick", "index": 2}
```
事件(Agent → Backend → Browser):
```json
{"type": "gemini_message", "text": "请问你的 PPT 主旨是什么?"}
{"type": "style_preview", "index": 1, "preview_url": "/api/v1/preview/{sid}/preview_1.html"}
{"type": "log", "message": "📸 截图第 3/10 页"}
{"type": "html_ready", "session_id": "..."}
{"type": "pptx_ready", "session_id": "..."}
{"type": "done"}
{"type": "error", "code": "GEMINI_TIMEOUT", "message": "..."}
```
@@ -0,0 +1,67 @@
# 通信协议与交互规约
> 传输选型 + 命令/事件分离 + 消息信封 + 语义保证 + 形式化 + 协议版本化。线上 JSON 契约字段一律 **snake_case**。各消息 payload 形状见 `communication-catalog.md`(按 paths 加载)。
## 传输层选型
| 通道 | 传输 | 适用 | 理由 |
|------|------|------|------|
| 交互 / 流式 | WebSocket | FE↔BE、BE↔Agent | 长连接、双向、服务端持续推流 |
| 幂等资源获取 | REST `/api/v1` | 下载 / 预览 | 无状态、可缓存、可重试 |
## 交互范式:异步事件驱动 + 命令/事件分离(CQRS 式消息)
- **命令(Command)**:客户端→服务端,表达*意图*,可被拒绝(`start_session` / `user_input` / `style_pick`
- **事件(Event)**:服务端→客户端,陈述*已发生的事实*,单向广播(`gemini_message` / `style_preview` / `html_ready` / `done` / `error`
- 命令与事件语义不混用
### 命令幂等(Idempotency,强制)
投递是 at-most-once,但断连/重连/前端重发会让同一命令到达多次,故**所有命令必须可安全重复执行**——重复执行的副作用等价于执行一次。这是命令的**后置条件契约**(属 DbC,见 `design-discipline.md`),非可选优化。
- **幂等键**:命令以 `correlation_id` 作幂等键去重;服务端对已处理的 `correlation_id` 直接回放上次结果,不重复触发副作用(不重复起会话 / 不重复扣资源 / 不重复落库)。
- **天然幂等优先**:能用「设置为目标态」就不用「增量变更」。`style_pick` 是选定某态(幂等)✅;任何"追加一次""+1"式语义需显式去重。
- **REST 幂等**`/download` `/preview` 为只读 GET,天然幂等可重试可缓存(见传输层选型)。
- **校验点**:命令处理入口先查幂等键,再执行;新增命令时在 PR 说明其幂等策略(天然幂等 / 键去重)。
## 消息信封(Envelope
所有消息统一外层结构,`type` 为判别式(discriminated union),决定 `payload` 形状。**外层与 payload 字段一律 snake_case。**
```json
{
"v": 1, // 协议版本
"type": "style_preview", // 判别式
"correlation_id": "uuid", // 全链路追踪 id
"ts": "2026-05-31T08:00:00Z", // 发出时间(ISO-8601
"payload": { ... } // 与 type 对应的强类型体
}
```
## 语义保证
- **有序性**:同一连接内有序(WS 保证);跨连接无序
- **投递**:至多一次(at-most-once);断连恢复靠 Checkpoint,不靠消息重放
- **背压**:超并发上限的会话排队,以 `CAPACITY_EXCEEDED` 事件告知位置
- **错误**:统一 `error` 事件,`code` 取自「错误码目录」(见 `error-codes.md`),前端按 `code` 分支
- **版本**:见下「协议版本化」
## 协议版本化
- 破坏性变更升信封 `v`;旧版本保留一个过渡期再下线
- 前端按 `v` 兼容处理;不兼容的 `v` 直接以 `INVALID_INPUT` 拒绝
## 形式化与一致性(契约单一来源,防漂移)
WebSocket 消息协议是三层共享契约。**禁止在 Pydantic 和 Zod 两处各写一遍**——会漂移。
- 契约单一来源:`backend/src/schemas/messages.py`Pydantic 判别联合,字段 snake_case
- 前端类型由契约**生成**,不手写:`pnpm gen:types``pydantic2ts` / openapi 导出 TS 类型到 `frontend/src/types/messages.ts`
- 前端 Zod schema 与生成的类型保持同源(`z.infer` 校验一致性)
- 可导出 **AsyncAPI** 文档作为对外形式化规约
- 后端代理**透明转发** BE↔Agent,不改写信封
- 改协议流程:改 Pydantic → 跑生成 → 前端编译报错处即需同步的点
## 消息目录
各消息 type 的 payload 形状见 `communication-catalog.md`(查阅型,按 paths 加载)。
+26
View File
@@ -0,0 +1,26 @@
# 配置管理
> .env 规约 + 命名映射坑 + API 文档开关。每个服务用 `.env` 注入配置,**不硬编码、不提交密钥**。
- `{服务}/.env.example` 提交 git(模板);`{服务}/.env` gitignore(含密钥)
- backend/agent`pydantic-settings``.env`,启动即校验类型,fail-fast
- frontendVite 读 `.env`**仅 `VITE_` 前缀变量**注入浏览器
- 优先级:环境变量 > `.env` 文件 > 代码默认值
## 命名映射(关键坑 —— 无需自定义 alias_generator
`.env`**SCREAMING_SNAKE_CASE**`AGENT_WS_URL`);Settings 字段用 **snake_case**`agent_ws_url`)。字段已是 snake_case`agent_ws_url``AGENT_WS_URL` 正是 **pydantic-settings 默认映射**。**不要引入任何自定义 `alias_generator`** —— 旧版 `to_screaming_snake` 桥接函数已删除。
```python
class Settings(BaseSettings):
agent_ws_url: str = "ws://localhost:8001"
enable_api_docs: bool = True # dev 默认开,prod 关
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
# 无 alias_generatorAGENT_WS_URL → agent_ws_url 为默认行为
```
## API 文档(Swagger / OpenAPI
- FastAPI 由 `.env``ENABLE_API_DOCS` 控制 `/docs` `/redoc` `/openapi.json`**dev trueprod false**(关闭时三者设 `None`
- 主要用于 backendagent 内部 WS 服务可直接关
- 外部可替换依赖(LLM CLI 命令、skill 来源/路径)也经 `.env` 注入,不硬编码(见 `architecture.md``agent.md`
+40
View File
@@ -0,0 +1,40 @@
# 全局约定
> 命名规范 + 禁 print/console + 注释 + 包管理概览。
## 命名规范(已定)
不是"全部 snake":各随语言生态,边界(wire)统一为 snake。
| 范围 | 命名 | 强制 |
|------|------|------|
| Pythonbackend+agent | snake_case 变量/函数、PascalCase 类、UPPER_SNAKE 常量 | ruff `N`pep8-naming |
| 前端 TS | camelCase 变量/函数、PascalCase 组件/类型 | eslint naming-convention |
| 通信线 JSON 契约 | snake_case | 契约源 Pydanticsnake),后端零映射 |
- Python 字段 `agent_ws_url` ↔ 环境变量 `AGENT_WS_URL` 为 pydantic-settings 默认映射,**无需 `alias_generator`**(见 `config.md`
- 旧文档中"Python camelCase"与 `to_screaming_snake` 描述已作废,一律按上表
## 禁 print / console
- 禁止 `print()`Python)和 `console.log()`TS):统一用各层 logger
- Python 用 `from loguru import logger`,禁用 stdlib `logging`
- ruff `T20`Python/ eslint `no-console`TS,允许 warn/error)强制
## 注释
- 不写无意义注释;WHY 不明显时才写注释
## 其他约定
- 每次生成请求使用独立 UUID 工作目录:`~/ppt-gen/{uuid}/`
## 包管理概览
| 目录 | 工具 | 锁文件 |
|------|------|--------|
| `backend/` | uv | `uv.lock` |
| `agent/` | uv | `uv.lock` |
| `frontend/` | pnpm | `pnpm-lock.yaml` |
锁文件必须提交 git,保证环境一致性。CI 用 `--frozen`/`--frozen-lockfile` 安装。各目录详细命令见对应服务规则。
+27
View File
@@ -0,0 +1,27 @@
---
paths: ["backend/**", "agent/**"]
---
# 数据生命周期与清理
> 工作目录 TTL + 磁盘预算 + 日志轮转 + DB 保留。不允许无界增长。
工作目录和日志会无限堆积,**必须有清理策略,不允许无界增长**。
| 数据 | 保留策略 | 执行方 |
|------|---------|--------|
| `~/ppt-gen/{uuid}/`HTML+截图+PPTX | TTL 默认 24h;总磁盘超阈值(默认 5GB)按 LRU 清理 | Agent 后台定时任务(每小时扫一次) |
| SQLite 会话记录 | 保留 30 天,过期软删除→定期硬删 | Backend 定时任务 |
| 日志文件 | loguru `rotation="100 MB"` + `retention="14 days"` + `compression="zip"` | 各服务 logging 配置 |
- 清理任务幂等、可重入;删除前校验路径在 workDir 内(防穿越)
- 配置项:`SESSION_TTL_HOURS``MAX_DISK_GB``DB_RETENTION_DAYS``LOG_RETENTION_DAYS`
- 会话结束(done/error)后,临时中间文件(preview_*.html)可立即清理,仅保留 slides.html + result.pptx
## 数据存储概览
| 层 | 存储 | 内容 |
|----|------|------|
| Backend | SQLite`~/ppt-gen/ppt_gen.db` | 会话记录(id、topic、style、status、时间戳) |
| Agent | 文件系统(`~/ppt-gen/{session_id}/` | slides.html、preview_*.html、截图、result.pptx |
| Frontend | localStorage | 图片源配置(Unsplash/Pexels API Key |
+48
View File
@@ -0,0 +1,48 @@
# 领域驱动设计(DDD
> 统一语言(术语表)+ 领域建模规约(6 条强制)。
本项目**领域优先**:先建模领域,再落实现。任何编码前须完成领域建模,模型即代码结构的蓝图。
## 统一语言(Ubiquitous Language
代码、注释、提交、文档、对话一律使用同一套领域术语,禁止同义异名:
| 术语 | 定义 |
|------|------|
| 会话 Session | 一次端到端生成交互的生命周期聚合根 |
| 演示文稿 Deck | 生成的 HTML 演示文稿,领域核心产物 |
| 幻灯片 Slide | Deck 内的单页 |
| 主旨 Intent | 用户表达的演示意图 |
| 风格 Style | 视觉美学配方(Aesthetic Recipe |
| 风格预览 StylePreview | 供选择的候选样张 |
| 精修 Refinement | 对已生成 Deck 的迭代修改 |
| 检查点 Checkpoint | 可恢复的会话状态快照 |
| 端口 Port | 领域对外部能力的抽象接口(LLM / 图片 / 导出 / 存储) |
术语变更须同步更新本表、代码标识符与契约,三者恒一致。
## 领域建模规约(强制)
目标:**让后续未预见的变化只改一处**(变化局部化)。不追求初期设计周全,追求封装良好——围绕「易变的决策」隐藏信息(Parnas Information Hiding)。以下为硬约束:
1. **按意图建模,不按 CRUD 建模**
领域对象暴露的是*领域操作*`change_password` / `authenticate` / `refine`),不是数据表的增删改查。先问"这个对象能做什么",而非"它有哪些字段的 getter/setter"。
2. **易变规则收拢为值对象(Value Object**
有规则、有约束的概念不用裸标量表示。规则全封进值对象,**一处定义、全局复用**。
反例:`password: str`,加密/校验规则散落各处 → 改一条动全身。
正例:`Password` 值对象(不可变、构造即自校验、`matches()` 比对),换算法/加规则只改它。
3. **禁止暴露裸可变属性(封装 + Tell-Don't-Ask**
不写 `obj.x = v` 这种外部直接改内部状态。通过意图方法操作,方法内强制不变量。
`user.change_password(raw)` ✅ `user.password = hash(raw)`
4. **充血模型,非贫血模型**
数据与操作它的行为放在一起。领域逻辑属于领域对象/领域服务,**禁止**把领域规则泄漏到 application/service 层做成一堆过程式代码。
5. **聚合根守护不变量**
外部只能经聚合根操作其内部(如 `User` 之于 `Password``Session` 之于其状态机),根负责保证整体一致性;不允许绕过根直接改内部对象。
6. **判断"该不该独立 CRUD"**
子概念若无独立生命周期(密码脱离用户无意义),它就不该有独立的增删改查,而是宿主聚合的行为。
+122
View File
@@ -0,0 +1,122 @@
# 架构与设计纪律
> 六边形 + SOLID + 契约式设计(DbC)+ 高内聚低耦合 + 扩展点机制概述。
整体遵循**六边形架构(Ports & Adapters+ 整洁架构**;类/函数级遵循 **SOLID** 与**契约式设计(Design by Contract**。
## 六边形架构(Ports & Adapters
- **端口(Port)**:领域定义的接口(`LLMSession` / `ImageProvider` / `Exporter` / `StorageBackend`),表达"领域需要什么能力"
- **适配器(Adapter)**:端口的具体实现(当前 LLM CLI 适配器 / `pptx_exporter` …),位于最外层,可替换
- 依赖方向恒指向领域;替换外层实现不触动内层
## SOLID(类/模块级强制)
| 原则 | 约束 |
|------|------|
| S 单一职责 | 一个类/函数仅一个变更理由 |
| O 开闭 | 扩展靠加实现,不改既有代码(即扩展点机制) |
| L 里氏替换 | 任一端口实现可无差别替换,不破坏调用方契约 |
| I 接口隔离 | 端口按消费者拆分,不强加无关方法 |
| D 依赖倒置 | 依赖抽象(Port),不依赖具体(Adapter |
## 契约式设计(Design by Contract)——编码前先定契约
**任何函数/类:先定义契约(签名 + 前置条件 + 后置条件 + 不变量),再写测试,再写实现。**
- 前置条件(Precondition):调用方须保证的输入约束
- 后置条件(Postcondition):函数承诺的输出与副作用
- 不变量(Invariant):对象生命周期内恒真的状态约束
- 契约落于类型签名 + docstring;关键不变量以断言运行时校验
- 公开接口的契约即其规格,**契约稳定性优先于实现自由**
## 核心设计原则(高内聚 / 低耦合)
上述纪律落到日常的可操作规则。**所有新功能从设计之初就遵循,不允许"先实现再重构"。**
1. **单一职责 + 高内聚**
每个模块/类/函数只做一件事。相关逻辑聚在一起(如 PTY 机制全在 `llm/pty_bridge.py`),不相关的拆开。判据是**职责**而非行数:一个函数做了两类事就该拆;文件超 ~300 行是「该审视是否多职责」的信号,不是硬上限——单一职责的长文件不必为凑行数而拆。
2. **依赖方向(低耦合的硬规则)**
依赖只能单向流动,禁止反向或跨层依赖(详见 `architecture.md`)。上层依赖下层,下层绝不反向 import 上层;`utils/` 是谁都可依赖的叶子层,自身不依赖任何业务模块。
3. **依赖抽象,不依赖实现(DI**
业务代码依赖**接口(Protocol/ABC)**,不依赖具体类。具体实现在边界处注入。
范例:DAG 节点依赖 `LLMSession` 端口,不知道背后是哪个 CLI 厂商(端口见 `agent/domain/ports.py`)。
实现类**必须显式继承 Protocol**`class MyImpl(MyProtocol):`)并用 `@override` 标注每个实现方法——见下「Protocol 实现强制写法」。
4. **稳定契约解耦服务**
三层之间只通过**消息契约**通信(WebSocket JSON 协议),不共享内部类型。契约是单一来源(见 `communication-protocol.md`),任一服务内部重构不影响其他服务。
5. **新功能 = 插到扩展点,不改核心**
加新能力时,优先实现已有扩展点接口;若没有合适接口,**先定义新接口**再实现,禁止往核心流程里塞 if/else 分支。
## 扩展点机制概述(插件接缝)
系统预留以下接口,保证可扩展性。新增同类能力 = 新增一个实现,**不改调用方**。端口统一声明于 `agent/domain/ports.py`(六边形架构:端口属领域核心);适配器实现散于对应目录。
| 扩展点(端口) | 当前适配器 | 适配器位置 | 未来扩展 |
|--------|---------|---------|---------|
| `LLMSession` | 当前 CLI 厂商适配器 | `agent/llm/` | 其他 CLI / API SDK |
| `ImageProvider` | unsplash / pexels / none | `agent/providers/` | 自建图库 / AI 生图 |
| `Exporter` | `pptx_exporter` | `agent/export/` | pdf_exporter / 长图 |
| `StorageBackend` | `local_storage`(本地 FS | `agent/storage/` | S3 / OSS |
每个端口都是 `Protocol`,新适配器注册到工厂(`registry`)即生效,调用方按配置选择。具体端口签名与注册规范见 `agent.md`
### Protocol 实现的强制写法(Python 3.12+
实现端口时,**必须同时满足两条约束**,缺一不可:
1. **显式继承 Protocol**`class MyImpl(MyProtocol):` — 让 mypy 能校验实现完整性,而非依赖鸭子类型隐式对齐
2. **`@override` 标注每个实现方法**:来自 `from typing import override`Python 3.12)— 方法名拼错或接口改签名时,类型检查器立即报错,不等到运行时
```python
from typing import Protocol, override
# ✅ 强制写法
class GeminiCliSession(LLMSession): # 显式继承,非隐式鸭子类型
@override
async def complete(self, prompt: str) -> str:
result = await self._read_until_done()
return result or ""
# ❌ 禁止:隐式对齐,接口改名或拼写错误时静默脱钩
class BadSession:
async def complet(self, prompt: str) -> str: # 拼错了,运行时才 crash
...
```
违规信号(Claude Code 应直接指出):
- 实现类未继承 Protocol → mypy 无法验证实现完整性
- 实现方法缺少 `@override` → 接口改名后实现类静默脱钩,直到运行时才发现
## 可靠性纪律(Resilience,强制)
核心链路全是易抖动的外部依赖(LLM CLI 进程 / Chromium 截图 / 图床 HTTP),故跨边界调用必须假定**会超时、会瞬时失败、会被重复触发**。以下与「命令幂等」(见 `communication-protocol.md`)同属可靠性约束,从设计之初遵循,不允许"先实现再加兜底"。
1. **超时无处不在(Ubiquitous Timeout**
任何跨进程/网络/IO 的等待**必须设显式超时**,禁止裸 `await` 无界等待。
- 外部调用统一用 `asyncio.timeout(...)` 或客户端原生 timeout 包裹;超时即抛领域错误(如 `GEMINI_TIMEOUT`),不静默挂起。
- 超时值经 `.env` 注入、不硬编码(见 `config.md`);每类外部依赖一个超时配置项。
- 反例:`await proc.read()` 无超时 → 进程卡死则会话永久僵死。
2. **重试 + 指数退避 + 抖动(Retry / Backoff + Jitter**
对**幂等**操作的瞬时失败(网络抖动、图床 5xx、截图偶发失败)才允许重试;非幂等操作禁止盲目重试。
- 重试须有**上限**(次数 + 总时长双封顶),退避用指数 + 随机抖动,禁固定间隔猛刷。
- 重试逻辑收拢为一处可复用策略(如 `utils/retry.py`),不在各调用点散写循环。
- 用户输入触发的命令依赖幂等键去重(见 `communication-protocol.md`),保证重试安全。
3. **降级而非整体失败(Graceful Degradation**
单个可选能力失败时,**降级到退化态**而非让整条生成链崩溃。
- 经端口的退化实现承接:`ImageProvider` 失败/超额 → 降级到 `none`(无图)继续生成,而非中断 Deck。
- 降级须**可观测**:记日志 + 必要时以事件告知前端(区别于 `error` 致命错误)。
- 哪些能力可降级、降级到何种退化态,在端口契约里显式声明。
4. **边界校验,内部信任(Validate at Boundary**
不可信输入(前端命令、上传 `.md`、外部响应)在**适配器/路由边界一次性校验**(Pydantic / 类型 / 业务规则),校验失败回 `INVALID_INPUT`;越过边界进入领域后**信任已校验**,领域内不重复防御性判空。
- 校验是边界职责,不是领域职责——领域用前置条件断言不变量,而非兜外部脏数据。
5. **资源管理强制(RAII / Context Manager**
持有外部资源(子进程 / pty fd / 浏览器页 / DB 连接 / 文件句柄)**必须用 context manager**`with` / `async with`)或在 `finally` 确定性释放,禁手动 `open`/`close` 配对裸写。
- 目的:连接被取消(WS 断开 → Task cancel)时资源仍确定性回收,杜绝僵尸进程与 fd 泄漏(落地细节见各 `*-concurrency.md`)。
+27
View File
@@ -0,0 +1,27 @@
---
paths: ["backend/**", "agent/**", "frontend/**"]
---
# 错误码目录(错误契约)
> 错误码是契约的一部分,集中登记,禁止散落定义。
错误码是契约的一部分,前端按 `code` 分支处理,不依赖 `message` 文本。**集中登记,禁止散落定义。**
| code | 含义 | 来源 |
|------|------|------|
| `SESSION_NOT_FOUND` | 会话不存在 | Backend |
| `AGENT_UNAVAILABLE` | Agent 不可用 | Backend |
| `GEMINI_STARTUP_ERROR` | LLM CLI 启动失败 | Agent |
| `GEMINI_TIMEOUT` | LLM CLI 阶段超时 | Agent |
| `SKILL_NOT_INSTALLED` | 幻灯片生成 skill 未装 | Agent |
| `HTML_EXTRACTION_FAILED` | 无法提取有效 HTML | Agent |
| `PLAYWRIGHT_ERROR` | 截图失败 | Agent |
| `CHECKPOINT_LOST` | 断点不可恢复 | Agent |
| `CAPACITY_EXCEEDED` | 并发超额(排队) | Agent |
| `INVALID_INPUT` | 输入校验失败 | Backend/Agent |
| `INTERNAL_ERROR` | 未分类内部错误 | 任意 |
- 命名:`大类_具体`,全大写蛇形;新增错误码必须先登记到此表
- code 字面值散落在各服务的异常类(backend `src/exceptions/base.py``AppException` 子类、agent `src/domain/exceptions.py``AgentException` 子类)。**本表是单一来源**,异常类的 `code` 与本表对齐**靠 review 把关**,不做代码生成(多数 code 服务私有、跨线共享少,生成机制收益不抵复杂度)。改 code 先改本表,再改对应异常类。
- `GEMINI_*` 等历史命名沿用现有 code 字面值(契约稳定性优先),其语义已泛化为"LLM CLI",不随当前厂商变化
+109
View File
@@ -0,0 +1,109 @@
---
paths: ["frontend/**"]
---
# frontend-runtime
前端运行时规范:错误处理三层防线、日志、WebSocket 消息验证、页面交互状态流。
## 错误处理三层防线
### 1. 组件级 ErrorBoundary(捕获渲染异常)
```tsx
// components/ErrorBoundary.tsx
class ErrorBoundary extends React.Component {
componentDidCatch(error: Error) {
logger.error('Component error', { error: error.message })
toast.error('页面渲染出错,请刷新重试')
}
render() {
return this.state.hasError ? <FallbackUI /> : this.props.children
}
}
```
### 2. WebSocket 消息验证失败(Zod parse 异常)
```typescript
// hooks/useWebSocket.ts
try {
const msg = wsMessageSchema.parse(JSON.parse(event.data))
dispatch(msg)
} catch (e) {
// 验证失败:记录日志,不更新状态,不崩溃
logger.warn('Invalid WS message', { raw: event.data, error: e })
}
```
### 3. 全局未捕获异常
```typescript
// main.tsx
window.addEventListener('unhandledrejection', (event) => {
logger.error('Unhandled promise rejection', { reason: event.reason })
toast.error('发生未知错误,请检查网络后重试')
})
```
### 错误展示规则
| 错误类型 | 展示方式 |
|---------|---------|
| WebSocket 连接失败 | Toasterror+ 重连提示 |
| Gemini 超时/失败 | 聊天气泡(系统消息)+ Toast |
| 文件下载失败 | Toasterror |
| 组件渲染崩溃 | ErrorBoundary fallback UI |
## 日志规范(utils/logger
使用轻量前端日志库或自封装(dev 输出 console,prod 静默或上报):
```typescript
// utils/logger.ts
const logger = {
info: (msg: string, ctx?: object) => {
if (import.meta.env.DEV) console.info(`[INFO] ${msg}`, ctx)
},
warn: (msg: string, ctx?: object) => {
console.warn(`[WARN] ${msg}`, ctx)
},
error: (msg: string, ctx?: object) => {
console.error(`[ERROR] ${msg}`, ctx)
// prod 环境可接入 Sentry / 自建上报
},
}
```
**禁止在组件/hooks 中直接使用 `console.log`,统一用 `logger`。**
## WebSocket 消息验证
收到消息必须先 Zod 验证(`safeParse`)再更新 store,验证失败不崩溃只记录:
```typescript
const msg = wsMessageSchema.safeParse(JSON.parse(event.data))
if (!msg.success) {
logger.warn('Invalid WS message', { error: msg.error.flatten() })
return
}
dispatch(msg.data)
```
## 页面交互状态流
```
idle ──► dialog ──► previewing ──► generating ──► screenshotting ──► done
refining ◄┘
```
| 状态 | 展示内容 |
|------|---------|
| `idle` | 初始引导(语言/图片源选择) |
| `dialog` | 聊天气泡 + ChatInput |
| `previewing` | StylePicker3 个 iframe 预览卡片) |
| `generating` | ProgressBar + 日志气泡 |
| `screenshotting` | ProgressBar(逐页进度) |
| `done` | PreviewFrame + DownloadBar + ChatInputrefine |
| `error` | 错误气泡 + 重试按钮 |
+111
View File
@@ -0,0 +1,111 @@
---
paths: ["frontend/**"]
---
# frontend
React + Vite + TypeScript + Tailwind 前端的技术栈、目录、各层职责边界、TS 规范与测试规范。
聊天式交互界面,实时展示 Gemini 对话、风格预览、HTML 演示文稿和下载入口。
## 技术栈
| 库 | 用途 |
|----|------|
| React 18 + Vite | 构建工具 + UI 框架 |
| TypeScriptstrict mode | 类型安全 |
| Tailwind CSS v3 | 样式(禁止自定义 CSS,全用 utility class |
| Zustand | 全局状态管理 |
| TanStack Query v5 | 服务端状态(REST 请求) |
| Zod | 运行时 WebSocket 消息验证 |
| sonner | Toast 通知(错误/成功提示) |
| Vitest + Testing Library | 单元测试 |
## 包管理(pnpm
```bash
pnpm add zustand @tanstack/react-query zod sonner
pnpm dev # 开发服务器 http://localhost:5173
pnpm build # 构建
pnpm test # vitestwatch 模式)
pnpm test:run # 单次运行(CI 用)
pnpm typecheck # tsc --noEmit
```
关键文件:`package.json``pnpm-lock.yaml`(锁文件,必须提交 git)、`vite.config.ts``tsconfig.json`
## 环境配置(.env
配置项见 `.env.example`(提交 git);真实配置放 `.env`gitignore)。
- **只有 `VITE_` 前缀的变量**才会被注入浏览器端代码,通过 `import.meta.env.VITE_XXX` 读取
- **前端变量对用户完全可见,严禁放任何密钥**(图片源 API Key 走 localStorage,不进 .env
```typescript
const wsUrl = import.meta.env.VITE_BACKEND_WS_URL // ws://localhost:8000
```
## 目录结构
```
frontend/
├── src/
│ ├── components/ # 纯 UI 组件(无业务逻辑,无直接 store 读写)
│ │ ├── ChatBubble.tsx # 单条对话气泡(Gemini / 用户)
│ │ ├── ChatInput.tsx # 输入框 + 发送按钮
│ │ ├── StylePicker.tsx # 3 张风格预览卡片,点击选择
│ │ ├── PreviewFrame.tsx # iframe 展示完整 HTML 演示文稿
│ │ ├── ProgressBar.tsx # 截图/合成进度条
│ │ ├── DownloadBar.tsx # HTML + PPTX 下载按钮
│ │ ├── ErrorBoundary.tsx # React 错误边界(组件级异常)
│ │ └── Toast.tsx # 全局 Toast 容器(sonner Toaster
│ ├── pages/ # 页面级组件(组合 components,接入 hooks
│ │ └── GeneratorPage.tsx
│ ├── hooks/ # 自定义 Hook(业务逻辑与副作用)
│ │ ├── useWebSocket.ts # WebSocket 生命周期管理
│ │ ├── usePresentation.ts # 会话状态机(dispatch + 状态衍生)
│ │ └── useImageSource.ts # 图片源配置读写(localStorage
│ ├── services/ # 外部通信封装(纯函数,无 React 依赖)
│ │ └── wsClient.ts # WebSocket send/receive,消息序列化
│ ├── store/ # Zustand store
│ │ └── presentationStore.ts # session、messages、previewUrls、status
│ ├── types/ # TypeScript 类型(只定义,不实现)
│ │ └── messages.ts # WsMessage、PreviewItem、SessionStatus 等
│ ├── schemas/ # Zod schema(运行时验证,与 types 一一对应)
│ │ └── messages.ts # wsMessageSchema、stylePickSchema 等
│ ├── utils/ # 纯函数工具(无副作用)
│ │ └── ansiStrip.ts # 过滤 ANSI 转义码
│ └── main.tsx # 入口:挂载 ErrorBoundary + Toaster
└── tests/
├── components/
├── hooks/
└── schemas/
```
## 各层职责边界
| 层 | 可以做 | 不可以做 |
|----|--------|---------|
| `components/` | 渲染 propsemit 回调 | 直接读 store、发 WebSocket |
| `pages/` | 组合 components,调用 hooks | 包含复杂业务逻辑 |
| `hooks/` | 读写 store,调用 services | 返回 JSX |
| `services/` | 网络通信、序列化 | 读 store,使用 React API |
| `store/` | 持有状态,提供 actions | 包含副作用 |
## TypeScript 规范
- 开启 `strict: true`
- 命名:camelCase 变量/函数、PascalCase 组件/类型
- 禁止 `any`(用 `unknown` + 类型守卫)
- 所有 Zustand action 必须有明确返回类型
- Zod schema 与 TypeScript 类型通过 `z.infer<>` 保持同源,禁止手写重复类型
- **`types/messages.ts` 由后端 Pydantic 契约生成**`pnpm gen:types`),禁止手改;改协议从后端改起(见根 CLAUDE.md「契约管理」)
- 线上 JSON 契约字段为 snake_case,故生成类型的 DTO 字段为 snake_case;前端自身内部标识符仍用 camelCase
## 测试规范
- 组件测试:Testing Library,测行为不测实现(不 query class/id,用 role/label
- Hook 测试:`renderHook` + mock `wsClient`
- Schema 测试:Zod parse 合法/非法用例各覆盖
- ErrorBoundary 测试:注入 throw 的子组件,验证 fallback 渲染
- 覆盖率目标:hooks 和 schemas 100%,组件 > 70%
+9
View File
@@ -0,0 +1,9 @@
# Git 工作流与提交规范
> Conventional Commits + 分支保护 + PR + SemVer。
- **提交信息**Conventional Commits`feat:` `fix:` `refactor:` `test:` `docs:` `chore:`),commit-msg 钩子校验
- **分支**:主干保护,禁止直推;功能走 `feat/xxx` 分支 + PR
- **PR 合并前**CI 全绿 + 至少一人 review;squash 合并保持历史线性
- **版本**:语义化版本(SemVer),变更记入 `CHANGELOG.md`
- 密钥不入库:gitleaks + detect-private-key 在 PC·CI 全量扫描;`.env``.gitignore` 排除
+19
View File
@@ -0,0 +1,19 @@
---
paths: ["backend/**", "agent/**", "frontend/**"]
---
# 可观测性
> correlation_id 全链路 + 结构化日志 + 健康探针。
- **correlation_id 贯穿全链路**:前端生成 → WS header/首帧带上 → Backend(作 request_id)→ Agent(随 session_id 一起进日志)。三层日志可用同一 id 串联检索。
- **结构化日志**:JSON 每行一条(见各服务 logging 章节)
| 层 | 文件 | 格式 |
|----|------|------|
| Backend | `~/ppt-gen/logs/backend.log` | JSON,每行一条,含 request_id |
| Agent | `~/ppt-gen/logs/agent.log` | JSON,每行一条,含 session_id |
| Frontend | consoledev/ 可接 Sentryprod | 结构化对象 |
- **探针**:每个服务暴露 `GET /health`(存活)与 `GET /ready`(依赖就绪,如 Agent 检查 skill 已装、Chromium 可启动)
- 禁止 `print()` / `console.log()`,统一用各层 logger(见 `conventions.md`
+22
View File
@@ -0,0 +1,22 @@
# 质量门禁(CI 强制)
> lint / format / type / test 门禁表 + 依赖安全 / 供应链。
提交即跑(`.pre-commit-config.yaml`),CI 再跑一遍(`.github/workflows/ci.yml`)。任一不过禁止合并。
| 语言 | Lint | 格式化 | 类型 | 测试 |
|------|------|--------|------|------|
| Python | ruff check | ruff format | mypystrict | pytest |
| TypeScript | eslint | prettier | tsc --noEmit | vitest |
- 安装钩子:`pre-commit install`
- mypy / tsc 必须零 errorruff / eslint 零 warning
- 配置位置:Python 在各 `pyproject.toml`TS 在 `frontend/` 的 eslint/prettier/tsconfig
- 架构边界由 import-linterPython/ dependency-cruiserTS)在 CI 强制(见 `architecture.md`
## 依赖安全 / 供应链
- 锁文件必须提交,CI 用 `--frozen`/`--frozen-lockfile` 安装(拒绝漂移)
- CI 跑依赖审计:Python `uv run pip-audit`,前端 `pnpm audit --audit-level high`
- 启用 Dependabot/Renovate 自动提依赖更新 PR`.github/dependabot.yml`
- 新增依赖须评估必要性,优先标准库;不引入无维护/单文件的小众包
+16
View File
@@ -0,0 +1,16 @@
# 安全基线
> 路径穿越、命令注入、上传、CORS、限流、密钥的硬约束。
| 风险 | 约束 |
|------|------|
| 路径穿越 | `/preview` `/download` 必须校验 session_id 为合法 UUID,且 resolve 后路径仍在 workDir 内(`utils/files.py` |
| 命令注入 | 子进程一律用 argv 列表,**禁止 `shell=True`**;用户输入只作为参数传入 |
| 文件上传 | 仅 `.md`,限制大小(默认 5MB),读取后按文本处理 |
| CORS | Backend 仅允许前端来源(配置项,非 `*` |
| 限流 | 复用 Agent 并发信号量,超额排队(非无限接收) |
| 密钥 | 只走 `.env` / localStorage`detect-private-key` 钩子防误提交 |
- 路径穿越在 CI 由 semgrep 自定义规则辅助检测(用户输入拼路径未经 workDir 校验)
- 命令注入由 ruff `S`banditS602/603/604/605)在 PC·CI 检测
- 密钥不入库由 gitleaks + detect-private-key 全量扫描
+31
View File
@@ -0,0 +1,31 @@
# TDD 开发流程与测试策略
> Red-Green-Refactor(强制)+ 测试金字塔 + 覆盖率门禁。
## TDD 开发流程(测试先行,强制)
**严禁先写实现后补测试。** 测试是契约的可执行规格,先于实现存在。每个单元按 Red-Green-Refactor 循环推进:
1. **Red**:依据契约写一个失败测试,描述期望行为(此时无实现)
2. **Green**:写**最小**实现让测试通过,不做超前设计
3. **Refactor**:在测试保护下重构,消除重复、提升内聚,测试须始终为绿
- 一个 R-G-R 循环对应一次原子提交(`test:``feat:` 可分可合)
- 测试名描述「行为 + 期望」,而非实现细节
- **学派**:应用/编排层面向端口用 mockist(伦敦学派,mock 协作者验交互);领域纯逻辑用 classicist(芝加哥学派,验状态、不 mock)
- 顺序恒为:**定契约 → 写测试(Red)→ 实现(Green)→ 重构(Refactor**
## 测试策略
承接 TDD 产出的单元层,补齐集成与 E2E。测试金字塔,**底层为主**。覆盖率在 CI 强制,不达标禁止合并。
| 层级 | 范围 | 占比 | 工具 |
|------|------|------|------|
| 单元 | 纯函数、节点、service、hookmock 边界) | ~70% | pytest / vitest |
| 集成 | 路由+DB、WS 代理转发、DAG 串联 | ~25% | httpx / Testing Library |
| E2E | 一条龙:输入→生成→下载(mock LLM CLI 输出) | ~5% | Playwright |
- **覆盖率门禁**:行覆盖 ≥ 80%(核心 hooks/schemas/dag 节点 100%),CI `--cov-fail-under=80` / vitest `coverage.thresholds`
- **mock 边界清晰**:只 mock 外部依赖(LLM CLI 子进程、Chromium、网络),不 mock 被测对象内部
- **测试命名**`test_<被测>_<场景>_<期望>`;一个测试只断言一件事
- **确定性**:禁止依赖真实时间/网络/随机;用 fixture 注入
+24
View File
@@ -0,0 +1,24 @@
version: 2
updates:
{%- if include_backend %}
- package-ecosystem: pip
directory: /backend
schedule:
interval: weekly
{%- endif %}
{%- if include_agent %}
- package-ecosystem: pip
directory: /agent
schedule:
interval: weekly
{%- endif %}
{%- if include_frontend %}
- package-ecosystem: npm
directory: /frontend
schedule:
interval: weekly
{%- endif %}
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
+69
View File
@@ -0,0 +1,69 @@
name: CI
on:
push:
pull_request:
jobs:
{%- if include_backend %}
backend:
runs-on: ubuntu-latest
defaults:
run:
working-directory: backend
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v3
with:
python-version: "{{ python_version }}"
- run: uv sync
- run: uv run ruff check src
- run: uv run ruff format --check src
- run: uv run mypy src
- run: uv run pip-audit
{%- if database == 'sqlite' %}
- run: uv run alembic upgrade head
{%- endif %}
- run: uv run pytest --cov=src --cov-fail-under=80
{%- endif %}
{%- if include_agent %}
agent:
runs-on: ubuntu-latest
defaults:
run:
working-directory: agent
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v3
with:
python-version: "{{ python_version }}"
- run: uv sync
- run: uv run ruff check src
- run: uv run ruff format --check src
- run: uv run mypy src
- run: uv run pip-audit
- run: uv run pytest --cov=src --cov-fail-under=80
{%- endif %}
{%- if include_frontend %}
frontend:
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: {{ node_version }}
cache: pnpm
cache-dependency-path: frontend/pnpm-lock.yaml
- run: pnpm install --frozen-lockfile
- run: pnpm audit --audit-level high
- run: pnpm lint
- run: pnpm typecheck
- run: pnpm test:run --coverage
- run: pnpm build
{%- endif %}
+34
View File
@@ -0,0 +1,34 @@
# 环境变量(含密钥,不入库)
.env
**/.env
# Python
__pycache__/
*.py[cod]
*.egg-info/
.venv/
dist/
.mypy_cache/
.ruff_cache/
.pytest_cache/
.coverage
htmlcov/
# Node
node_modules/
dist/
.pnpm-store/
# IDE
.idea/
.vscode/
*.swp
# OS
.DS_Store
Thumbs.db
# 生成产物
*.pptx
*.db
logs/
+67
View File
@@ -0,0 +1,67 @@
# 提交前自动跑的质量门禁。安装:pre-commit install
# 手动全量:pre-commit run --all-files
repos:
# 通用
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-added-large-files
- id: detect-private-key
# 提交信息规范(Conventional Commits
- repo: https://github.com/compilerla/conventional-pre-commit
rev: v3.4.0
hooks:
- id: conventional-pre-commit
stages: [commit-msg]
{%- if include_backend or include_agent %}
# Pythonruff 同时做 lint + format
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.6.9
hooks:
- id: ruff
args: [--fix]
- id: ruff-format
{%- endif %}
{%- if include_backend %}
- repo: local
hooks:
- id: mypy-backend
name: mypy (backend)
entry: bash -c 'cd backend && uv run mypy src'
language: system
files: ^backend/.*\.py$
pass_filenames: false
{%- endif %}
{%- if include_agent %}
- repo: local
hooks:
- id: mypy-agent
name: mypy (agent)
entry: bash -c 'cd agent && uv run mypy src'
language: system
files: ^agent/.*\.py$
pass_filenames: false
{%- endif %}
{%- if include_frontend %}
# 前端:lint + 类型检查
- repo: local
hooks:
- id: frontend-lint
name: eslint + tsc (frontend)
entry: bash -c 'cd frontend && pnpm lint && pnpm typecheck'
language: system
files: ^frontend/.*\.(ts|tsx)$
pass_filenames: false
{%- endif %}
+78
View File
@@ -0,0 +1,78 @@
# {{ project_name }}
{{ description }}
## 架构
```
{%- if include_frontend %}
frontend/ React+Vite+TS+Tailwind 通用域
{%- endif %}
{%- if include_backend %}
backend/ FastAPI :{{ backend_port }} WebSocket 代理 + 持久化 支撑域
{%- endif %}
{%- if include_agent %}
agent/ FastAPI :{{ agent_port }} LLM 编排 + 业务核心域
{%- endif %}
```
---
## 启动方式
```bash
# 首次:每个目录 cp .env.example .env,按需修改
{%- if include_backend or include_agent %}
# backend/agent 用 uv sync
{%- endif %}
{%- if include_frontend %}
# frontend 用 pnpm install
{%- endif %}
# 运行(各服务独立终端)
{%- if include_agent %}
cd agent && uv run python -m src.main
{%- endif %}
{%- if include_backend %}
cd backend && uv run python -m src.main
{%- endif %}
{%- if include_frontend %}
cd frontend && pnpm dev
{%- endif %}
# 或 docker compose up --build
```
包管理:
{%- if include_backend or include_agent %}
backend/agent 用 **uv**`uv.lock`
{%- endif %}
{%- if include_frontend %}
frontend 用 **pnpm**`pnpm-lock.yaml`
{%- endif %}
锁文件必须提交。
---
## 关键约定(详见 rules
- 命名:**Python = snake_case****前端 TS = camelCase****通信线 JSON = snake_case**
- 禁 `print()` / `console.log()`,统一用各层 logger
- **先定契约(DbC)→ 测试先行(TDD)→ 实现 → 重构**
- 新功能插扩展点,不改核心;依赖恒向内指向 domain
---
## 规则索引(`.claude/rules/`
| 全局(恒加载) | 内容 |
|------|------|
| architecture.md | 三层架构 / 限界上下文 / 六边形分层 / 依赖方向 |
| ddd.md | 统一语言术语表 / 领域建模 6 条强制 |
| design-discipline.md | SOLID / 契约式设计 / 高内聚低耦合 / 扩展点 |
| communication-protocol.md | 传输选型 / 命令-事件 / 信封 / 语义 |
| testing-tdd.md | TDD 流程 / 测试金字塔 / 覆盖率门禁 |
| quality-gates.md | lint/format/type/test / 依赖安全 |
| security.md | 安全基线 |
| config.md | .env 规约 |
| git-workflow.md | Conventional Commits / 分支 / PR / SemVer |
| conventions.md | 命名 / 禁 print&console / 注释 / 包管理 |
+39
View File
@@ -0,0 +1,39 @@
# Agent 配置模板 —— 复制为 .env 后按需修改
# .env 含真实配置,不提交 git;本文件是提交到 git 的模板
# 运行环境:dev | prod
ENV=dev
# 服务端口
PORT={{ agent_port }}
# 工作目录
WORK_DIR=~/{{ project_slug }}
# 日志级别:DEBUG | INFO | WARNING | ERROR
LOG_LEVEL=DEBUG
{%- if llm_provider == 'gemini-cli' %}
# LLM CLI 命令
LLM_CMD={{ llm_cmd }}
# 并发控制
MAX_CONCURRENT_SESSIONS=4
PROCESS_POOL_WORKERS=4
PTY_READ_BUFFER_SIZE=4096
{%- elif llm_provider == 'openai-api' %}
# OpenAI API
OPENAI_API_KEY=sk-...
OPENAI_MODEL=gpt-4o
{%- elif llm_provider == 'anthropic-api' %}
# Anthropic API
ANTHROPIC_API_KEY=sk-ant-...
ANTHROPIC_MODEL=claude-sonnet-4-6
{%- else %}
# 自定义 LLM 配置
LLM_CMD={{ llm_cmd }}
{%- endif %}
+22
View File
@@ -0,0 +1,22 @@
FROM python:{{ python_version }}-slim
WORKDIR /app
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
RUN apt-get update && apt-get install -y --no-install-recommends \
libglib2.0-0 libnss3 libatk1.0-0 libatk-bridge2.0-0 \
libcups2 libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 \
libxfixes3 libxrandr2 libgbm1 libasound2 \
&& rm -rf /var/lib/apt/lists/*
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev
{%- if llm_provider == 'gemini-cli' %}
RUN uv run playwright install chromium
{%- endif %}
COPY src ./src
EXPOSE {{ agent_port }}
CMD ["uv", "run", "python", "-m", "src.main"]
+53
View File
@@ -0,0 +1,53 @@
[project]
name = "{{ project_slug }}-agent"
version = "0.1.0"
description = "{{ description }} — agent service"
requires-python = ">={{ python_version }}"
dependencies = [
"fastapi",
"uvicorn[standard]",
"loguru",
"pydantic-settings",
{%- if llm_provider == 'gemini-cli' %}
"playwright",
"python-pptx",
{%- elif llm_provider == 'openai-api' %}
"openai",
{%- elif llm_provider == 'anthropic-api' %}
"anthropic",
{%- endif %}
]
[dependency-groups]
dev = [
"pytest",
"pytest-asyncio",
"pytest-cov",
"mypy",
"ruff",
"pip-audit",
]
[tool.ruff.lint]
select = ["E", "F", "N", "T20", "I", "C901", "ANN", "S", "PLR"]
ignore = ["ANN101", "ANN102"]
[tool.ruff.lint.per-file-ignores]
"tests/**" = ["ANN"]
[tool.mypy]
strict = true
ignore_missing_imports = true
[tool.pytest.ini_options]
addopts = "--cov=src --cov-report=term-missing --cov-fail-under=80"
asyncio_mode = "auto"
[tool.importlinter]
root_packages = ["src"]
[[tool.importlinter.contracts]]
name = "Domain zero IO"
type = "forbidden"
source_modules = ["src.domain"]
forbidden_modules = ["fastapi", "httpx", "subprocess", "playwright"]
+32
View File
@@ -0,0 +1,32 @@
from fastapi import FastAPI
from loguru import logger
from .core.config import get_settings
from .core.logging import configure_logging
settings = get_settings()
configure_logging(settings.log_level)
app = FastAPI(
title="{{ project_name }} Agent",
docs_url="/docs" if settings.enable_api_docs else None,
redoc_url="/redoc" if settings.enable_api_docs else None,
openapi_url="/openapi.json" if settings.enable_api_docs else None,
)
@app.get("/health")
async def health() -> dict[str, str]:
return {"status": "ok"}
@app.get("/ready")
async def ready() -> dict[str, str]:
return {"status": "ok"}
if __name__ == "__main__":
import uvicorn
logger.info("Starting agent | port={}", settings.port)
uvicorn.run("src.main:app", host="0.0.0.0", port=settings.port, reload=True)
+33
View File
@@ -0,0 +1,33 @@
# Backend 配置模板 —— 复制为 .env 后按需修改
# .env 含真实配置,不提交 git;本文件是提交到 git 的模板
# 运行环境:dev | prod
ENV=dev
# 服务端口
PORT={{ backend_port }}
{%- if include_agent %}
# Agent WebSocket 地址
AGENT_WS_URL=ws://localhost:{{ agent_port }}
{%- endif %}
# 工作目录
WORK_DIR=~/{{ project_slug }}
{%- if database == 'sqlite' %}
# 数据库连接串(SQLite
DATABASE_URL=sqlite+aiosqlite:///~/{{ project_slug }}/{{ project_slug }}.db
{%- elif database == 'postgresql' %}
# 数据库连接串(PostgreSQL
DATABASE_URL=postgresql+asyncpg://user:password@localhost:5432/{{ project_slug }}
{%- endif %}
# 日志级别:DEBUG | INFO | WARNING | ERROR
LOG_LEVEL=DEBUG
# Swagger 文档(dev 开,prod 关)
ENABLE_API_DOCS=true
+13
View File
@@ -0,0 +1,13 @@
FROM python:{{ python_version }}-slim
WORKDIR /app
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev
COPY src ./src
EXPOSE {{ backend_port }}
CMD ["uv", "run", "python", "-m", "src.main"]
+60
View File
@@ -0,0 +1,60 @@
[project]
name = "{{ project_slug }}-backend"
version = "0.1.0"
description = "{{ description }} — backend service"
requires-python = ">={{ python_version }}"
dependencies = [
"fastapi",
"uvicorn[standard]",
"sqlalchemy[asyncio]",
{%- if database == 'sqlite' %}
"aiosqlite",
"alembic",
{%- elif database == 'postgresql' %}
"asyncpg",
"alembic",
{%- endif %}
"loguru",
"pydantic-settings",
"websockets",
]
[dependency-groups]
dev = [
"pytest",
"httpx",
"pytest-asyncio",
"pytest-cov",
"mypy",
"ruff",
"pip-audit",
]
[tool.ruff.lint]
select = ["E", "F", "N", "T20", "I", "C901", "ANN", "S", "PLR"]
ignore = ["ANN101", "ANN102"]
[tool.ruff.lint.per-file-ignores]
"tests/**" = ["ANN"]
[tool.mypy]
strict = true
ignore_missing_imports = true
[tool.pytest.ini_options]
addopts = "--cov=src --cov-report=term-missing --cov-fail-under=80"
asyncio_mode = "auto"
[tool.importlinter]
root_packages = ["src"]
[[tool.importlinter.contracts]]
name = "Layered architecture"
type = "layers"
layers = ["src.infrastructure", "src.application", "src.domain"]
[[tool.importlinter.contracts]]
name = "Domain zero IO"
type = "forbidden"
source_modules = ["src.domain"]
forbidden_modules = ["fastapi", "sqlalchemy", "httpx", "subprocess"]
+27
View File
@@ -0,0 +1,27 @@
from fastapi import FastAPI
from loguru import logger
from .core.config import get_settings
from .core.logging import configure_logging
settings = get_settings()
configure_logging(settings.log_level)
app = FastAPI(
title="{{ project_name }} Backend",
docs_url="/docs" if settings.enable_api_docs else None,
redoc_url="/redoc" if settings.enable_api_docs else None,
openapi_url="/openapi.json" if settings.enable_api_docs else None,
)
@app.get("/health")
async def health() -> dict[str, str]:
return {"status": "ok"}
if __name__ == "__main__":
import uvicorn
logger.info("Starting backend | port={}", settings.port)
uvicorn.run("src.main:app", host="0.0.0.0", port=settings.port, reload=True)
+40
View File
@@ -0,0 +1,40 @@
services:
{%- if include_agent %}
agent:
build: ./agent
env_file: ./agent/.env
ports:
- "{{ agent_port }}:{{ agent_port }}"
restart: unless-stopped
{%- endif %}
{%- if include_backend %}
backend:
build: ./backend
env_file: ./backend/.env
ports:
- "{{ backend_port }}:{{ backend_port }}"
{%- if include_agent %}
depends_on:
- agent
{%- endif %}
restart: unless-stopped
{%- endif %}
{%- if include_frontend %}
frontend:
build:
context: ./frontend
{%- if include_backend %}
args:
VITE_BACKEND_WS_URL: ws://localhost:{{ backend_port }}
VITE_BACKEND_HTTP_URL: http://localhost:{{ backend_port }}
{%- endif %}
ports:
- "{{ frontend_port }}:80"
{%- if include_backend %}
depends_on:
- backend
{%- endif %}
restart: unless-stopped
{%- endif %}
+11
View File
@@ -0,0 +1,11 @@
# Frontend 配置模板 —— 复制为 .env 后按需修改
# Vite 规则:只有 VITE_ 前缀的变量才会被注入浏览器端代码
# 切勿在此放任何密钥(前端变量对用户完全可见)
{%- if include_backend %}
# Backend WebSocket 地址
VITE_BACKEND_WS_URL=ws://localhost:{{ backend_port }}
# Backend HTTP 地址(文件下载 / 预览)
VITE_BACKEND_HTTP_URL=http://localhost:{{ backend_port }}
{%- endif %}
+7
View File
@@ -0,0 +1,7 @@
{
"semi": true,
"singleQuote": false,
"tabWidth": 2,
"trailingComma": "es5",
"printWidth": 100
}
+38
View File
@@ -0,0 +1,38 @@
import js from "@eslint/js";
import globals from "globals";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from "typescript-eslint";
export default tseslint.config(
{ ignores: ["dist"] },
{
extends: [js.configs.recommended, ...tseslint.configs.strict],
files: ["**/*.{ts,tsx}"],
languageOptions: {
ecmaVersion: 2022,
globals: globals.browser,
},
plugins: {
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
// 禁 console.log(允许 warn/error
"no-console": ["error", { allow: ["warn", "error"] }],
// 禁 any
"@typescript-eslint/no-explicit-any": "error",
// 命名约定
"@typescript-eslint/naming-convention": [
"error",
{ selector: "variable", format: ["camelCase", "UPPER_CASE", "PascalCase"] },
{ selector: "function", format: ["camelCase", "PascalCase"] },
{ selector: "typeLike", format: ["PascalCase"] },
],
// 单文件行数上限
"max-lines": ["warn", { max: 200, skipBlankLines: true, skipComments: true }],
},
}
);
+43
View File
@@ -0,0 +1,43 @@
{
"name": "{{ project_slug }}-frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"lint": "eslint src",
"typecheck": "tsc --noEmit",
"test": "vitest",
"test:run": "vitest run"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"zustand": "^5.0.0",
"@tanstack/react-query": "^5.0.0",
"zod": "^3.23.0",
"sonner": "^1.5.0"
},
"devDependencies": {
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.0",
"vite": "^5.4.0",
"typescript": "^5.5.0",
"eslint": "^9.11.0",
"@typescript-eslint/eslint-plugin": "^8.7.0",
"@typescript-eslint/parser": "^8.7.0",
"eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-react-refresh": "^0.4.0",
"prettier": "^3.3.0",
"vitest": "^2.1.0",
"@testing-library/react": "^16.0.0",
"@testing-library/jest-dom": "^6.5.0",
"@vitest/coverage-v8": "^2.1.0",
"tailwindcss": "^3.4.0",
"autoprefixer": "^10.4.0",
"postcss": "^8.4.0",
"jsdom": "^25.0.0"
}
}
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
port: {{ frontend_port }},
},
});
+19
View File
@@ -0,0 +1,19 @@
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
test: {
environment: "jsdom",
setupFiles: ["./src/test/setup.ts"],
coverage: {
provider: "v8",
thresholds: {
lines: 80,
functions: 80,
branches: 80,
statements: 80,
},
},
},
});