From 39fe248f9f2fe54ebbb9d66a505d96a431a52972 Mon Sep 17 00:00:00 2001 From: baozaotumao Date: Mon, 1 Jun 2026 21:19:45 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=87=8D=E7=BB=84=E4=B8=BA=E6=A0=87?= =?UTF-8?q?=E5=87=86=20skill=20=E5=8C=85=E7=BB=93=E6=9E=84=20+=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=20verify.sh=20bash=203.2=20=E5=85=BC=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 .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 入库 --- .claude/commands/new-project.md | 38 -- .claude/skills/new-project/SKILL.md | 69 ++++ README.md | 63 +++- copier.yml | 156 ++++++++ scripts/install-skill.sh | 51 +++ scripts/retrofit.sh | 330 ++++++++++++++++ scripts/verify.sh | 354 ++++++++++++++++++ .../.claude/rules/agent-concurrency.md.jinja | 136 +++++++ template/.claude/rules/agent-dag.md | 168 +++++++++ .../.claude/rules/agent-extension-points.md | 43 +++ template/.claude/rules/agent-llm-api.md | 110 ++++++ template/.claude/rules/agent-llm-pty.md | 98 +++++ template/.claude/rules/agent.md.jinja | 185 +++++++++ template/.claude/rules/architecture.md | 65 ++++ template/.claude/rules/backend-concurrency.md | 47 +++ template/.claude/rules/backend-db.md.jinja | 245 ++++++++++++ template/.claude/rules/backend.md | 201 ++++++++++ .../.claude/rules/communication-catalog.md | 27 ++ .../.claude/rules/communication-protocol.md | 67 ++++ template/.claude/rules/config.md | 26 ++ template/.claude/rules/conventions.md | 40 ++ template/.claude/rules/data-lifecycle.md | 27 ++ template/.claude/rules/ddd.md | 48 +++ template/.claude/rules/design-discipline.md | 122 ++++++ template/.claude/rules/error-codes.md | 27 ++ template/.claude/rules/frontend-runtime.md | 109 ++++++ template/.claude/rules/frontend.md | 111 ++++++ template/.claude/rules/git-workflow.md | 9 + template/.claude/rules/observability.md | 19 + template/.claude/rules/quality-gates.md | 22 ++ template/.claude/rules/security.md | 16 + template/.claude/rules/testing-tdd.md | 31 ++ template/.github/dependabot.yml.jinja | 24 ++ template/.github/workflows/ci.yml.jinja | 69 ++++ template/.gitignore.jinja | 34 ++ template/.pre-commit-config.yaml.jinja | 67 ++++ template/CLAUDE.md.jinja | 78 ++++ template/agent/.env.example.jinja | 39 ++ template/agent/Dockerfile.jinja | 22 ++ template/agent/pyproject.toml.jinja | 53 +++ template/agent/src/main.py.jinja | 32 ++ template/backend/.env.example.jinja | 33 ++ template/backend/Dockerfile.jinja | 13 + template/backend/pyproject.toml.jinja | 60 +++ template/backend/src/main.py.jinja | 27 ++ template/docker-compose.yml.jinja | 40 ++ template/frontend/.env.example.jinja | 11 + template/frontend/.prettierrc.jinja | 7 + template/frontend/eslint.config.js.jinja | 38 ++ template/frontend/package.json.jinja | 43 +++ template/frontend/tsconfig.json.jinja | 21 ++ template/frontend/vite.config.ts.jinja | 9 + template/frontend/vitest.config.ts.jinja | 19 + 53 files changed, 3744 insertions(+), 55 deletions(-) delete mode 100644 .claude/commands/new-project.md create mode 100644 .claude/skills/new-project/SKILL.md create mode 100644 copier.yml create mode 100755 scripts/install-skill.sh create mode 100755 scripts/retrofit.sh create mode 100755 scripts/verify.sh create mode 100644 template/.claude/rules/agent-concurrency.md.jinja create mode 100644 template/.claude/rules/agent-dag.md create mode 100644 template/.claude/rules/agent-extension-points.md create mode 100644 template/.claude/rules/agent-llm-api.md create mode 100644 template/.claude/rules/agent-llm-pty.md create mode 100644 template/.claude/rules/agent.md.jinja create mode 100644 template/.claude/rules/architecture.md create mode 100644 template/.claude/rules/backend-concurrency.md create mode 100644 template/.claude/rules/backend-db.md.jinja create mode 100644 template/.claude/rules/backend.md create mode 100644 template/.claude/rules/communication-catalog.md create mode 100644 template/.claude/rules/communication-protocol.md create mode 100644 template/.claude/rules/config.md create mode 100644 template/.claude/rules/conventions.md create mode 100644 template/.claude/rules/data-lifecycle.md create mode 100644 template/.claude/rules/ddd.md create mode 100644 template/.claude/rules/design-discipline.md create mode 100644 template/.claude/rules/error-codes.md create mode 100644 template/.claude/rules/frontend-runtime.md create mode 100644 template/.claude/rules/frontend.md create mode 100644 template/.claude/rules/git-workflow.md create mode 100644 template/.claude/rules/observability.md create mode 100644 template/.claude/rules/quality-gates.md create mode 100644 template/.claude/rules/security.md create mode 100644 template/.claude/rules/testing-tdd.md create mode 100644 template/.github/dependabot.yml.jinja create mode 100644 template/.github/workflows/ci.yml.jinja create mode 100644 template/.gitignore.jinja create mode 100644 template/.pre-commit-config.yaml.jinja create mode 100644 template/CLAUDE.md.jinja create mode 100644 template/agent/.env.example.jinja create mode 100644 template/agent/Dockerfile.jinja create mode 100644 template/agent/pyproject.toml.jinja create mode 100644 template/agent/src/main.py.jinja create mode 100644 template/backend/.env.example.jinja create mode 100644 template/backend/Dockerfile.jinja create mode 100644 template/backend/pyproject.toml.jinja create mode 100644 template/backend/src/main.py.jinja create mode 100644 template/docker-compose.yml.jinja create mode 100644 template/frontend/.env.example.jinja create mode 100644 template/frontend/.prettierrc.jinja create mode 100644 template/frontend/eslint.config.js.jinja create mode 100644 template/frontend/package.json.jinja create mode 100644 template/frontend/tsconfig.json.jinja create mode 100644 template/frontend/vite.config.ts.jinja create mode 100644 template/frontend/vitest.config.ts.jinja diff --git a/.claude/commands/new-project.md b/.claude/commands/new-project.md deleted file mode 100644 index db967b9..0000000 --- a/.claude/commands/new-project.md +++ /dev/null @@ -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= \ - --data include_agent= \ - --data include_frontend= \ - --data database= \ - --data llm_provider= # 仅 include_agent=true 时加此项 -``` - -3. 生成完成后,运行验证: -```bash -./scripts/verify.sh -v <项目目录> -``` - -4. 汇报生成结果和验证输出。如有失败项,给出修复建议。 - -## 注意 - -- 如果用户已在命令后直接提供了参数(如 `/new-project my-app backend+frontend`),直接解析并执行,不要再问一遍。 -- 生成目录默认放在当前工作目录下。 -- 不要修改 scaffold 模板本身。 diff --git a/.claude/skills/new-project/SKILL.md b/.claude/skills/new-project/SKILL.md new file mode 100644 index 0000000..58dc403 --- /dev/null +++ b/.claude/skills/new-project/SKILL.md @@ -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= \ + --data include_agent= \ + --data include_frontend= \ + --data database= \ + --data llm_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` — "两层规则体系" 一节 diff --git a/README.md b/README.md index 63f4171..fb83bea 100644 --- a/README.md +++ b/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 ``` --- diff --git a/copier.yml b/copier.yml new file mode 100644 index 0000000..4477f9e --- /dev/null +++ b/copier.yml @@ -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 }}" diff --git a/scripts/install-skill.sh b/scripts/install-skill.sh new file mode 100755 index 0000000..36b196d --- /dev/null +++ b/scripts/install-skill.sh @@ -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 + 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 或描述\"新建项目\"即可触发" diff --git a/scripts/retrofit.sh b/scripts/retrofit.sh new file mode 100755 index 0000000..a047b07 --- /dev/null +++ b/scripts/retrofit.sh @@ -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 <&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 diff --git a/scripts/verify.sh b/scripts/verify.sh new file mode 100755 index 0000000..e7857d7 --- /dev/null +++ b/scripts/verify.sh @@ -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 <&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 diff --git a/template/.claude/rules/agent-concurrency.md.jinja b/template/.claude/rules/agent-concurrency.md.jinja new file mode 100644 index 0000000..dc61e5d --- /dev/null +++ b/template/.claude/rules/agent-concurrency.md.jinja @@ -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`。** diff --git a/template/.claude/rules/agent-dag.md b/template/.claude/rules/agent-dag.md new file mode 100644 index 0000000..7e8b085 --- /dev/null +++ b/template/.claude/rules/agent-dag.md @@ -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 异常** diff --git a/template/.claude/rules/agent-extension-points.md b/template/.claude/rules/agent-extension-points.md new file mode 100644 index 0000000..cf495e7 --- /dev/null +++ b/template/.claude/rules/agent-extension-points.md @@ -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 节点)零改动。 diff --git a/template/.claude/rules/agent-llm-api.md b/template/.claude/rules/agent-llm-api.md new file mode 100644 index 0000000..0ad6bea --- /dev/null +++ b/template/.claude/rules/agent-llm-api.md @@ -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/_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 统一回收。 diff --git a/template/.claude/rules/agent-llm-pty.md b/template/.claude/rules/agent-llm-pty.md new file mode 100644 index 0000000..cd267e1 --- /dev/null +++ b/template/.claude/rules/agent-llm-pty.md @@ -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]+?)```|()', 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`,触发后续节点 diff --git a/template/.claude/rules/agent.md.jinja b/template/.claude/rules/agent.md.jinja new file mode 100644 index 0000000..dd762a7 --- /dev/null +++ b/template/.claude/rules/agent.md.jinja @@ -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` diff --git a/template/.claude/rules/architecture.md b/template/.claude/rules/architecture.md new file mode 100644 index 0000000..f248440 --- /dev/null +++ b/template/.claude/rules/architecture.md @@ -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 强制 diff --git a/template/.claude/rules/backend-concurrency.md b/template/.claude/rules/backend-concurrency.md new file mode 100644 index 0000000..fbc1deb --- /dev/null +++ b/template/.claude/rules/backend-concurrency.md @@ -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 不变) diff --git a/template/.claude/rules/backend-db.md.jinja b/template/.claude/rules/backend-db.md.jinja new file mode 100644 index 0000000..77d3a0e --- /dev/null +++ b/template/.claude/rules/backend-db.md.jinja @@ -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` 定时任务软删→硬删过期记录。 diff --git a/template/.claude/rules/backend.md b/template/.claude/rules/backend.md new file mode 100644 index 0000000..58084d6 --- /dev/null +++ b/template/.claude/rules/backend.md @@ -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) diff --git a/template/.claude/rules/communication-catalog.md b/template/.claude/rules/communication-catalog.md new file mode 100644 index 0000000..766c845 --- /dev/null +++ b/template/.claude/rules/communication-catalog.md @@ -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": "..."} +``` diff --git a/template/.claude/rules/communication-protocol.md b/template/.claude/rules/communication-protocol.md new file mode 100644 index 0000000..a16df61 --- /dev/null +++ b/template/.claude/rules/communication-protocol.md @@ -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 加载)。 diff --git a/template/.claude/rules/config.md b/template/.claude/rules/config.md new file mode 100644 index 0000000..d4470ba --- /dev/null +++ b/template/.claude/rules/config.md @@ -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`) diff --git a/template/.claude/rules/conventions.md b/template/.claude/rules/conventions.md new file mode 100644 index 0000000..8d7d1ce --- /dev/null +++ b/template/.claude/rules/conventions.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` 安装。各目录详细命令见对应服务规则。 diff --git a/template/.claude/rules/data-lifecycle.md b/template/.claude/rules/data-lifecycle.md new file mode 100644 index 0000000..89477e4 --- /dev/null +++ b/template/.claude/rules/data-lifecycle.md @@ -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) | diff --git a/template/.claude/rules/ddd.md b/template/.claude/rules/ddd.md new file mode 100644 index 0000000..298dc3e --- /dev/null +++ b/template/.claude/rules/ddd.md @@ -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"** + 子概念若无独立生命周期(密码脱离用户无意义),它就不该有独立的增删改查,而是宿主聚合的行为。 diff --git a/template/.claude/rules/design-discipline.md b/template/.claude/rules/design-discipline.md new file mode 100644 index 0000000..8045bc8 --- /dev/null +++ b/template/.claude/rules/design-discipline.md @@ -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`)。 diff --git a/template/.claude/rules/error-codes.md b/template/.claude/rules/error-codes.md new file mode 100644 index 0000000..54d8dfa --- /dev/null +++ b/template/.claude/rules/error-codes.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",不随当前厂商变化 diff --git a/template/.claude/rules/frontend-runtime.md b/template/.claude/rules/frontend-runtime.md new file mode 100644 index 0000000..be6c49f --- /dev/null +++ b/template/.claude/rules/frontend-runtime.md @@ -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 ? : 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` | 错误气泡 + 重试按钮 | diff --git a/template/.claude/rules/frontend.md b/template/.claude/rules/frontend.md new file mode 100644 index 0000000..6f0f110 --- /dev/null +++ b/template/.claude/rules/frontend.md @@ -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% diff --git a/template/.claude/rules/git-workflow.md b/template/.claude/rules/git-workflow.md new file mode 100644 index 0000000..67fba5a --- /dev/null +++ b/template/.claude/rules/git-workflow.md @@ -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` 排除 diff --git a/template/.claude/rules/observability.md b/template/.claude/rules/observability.md new file mode 100644 index 0000000..0209b6b --- /dev/null +++ b/template/.claude/rules/observability.md @@ -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`) diff --git a/template/.claude/rules/quality-gates.md b/template/.claude/rules/quality-gates.md new file mode 100644 index 0000000..63891f2 --- /dev/null +++ b/template/.claude/rules/quality-gates.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`) +- 新增依赖须评估必要性,优先标准库;不引入无维护/单文件的小众包 diff --git a/template/.claude/rules/security.md b/template/.claude/rules/security.md new file mode 100644 index 0000000..f9b3fb7 --- /dev/null +++ b/template/.claude/rules/security.md @@ -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 全量扫描 diff --git a/template/.claude/rules/testing-tdd.md b/template/.claude/rules/testing-tdd.md new file mode 100644 index 0000000..cf97d9f --- /dev/null +++ b/template/.claude/rules/testing-tdd.md @@ -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 注入 diff --git a/template/.github/dependabot.yml.jinja b/template/.github/dependabot.yml.jinja new file mode 100644 index 0000000..a05dd77 --- /dev/null +++ b/template/.github/dependabot.yml.jinja @@ -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 diff --git a/template/.github/workflows/ci.yml.jinja b/template/.github/workflows/ci.yml.jinja new file mode 100644 index 0000000..9101fd0 --- /dev/null +++ b/template/.github/workflows/ci.yml.jinja @@ -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 %} diff --git a/template/.gitignore.jinja b/template/.gitignore.jinja new file mode 100644 index 0000000..a8efc48 --- /dev/null +++ b/template/.gitignore.jinja @@ -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/ diff --git a/template/.pre-commit-config.yaml.jinja b/template/.pre-commit-config.yaml.jinja new file mode 100644 index 0000000..38c2665 --- /dev/null +++ b/template/.pre-commit-config.yaml.jinja @@ -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 %} diff --git a/template/CLAUDE.md.jinja b/template/CLAUDE.md.jinja new file mode 100644 index 0000000..0bfb053 --- /dev/null +++ b/template/CLAUDE.md.jinja @@ -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 / 注释 / 包管理 | diff --git a/template/agent/.env.example.jinja b/template/agent/.env.example.jinja new file mode 100644 index 0000000..e54233e --- /dev/null +++ b/template/agent/.env.example.jinja @@ -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 %} diff --git a/template/agent/Dockerfile.jinja b/template/agent/Dockerfile.jinja new file mode 100644 index 0000000..9736576 --- /dev/null +++ b/template/agent/Dockerfile.jinja @@ -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"] diff --git a/template/agent/pyproject.toml.jinja b/template/agent/pyproject.toml.jinja new file mode 100644 index 0000000..1e58b1d --- /dev/null +++ b/template/agent/pyproject.toml.jinja @@ -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"] diff --git a/template/agent/src/main.py.jinja b/template/agent/src/main.py.jinja new file mode 100644 index 0000000..9f7a593 --- /dev/null +++ b/template/agent/src/main.py.jinja @@ -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) diff --git a/template/backend/.env.example.jinja b/template/backend/.env.example.jinja new file mode 100644 index 0000000..ccd69fd --- /dev/null +++ b/template/backend/.env.example.jinja @@ -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 diff --git a/template/backend/Dockerfile.jinja b/template/backend/Dockerfile.jinja new file mode 100644 index 0000000..841218c --- /dev/null +++ b/template/backend/Dockerfile.jinja @@ -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"] diff --git a/template/backend/pyproject.toml.jinja b/template/backend/pyproject.toml.jinja new file mode 100644 index 0000000..f11a2db --- /dev/null +++ b/template/backend/pyproject.toml.jinja @@ -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"] diff --git a/template/backend/src/main.py.jinja b/template/backend/src/main.py.jinja new file mode 100644 index 0000000..e3138ab --- /dev/null +++ b/template/backend/src/main.py.jinja @@ -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) diff --git a/template/docker-compose.yml.jinja b/template/docker-compose.yml.jinja new file mode 100644 index 0000000..3c71257 --- /dev/null +++ b/template/docker-compose.yml.jinja @@ -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 %} diff --git a/template/frontend/.env.example.jinja b/template/frontend/.env.example.jinja new file mode 100644 index 0000000..392ff64 --- /dev/null +++ b/template/frontend/.env.example.jinja @@ -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 %} diff --git a/template/frontend/.prettierrc.jinja b/template/frontend/.prettierrc.jinja new file mode 100644 index 0000000..1a88ab1 --- /dev/null +++ b/template/frontend/.prettierrc.jinja @@ -0,0 +1,7 @@ +{ + "semi": true, + "singleQuote": false, + "tabWidth": 2, + "trailingComma": "es5", + "printWidth": 100 +} diff --git a/template/frontend/eslint.config.js.jinja b/template/frontend/eslint.config.js.jinja new file mode 100644 index 0000000..4b317e3 --- /dev/null +++ b/template/frontend/eslint.config.js.jinja @@ -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 }], + }, + } +); diff --git a/template/frontend/package.json.jinja b/template/frontend/package.json.jinja new file mode 100644 index 0000000..cb5d8e8 --- /dev/null +++ b/template/frontend/package.json.jinja @@ -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" + } +} diff --git a/template/frontend/tsconfig.json.jinja b/template/frontend/tsconfig.json.jinja new file mode 100644 index 0000000..02b8ba6 --- /dev/null +++ b/template/frontend/tsconfig.json.jinja @@ -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" }] +} diff --git a/template/frontend/vite.config.ts.jinja b/template/frontend/vite.config.ts.jinja new file mode 100644 index 0000000..a651f06 --- /dev/null +++ b/template/frontend/vite.config.ts.jinja @@ -0,0 +1,9 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + server: { + port: {{ frontend_port }}, + }, +}); diff --git a/template/frontend/vitest.config.ts.jinja b/template/frontend/vitest.config.ts.jinja new file mode 100644 index 0000000..fc89360 --- /dev/null +++ b/template/frontend/vitest.config.ts.jinja @@ -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, + }, + }, + }, +});