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:
@@ -1,38 +0,0 @@
|
||||
帮用户用这个 scaffold 生成一个新项目。
|
||||
|
||||
用户输入:$ARGUMENTS
|
||||
|
||||
## 步骤
|
||||
|
||||
1. 如果用户没有提供项目名,先问清楚以下信息(一次性问完,不要逐个问):
|
||||
- 项目目录名(my-project)
|
||||
- 是否包含 backend(FastAPI)
|
||||
- 是否包含 frontend(React + Vite)
|
||||
- 是否包含 agent(LLM 服务)
|
||||
- 数据库:sqlite 还是 postgresql
|
||||
- 如果 include_agent=true,LLM provider:gemini-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 模板本身。
|
||||
@@ -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+frontend,sqlite |
|
||||
| `fullstack-ai` | backend+frontend+agent,postgresql,openai-api |
|
||||
| `api-only` | 仅 backend,sqlite |
|
||||
|
||||
### 第二步:执行生成
|
||||
|
||||
```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` — "两层规则体系" 一节
|
||||
@@ -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
|
||||
|
||||
# 仅 API(backend only,SQLite)
|
||||
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
|
||||
|
||||
# 打版本 tag(copier 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
@@ -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: 项目 slug(snake_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 }}"
|
||||
Executable
+51
@@ -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 或描述\"新建项目\"即可触发"
|
||||
Executable
+330
@@ -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 copy,src/ 代码不会被覆盖..."
|
||||
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"
|
||||
|
||||
# ── 步骤 2:Backend 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"
|
||||
|
||||
# ── 步骤 3:Agent 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"
|
||||
|
||||
# ── 步骤 4:Frontend 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
|
||||
Executable
+354
@@ -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`。**
|
||||
@@ -0,0 +1,168 @@
|
||||
---
|
||||
paths:
|
||||
- "agent/**"
|
||||
---
|
||||
|
||||
# Agent DAG 编排与异常体系
|
||||
|
||||
PresentationState 聚合根、DAG 节点表、runner 协调器(含 checkpoint 恢复与写入点)以及异常体系与传播规则。
|
||||
|
||||
## DAG 编排(dag/)
|
||||
|
||||
### PresentationState(domain/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,启动 PTY,skill 检查 |
|
||||
| `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 节点)零改动。
|
||||
@@ -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_preview(3 张预览) | `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 统一回收。
|
||||
@@ -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_preview(3 张预览) | 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`,触发后续节点
|
||||
@@ -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 -%}
|
||||
| playwright(async) | 截取每页幻灯片截图 |
|
||||
| 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 app,WebSocket 端点
|
||||
│ │
|
||||
│ ├── 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`
|
||||
@@ -0,0 +1,65 @@
|
||||
# 架构总览
|
||||
|
||||
> 三层架构、通信链路、限界上下文、六边形分层与依赖方向。
|
||||
|
||||
## 三层架构
|
||||
|
||||
```
|
||||
frontend/ React + Vite + TypeScript + Tailwind
|
||||
backend/ FastAPI(port 8000)—— WebSocket 代理 + 会话持久化
|
||||
agent/ FastAPI(port 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-linter(Python)/ dependency-cruiser(TS)在 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` 与 driver,ORM 不变)
|
||||
@@ -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` 定时任务软删→硬删过期记录。
|
||||
@@ -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 # SessionStatus(StrEnum,含 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 # Settings(pydantic-settings)
|
||||
│ ├── deps.py # FastAPI 依赖注入(get_db、get_session_repo、get_settings)
|
||||
│ └── logging.py # loguru 初始化、sink 配置
|
||||
└── tests/
|
||||
├── conftest.py # 测试 DB(in-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 + 可选 spectral(OpenAPI 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_SNAKE,Settings 字段用 snake_case;env 映射(`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,两个 sink:stderr(dev 人类可读彩色)+ `~/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 加载)。
|
||||
@@ -0,0 +1,26 @@
|
||||
# 配置管理
|
||||
|
||||
> .env 规约 + 命名映射坑 + API 文档开关。每个服务用 `.env` 注入配置,**不硬编码、不提交密钥**。
|
||||
|
||||
- `{服务}/.env.example` 提交 git(模板);`{服务}/.env` gitignore(含密钥)
|
||||
- backend/agent:`pydantic-settings` 读 `.env`,启动即校验类型,fail-fast
|
||||
- frontend:Vite 读 `.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_generator:AGENT_WS_URL → agent_ws_url 为默认行为
|
||||
```
|
||||
|
||||
## API 文档(Swagger / OpenAPI)
|
||||
|
||||
- FastAPI 由 `.env` 的 `ENABLE_API_DOCS` 控制 `/docs` `/redoc` `/openapi.json`:**dev true,prod false**(关闭时三者设 `None`)
|
||||
- 主要用于 backend;agent 内部 WS 服务可直接关
|
||||
- 外部可替换依赖(LLM CLI 命令、skill 来源/路径)也经 `.env` 注入,不硬编码(见 `architecture.md`、`agent.md`)
|
||||
@@ -0,0 +1,40 @@
|
||||
# 全局约定
|
||||
|
||||
> 命名规范 + 禁 print/console + 注释 + 包管理概览。
|
||||
|
||||
## 命名规范(已定)
|
||||
|
||||
不是"全部 snake":各随语言生态,边界(wire)统一为 snake。
|
||||
|
||||
| 范围 | 命名 | 强制 |
|
||||
|------|------|------|
|
||||
| Python(backend+agent) | snake_case 变量/函数、PascalCase 类、UPPER_SNAKE 常量 | ruff `N`(pep8-naming) |
|
||||
| 前端 TS | camelCase 变量/函数、PascalCase 组件/类型 | eslint naming-convention |
|
||||
| 通信线 JSON 契约 | snake_case | 契约源 Pydantic(snake),后端零映射 |
|
||||
|
||||
- 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` 安装。各目录详细命令见对应服务规则。
|
||||
@@ -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) |
|
||||
@@ -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"**
|
||||
子概念若无独立生命周期(密码脱离用户无意义),它就不该有独立的增删改查,而是宿主聚合的行为。
|
||||
@@ -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`)。
|
||||
@@ -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",不随当前厂商变化
|
||||
@@ -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 连接失败 | Toast(error)+ 重连提示 |
|
||||
| Gemini 超时/失败 | 聊天气泡(系统消息)+ Toast |
|
||||
| 文件下载失败 | Toast(error) |
|
||||
| 组件渲染崩溃 | 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` | StylePicker(3 个 iframe 预览卡片) |
|
||||
| `generating` | ProgressBar + 日志气泡 |
|
||||
| `screenshotting` | ProgressBar(逐页进度) |
|
||||
| `done` | PreviewFrame + DownloadBar + ChatInput(refine) |
|
||||
| `error` | 错误气泡 + 重试按钮 |
|
||||
@@ -0,0 +1,111 @@
|
||||
---
|
||||
paths: ["frontend/**"]
|
||||
---
|
||||
|
||||
# frontend
|
||||
|
||||
React + Vite + TypeScript + Tailwind 前端的技术栈、目录、各层职责边界、TS 规范与测试规范。
|
||||
|
||||
聊天式交互界面,实时展示 Gemini 对话、风格预览、HTML 演示文稿和下载入口。
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 库 | 用途 |
|
||||
|----|------|
|
||||
| React 18 + Vite | 构建工具 + UI 框架 |
|
||||
| TypeScript(strict 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 # vitest(watch 模式)
|
||||
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/` | 渲染 props,emit 回调 | 直接读 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%
|
||||
@@ -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` 排除
|
||||
@@ -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 | console(dev)/ 可接 Sentry(prod) | 结构化对象 |
|
||||
|
||||
- **探针**:每个服务暴露 `GET /health`(存活)与 `GET /ready`(依赖就绪,如 Agent 检查 skill 已装、Chromium 可启动)
|
||||
- 禁止 `print()` / `console.log()`,统一用各层 logger(见 `conventions.md`)
|
||||
@@ -0,0 +1,22 @@
|
||||
# 质量门禁(CI 强制)
|
||||
|
||||
> lint / format / type / test 门禁表 + 依赖安全 / 供应链。
|
||||
|
||||
提交即跑(`.pre-commit-config.yaml`),CI 再跑一遍(`.github/workflows/ci.yml`)。任一不过禁止合并。
|
||||
|
||||
| 语言 | Lint | 格式化 | 类型 | 测试 |
|
||||
|------|------|--------|------|------|
|
||||
| Python | ruff check | ruff format | mypy(strict) | pytest |
|
||||
| TypeScript | eslint | prettier | tsc --noEmit | vitest |
|
||||
|
||||
- 安装钩子:`pre-commit install`
|
||||
- mypy / tsc 必须零 error;ruff / eslint 零 warning
|
||||
- 配置位置:Python 在各 `pyproject.toml`,TS 在 `frontend/` 的 eslint/prettier/tsconfig
|
||||
- 架构边界由 import-linter(Python)/ dependency-cruiser(TS)在 CI 强制(见 `architecture.md`)
|
||||
|
||||
## 依赖安全 / 供应链
|
||||
|
||||
- 锁文件必须提交,CI 用 `--frozen`/`--frozen-lockfile` 安装(拒绝漂移)
|
||||
- CI 跑依赖审计:Python `uv run pip-audit`,前端 `pnpm audit --audit-level high`
|
||||
- 启用 Dependabot/Renovate 自动提依赖更新 PR(`.github/dependabot.yml`)
|
||||
- 新增依赖须评估必要性,优先标准库;不引入无维护/单文件的小众包
|
||||
@@ -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`(bandit:S602/603/604/605)在 PC·CI 检测
|
||||
- 密钥不入库由 gitleaks + detect-private-key 全量扫描
|
||||
@@ -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、hook(mock 边界) | ~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
@@ -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
@@ -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 %}
|
||||
@@ -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/
|
||||
@@ -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 %}
|
||||
|
||||
# Python(ruff 同时做 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 %}
|
||||
@@ -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 / 注释 / 包管理 |
|
||||
@@ -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 %}
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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)
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "es5",
|
||||
"printWidth": 100
|
||||
}
|
||||
@@ -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 }],
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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" }]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: {{ frontend_port }},
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user