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

- 新增 .claude/skills/new-project/SKILL.md(标准 skill 格式)
- install-skill.sh 安装时组装 SKILL.md + verify.sh,单一源无重复
- verify.sh 改用索引数组替代 declare -A,兼容 macOS bash 3.2
- log_pass/log_skip 显式 return 0,避免 set -e 下非 verbose 误退出
- README 改为 skill 优先;补全 copier.yml/scripts/template 入库
This commit is contained in:
baozaotumao
2026-06-01 21:19:45 -07:00
parent 167fa10dde
commit 39fe248f9f
53 changed files with 3744 additions and 55 deletions
@@ -0,0 +1,136 @@
---
paths:
- "agent/**"
---
# Agent 并发模型与资源管理
事件驱动的异步 I/O、进程池下放 CPU 密集任务、并发上限背压、浏览器复用、生命周期取消、工作目录清理与日志规范。
## 并发模型与资源管理
Agent 是最重的并发场景:单进程 FastAPI(单事件循环)要同时服务多个会话,每个会话持有{% if llm_provider == 'gemini-cli' %}一个 LLM CLI 子进程 + {% endif %}一个 Chromium 上下文 + 阶段性 CPU 密集合成。**核心铁律:绝不阻塞事件循环。**
### 工作类型与处理方式
| 工作类型 | 例子 | 处理方式 |
|---------|------|---------|
| 异步 I/O | asyncio 子进程、网络、WebSocket | 直接 `await` |
{% if llm_provider == 'gemini-cli' -%}
| 阻塞 fd 读 | PTY master_fd 读取 | **非阻塞 fd + `loop.add_reader`**,不要 thread-per-session |
{% endif -%}
| CPU 密集 | python-pptx 合成、图像处理 | `ProcessPoolExecutor` via `run_in_executor` |
{% if llm_provider != 'gemini-cli' -%}
| LLM 流式 | SDK `async for token in stream` | 直接 `await`,事件驱动 |
{% endif %}
{% if llm_provider == 'gemini-cli' %}
### 1. PTY 读取:事件驱动,零线程
**不要**为每个会话开一个线程跑阻塞 `os.read`(线程池会被会话数打爆)。正确做法是把 master fd 设为非阻塞,注册到事件循环:
```python
os.set_blocking(master_fd, False)
loop.add_reader(master_fd, on_pty_readable) # 可读时回调,零额外线程
def on_pty_readable():
try:
data = os.read(master_fd, 4096)
except BlockingIOError:
return
queue.put_nowait(strip_ansi(data.decode(errors="replace")))
```
清理时务必 `loop.remove_reader(master_fd)`。
### 2. CPU 密集:进程池
{% else %}
### 1. CPU 密集:进程池
{% endif %}
`synthesize` 节点(python-pptx 合成)是纯 CPU + 持 GIL,会卡住整个事件循环 1–3 秒,影响所有其他会话。必须下放到进程池:
```python
process_pool = ProcessPoolExecutor(max_workers=os.cpu_count())
await loop.run_in_executor(process_pool, build_pptx, screenshots_dir, pptx_path)
```
进程池函数必须是**纯函数**(参数 + 返回值可序列化),禁止访问共享状态。
### {% if llm_provider == 'gemini-cli' %}3{% else %}2{% endif %}. 并发上限与背压
每个会话 = {% if llm_provider == 'gemini-cli' %}1 个 CLI 进程 + {% endif %}1 个 Chromium context,内存/CPU 开销大。用全局信号量封顶,超出排队并向前端反馈位置:
```python
session_semaphore = asyncio.Semaphore(settings.max_concurrent_sessions)
async with session_semaphore:
await run_session(...)
# 排队期间向前端推 {"type": "queued", "position": N}
```
### {% if llm_provider == 'gemini-cli' %}4{% else %}3{% endif %}. Playwright 浏览器复用
启动时拉起**一个** Chromium,每个会话开独立 `browser_context`(廉价),用完关 context,不要每次 `launch()`
```python
# lifespan 启动
app.state.browser = await playwright.chromium.launch()
# 截图节点
context = await app.state.browser.new_context(viewport={"width": 1920, "height": 1080})
try:
page = await context.new_page()
...
finally:
await context.close()
```
### {% if llm_provider == 'gemini-cli' %}5{% else %}4{% endif %}. 资源生命周期与取消
每个会话是一个 asyncio Task。WebSocket 断开 → 取消 Task → `finally` 块彻底清理:
```python
finally:
{% if llm_provider == 'gemini-cli' %}
loop.remove_reader(master_fd)
proc.kill()
await proc.wait() # 必须 wait,回收僵尸进程
os.close(master_fd)
{% else %}
await llm_session.close() # 释放 HTTP 连接
{% endif %}
await context.close()
```
### 共享状态线程安全
所有会话状态活在事件循环里——纯 async 访问无需锁。下放到进程池的函数**严禁触碰共享 dict**,只能通过参数传入、返回值传出。
## 工作目录清理(后台任务)
`lifespan` 启动定时任务(每小时),按 TTL + 磁盘预算清理 `~{{ '/' }}{{ project_slug }}/{uuid}/`
- 删除超 `SESSION_TTL_HOURS`(默认 24h)的会话目录
- 总占用超 `MAX_DISK_GB`(默认 5GB)时按 LRU 删到阈值以下
- 删除前校验路径在 work_dir 内(防穿越)
## 日志规范(core/logging.py
使用 loguru,两个 sink
| Sink | 格式 | 条件 |
|------|------|------|
| stderr | 人类可读彩色格式 | 开发环境(`ENV=dev` |
| `~{{ '/' }}{{ project_slug }}/logs/agent.log` | JSON(每行一条) | 所有环境 |
文件 sink 配轮转与保留:`rotation="100 MB"`、`retention="14 days"`、`compression="zip"`。
每条日志必须携带 `session_id`(通过 loguru contextualize 注入):
```python
with logger.contextualize(session_id=state.session_id):
logger.info("session started")
```
**禁止使用 `print()`,禁止使用 stdlib `logging`。统一用 `from loguru import logger`。**
+168
View File
@@ -0,0 +1,168 @@
---
paths:
- "agent/**"
---
# Agent DAG 编排与异常体系
PresentationState 聚合根、DAG 节点表、runner 协调器(含 checkpoint 恢复与写入点)以及异常体系与传播规则。
## DAG 编排(dag/
### PresentationStatedomain/state.py,聚合根状态)
> 属领域层:是会话聚合根的状态载体,封装状态机不变量(如合法的 status 迁移),纯逻辑零 IO。
```python
@dataclass
class PresentationState:
session_id: str
work_dir: Path
html_path: Path | None = None
pptx_path: Path | None = None
preview_paths: list[Path] = field(default_factory=list)
image_source: str = ""
image_api_key: str = ""
status: Literal[
"idle", "dialog", "previewing", "generating",
"screenshotting", "synthesizing", "done", "refining", "error"
] = "idle"
error_message: str | None = None
```
### DAG 节点(dag/nodes.py
节点签名统一:
```python
async def node_xxx(state: PresentationState, send: Callable, recv_queue: asyncio.Queue) -> PresentationState
```
| 节点 | 触发条件 | 行为 |
|------|---------|------|
| `session_start` | 收到 `start_session` | 创建 work_dir,启动 PTYskill 检查 |
| `phase1_dialog` | PTY 启动后 | 透传对话消息;用户 `user_input` → 写 stdin |
| `phase2_preview` | 检测到首个预览 HTML | 拦截 3 个 HTML → 推 `style_preview``style_pick` → 写 stdin |
| `phase3_generate` | 用户选风格 | 监听 PTY 直到完整 HTML;保存 `slides.html` |
| `screenshot` | `html_ready` 后自动 | Playwright 截图,逐页推 `log` 进度 |
| `synthesize` | `screenshot` 完成 | python-pptx 合成,推 `pptx_ready` |
| `refine_loop` | `done` 状态下收到 `user_input` | 写 PTY stdin → 重进 `phase3_generate` |
### DAG 协调器(dag/runner.py
`runner.py` 有两个职责:**恢复入口**(连接建立时检查 checkpoint)和**正常流程**(串联所有节点)。
```python
async def run_session(state: PresentationState, send, recv_queue: asyncio.Queue,
checkpoint: dict | None = None):
try:
# ── 断点恢复入口 ──────────────────────────────────────────────
if checkpoint:
resume_status = checkpoint.get("status")
if resume_status == "done":
# 完全恢复:直接推送已有结果,无需任何 Gemini 调用
state.html_path = Path(checkpoint["html_path"])
state.pptx_path = Path(checkpoint["pptx_path"])
await send({"type": "html_ready", "session_id": state.session_id})
await send({"type": "pptx_ready", "session_id": state.session_id})
await send({"type": "done"})
# 进入 refine 循环等待用户继续
await _refine_loop(state, send, recv_queue)
return
if resume_status in ("screenshotting", "synthesizing"):
# slides.html 已在磁盘,跳过 Gemini,直接从截图节点恢复
state.html_path = Path(checkpoint["html_path"])
state.status = "screenshotting"
logger.info("Resuming from checkpoint | status={}", resume_status)
state = await screenshot(state, send) # ← checkpoint 写入点 A
state = await synthesize(state, send) # ← checkpoint 写入点 B
await send({"type": "done"})
await _refine_loop(state, send, recv_queue)
return
# dialog / generating / error:无法恢复,PTY 已死
await send({"type": "checkpoint_lost",
"message": "上次会话在生成过程中中断,需要重新开始"})
# ── 正常流程 ──────────────────────────────────────────────────
state = await session_start(state, send, recv_queue)
state = await phase1_dialog(state, send, recv_queue)
state = await phase2_preview(state, send, recv_queue)
state = await phase3_generate(state, send, recv_queue)
state = await screenshot(state, send) # ← checkpoint 写入点 A
state = await synthesize(state, send) # ← checkpoint 写入点 B
await send({"type": "done"})
await _refine_loop(state, send, recv_queue)
except AgentException as e:
logger.error("DAG error | code={} msg={}", e.code, e.message)
await send({"type": "error", "code": e.code, "message": e.message})
except Exception as e:
logger.exception("Unexpected DAG error")
await send({"type": "error", "code": "INTERNAL_ERROR", "message": str(e)})
finally:
cleanup_pty(state)
async def _refine_loop(state, send, recv_queue):
while True:
msg = await recv_queue.get()
if msg["type"] == "user_input":
state = await refine_loop(state, send, recv_queue, msg["text"])
state = await screenshot(state, send) # ← checkpoint 写入点 A
state = await synthesize(state, send) # ← checkpoint 写入点 B
await send({"type": "done"})
```
### Checkpoint 写入点
`screenshot``synthesize` 节点完成后,通过推送特定消息触发 Backend 写入 DB
| 写入点 | 节点 | 推送消息(Backend 监听后写 DB |
|--------|------|---------------------------------|
| A | `screenshot` 完成 | `{"type": "html_ready", "html_path": "..."}` |
| B | `synthesize` 完成 | `{"type": "pptx_ready", "pptx_path": "..."}` |
| — | `done` | `{"type": "done"}` → Backend 将 status 更新为 `"done"` |
**checkpoint 写入由 Backend 负责(监听 Agent 推送的消息),Agent 只负责推送正确的消息。**
## 异常体系(domain/exceptions.py
异常类型是**领域错误词汇**(统一语言的一部分,且 `code` 对应错误码契约 `error-codes.md`),故归领域层 `domain/exceptions.py`,由 domain 拥有。框架级的 `exception_handler` 注册属接口/基础设施关注点,放 `middleware/error_handler.py`,不污染 domain 的纯净(零框架/IO 依赖)。
### domain/exceptions.py
```python
class AgentException(Exception):
def __init__(self, code: str, message: str, status_code: int = 500):
self.code = code
self.message = message
self.status_code = status_code
class GeminiStartupError(AgentException):
def __init__(self):
super().__init__("GEMINI_STARTUP_ERROR", "Gemini CLI 启动失败")
class GeminiTimeoutError(AgentException):
def __init__(self, phase: str):
super().__init__("GEMINI_TIMEOUT", f"Gemini 在 {phase} 阶段超时")
class SkillNotInstalledError(AgentException):
def __init__(self):
super().__init__("SKILL_NOT_INSTALLED", "skill 未安装")
class PlaywrightError(AgentException):
def __init__(self, detail: str):
super().__init__("PLAYWRIGHT_ERROR", f"截图失败: {detail}")
class HtmlExtractionError(AgentException):
def __init__(self):
super().__init__("HTML_EXTRACTION_FAILED", "无法从 Gemini 输出中提取有效 HTML")
```
### 异常传播规则
- DAG 节点内捕获异常 → 更新 `state.status = "error"` → 通过 `send({"type": "error", "message": ...})` 推送给前端 → 抛给 runner
- Runner 捕获 → 记录日志 → 关闭 PTY → 清理资源
- **禁止在节点内静默 swallow 异常**
@@ -0,0 +1,43 @@
---
paths:
- "agent/**"
---
# Agent 扩展点规范
providers / export / storage / llm 四个扩展点的统一模式:端口(Protocol)+ 适配器多实现 + registry 工厂。
## 扩展点规范(providers / export / storage
四个扩展点(含 llm)都遵循同一模式:**端口(Protocol,统一声明于 `domain/ports.py`+ 适配器多实现 + registry 工厂**。调用方只依赖端口,按配置选实现,新增能力不改调用方。
```python
# domain/ports.py —— 所有端口在此声明(六边形架构:端口属领域核心)
class Exporter(Protocol):
async def export(self, screenshots: list[Path], out_path: Path) -> Path: ...
class StorageBackend(Protocol):
async def save(self, key: str, data: bytes) -> None: ...
async def read(self, key: str) -> bytes: ...
def url_for(self, key: str) -> str: ...
class ImageProvider(Protocol):
def build_prompt_context(self) -> str: ... # 注入 gemini prompt 的图片来源说明
```
**节点必须通过接口调用,禁止硬编码具体实现:**
- `synthesize` 节点调用 `exporter.export(...)`,不直接调 python-pptx
- 所有文件读写经 `StorageBackend`,不直接碰 `Path.write_bytes`(本地实现内部才碰 FS
- 加 PDF 导出 = 新增 `pdf_exporter.py` 注册到 registry`synthesize` 一行不改
## registry 工厂
每个扩展点目录提供 `registry.py`,按 `Settings` 配置选择具体实现:
- `llm/`:按 `gemini_cmd` / 厂商配置选 `GeminiCliSession``LLMSession` 实现
- `providers/`:按 `image_source``unsplash` / `pexels` / `none``ImageProvider` 实现
- `export/`:按导出格式选 `pptx_exporter`(未来 `pdf_exporter`)等 `Exporter` 实现
- `storage/`:按存储后端选 `local_storage`(未来 `s3_storage`)等 `StorageBackend` 实现
新增能力 = 新增一个实现文件 + 在对应 `registry.py` 注册,调用方(DAG 节点)零改动。
+110
View File
@@ -0,0 +1,110 @@
---
paths:
- "agent/**"
---
# Agent LLM 驱动层与 API 桥接
厂商无关的 LLM API 抽象、HTTP SDK 集成机制、超时保护与流式响应处理。
## 重要:LLMSession 端口是协议,不是机制
- `LLMSession` Protocol 只定义"能力"(发消息、收推送流、关闭),不规定底层是哪家 SDK
- 本文件描述 API SDK 适配器实现模式(OpenAI / Anthropic 等)
- PTY CLI 路径见 `agent-llm-pty.md`(当前配置未启用)
## LLMSession 协议(domain/ports.py
API SDK 场景使用**推送式流**`AsyncIterator`),而非拉取式 `stream_until`
```python
from typing import Protocol, AsyncIterator
class LLMSession(Protocol):
async def generate(
self,
messages: list[dict], # [{"role": "user", "content": "..."}]
) -> AsyncIterator[str]: ... # 逐 token 推送
async def close(self) -> None: ... # 释放 HTTP 连接池
```
## 适配器结构(llm/
| 文件 | 职责 |
|------|------|
| `domain/ports.py` | `LLMSession` 协议(端口,接口定义) |
| `llm/<provider>_api.py` | SDK 适配器实现(实现 `LLMSession` |
| `llm/registry.py` | 按 `Settings.llm_provider` 选择实现 |
`pty_bridge.py`,无 `skills/installer.py`——API 模式不需要 PTY 或 skill 安装。
## 适配器实现模式
```python
from typing import AsyncIterator, override
from domain.ports import LLMSession
class OpenAISession(LLMSession):
def __init__(self, settings) -> None:
from openai import AsyncOpenAI
self._client = AsyncOpenAI(
api_key=settings.llm_api_key,
base_url=settings.llm_base_url or None,
)
self._model = settings.llm_model
@override
async def generate(self, messages: list[dict]) -> AsyncIterator[str]:
stream = await self._client.chat.completions.create(
model=self._model,
messages=messages,
stream=True,
)
async for chunk in stream:
delta = chunk.choices[0].delta.content
if delta:
yield delta
@override
async def close(self) -> None:
await self._client.close()
```
Anthropic 适配器同理,将 `AsyncOpenAI` 换为 `AsyncAnthropic`,流式 API 换为 `client.messages.stream`
## DAG 节点中的调用模式
```python
async def phase3_generate(state, send, recv_queue):
try:
async with asyncio.timeout(settings.generate_timeout_seconds):
chunks: list[str] = []
async for token in llm_session.generate(state.messages):
chunks.append(token)
await send({"type": "llm_token", "text": token})
html = extract_html("".join(chunks))
if not html:
raise HtmlExtractionError()
except TimeoutError:
raise LLMTimeoutError(phase="generate")
```
不需要 ANSI 过滤——API 响应是纯文本,无终端转义码。
## 超时保护
每个 DAG 阶段用 `asyncio.timeout()` 包裹 LLM 调用:
| 阶段 | 配置项 |
|------|--------|
| phase1_dialog(单轮对话) | `dialog_timeout_seconds`(默认 60s |
| phase2_preview3 张预览) | `preview_timeout_seconds`(默认 180s |
| phase3_generate(完整 HTML | `generate_timeout_seconds`(默认 300s |
超时后抛 `LLMTimeoutError``domain/exceptions.py`),不静默挂起。
## 连接管理
`LLMSession` 实例在 DAG 节点开始时创建,在 `runner.py``finally` 块中通过 `await llm_session.close()` 确定性释放 HTTP 连接池,防止连接泄漏。
禁止在节点内手动调用 `close()`——由 runner 统一回收。
+98
View File
@@ -0,0 +1,98 @@
---
paths:
- "agent/**"
---
# Agent LLM 驱动层与 PTY 桥接
厂商无关的 LLM CLI 抽象、PTY 伪终端桥接机制、超时保护与 HTML 拦截检测。
## LLM CLI 驱动层(llm/
厂商无关的抽象。`LLMSession` 端口定义在 `domain/ports.py`,本目录只放适配器实现。DAG 节点只依赖端口,不感知具体厂商。
| 文件 | 职责 |
|------|------|
| `domain/ports.py` | `LLMSession` 协议(端口,接口定义) |
| `pty_bridge.py` | 共享 PTY 机制(所有 CLI 厂商复用) |
| `gemini_cli.py` | `GeminiCliSession`gemini 命令 + prompt 约定 + HTML 提取 |
**扩展新厂商**:新增 `claude_cli.py` 等实现 `LLMSession` 端口即可,PTY 机制无需重写。
> 注意:当前只支持 **PTY 驱动的交互式 CLI**。若未来要接入直接 API 调用(SDK),那是不同机制,应另开 `llm/api/` 子目录,不要塞进 PTY 层。
## LLMSession 协议(domain/ports.py
```python
class LLMSession(Protocol):
async def start(self) -> None: ... # 启动 CLI 进程
async def send(self, text: str) -> None: ... # 写入用户输入
async def stream_until(self, predicate) -> str: ... # 流式读取直到满足条件
async def close(self) -> None: ... # 关闭进程,清理资源
```
## PTY 桥接(pty_bridge.py
### 原理
```
master_fd ──读──► 解析输出 ──► WebSocket 推送
master_fd ──写◄── WebSocket 收到用户输入
slave_fd ──────► CLI 进程 stdin/stdout(认为自己在真实终端)
```
### 关键实现
**启动进程:**
```python
master_fd, slave_fd = pty.openpty()
proc = await asyncio.create_subprocess_exec(
'gemini',
stdin=slave_fd, stdout=slave_fd, stderr=slave_fd,
env={**os.environ, 'TERM': 'xterm-256color'},
)
os.close(slave_fd) # 父进程关闭 slave,只用 master
```
**异步读取(事件驱动,非阻塞 fd + add_reader,零线程):**
```python
os.set_blocking(master_fd, False)
loop.add_reader(master_fd, on_pty_readable) # 详见「并发模型」章节
```
> 不要用 thread-per-session 的阻塞 `os.read`,会话多时线程池会被打爆。
**ANSI 转义码过滤(推送前必须清洗,实现在 `utils/ansi.py`):**
```python
ANSI_ESCAPE = re.compile(r'\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])')
clean_text = ANSI_ESCAPE.sub('', raw_text)
```
**写入用户输入:**
```python
os.write(master_fd, (user_text + '\n').encode('utf-8'))
```
### 超时保护
每个 DAG 阶段设置独立超时(`asyncio.wait_for`):
| 阶段 | 超时 |
|------|------|
| phase1_dialog(单轮回答) | 60s |
| phase2_preview3 张预览) | 180s |
| phase3_generate(完整 HTML | 300s |
| screenshot | 180s |
超时后抛 `GeminiTimeoutError`
## HTML 拦截检测
```python
def detect_html(text: str) -> str | None:
m = re.search(r'```html\s*([\s\S]+?)```|(<html[\s\S]+?</html>)', text, re.IGNORECASE)
return (m.group(1) or m.group(2)).strip() if m else None
```
根据 `state.status` 决定处理:
- `"previewing"` → 保存 `preview_{n}.html`,推送 `style_preview` 事件
- `"generating"` → 保存 `slides.html`,推送 `html_ready`,触发后续节点
+185
View File
@@ -0,0 +1,185 @@
---
paths:
- "agent/**"
---
# Agent 服务总览
Agent 服务的职责、技术栈、目录结构、配置{% if llm_provider == 'gemini-cli' %}、Skill 安装{% endif %}与测试规范。
## 概览(职责)
FastAPI 服务(port {{ agent_port }})。核心职责:{% if llm_provider == 'gemini-cli' %}PTY 桥接 LLM CLI 交互式会话{% else %}通过 SDK 调用 LLM API{% endif %} + DAG 编排(对话 → 风格预览 → 生成 → 截图 → 合成)+ 事件流式推送给 Backend。
## 技术栈
| 库 | 用途 |
|----|------|
| FastAPI + uvicorn | WebSocket 服务端 |
{% if llm_provider == 'gemini-cli' -%}
| pty(标准库) | 伪终端,让 LLM CLI 认为自己在真实终端 |
| asyncio | 异步 I/O,协调 PTY 读写与 WebSocket |
{% elif llm_provider == 'openai-api' -%}
| openai | OpenAI SDK,流式生成(`AsyncOpenAI` |
| asyncio | 异步 I/O,协调 SDK 调用与 WebSocket |
{% elif llm_provider == 'anthropic-api' -%}
| anthropic | Anthropic SDK,流式生成(`AsyncAnthropic` |
| asyncio | 异步 I/O,协调 SDK 调用与 WebSocket |
{% else -%}
| asyncio | 异步 I/O,协调 LLM 调用与 WebSocket |
{% endif -%}
| playwrightasync | 截取每页幻灯片截图 |
| python-pptx | 合成 PPTX |
| loguru | 结构化日志 |
| pytest + pytest-asyncio | 测试 |
## 包管理(uv
```bash
# 添加依赖
{% if llm_provider == 'gemini-cli' -%}
uv add fastapi uvicorn playwright python-pptx loguru pydantic-settings pytest pytest-asyncio
{% elif llm_provider == 'openai-api' -%}
uv add fastapi uvicorn openai playwright python-pptx loguru pydantic-settings pytest pytest-asyncio
{% elif llm_provider == 'anthropic-api' -%}
uv add fastapi uvicorn anthropic playwright python-pptx loguru pydantic-settings pytest pytest-asyncio
{% else -%}
uv add fastapi uvicorn playwright python-pptx loguru pydantic-settings pytest pytest-asyncio
{% endif %}
# 安装 Playwright 浏览器(首次)
uv run playwright install chromium
# 运行服务
uv run python -m src.main
# 运行测试
uv run pytest
```
关键文件:`pyproject.toml`(依赖声明)、`uv.lock`(锁文件,必须提交 git
## 数据库
**Agent 不操作数据库,不引入 SQLAlchemy / Alembic。**
Agent 是无状态处理服务。DB 由 Backend 单一持有。checkpoint 信息由 Backend 在 WebSocket 建连时作为参数传入,Agent 只读取,不写入任何持久化存储。
## 目录结构(clean-arch
```
agent/
├── src/
│ ├── main.py # 接口适配器:FastAPI appWebSocket 端点
│ │
│ ├── domain/ # 领域层:纯逻辑零 IO
│ │ ├── models.py
│ │ ├── state.py # 聚合根状态
│ │ ├── ports.py # 端口定义(LLMSession、Exporter 等)
│ │ └── exceptions.py
│ │
│ ├── dag/ # 应用层:用例编排
│ │ ├── nodes.py
│ │ └── runner.py
│ │
│ ├── llm/ # 扩展点①:LLMSession 适配器
{% if llm_provider == 'gemini-cli' -%}
│ │ ├── pty_bridge.py # 共享 PTY 机制
│ │ └── gemini_cli.py # GeminiCliSession 实现
{% elif llm_provider == 'openai-api' -%}
│ │ └── openai_api.py # OpenAISession 实现
{% elif llm_provider == 'anthropic-api' -%}
│ │ └── anthropic_api.py # AnthropicSession 实现
{% else -%}
│ │ └── custom_llm.py # 自定义 LLMSession 实现
{% endif -%}
│ │ └── registry.py
│ ├── providers/ # 扩展点②:ImageProvider 适配器
│ │ └── registry.py
│ ├── export/ # 扩展点③:Exporter 适配器
│ │ └── pptx_exporter.py
│ ├── storage/ # 扩展点④:StorageBackend 适配器
│ │ └── local_storage.py
{% if llm_provider == 'gemini-cli' -%}
│ ├── skills/
│ │ └── installer.py # CLI skill 自动安装
{% endif -%}
│ ├── utils/
{% if llm_provider == 'gemini-cli' -%}
│ │ ├── ansi.py # ANSI 转义码过滤
{% endif -%}
│ │ ├── concurrency.py
│ │ ├── files.py
│ │ └── ids.py
│ ├── middleware/
│ └── core/
│ ├── config.py
│ └── logging.py
└── tests/
├── conftest.py
{% if llm_provider == 'gemini-cli' -%}
├── test_pty_bridge.py
├── test_gemini_cli.py
├── test_installer.py
{% else -%}
├── test_llm_session.py
{% endif -%}
├── test_nodes.py
└── test_runner.py
```
## 配置(core/config.py
```python
class Settings(BaseSettings):
env: Literal["dev", "prod"] = "dev"
work_dir: Path = Path.home() / "{{ project_slug }}"
port: int = {{ agent_port }}
log_level: str = "DEBUG"
{% if llm_provider == 'gemini-cli' %}
llm_cmd: str = "gemini"
skill_name: str = "frontend-slides"
skill_repo: str = "" # 从 .env 注入
pty_read_buffer_size: int = 4096
{% elif llm_provider == 'openai-api' %}
llm_api_key: str = "" # 从 .env 注入,不硬编码
llm_model: str = "gpt-4o"
llm_base_url: str = "" # 非空时覆盖 SDK 默认 endpoint
{% elif llm_provider == 'anthropic-api' %}
llm_api_key: str = "" # 从 .env 注入,不硬编码
llm_model: str = "claude-sonnet-4-6"
llm_base_url: str = ""
{% else %}
llm_api_key: str = ""
llm_model: str = ""
llm_base_url: str = ""
{% endif %}
generate_timeout_seconds: int = 300
preview_timeout_seconds: int = 180
dialog_timeout_seconds: int = 60
max_concurrent_sessions: int = 4
process_pool_workers: int = os.cpu_count() or 4
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
```
{% if llm_provider == 'gemini-cli' %}
## Skill 安装(skills/installer.py
Agent 启动时自动确保配置指定的 skill 已安装。`skill_name` 与 `skill_repo` 均来自 `Settings`,换 skill 只改 `.env`,不改代码。
> 注意:安装目的地路径(`~/.gemini/skills/`)是 Gemini CLI 约定;换 CLI 时 `installer.py` 需同步修改。
{% endif %}
## 测试规范
{% if llm_provider == 'gemini-cli' -%}
- PTY 测试:mock `pty.openpty`、`os.read/write`,验证 ANSI 过滤和 HTML 检测
- 超时测试:注入假 PTY 不输出,验证 `LLMTimeoutError` 被抛出
- skill 安装测试:mock `git clone`,验证成功/失败分支
{% else -%}
- LLM session 测试:mock `LLMSession.generate` 返回 `AsyncIterator[str]`,验证流式处理
- 超时测试:`generate` mock 为永不完成的迭代器,验证 `LLMTimeoutError` 被抛出
{% endif -%}
- DAG 节点测试:mock `send` 回调,验证状态转换和推送消息类型
- checkpoint 恢复测试:
- 注入 `checkpoint={status: "done"}` → 验证直接推送结果,不启动 LLM
- 注入 `checkpoint={status: "screenshotting"}` → 验证跳过 LLM 直接进截图节点
- 注入 `checkpoint={status: "dialog"}` → 验证推送 `checkpoint_lost`
+65
View File
@@ -0,0 +1,65 @@
# 架构总览
> 三层架构、通信链路、限界上下文、六边形分层与依赖方向。
## 三层架构
```
frontend/ React + Vite + TypeScript + Tailwind
backend/ FastAPIport 8000)—— WebSocket 代理 + 会话持久化
agent/ FastAPIport 8001)—— LLM CLI PTY 桥接 + DAG 编排
```
Backend 是纯代理层,不持有 AI 业务逻辑。所有交互编排在 Agent。
## 通信链路概览
```
Browser
↕ WebSocket ws://localhost:8000/ws/{session_id}
Backend (port 8000)
↕ WebSocket ws://localhost:8001/ws/{session_id}
Agent (port 8001)
↕ pty.openpty()
LLM CLI(加载所配置的幻灯片生成 skill)
```
- LLM CLI(当前适配器为 Gemini CLI)与所加载的 skill 都是**可替换的外部依赖**,不在领域内硬编码。CLI 命令、skill 来源(仓库/路径)与安装路径均经配置注入(见 `config.md``agent.md`),换 CLI 或换 skill 不触动领域逻辑。
## 限界上下文(Bounded Context
| 上下文 | 载体 | 子域 |
|--------|------|------|
| 生成编排 Generation | agent | 核心域(Core |
| 会话管理 SessionMgmt | backend | 支撑域(Supporting |
| 交互表现 Presentation | frontend | 通用域(Generic |
- 上下文间**仅经消息契约集成**(即 Shared Kernel,见「通信协议与交互规约」)
- LLM CLI 为外部系统,`agent/llm/` 适配器充当**防腐层(Anti-Corruption Layer**,把终端协议翻译为领域语言,隔离外部模型对领域的侵蚀
## 分层(六边形,依赖恒向内)
领域层不依赖任何框架/IO
```
infrastructure 适配器:pty / playwright / pptx / db / ws
│ 实现端口(依赖倒置)
application 用例编排:DAG runner+nodes、backend services
│ 依赖
domain 实体 / 值对象 / 领域服务 / 端口定义;纯逻辑,零 IO
```
## 依赖方向(低耦合的硬规则)
依赖只能单向流动,**禁止反向或跨层依赖**:
```
Backend: routers → services → domain.ports ← db.repositories # 依赖倒置:服务依赖端口,db 适配器实现端口;domain 不依赖任何框架
Agent: main(接口) → dag(application) → domain # 适配器(llm/providers/export/storage)实现 domain 端口
依赖恒向内指向 domain;domain 不依赖任何外层与框架
Frontend: pages → hooks → services/store # components 不碰 store/网络
```
- 上层依赖下层,下层**绝不**反向 import 上层
- `utils/` 是叶子层,谁都可依赖它,它不依赖任何业务模块
- 依赖方向由 import-linterPython/ dependency-cruiserTS)在 CI 强制
@@ -0,0 +1,47 @@
---
paths: ["backend/**"]
---
# backend-concurrency
Backend 并发模型:WebSocket 双向转发的 Task 管理 + SQLite 并发处理。
Backend 是纯异步 I/O 代理,无 CPU 密集工作,无需进程池。关注点在两处:双向转发的任务管理、SQLite 并发。
## 1. WebSocket 双向转发
每个连接 = 两个 Task(上行 / 下行),并联运行;任一侧断开必须取消另一侧,避免 Task 泄漏:
```python
@router.websocket("/ws/{session_id}")
async def ws_proxy(ws: WebSocket, session_id: str, repo: SessionRepository = Depends(get_session_repo)):
await ws.accept()
await session_service.get_or_create(repo, session_id)
checkpoint = await session_service.load_checkpoint(repo, session_id)
async with agent_client.connect(session_id, checkpoint=checkpoint) as agent_ws:
to_agent = asyncio.create_task(forward_to_agent(ws, agent_ws))
to_browser = asyncio.create_task(forward_to_browser(agent_ws, ws, repo)) # 转发同时写 checkpoint
done, pending = await asyncio.wait(
{to_agent, to_browser}, return_when=asyncio.FIRST_COMPLETED
)
for task in pending: # 一侧结束,取消另一侧
task.cancel()
```
## 2. SQLite 并发
SQLite 默认串行写,并发下易触发 `database is locked`。引擎连接时必须开 WAL + busy_timeout
```python
# db/base.py —— connect 时执行
@event.listens_for(engine.sync_engine, "connect")
def set_sqlite_pragma(dbapi_conn, _):
cursor = dbapi_conn.cursor()
cursor.execute("PRAGMA journal_mode=WAL") # 读写不互斥
cursor.execute("PRAGMA busy_timeout=5000") # 锁等待 5s 而非立即报错
cursor.execute("PRAGMA foreign_keys=ON")
cursor.close()
```
- 一个请求/连接一个 `AsyncSession``get_db` 作用域),**禁止跨 Task 共享 Session**
- 本工作负载写入量低,WAL 足够;若未来高并发,迁移到 Postgres(仅换 `database_url` 与 driverORM 不变)
+245
View File
@@ -0,0 +1,245 @@
---
paths: ["backend/**"]
---
# backend-db
数据库({% if database == 'sqlite' %}SQLite{% else %}PostgreSQL{% endif %} + SQLAlchemy async)、Session 模型、Alembic 迁移、Checkpoint 断点恢复与会话保留。
## 数据库配置
{% if database == 'sqlite' %}
### 文件位置
```
~/{{ project_slug }}/{{ project_slug }}.db # 生产
:memory: # 测试(conftest.py 中覆盖)
```
### 引擎配置(db/engine.py
```python
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
engine = create_async_engine(
f"sqlite+aiosqlite:///{settings.db_path}",
echo=settings.env == "dev",
connect_args={"check_same_thread": False},
)
# WAL 模式:提升并发读性能
@event.listens_for(engine.sync_engine, "connect")
def set_wal_mode(dbapi_conn, _):
dbapi_conn.execute("PRAGMA journal_mode=WAL")
AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False)
```
SQLite 无需连接池配置——`aiosqlite` 内部以串行化方式处理并发写。
{% else %}
### 连接字符串
```
# .env
DATABASE_URL=postgresql+asyncpg://user:password@localhost:5432/{{ project_slug }}
```
### 引擎配置(db/engine.py
```python
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
engine = create_async_engine(
settings.database_url,
echo=settings.env == "dev",
pool_size=10,
max_overflow=20,
pool_pre_ping=True, # 连接健康检查,防止使用断开的连接
pool_recycle=3600, # 1 小时回收连接,防止 idle timeout
)
AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False)
```
### PostgreSQL 迁移注意事项
- 大表加列(NOT NULL)需提供服务端默认值,避免全表 lock
- 建索引用 `CREATE INDEX CONCURRENTLY`(非阻塞)
- 枚举类型变更需显式 `ALTER TYPE`Alembic autogenerate 不自动处理
{% endif %}
## Session 聚合根(domain/entities.py
`Session` 是领域核心实体,**纯 Python,零框架/IO 依赖**。业务规则(状态迁移、恢复判断)封装在实体方法内,不外泄到 service 层。
```python
from dataclasses import dataclass
from .value_objects import SessionStatus
@dataclass
class Session:
id: str
topic: str
language: str = "中文"
style: str | None = None
image_source: str | None = None
work_dir: str = ""
status: SessionStatus = SessionStatus.IDLE
html_path: str | None = None
pptx_path: str | None = None
error_message: str | None = None
def mark_screenshotting(self, html_path: str) -> None:
if self.status != SessionStatus.GENERATING:
raise InvalidTransitionError(self.status, SessionStatus.SCREENSHOTTING)
self.html_path = html_path
self.status = SessionStatus.SCREENSHOTTING
def mark_synthesizing(self, pptx_path: str) -> None:
self.pptx_path = pptx_path
self.status = SessionStatus.SYNTHESIZING
def mark_done(self) -> None:
self.status = SessionStatus.DONE
def mark_error(self, message: str) -> None:
self.error_message = message
self.status = SessionStatus.ERROR
def can_recover(self) -> bool:
return self.status.can_recover()
```
`SessionStatus` 值对象(`domain/value_objects.py`):
```python
from enum import StrEnum
class SessionStatus(StrEnum):
IDLE = "idle"
DIALOG = "dialog"
GENERATING = "generating"
SCREENSHOTTING = "screenshotting"
SYNTHESIZING = "synthesizing"
DONE = "done"
ERROR = "error"
def can_recover(self) -> bool:
return self in (self.SCREENSHOTTING, self.SYNTHESIZING, self.DONE)
```
## SessionRecord ORM 映射(db/models.py
ORM 类仅做持久化映射,**不含任何业务逻辑**。
```python
class SessionRecord(Base):
__tablename__ = "sessions"
id: Mapped[str] = mapped_column(String(36), primary_key=True)
topic: Mapped[str] = mapped_column(String(500))
style: Mapped[str | None] = mapped_column(String(100))
language: Mapped[str] = mapped_column(String(20), default="中文")
image_source: Mapped[str | None] = mapped_column(String(50))
work_dir: Mapped[str] = mapped_column(String(500))
status: Mapped[str] = mapped_column(String(30), default="idle")
error_message: Mapped[str | None] = mapped_column(Text)
html_path: Mapped[str | None] = mapped_column(String(500))
pptx_path: Mapped[str | None] = mapped_column(String(500))
checkpoint_at: Mapped[datetime | None] = mapped_column(DateTime)
created_at: Mapped[datetime] = mapped_column(DateTime, default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime, default=func.now(), onupdate=func.now())
completed_at: Mapped[datetime | None] = mapped_column(DateTime)
```
## SessionRepository 适配器(db/repositories.py
`SQLAlchemySessionRepository` 实现 `domain/ports.py` 的 `SessionRepository` 端口,负责 ORM ↔ 领域实体的互转。调用方(services)对 SQLAlchemy 零感知。
```python
from typing import override
from domain.ports import SessionRepository
from domain.entities import Session
from .models import SessionRecord
class SQLAlchemySessionRepository(SessionRepository):
def __init__(self, db: AsyncSession) -> None:
self._db = db
@override
async def get(self, session_id: str) -> Session | None:
record = await self._db.get(SessionRecord, session_id)
return self._to_entity(record) if record else None
@override
async def save(self, session: Session) -> None:
record = await self._db.get(SessionRecord, session.id)
record.status = session.status
record.html_path = session.html_path
record.pptx_path = session.pptx_path
record.error_message = session.error_message
record.checkpoint_at = func.now()
await self._db.flush()
@override
async def create(self, session: Session) -> None:
record = SessionRecord(
id=session.id, topic=session.topic, language=session.language,
style=session.style, image_source=session.image_source,
work_dir=session.work_dir, status=session.status,
)
self._db.add(record)
await self._db.flush()
def _to_entity(self, r: SessionRecord) -> Session:
return Session(
id=r.id, topic=r.topic, language=r.language,
style=r.style, image_source=r.image_source, work_dir=r.work_dir,
status=SessionStatus(r.status),
html_path=r.html_path, pptx_path=r.pptx_path,
error_message=r.error_message,
)
```
## 迁移工作流(Alembic
```bash
uv run alembic revision --autogenerate -m "描述变更"
uv run alembic upgrade head
uv run alembic downgrade -1
```
**规则:禁止直接修改已提交的迁移脚本。每次 model 变更必须生成新迁移脚本。**
## Checkpoint / 断点恢复
`slides.html` 成功落盘是 checkpoint 分界线:
| 断连时状态 | 能否恢复 | 恢复行为 |
|-----------|---------|---------|
| `dialog` / `generating` | ❌ | 推送 `checkpoint_lost`,提示用户重新开始 |
| `screenshotting` | ✅ | 跳过 LLM,重跑截图 |
| `synthesizing` | ✅ | 截图已在磁盘,重跑合成 |
| `done` | ✅ | 直接推送 `html_ready` + `pptx_ready` |
## DB 依赖注入(core/deps.py
路由层通过 `get_session_repo` 获得 `SessionRepository` 实例,**不直接依赖 `get_db`**
```python
async def get_db() -> AsyncGenerator[AsyncSession, None]:
async with AsyncSessionLocal() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
async def get_session_repo(db: AsyncSession = Depends(get_db)) -> SessionRepository:
return SQLAlchemySessionRepository(db)
```
## 会话保留(DB_RETENTION_DAYS
会话记录保留 `DB_RETENTION_DAYS`(默认 30 天),`lifespan` 定时任务软删→硬删过期记录。
+201
View File
@@ -0,0 +1,201 @@
---
paths: ["backend/**"]
---
# backend
FastAPI 服务(port 8000)的技术栈、目录、分层、路由、中间件与配置规范。
## 概览
职责:WebSocket 代理层 + 文件服务 + 会话持久化。
**Backend 不连接任何 LLM**——所有大模型交互都在 Agent。Backend 只做 WebSocket 代理 + 数据持久化。
## 技术栈
| 库 | 用途 |
|----|------|
| FastAPI + uvicorn | Web 框架 + ASGI 服务器 |
| SQLAlchemy 2.x (async) | ORM |
| SQLite | 轻量数据库(单文件,无需独立服务) |
| Alembic | 数据库迁移 |
| loguru | 结构化日志 |
| pydantic-settings | 环境变量管理 |
| websockets | Agent WebSocket 客户端 |
| pytest + httpx + pytest-asyncio | 测试 |
## 包管理(uv
```bash
uv add fastapi uvicorn "sqlalchemy[asyncio]" aiosqlite alembic loguru pydantic-settings websockets pytest httpx pytest-asyncio
uv run python -m src.main # 运行服务
uv run pytest # 运行测试
```
关键文件:`pyproject.toml`(依赖声明)、`uv.lock`(锁文件,必须提交 git)、`alembic.ini`(迁移配置)
## 目录结构
```
backend/
├── alembic.ini # Alembic 入口配置
├── alembic/
│ ├── env.py # 迁移环境(导入所有 Model)
│ └── versions/ # 自动生成的迁移脚本
├── src/
│ ├── main.py # FastAPI app 实例、启动/关闭事件
│ ├── domain/ # 领域层,纯逻辑,零框架/IO 依赖
│ │ ├── entities.py # Session 聚合根(纯 Python dataclass,含状态机方法)
│ │ ├── value_objects.py # SessionStatusStrEnum,含 can_recover 规则)
│ │ └── ports.py # SessionRepository Protocol(仓储端口)
│ ├── routers/
│ │ ├── ws.py # WebSocket 代理 /ws/{session_id}
│ │ └── files.py # 文件下载 + 预览路由
│ ├── services/
│ │ ├── agent_client.py # 连接 Agent WebSocket
│ │ └── session_service.py # 会话用例编排(依赖 SessionRepository 端口)
│ ├── schemas/
│ │ ├── messages.py # Pydantic WebSocket 消息模型
│ │ └── session.py # Pydantic 会话读写 schema(与 ORM 解耦)
│ ├── middleware/
│ │ ├── request_id.py # 每个请求注入唯一 X-Request-Id
│ │ ├── access_log.py # 请求/响应结构化日志
│ │ └── error_handler.py # 全局未捕获异常 → 标准错误响应
│ ├── exceptions/
│ │ ├── base.py # AppException 基类
│ │ └── handlers.py # FastAPI exception_handler 注册
│ ├── db/
│ │ ├── base.py # async_engine、AsyncSessionLocal、Base 声明基类
│ │ ├── models.py # SessionRecord ORM 映射(纯持久化,无业务逻辑)
│ │ └── repositories.py # SQLAlchemySessionRepository(实现 SessionRepository 端口)
│ ├── utils/ # 横切纯函数工具(无业务逻辑、无状态)
│ │ ├── ids.py # request_id / uuid 生成
│ │ └── timing.py # 计时、超时上下文管理器
│ └── core/
│ ├── config.py # Settingspydantic-settings
│ ├── deps.py # FastAPI 依赖注入(get_db、get_session_repo、get_settings
│ └── logging.py # loguru 初始化、sink 配置
└── tests/
├── conftest.py # 测试 DBin-memory SQLite)、app fixture
├── test_session_entity.py # Session 领域实体单元测试(classicist,不 mock
├── test_ws.py
├── test_files.py
└── test_session_service.py
```
## 分层映射(六边形架构)
backend 是支撑域,遵循六边形架构,依赖恒向内:
| 层 | 目录 | 职责 |
|----|------|------|
| 接口适配器 | `routers/` | WS/HTTP 入站适配,仅转译协议 |
| 应用(用例) | `services/` | 会话用例编排,依赖 `SessionRepository` 端口 |
| 领域 | `domain/` | `Session` 聚合根、`SessionStatus` 值对象、`SessionRepository` 端口;纯逻辑零 IO |
| 基础设施 | `db/repositories.py``agent_client` | 实现 `SessionRepository` 端口、出站适配 |
## 路由层规范
- 路由函数只做:参数校验 + 调用 service + 返回响应
- 禁止在路由函数中写业务 if/else
- 所有 DB 操作必须通过 `session_service`,禁止在路由层直接操作 DB
- REST 路由统一挂 `/api/v1` 前缀(下载/预览等);WS 消息带 `v` 字段(见根「协议版本化」)
- 会话记录保留 `DB_RETENTION_DAYS`(默认 30 天),`lifespan` 定时任务软删→硬删过期记录
### RESTful 资源命名规范
| 项 | 规范 |
|----|------|
| 资源 | 名词复数:`/api/v1/sessions``/api/v1/sessions/{session_id}/pptx` |
| 路径段 | 小写 + kebab-case,不放动词(动作用 HTTP method |
| 方法 | GET 读 / POST 建 / PUT 全量改 / PATCH 局部改 / DELETE 删 |
| JSON 字段 | snake_case |
| 版本 | `/api/v1` 前缀 |
| 状态码 | 语义化 200/201/204/4xx/5xx |
例:`GET /api/v1/sessions/{session_id}/pptx`。路径/字段命名靠 review + 可选 spectralOpenAPI lint);HTTP 语义靠 review。
### Swagger / OpenAPI 文档
- FastAPI 自动生成 `/docs`Swagger UI)、`/redoc``/openapi.json`
-`.env``ENABLE_API_DOCS` 控制:dev 默认开,prod 关(`docs_url=None` 等)
- Settings 字段 `enable_api_docs: bool = True`,映射环境变量 `ENABLE_API_DOCS`
## 中间件(middleware/
### request_id.py
每个请求(含 WebSocket 握手)注入 `X-Request-Id`,传入 loguru 上下文,在所有日志条目中携带,便于全链路追踪。
### access_log.py
记录每个 HTTP 请求的结构化日志(method、path、status_code、duration_ms、request_id)。WebSocket 连接记录 connect / disconnect 事件。
### error_handler.py
捕获所有未处理异常,返回统一错误结构:
```json
{
"error": {
"code": "INTERNAL_ERROR",
"message": "服务器内部错误",
"request_id": "xxx"
}
}
```
生产环境不暴露堆栈信息;开发环境在日志中完整记录。
异常体系(exceptions/):业务错误必须抛 `AppException` 子类(携带 `code`/`message`/`status_code`),禁止在路由层直接 raise HTTPException。在 `main.py` 注册 `app.add_exception_handler(AppException, app_exception_handler)``app.add_exception_handler(Exception, global_exception_handler)`
## 配置(core/config.py
配置项见 `.env.example`(提交 git 的模板);真实配置放 `.env`gitignore)。`.env` 用 SCREAMING_SNAKESettings 字段用 snake_caseenv 映射(`agent_ws_url``AGENT_WS_URL`)为 pydantic-settings 默认行为,无需 alias_generator。
```python
class Settings(BaseSettings):
env: Literal["dev", "prod"] = "dev"
agent_ws_url: str = "ws://localhost:8001"
work_dir: Path = Path.home() / "ppt-gen"
database_url: str = f"sqlite+aiosqlite:///{Path.home()}/ppt-gen/ppt_gen.db"
port: int = 8000
log_level: str = "DEBUG"
enable_api_docs: bool = True
model_config = SettingsConfigDict(
env_file=".env", env_file_encoding="utf-8",
)
```
- 缺失/类型错误在启动时即报错(fail-fast)
- 优先级:环境变量 > `.env` > 默认值
- 敏感配置只从环境读取,`.env` 不提交 git
## WebSocket 代理核心
重连入口(routers/ws.py):
```python
@router.websocket("/ws/{session_id}")
async def ws_proxy(ws: WebSocket, session_id: str, repo: SessionRepository = Depends(get_session_repo)):
await ws.accept()
checkpoint = await session_service.load_checkpoint(repo, session_id)
# 将 checkpoint 信息附在 start_session 消息中转发给 Agent
async with agent_client.connect(session_id, checkpoint=checkpoint) as agent_ws:
await asyncio.gather(forward_to_agent(ws, agent_ws), forward_to_browser(agent_ws, ws))
```
详见 `backend-concurrency.md`(双向转发 + Task 管理)与 `backend-db.md`checkpoint)。
## 日志规范(core/logging.py
loguru,两个 sinkstderrdev 人类可读彩色)+ `~/ppt-gen/logs/backend.log`(JSON 每行一条,所有环境)。文件 sink 配 `rotation="100 MB"``retention="14 days"``compression="zip"`。级别 `DEBUG`(dev)/`INFO`(prod)。每条日志必须携带 `request_id`loguru contextualize 注入)。禁止 `print()` 与 stdlib `logging`,统一 `from loguru import logger`
## 测试规范
- **领域实体(classicist**`test_session_entity.py` 直接实例化 `Session`,验证状态机方法与不变量,零 mock,零 IO
- **service 层(mockist**`test_session_service.py` mock `SessionRepository` 端口,验证用例编排(状态流转调用顺序)
- **集成(真实 DB**`conftest.py` 提供 in-memory SQLite + `SQLAlchemySessionRepository``test_ws.py` mock Agent WS,验证消息原样转发
- checkpoint 测试:验证不同 status 下重连时返回正确的 `Session` 数据,`can_recover()` 结果符合预期
- 每次 CI 前自动运行 `alembic upgrade head`(针对测试 DB
@@ -0,0 +1,27 @@
---
paths: ["backend/**", "agent/**", "frontend/**"]
---
# 消息目录(payload 形状)
> 查阅型:各消息 type 的 payload 形状,外层由信封包裹(见 `communication-protocol.md`)。字段一律 snake_case。
命令(Browser → Backend → Agent):
```json
{"type": "start_session", "language": "中文", "image_source": "unsplash", "image_api_key": "xxx"}
{"type": "user_input", "text": "深圳科技政策解读"}
{"type": "style_pick", "index": 2}
```
事件(Agent → Backend → Browser):
```json
{"type": "gemini_message", "text": "请问你的 PPT 主旨是什么?"}
{"type": "style_preview", "index": 1, "preview_url": "/api/v1/preview/{sid}/preview_1.html"}
{"type": "log", "message": "📸 截图第 3/10 页"}
{"type": "html_ready", "session_id": "..."}
{"type": "pptx_ready", "session_id": "..."}
{"type": "done"}
{"type": "error", "code": "GEMINI_TIMEOUT", "message": "..."}
```
@@ -0,0 +1,67 @@
# 通信协议与交互规约
> 传输选型 + 命令/事件分离 + 消息信封 + 语义保证 + 形式化 + 协议版本化。线上 JSON 契约字段一律 **snake_case**。各消息 payload 形状见 `communication-catalog.md`(按 paths 加载)。
## 传输层选型
| 通道 | 传输 | 适用 | 理由 |
|------|------|------|------|
| 交互 / 流式 | WebSocket | FE↔BE、BE↔Agent | 长连接、双向、服务端持续推流 |
| 幂等资源获取 | REST `/api/v1` | 下载 / 预览 | 无状态、可缓存、可重试 |
## 交互范式:异步事件驱动 + 命令/事件分离(CQRS 式消息)
- **命令(Command)**:客户端→服务端,表达*意图*,可被拒绝(`start_session` / `user_input` / `style_pick`
- **事件(Event)**:服务端→客户端,陈述*已发生的事实*,单向广播(`gemini_message` / `style_preview` / `html_ready` / `done` / `error`
- 命令与事件语义不混用
### 命令幂等(Idempotency,强制)
投递是 at-most-once,但断连/重连/前端重发会让同一命令到达多次,故**所有命令必须可安全重复执行**——重复执行的副作用等价于执行一次。这是命令的**后置条件契约**(属 DbC,见 `design-discipline.md`),非可选优化。
- **幂等键**:命令以 `correlation_id` 作幂等键去重;服务端对已处理的 `correlation_id` 直接回放上次结果,不重复触发副作用(不重复起会话 / 不重复扣资源 / 不重复落库)。
- **天然幂等优先**:能用「设置为目标态」就不用「增量变更」。`style_pick` 是选定某态(幂等)✅;任何"追加一次""+1"式语义需显式去重。
- **REST 幂等**`/download` `/preview` 为只读 GET,天然幂等可重试可缓存(见传输层选型)。
- **校验点**:命令处理入口先查幂等键,再执行;新增命令时在 PR 说明其幂等策略(天然幂等 / 键去重)。
## 消息信封(Envelope
所有消息统一外层结构,`type` 为判别式(discriminated union),决定 `payload` 形状。**外层与 payload 字段一律 snake_case。**
```json
{
"v": 1, // 协议版本
"type": "style_preview", // 判别式
"correlation_id": "uuid", // 全链路追踪 id
"ts": "2026-05-31T08:00:00Z", // 发出时间(ISO-8601
"payload": { ... } // 与 type 对应的强类型体
}
```
## 语义保证
- **有序性**:同一连接内有序(WS 保证);跨连接无序
- **投递**:至多一次(at-most-once);断连恢复靠 Checkpoint,不靠消息重放
- **背压**:超并发上限的会话排队,以 `CAPACITY_EXCEEDED` 事件告知位置
- **错误**:统一 `error` 事件,`code` 取自「错误码目录」(见 `error-codes.md`),前端按 `code` 分支
- **版本**:见下「协议版本化」
## 协议版本化
- 破坏性变更升信封 `v`;旧版本保留一个过渡期再下线
- 前端按 `v` 兼容处理;不兼容的 `v` 直接以 `INVALID_INPUT` 拒绝
## 形式化与一致性(契约单一来源,防漂移)
WebSocket 消息协议是三层共享契约。**禁止在 Pydantic 和 Zod 两处各写一遍**——会漂移。
- 契约单一来源:`backend/src/schemas/messages.py`Pydantic 判别联合,字段 snake_case
- 前端类型由契约**生成**,不手写:`pnpm gen:types``pydantic2ts` / openapi 导出 TS 类型到 `frontend/src/types/messages.ts`
- 前端 Zod schema 与生成的类型保持同源(`z.infer` 校验一致性)
- 可导出 **AsyncAPI** 文档作为对外形式化规约
- 后端代理**透明转发** BE↔Agent,不改写信封
- 改协议流程:改 Pydantic → 跑生成 → 前端编译报错处即需同步的点
## 消息目录
各消息 type 的 payload 形状见 `communication-catalog.md`(查阅型,按 paths 加载)。
+26
View File
@@ -0,0 +1,26 @@
# 配置管理
> .env 规约 + 命名映射坑 + API 文档开关。每个服务用 `.env` 注入配置,**不硬编码、不提交密钥**。
- `{服务}/.env.example` 提交 git(模板);`{服务}/.env` gitignore(含密钥)
- backend/agent`pydantic-settings``.env`,启动即校验类型,fail-fast
- frontendVite 读 `.env`**仅 `VITE_` 前缀变量**注入浏览器
- 优先级:环境变量 > `.env` 文件 > 代码默认值
## 命名映射(关键坑 —— 无需自定义 alias_generator
`.env`**SCREAMING_SNAKE_CASE**`AGENT_WS_URL`);Settings 字段用 **snake_case**`agent_ws_url`)。字段已是 snake_case`agent_ws_url``AGENT_WS_URL` 正是 **pydantic-settings 默认映射**。**不要引入任何自定义 `alias_generator`** —— 旧版 `to_screaming_snake` 桥接函数已删除。
```python
class Settings(BaseSettings):
agent_ws_url: str = "ws://localhost:8001"
enable_api_docs: bool = True # dev 默认开,prod 关
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
# 无 alias_generatorAGENT_WS_URL → agent_ws_url 为默认行为
```
## API 文档(Swagger / OpenAPI
- FastAPI 由 `.env``ENABLE_API_DOCS` 控制 `/docs` `/redoc` `/openapi.json`**dev trueprod false**(关闭时三者设 `None`
- 主要用于 backendagent 内部 WS 服务可直接关
- 外部可替换依赖(LLM CLI 命令、skill 来源/路径)也经 `.env` 注入,不硬编码(见 `architecture.md``agent.md`
+40
View File
@@ -0,0 +1,40 @@
# 全局约定
> 命名规范 + 禁 print/console + 注释 + 包管理概览。
## 命名规范(已定)
不是"全部 snake":各随语言生态,边界(wire)统一为 snake。
| 范围 | 命名 | 强制 |
|------|------|------|
| Pythonbackend+agent | snake_case 变量/函数、PascalCase 类、UPPER_SNAKE 常量 | ruff `N`pep8-naming |
| 前端 TS | camelCase 变量/函数、PascalCase 组件/类型 | eslint naming-convention |
| 通信线 JSON 契约 | snake_case | 契约源 Pydanticsnake),后端零映射 |
- Python 字段 `agent_ws_url` ↔ 环境变量 `AGENT_WS_URL` 为 pydantic-settings 默认映射,**无需 `alias_generator`**(见 `config.md`
- 旧文档中"Python camelCase"与 `to_screaming_snake` 描述已作废,一律按上表
## 禁 print / console
- 禁止 `print()`Python)和 `console.log()`TS):统一用各层 logger
- Python 用 `from loguru import logger`,禁用 stdlib `logging`
- ruff `T20`Python/ eslint `no-console`TS,允许 warn/error)强制
## 注释
- 不写无意义注释;WHY 不明显时才写注释
## 其他约定
- 每次生成请求使用独立 UUID 工作目录:`~/ppt-gen/{uuid}/`
## 包管理概览
| 目录 | 工具 | 锁文件 |
|------|------|--------|
| `backend/` | uv | `uv.lock` |
| `agent/` | uv | `uv.lock` |
| `frontend/` | pnpm | `pnpm-lock.yaml` |
锁文件必须提交 git,保证环境一致性。CI 用 `--frozen`/`--frozen-lockfile` 安装。各目录详细命令见对应服务规则。
+27
View File
@@ -0,0 +1,27 @@
---
paths: ["backend/**", "agent/**"]
---
# 数据生命周期与清理
> 工作目录 TTL + 磁盘预算 + 日志轮转 + DB 保留。不允许无界增长。
工作目录和日志会无限堆积,**必须有清理策略,不允许无界增长**。
| 数据 | 保留策略 | 执行方 |
|------|---------|--------|
| `~/ppt-gen/{uuid}/`HTML+截图+PPTX | TTL 默认 24h;总磁盘超阈值(默认 5GB)按 LRU 清理 | Agent 后台定时任务(每小时扫一次) |
| SQLite 会话记录 | 保留 30 天,过期软删除→定期硬删 | Backend 定时任务 |
| 日志文件 | loguru `rotation="100 MB"` + `retention="14 days"` + `compression="zip"` | 各服务 logging 配置 |
- 清理任务幂等、可重入;删除前校验路径在 workDir 内(防穿越)
- 配置项:`SESSION_TTL_HOURS``MAX_DISK_GB``DB_RETENTION_DAYS``LOG_RETENTION_DAYS`
- 会话结束(done/error)后,临时中间文件(preview_*.html)可立即清理,仅保留 slides.html + result.pptx
## 数据存储概览
| 层 | 存储 | 内容 |
|----|------|------|
| Backend | SQLite`~/ppt-gen/ppt_gen.db` | 会话记录(id、topic、style、status、时间戳) |
| Agent | 文件系统(`~/ppt-gen/{session_id}/` | slides.html、preview_*.html、截图、result.pptx |
| Frontend | localStorage | 图片源配置(Unsplash/Pexels API Key |
+48
View File
@@ -0,0 +1,48 @@
# 领域驱动设计(DDD
> 统一语言(术语表)+ 领域建模规约(6 条强制)。
本项目**领域优先**:先建模领域,再落实现。任何编码前须完成领域建模,模型即代码结构的蓝图。
## 统一语言(Ubiquitous Language
代码、注释、提交、文档、对话一律使用同一套领域术语,禁止同义异名:
| 术语 | 定义 |
|------|------|
| 会话 Session | 一次端到端生成交互的生命周期聚合根 |
| 演示文稿 Deck | 生成的 HTML 演示文稿,领域核心产物 |
| 幻灯片 Slide | Deck 内的单页 |
| 主旨 Intent | 用户表达的演示意图 |
| 风格 Style | 视觉美学配方(Aesthetic Recipe |
| 风格预览 StylePreview | 供选择的候选样张 |
| 精修 Refinement | 对已生成 Deck 的迭代修改 |
| 检查点 Checkpoint | 可恢复的会话状态快照 |
| 端口 Port | 领域对外部能力的抽象接口(LLM / 图片 / 导出 / 存储) |
术语变更须同步更新本表、代码标识符与契约,三者恒一致。
## 领域建模规约(强制)
目标:**让后续未预见的变化只改一处**(变化局部化)。不追求初期设计周全,追求封装良好——围绕「易变的决策」隐藏信息(Parnas Information Hiding)。以下为硬约束:
1. **按意图建模,不按 CRUD 建模**
领域对象暴露的是*领域操作*`change_password` / `authenticate` / `refine`),不是数据表的增删改查。先问"这个对象能做什么",而非"它有哪些字段的 getter/setter"。
2. **易变规则收拢为值对象(Value Object**
有规则、有约束的概念不用裸标量表示。规则全封进值对象,**一处定义、全局复用**。
反例:`password: str`,加密/校验规则散落各处 → 改一条动全身。
正例:`Password` 值对象(不可变、构造即自校验、`matches()` 比对),换算法/加规则只改它。
3. **禁止暴露裸可变属性(封装 + Tell-Don't-Ask**
不写 `obj.x = v` 这种外部直接改内部状态。通过意图方法操作,方法内强制不变量。
`user.change_password(raw)` ✅ `user.password = hash(raw)`
4. **充血模型,非贫血模型**
数据与操作它的行为放在一起。领域逻辑属于领域对象/领域服务,**禁止**把领域规则泄漏到 application/service 层做成一堆过程式代码。
5. **聚合根守护不变量**
外部只能经聚合根操作其内部(如 `User` 之于 `Password``Session` 之于其状态机),根负责保证整体一致性;不允许绕过根直接改内部对象。
6. **判断"该不该独立 CRUD"**
子概念若无独立生命周期(密码脱离用户无意义),它就不该有独立的增删改查,而是宿主聚合的行为。
+122
View File
@@ -0,0 +1,122 @@
# 架构与设计纪律
> 六边形 + SOLID + 契约式设计(DbC)+ 高内聚低耦合 + 扩展点机制概述。
整体遵循**六边形架构(Ports & Adapters+ 整洁架构**;类/函数级遵循 **SOLID** 与**契约式设计(Design by Contract**。
## 六边形架构(Ports & Adapters
- **端口(Port)**:领域定义的接口(`LLMSession` / `ImageProvider` / `Exporter` / `StorageBackend`),表达"领域需要什么能力"
- **适配器(Adapter)**:端口的具体实现(当前 LLM CLI 适配器 / `pptx_exporter` …),位于最外层,可替换
- 依赖方向恒指向领域;替换外层实现不触动内层
## SOLID(类/模块级强制)
| 原则 | 约束 |
|------|------|
| S 单一职责 | 一个类/函数仅一个变更理由 |
| O 开闭 | 扩展靠加实现,不改既有代码(即扩展点机制) |
| L 里氏替换 | 任一端口实现可无差别替换,不破坏调用方契约 |
| I 接口隔离 | 端口按消费者拆分,不强加无关方法 |
| D 依赖倒置 | 依赖抽象(Port),不依赖具体(Adapter |
## 契约式设计(Design by Contract)——编码前先定契约
**任何函数/类:先定义契约(签名 + 前置条件 + 后置条件 + 不变量),再写测试,再写实现。**
- 前置条件(Precondition):调用方须保证的输入约束
- 后置条件(Postcondition):函数承诺的输出与副作用
- 不变量(Invariant):对象生命周期内恒真的状态约束
- 契约落于类型签名 + docstring;关键不变量以断言运行时校验
- 公开接口的契约即其规格,**契约稳定性优先于实现自由**
## 核心设计原则(高内聚 / 低耦合)
上述纪律落到日常的可操作规则。**所有新功能从设计之初就遵循,不允许"先实现再重构"。**
1. **单一职责 + 高内聚**
每个模块/类/函数只做一件事。相关逻辑聚在一起(如 PTY 机制全在 `llm/pty_bridge.py`),不相关的拆开。判据是**职责**而非行数:一个函数做了两类事就该拆;文件超 ~300 行是「该审视是否多职责」的信号,不是硬上限——单一职责的长文件不必为凑行数而拆。
2. **依赖方向(低耦合的硬规则)**
依赖只能单向流动,禁止反向或跨层依赖(详见 `architecture.md`)。上层依赖下层,下层绝不反向 import 上层;`utils/` 是谁都可依赖的叶子层,自身不依赖任何业务模块。
3. **依赖抽象,不依赖实现(DI**
业务代码依赖**接口(Protocol/ABC)**,不依赖具体类。具体实现在边界处注入。
范例:DAG 节点依赖 `LLMSession` 端口,不知道背后是哪个 CLI 厂商(端口见 `agent/domain/ports.py`)。
实现类**必须显式继承 Protocol**`class MyImpl(MyProtocol):`)并用 `@override` 标注每个实现方法——见下「Protocol 实现强制写法」。
4. **稳定契约解耦服务**
三层之间只通过**消息契约**通信(WebSocket JSON 协议),不共享内部类型。契约是单一来源(见 `communication-protocol.md`),任一服务内部重构不影响其他服务。
5. **新功能 = 插到扩展点,不改核心**
加新能力时,优先实现已有扩展点接口;若没有合适接口,**先定义新接口**再实现,禁止往核心流程里塞 if/else 分支。
## 扩展点机制概述(插件接缝)
系统预留以下接口,保证可扩展性。新增同类能力 = 新增一个实现,**不改调用方**。端口统一声明于 `agent/domain/ports.py`(六边形架构:端口属领域核心);适配器实现散于对应目录。
| 扩展点(端口) | 当前适配器 | 适配器位置 | 未来扩展 |
|--------|---------|---------|---------|
| `LLMSession` | 当前 CLI 厂商适配器 | `agent/llm/` | 其他 CLI / API SDK |
| `ImageProvider` | unsplash / pexels / none | `agent/providers/` | 自建图库 / AI 生图 |
| `Exporter` | `pptx_exporter` | `agent/export/` | pdf_exporter / 长图 |
| `StorageBackend` | `local_storage`(本地 FS | `agent/storage/` | S3 / OSS |
每个端口都是 `Protocol`,新适配器注册到工厂(`registry`)即生效,调用方按配置选择。具体端口签名与注册规范见 `agent.md`
### Protocol 实现的强制写法(Python 3.12+
实现端口时,**必须同时满足两条约束**,缺一不可:
1. **显式继承 Protocol**`class MyImpl(MyProtocol):` — 让 mypy 能校验实现完整性,而非依赖鸭子类型隐式对齐
2. **`@override` 标注每个实现方法**:来自 `from typing import override`Python 3.12)— 方法名拼错或接口改签名时,类型检查器立即报错,不等到运行时
```python
from typing import Protocol, override
# ✅ 强制写法
class GeminiCliSession(LLMSession): # 显式继承,非隐式鸭子类型
@override
async def complete(self, prompt: str) -> str:
result = await self._read_until_done()
return result or ""
# ❌ 禁止:隐式对齐,接口改名或拼写错误时静默脱钩
class BadSession:
async def complet(self, prompt: str) -> str: # 拼错了,运行时才 crash
...
```
违规信号(Claude Code 应直接指出):
- 实现类未继承 Protocol → mypy 无法验证实现完整性
- 实现方法缺少 `@override` → 接口改名后实现类静默脱钩,直到运行时才发现
## 可靠性纪律(Resilience,强制)
核心链路全是易抖动的外部依赖(LLM CLI 进程 / Chromium 截图 / 图床 HTTP),故跨边界调用必须假定**会超时、会瞬时失败、会被重复触发**。以下与「命令幂等」(见 `communication-protocol.md`)同属可靠性约束,从设计之初遵循,不允许"先实现再加兜底"。
1. **超时无处不在(Ubiquitous Timeout**
任何跨进程/网络/IO 的等待**必须设显式超时**,禁止裸 `await` 无界等待。
- 外部调用统一用 `asyncio.timeout(...)` 或客户端原生 timeout 包裹;超时即抛领域错误(如 `GEMINI_TIMEOUT`),不静默挂起。
- 超时值经 `.env` 注入、不硬编码(见 `config.md`);每类外部依赖一个超时配置项。
- 反例:`await proc.read()` 无超时 → 进程卡死则会话永久僵死。
2. **重试 + 指数退避 + 抖动(Retry / Backoff + Jitter**
对**幂等**操作的瞬时失败(网络抖动、图床 5xx、截图偶发失败)才允许重试;非幂等操作禁止盲目重试。
- 重试须有**上限**(次数 + 总时长双封顶),退避用指数 + 随机抖动,禁固定间隔猛刷。
- 重试逻辑收拢为一处可复用策略(如 `utils/retry.py`),不在各调用点散写循环。
- 用户输入触发的命令依赖幂等键去重(见 `communication-protocol.md`),保证重试安全。
3. **降级而非整体失败(Graceful Degradation**
单个可选能力失败时,**降级到退化态**而非让整条生成链崩溃。
- 经端口的退化实现承接:`ImageProvider` 失败/超额 → 降级到 `none`(无图)继续生成,而非中断 Deck。
- 降级须**可观测**:记日志 + 必要时以事件告知前端(区别于 `error` 致命错误)。
- 哪些能力可降级、降级到何种退化态,在端口契约里显式声明。
4. **边界校验,内部信任(Validate at Boundary**
不可信输入(前端命令、上传 `.md`、外部响应)在**适配器/路由边界一次性校验**(Pydantic / 类型 / 业务规则),校验失败回 `INVALID_INPUT`;越过边界进入领域后**信任已校验**,领域内不重复防御性判空。
- 校验是边界职责,不是领域职责——领域用前置条件断言不变量,而非兜外部脏数据。
5. **资源管理强制(RAII / Context Manager**
持有外部资源(子进程 / pty fd / 浏览器页 / DB 连接 / 文件句柄)**必须用 context manager**`with` / `async with`)或在 `finally` 确定性释放,禁手动 `open`/`close` 配对裸写。
- 目的:连接被取消(WS 断开 → Task cancel)时资源仍确定性回收,杜绝僵尸进程与 fd 泄漏(落地细节见各 `*-concurrency.md`)。
+27
View File
@@ -0,0 +1,27 @@
---
paths: ["backend/**", "agent/**", "frontend/**"]
---
# 错误码目录(错误契约)
> 错误码是契约的一部分,集中登记,禁止散落定义。
错误码是契约的一部分,前端按 `code` 分支处理,不依赖 `message` 文本。**集中登记,禁止散落定义。**
| code | 含义 | 来源 |
|------|------|------|
| `SESSION_NOT_FOUND` | 会话不存在 | Backend |
| `AGENT_UNAVAILABLE` | Agent 不可用 | Backend |
| `GEMINI_STARTUP_ERROR` | LLM CLI 启动失败 | Agent |
| `GEMINI_TIMEOUT` | LLM CLI 阶段超时 | Agent |
| `SKILL_NOT_INSTALLED` | 幻灯片生成 skill 未装 | Agent |
| `HTML_EXTRACTION_FAILED` | 无法提取有效 HTML | Agent |
| `PLAYWRIGHT_ERROR` | 截图失败 | Agent |
| `CHECKPOINT_LOST` | 断点不可恢复 | Agent |
| `CAPACITY_EXCEEDED` | 并发超额(排队) | Agent |
| `INVALID_INPUT` | 输入校验失败 | Backend/Agent |
| `INTERNAL_ERROR` | 未分类内部错误 | 任意 |
- 命名:`大类_具体`,全大写蛇形;新增错误码必须先登记到此表
- code 字面值散落在各服务的异常类(backend `src/exceptions/base.py``AppException` 子类、agent `src/domain/exceptions.py``AgentException` 子类)。**本表是单一来源**,异常类的 `code` 与本表对齐**靠 review 把关**,不做代码生成(多数 code 服务私有、跨线共享少,生成机制收益不抵复杂度)。改 code 先改本表,再改对应异常类。
- `GEMINI_*` 等历史命名沿用现有 code 字面值(契约稳定性优先),其语义已泛化为"LLM CLI",不随当前厂商变化
+109
View File
@@ -0,0 +1,109 @@
---
paths: ["frontend/**"]
---
# frontend-runtime
前端运行时规范:错误处理三层防线、日志、WebSocket 消息验证、页面交互状态流。
## 错误处理三层防线
### 1. 组件级 ErrorBoundary(捕获渲染异常)
```tsx
// components/ErrorBoundary.tsx
class ErrorBoundary extends React.Component {
componentDidCatch(error: Error) {
logger.error('Component error', { error: error.message })
toast.error('页面渲染出错,请刷新重试')
}
render() {
return this.state.hasError ? <FallbackUI /> : this.props.children
}
}
```
### 2. WebSocket 消息验证失败(Zod parse 异常)
```typescript
// hooks/useWebSocket.ts
try {
const msg = wsMessageSchema.parse(JSON.parse(event.data))
dispatch(msg)
} catch (e) {
// 验证失败:记录日志,不更新状态,不崩溃
logger.warn('Invalid WS message', { raw: event.data, error: e })
}
```
### 3. 全局未捕获异常
```typescript
// main.tsx
window.addEventListener('unhandledrejection', (event) => {
logger.error('Unhandled promise rejection', { reason: event.reason })
toast.error('发生未知错误,请检查网络后重试')
})
```
### 错误展示规则
| 错误类型 | 展示方式 |
|---------|---------|
| WebSocket 连接失败 | Toasterror+ 重连提示 |
| Gemini 超时/失败 | 聊天气泡(系统消息)+ Toast |
| 文件下载失败 | Toasterror |
| 组件渲染崩溃 | ErrorBoundary fallback UI |
## 日志规范(utils/logger
使用轻量前端日志库或自封装(dev 输出 console,prod 静默或上报):
```typescript
// utils/logger.ts
const logger = {
info: (msg: string, ctx?: object) => {
if (import.meta.env.DEV) console.info(`[INFO] ${msg}`, ctx)
},
warn: (msg: string, ctx?: object) => {
console.warn(`[WARN] ${msg}`, ctx)
},
error: (msg: string, ctx?: object) => {
console.error(`[ERROR] ${msg}`, ctx)
// prod 环境可接入 Sentry / 自建上报
},
}
```
**禁止在组件/hooks 中直接使用 `console.log`,统一用 `logger`。**
## WebSocket 消息验证
收到消息必须先 Zod 验证(`safeParse`)再更新 store,验证失败不崩溃只记录:
```typescript
const msg = wsMessageSchema.safeParse(JSON.parse(event.data))
if (!msg.success) {
logger.warn('Invalid WS message', { error: msg.error.flatten() })
return
}
dispatch(msg.data)
```
## 页面交互状态流
```
idle ──► dialog ──► previewing ──► generating ──► screenshotting ──► done
refining ◄┘
```
| 状态 | 展示内容 |
|------|---------|
| `idle` | 初始引导(语言/图片源选择) |
| `dialog` | 聊天气泡 + ChatInput |
| `previewing` | StylePicker3 个 iframe 预览卡片) |
| `generating` | ProgressBar + 日志气泡 |
| `screenshotting` | ProgressBar(逐页进度) |
| `done` | PreviewFrame + DownloadBar + ChatInputrefine |
| `error` | 错误气泡 + 重试按钮 |
+111
View File
@@ -0,0 +1,111 @@
---
paths: ["frontend/**"]
---
# frontend
React + Vite + TypeScript + Tailwind 前端的技术栈、目录、各层职责边界、TS 规范与测试规范。
聊天式交互界面,实时展示 Gemini 对话、风格预览、HTML 演示文稿和下载入口。
## 技术栈
| 库 | 用途 |
|----|------|
| React 18 + Vite | 构建工具 + UI 框架 |
| TypeScriptstrict mode | 类型安全 |
| Tailwind CSS v3 | 样式(禁止自定义 CSS,全用 utility class |
| Zustand | 全局状态管理 |
| TanStack Query v5 | 服务端状态(REST 请求) |
| Zod | 运行时 WebSocket 消息验证 |
| sonner | Toast 通知(错误/成功提示) |
| Vitest + Testing Library | 单元测试 |
## 包管理(pnpm
```bash
pnpm add zustand @tanstack/react-query zod sonner
pnpm dev # 开发服务器 http://localhost:5173
pnpm build # 构建
pnpm test # vitestwatch 模式)
pnpm test:run # 单次运行(CI 用)
pnpm typecheck # tsc --noEmit
```
关键文件:`package.json``pnpm-lock.yaml`(锁文件,必须提交 git)、`vite.config.ts``tsconfig.json`
## 环境配置(.env
配置项见 `.env.example`(提交 git);真实配置放 `.env`gitignore)。
- **只有 `VITE_` 前缀的变量**才会被注入浏览器端代码,通过 `import.meta.env.VITE_XXX` 读取
- **前端变量对用户完全可见,严禁放任何密钥**(图片源 API Key 走 localStorage,不进 .env
```typescript
const wsUrl = import.meta.env.VITE_BACKEND_WS_URL // ws://localhost:8000
```
## 目录结构
```
frontend/
├── src/
│ ├── components/ # 纯 UI 组件(无业务逻辑,无直接 store 读写)
│ │ ├── ChatBubble.tsx # 单条对话气泡(Gemini / 用户)
│ │ ├── ChatInput.tsx # 输入框 + 发送按钮
│ │ ├── StylePicker.tsx # 3 张风格预览卡片,点击选择
│ │ ├── PreviewFrame.tsx # iframe 展示完整 HTML 演示文稿
│ │ ├── ProgressBar.tsx # 截图/合成进度条
│ │ ├── DownloadBar.tsx # HTML + PPTX 下载按钮
│ │ ├── ErrorBoundary.tsx # React 错误边界(组件级异常)
│ │ └── Toast.tsx # 全局 Toast 容器(sonner Toaster
│ ├── pages/ # 页面级组件(组合 components,接入 hooks
│ │ └── GeneratorPage.tsx
│ ├── hooks/ # 自定义 Hook(业务逻辑与副作用)
│ │ ├── useWebSocket.ts # WebSocket 生命周期管理
│ │ ├── usePresentation.ts # 会话状态机(dispatch + 状态衍生)
│ │ └── useImageSource.ts # 图片源配置读写(localStorage
│ ├── services/ # 外部通信封装(纯函数,无 React 依赖)
│ │ └── wsClient.ts # WebSocket send/receive,消息序列化
│ ├── store/ # Zustand store
│ │ └── presentationStore.ts # session、messages、previewUrls、status
│ ├── types/ # TypeScript 类型(只定义,不实现)
│ │ └── messages.ts # WsMessage、PreviewItem、SessionStatus 等
│ ├── schemas/ # Zod schema(运行时验证,与 types 一一对应)
│ │ └── messages.ts # wsMessageSchema、stylePickSchema 等
│ ├── utils/ # 纯函数工具(无副作用)
│ │ └── ansiStrip.ts # 过滤 ANSI 转义码
│ └── main.tsx # 入口:挂载 ErrorBoundary + Toaster
└── tests/
├── components/
├── hooks/
└── schemas/
```
## 各层职责边界
| 层 | 可以做 | 不可以做 |
|----|--------|---------|
| `components/` | 渲染 propsemit 回调 | 直接读 store、发 WebSocket |
| `pages/` | 组合 components,调用 hooks | 包含复杂业务逻辑 |
| `hooks/` | 读写 store,调用 services | 返回 JSX |
| `services/` | 网络通信、序列化 | 读 store,使用 React API |
| `store/` | 持有状态,提供 actions | 包含副作用 |
## TypeScript 规范
- 开启 `strict: true`
- 命名:camelCase 变量/函数、PascalCase 组件/类型
- 禁止 `any`(用 `unknown` + 类型守卫)
- 所有 Zustand action 必须有明确返回类型
- Zod schema 与 TypeScript 类型通过 `z.infer<>` 保持同源,禁止手写重复类型
- **`types/messages.ts` 由后端 Pydantic 契约生成**`pnpm gen:types`),禁止手改;改协议从后端改起(见根 CLAUDE.md「契约管理」)
- 线上 JSON 契约字段为 snake_case,故生成类型的 DTO 字段为 snake_case;前端自身内部标识符仍用 camelCase
## 测试规范
- 组件测试:Testing Library,测行为不测实现(不 query class/id,用 role/label
- Hook 测试:`renderHook` + mock `wsClient`
- Schema 测试:Zod parse 合法/非法用例各覆盖
- ErrorBoundary 测试:注入 throw 的子组件,验证 fallback 渲染
- 覆盖率目标:hooks 和 schemas 100%,组件 > 70%
+9
View File
@@ -0,0 +1,9 @@
# Git 工作流与提交规范
> Conventional Commits + 分支保护 + PR + SemVer。
- **提交信息**Conventional Commits`feat:` `fix:` `refactor:` `test:` `docs:` `chore:`),commit-msg 钩子校验
- **分支**:主干保护,禁止直推;功能走 `feat/xxx` 分支 + PR
- **PR 合并前**CI 全绿 + 至少一人 review;squash 合并保持历史线性
- **版本**:语义化版本(SemVer),变更记入 `CHANGELOG.md`
- 密钥不入库:gitleaks + detect-private-key 在 PC·CI 全量扫描;`.env``.gitignore` 排除
+19
View File
@@ -0,0 +1,19 @@
---
paths: ["backend/**", "agent/**", "frontend/**"]
---
# 可观测性
> correlation_id 全链路 + 结构化日志 + 健康探针。
- **correlation_id 贯穿全链路**:前端生成 → WS header/首帧带上 → Backend(作 request_id)→ Agent(随 session_id 一起进日志)。三层日志可用同一 id 串联检索。
- **结构化日志**:JSON 每行一条(见各服务 logging 章节)
| 层 | 文件 | 格式 |
|----|------|------|
| Backend | `~/ppt-gen/logs/backend.log` | JSON,每行一条,含 request_id |
| Agent | `~/ppt-gen/logs/agent.log` | JSON,每行一条,含 session_id |
| Frontend | consoledev/ 可接 Sentryprod | 结构化对象 |
- **探针**:每个服务暴露 `GET /health`(存活)与 `GET /ready`(依赖就绪,如 Agent 检查 skill 已装、Chromium 可启动)
- 禁止 `print()` / `console.log()`,统一用各层 logger(见 `conventions.md`
+22
View File
@@ -0,0 +1,22 @@
# 质量门禁(CI 强制)
> lint / format / type / test 门禁表 + 依赖安全 / 供应链。
提交即跑(`.pre-commit-config.yaml`),CI 再跑一遍(`.github/workflows/ci.yml`)。任一不过禁止合并。
| 语言 | Lint | 格式化 | 类型 | 测试 |
|------|------|--------|------|------|
| Python | ruff check | ruff format | mypystrict | pytest |
| TypeScript | eslint | prettier | tsc --noEmit | vitest |
- 安装钩子:`pre-commit install`
- mypy / tsc 必须零 errorruff / eslint 零 warning
- 配置位置:Python 在各 `pyproject.toml`TS 在 `frontend/` 的 eslint/prettier/tsconfig
- 架构边界由 import-linterPython/ dependency-cruiserTS)在 CI 强制(见 `architecture.md`
## 依赖安全 / 供应链
- 锁文件必须提交,CI 用 `--frozen`/`--frozen-lockfile` 安装(拒绝漂移)
- CI 跑依赖审计:Python `uv run pip-audit`,前端 `pnpm audit --audit-level high`
- 启用 Dependabot/Renovate 自动提依赖更新 PR`.github/dependabot.yml`
- 新增依赖须评估必要性,优先标准库;不引入无维护/单文件的小众包
+16
View File
@@ -0,0 +1,16 @@
# 安全基线
> 路径穿越、命令注入、上传、CORS、限流、密钥的硬约束。
| 风险 | 约束 |
|------|------|
| 路径穿越 | `/preview` `/download` 必须校验 session_id 为合法 UUID,且 resolve 后路径仍在 workDir 内(`utils/files.py` |
| 命令注入 | 子进程一律用 argv 列表,**禁止 `shell=True`**;用户输入只作为参数传入 |
| 文件上传 | 仅 `.md`,限制大小(默认 5MB),读取后按文本处理 |
| CORS | Backend 仅允许前端来源(配置项,非 `*` |
| 限流 | 复用 Agent 并发信号量,超额排队(非无限接收) |
| 密钥 | 只走 `.env` / localStorage`detect-private-key` 钩子防误提交 |
- 路径穿越在 CI 由 semgrep 自定义规则辅助检测(用户输入拼路径未经 workDir 校验)
- 命令注入由 ruff `S`banditS602/603/604/605)在 PC·CI 检测
- 密钥不入库由 gitleaks + detect-private-key 全量扫描
+31
View File
@@ -0,0 +1,31 @@
# TDD 开发流程与测试策略
> Red-Green-Refactor(强制)+ 测试金字塔 + 覆盖率门禁。
## TDD 开发流程(测试先行,强制)
**严禁先写实现后补测试。** 测试是契约的可执行规格,先于实现存在。每个单元按 Red-Green-Refactor 循环推进:
1. **Red**:依据契约写一个失败测试,描述期望行为(此时无实现)
2. **Green**:写**最小**实现让测试通过,不做超前设计
3. **Refactor**:在测试保护下重构,消除重复、提升内聚,测试须始终为绿
- 一个 R-G-R 循环对应一次原子提交(`test:``feat:` 可分可合)
- 测试名描述「行为 + 期望」,而非实现细节
- **学派**:应用/编排层面向端口用 mockist(伦敦学派,mock 协作者验交互);领域纯逻辑用 classicist(芝加哥学派,验状态、不 mock)
- 顺序恒为:**定契约 → 写测试(Red)→ 实现(Green)→ 重构(Refactor**
## 测试策略
承接 TDD 产出的单元层,补齐集成与 E2E。测试金字塔,**底层为主**。覆盖率在 CI 强制,不达标禁止合并。
| 层级 | 范围 | 占比 | 工具 |
|------|------|------|------|
| 单元 | 纯函数、节点、service、hookmock 边界) | ~70% | pytest / vitest |
| 集成 | 路由+DB、WS 代理转发、DAG 串联 | ~25% | httpx / Testing Library |
| E2E | 一条龙:输入→生成→下载(mock LLM CLI 输出) | ~5% | Playwright |
- **覆盖率门禁**:行覆盖 ≥ 80%(核心 hooks/schemas/dag 节点 100%),CI `--cov-fail-under=80` / vitest `coverage.thresholds`
- **mock 边界清晰**:只 mock 外部依赖(LLM CLI 子进程、Chromium、网络),不 mock 被测对象内部
- **测试命名**`test_<被测>_<场景>_<期望>`;一个测试只断言一件事
- **确定性**:禁止依赖真实时间/网络/随机;用 fixture 注入