refactor: 规则中性化——剥离 PPT 业务,统一为订单示范域

把规则从原 ppt-gen 项目泛化为通用脚手架,覆盖 19 个文件:
- ddd/architecture/design-discipline/error-codes/communication-* 等:
  领域术语、错误码、消息示例、扩展点、状态流统一为「订单」示范域
- agent-dag/agent.md/agent-concurrency/agent-llm-*:DAG 节点、配置、并发、
  LLM 适配从「对话→预览→生成→截图→合成」改为「intake→process→validate→finalize」
- backend-db:Session 实体/状态机/ORM/仓储/checkpoint 对齐新流水线
- 移除幻灯片专属依赖与产物:agent 去 playwright/python-pptx,
  Dockerfile 去 Chromium 系统库,.gitignore 去 *.pptx
- 保留合法通用项:ANSI(PTY 处理)、Playwright(E2E 测试工具)、Gemini CLI(真实 provider)

验证:gemini-cli/openai-api 两变体生成退出 0,生成项目零 PPT 残留,
backend/agent 骨架均可 import 运行。
This commit is contained in:
baozaotumao
2026-06-01 23:15:05 -07:00
parent bb6503c3f8
commit df48c36422
23 changed files with 257 additions and 285 deletions
@@ -5,11 +5,11 @@ paths:
# Agent 并发模型与资源管理 # Agent 并发模型与资源管理
事件驱动的异步 I/O、进程池下放 CPU 密集任务、并发上限背压、浏览器复用、生命周期取消、工作目录清理与日志规范。 事件驱动的异步 I/O、进程池下放 CPU 密集任务、并发上限背压、昂贵外部资源复用、生命周期取消、工作目录清理与日志规范。
## 并发模型与资源管理 ## 并发模型与资源管理
Agent 是最重的并发场景:单进程 FastAPI(单事件循环)要同时服务多个会话,每个会话持有{% if llm_provider == 'gemini-cli' %}一个 LLM CLI 子进程 + {% endif %}一个 Chromium 上下文 + 阶段性 CPU 密集合成。**核心铁律:绝不阻塞事件循环。** Agent 是最重的并发场景:单进程 FastAPI(单事件循环)要同时服务多个会话,每个会话持有{% if llm_provider == 'gemini-cli' %}一个 LLM CLI 子进程 + {% endif %}若干外部资源 + 阶段性 CPU 密集任务。**核心铁律:绝不阻塞事件循环。**
### 工作类型与处理方式 ### 工作类型与处理方式
@@ -19,7 +19,7 @@ Agent 是最重的并发场景:单进程 FastAPI(单事件循环)要同时
{% if llm_provider == 'gemini-cli' -%} {% if llm_provider == 'gemini-cli' -%}
| 阻塞 fd 读 | PTY master_fd 读取 | **非阻塞 fd + `loop.add_reader`**,不要 thread-per-session | | 阻塞 fd 读 | PTY master_fd 读取 | **非阻塞 fd + `loop.add_reader`**,不要 thread-per-session |
{% endif -%} {% endif -%}
| CPU 密集 | python-pptx 合成、图像处理 | `ProcessPoolExecutor` via `run_in_executor` | | CPU 密集 | 文档/图像处理、加解密、压缩等 | `ProcessPoolExecutor` via `run_in_executor` |
{% if llm_provider != 'gemini-cli' -%} {% if llm_provider != 'gemini-cli' -%}
| LLM 流式 | SDK `async for token in stream` | 直接 `await`,事件驱动 | | LLM 流式 | SDK `async for token in stream` | 直接 `await`,事件驱动 |
{% endif %} {% endif %}
@@ -47,19 +47,19 @@ def on_pty_readable():
### 1. CPU 密集:进程池 ### 1. CPU 密集:进程池
{% endif %} {% endif %}
`synthesize` 节点(python-pptx 合成)是纯 CPU + 持 GIL,会卡住整个事件循环 1–3 秒,影响所有其他会话。必须下放到进程池: CPU 密集节点(如文档合成、图像处理)是纯 CPU + 持 GIL,会卡住整个事件循环 1–3 秒,影响所有其他会话。必须下放到进程池:
```python ```python
process_pool = ProcessPoolExecutor(max_workers=os.cpu_count()) process_pool = ProcessPoolExecutor(max_workers=os.cpu_count())
await loop.run_in_executor(process_pool, build_pptx, screenshots_dir, pptx_path) await loop.run_in_executor(process_pool, build_output, input_dir, out_path)
``` ```
进程池函数必须是**纯函数**(参数 + 返回值可序列化),禁止访问共享状态。 进程池函数必须是**纯函数**(参数 + 返回值可序列化),禁止访问共享状态。
### {% if llm_provider == 'gemini-cli' %}3{% else %}2{% endif %}. 并发上限与背压 ### {% if llm_provider == 'gemini-cli' %}3{% else %}2{% endif %}. 并发上限与背压
每个会话 = {% if llm_provider == 'gemini-cli' %}1 个 CLI 进程 + {% endif %}1 个 Chromium context,内存/CPU 开销大。用全局信号量封顶,超出排队并向前端反馈位置: 每个会话 = {% if llm_provider == 'gemini-cli' %}1 个 CLI 进程 + {% endif %}若干外部资源,内存/CPU 开销大。用全局信号量封顶,超出排队并向前端反馈位置:
```python ```python
session_semaphore = asyncio.Semaphore(settings.max_concurrent_sessions) session_semaphore = asyncio.Semaphore(settings.max_concurrent_sessions)
@@ -69,21 +69,20 @@ async with session_semaphore:
# 排队期间向前端推 {"type": "queued", "position": N} # 排队期间向前端推 {"type": "queued", "position": N}
``` ```
### {% if llm_provider == 'gemini-cli' %}4{% else %}3{% endif %}. Playwright 浏览器复用 ### {% if llm_provider == 'gemini-cli' %}4{% else %}3{% endif %}. 昂贵外部资源复用
启动时拉起**一个** Chromium,每个会话开独立 `browser_context`(廉价),用完关 context,不要每次 `launch()` 若某外部资源初始化昂贵(如浏览器、连接池、客户端),启动时拉起**一个**全局实例,每个会话借用廉价的子句柄,用完即还,不要每次重新 `launch()` / 建连
```python ```python
# lifespan 启动 # lifespan 启动:全局单例(示范用浏览器,按需替换为连接池等)
app.state.browser = await playwright.chromium.launch() app.state.pool = await create_expensive_resource()
# 截图节点 # 会话节点:借一个廉价子句柄
context = await app.state.browser.new_context(viewport={"width": 1920, "height": 1080}) handle = await app.state.pool.acquire()
try: try:
page = await context.new_page()
... ...
finally: finally:
await context.close() await handle.close()
``` ```
### {% if llm_provider == 'gemini-cli' %}5{% else %}4{% endif %}. 资源生命周期与取消 ### {% if llm_provider == 'gemini-cli' %}5{% else %}4{% endif %}. 资源生命周期与取消
@@ -100,7 +99,7 @@ finally:
{% else %} {% else %}
await llm_session.close() # 释放 HTTP 连接 await llm_session.close() # 释放 HTTP 连接
{% endif %} {% endif %}
await context.close() await handle.close() # 归还借用的外部资源句柄
``` ```
### 共享状态线程安全 ### 共享状态线程安全
+53 -62
View File
@@ -5,27 +5,26 @@ paths:
# Agent DAG 编排与异常体系 # Agent DAG 编排与异常体系
PresentationState 聚合根、DAG 节点表、runner 协调器(含 checkpoint 恢复与写入点)以及异常体系与传播规则。 SessionState 聚合根、DAG 节点表、runner 协调器(含 checkpoint 恢复与写入点)以及异常体系与传播规则。
> 下文以一条「会话→LLM 处理→结果」的通用流水线作示范,仅占位;节点与状态按你的业务替换,DAG 结构、checkpoint 与异常规则不变。
## DAG 编排(dag/ ## DAG 编排(dag/
### PresentationStatedomain/state.py,聚合根状态) ### SessionStatedomain/state.py,聚合根状态)
> 属领域层:是会话聚合根的状态载体,封装状态机不变量(如合法的 status 迁移),纯逻辑零 IO。 > 属领域层:是会话聚合根的状态载体,封装状态机不变量(如合法的 status 迁移),纯逻辑零 IO。
```python ```python
@dataclass @dataclass
class PresentationState: class SessionState:
session_id: str session_id: str
work_dir: Path work_dir: Path
html_path: Path | None = None input_payload: dict | None = None
pptx_path: Path | None = None result_path: Path | None = None
preview_paths: list[Path] = field(default_factory=list)
image_source: str = ""
image_api_key: str = ""
status: Literal[ status: Literal[
"idle", "dialog", "previewing", "generating", "idle", "intake", "processing", "validating",
"screenshotting", "synthesizing", "done", "refining", "error" "finalizing", "done", "revising", "error"
] = "idle" ] = "idle"
error_message: str | None = None error_message: str | None = None
``` ```
@@ -34,25 +33,24 @@ class PresentationState:
节点签名统一: 节点签名统一:
```python ```python
async def node_xxx(state: PresentationState, send: Callable, recv_queue: asyncio.Queue) -> PresentationState async def node_xxx(state: SessionState, send: Callable, recv_queue: asyncio.Queue) -> SessionState
``` ```
| 节点 | 触发条件 | 行为 | | 节点 | 触发条件 | 行为 |
|------|---------|------| |------|---------|------|
| `session_start` | 收到 `start_session` | 创建 work_dir,启动 PTYskill 检查 | | `session_start` | 收到 `start_session` | 创建 work_dir,启动 LLM 会话(PTY 或 API),就绪检查 |
| `phase1_dialog` | PTY 启动后 | 透传对话消息;用户 `user_input`写 stdin | | `intake` | 会话就绪后 | 收集/校验输入;用户 `user_input`转交 LLM |
| `phase2_preview` | 检测到首个预览 HTML | 拦截 3 个 HTML → 推 `style_preview``style_pick` → 写 stdin | | `process` | 输入就绪 | 调 LLM 产出结果,流式推 `log` 进度 |
| `phase3_generate` | 用户选风格 | 监听 PTY 直到完整 HTML;保存 `slides.html` | | `validate` | `process` 完成 | 业务规则校验产出,不合规则回错误或重试 |
| `screenshot` | `html_ready` 后自动 | Playwright 截图,逐页推 `log` 进度 | | `finalize` | `validate` 通过 | 落地最终结果文件,推 `result_ready` |
| `synthesize` | `screenshot` 完成 | python-pptx 合成,推 `pptx_ready` | | `revise_loop` | `done` 状态下收到 `user_input` | 转交 LLM → 重进 `process` |
| `refine_loop` | `done` 状态下收到 `user_input` | 写 PTY stdin → 重进 `phase3_generate` |
### DAG 协调器(dag/runner.py ### DAG 协调器(dag/runner.py
`runner.py` 有两个职责:**恢复入口**(连接建立时检查 checkpoint)和**正常流程**(串联所有节点)。 `runner.py` 有两个职责:**恢复入口**(连接建立时检查 checkpoint)和**正常流程**(串联所有节点)。
```python ```python
async def run_session(state: PresentationState, send, recv_queue: asyncio.Queue, async def run_session(state: SessionState, send, recv_queue: asyncio.Queue,
checkpoint: dict | None = None): checkpoint: dict | None = None):
try: try:
# ── 断点恢复入口 ────────────────────────────────────────────── # ── 断点恢复入口 ──────────────────────────────────────────────
@@ -60,40 +58,37 @@ async def run_session(state: PresentationState, send, recv_queue: asyncio.Queue,
resume_status = checkpoint.get("status") resume_status = checkpoint.get("status")
if resume_status == "done": if resume_status == "done":
# 完全恢复:直接推送已有结果,无需任何 Gemini 调用 # 完全恢复:直接推送已有结果,无需任何 LLM 调用
state.html_path = Path(checkpoint["html_path"]) state.result_path = Path(checkpoint["result_path"])
state.pptx_path = Path(checkpoint["pptx_path"]) await send({"type": "result_ready", "session_id": state.session_id})
await send({"type": "html_ready", "session_id": state.session_id})
await send({"type": "pptx_ready", "session_id": state.session_id})
await send({"type": "done"}) await send({"type": "done"})
# 进入 refine 循环等待用户继续 # 进入 revise 循环等待用户继续
await _refine_loop(state, send, recv_queue) await _revise_loop(state, send, recv_queue)
return return
if resume_status in ("screenshotting", "synthesizing"): if resume_status in ("validating", "finalizing"):
# slides.html 已在磁盘,跳过 Gemini,直接从截图节点恢复 # 中间产物已在磁盘,跳过 LLM,直接从校验节点恢复
state.html_path = Path(checkpoint["html_path"]) state.result_path = Path(checkpoint["result_path"])
state.status = "screenshotting" state.status = "validating"
logger.info("Resuming from checkpoint | status={}", resume_status) logger.info("Resuming from checkpoint | status={}", resume_status)
state = await screenshot(state, send) # ← checkpoint 写入点 A state = await validate(state, send) # ← checkpoint 写入点 A
state = await synthesize(state, send) # ← checkpoint 写入点 B state = await finalize(state, send) # ← checkpoint 写入点 B
await send({"type": "done"}) await send({"type": "done"})
await _refine_loop(state, send, recv_queue) await _revise_loop(state, send, recv_queue)
return return
# dialog / generating / error:无法恢复,PTY 已死 # intake / processing / error:无法恢复,LLM 会话已死
await send({"type": "checkpoint_lost", await send({"type": "checkpoint_lost",
"message": "上次会话在生成过程中中断,需要重新开始"}) "message": "上次会话在处理过程中中断,需要重新开始"})
# ── 正常流程 ────────────────────────────────────────────────── # ── 正常流程 ──────────────────────────────────────────────────
state = await session_start(state, send, recv_queue) state = await session_start(state, send, recv_queue)
state = await phase1_dialog(state, send, recv_queue) state = await intake(state, send, recv_queue)
state = await phase2_preview(state, send, recv_queue) state = await process(state, send, recv_queue)
state = await phase3_generate(state, send, recv_queue) state = await validate(state, send) # ← checkpoint 写入点 A
state = await screenshot(state, send) # ← checkpoint 写入点 A state = await finalize(state, send) # ← checkpoint 写入点 B
state = await synthesize(state, send) # ← checkpoint 写入点 B
await send({"type": "done"}) await send({"type": "done"})
await _refine_loop(state, send, recv_queue) await _revise_loop(state, send, recv_queue)
except AgentException as e: except AgentException as e:
logger.error("DAG error | code={} msg={}", e.code, e.message) logger.error("DAG error | code={} msg={}", e.code, e.message)
@@ -102,27 +97,27 @@ async def run_session(state: PresentationState, send, recv_queue: asyncio.Queue,
logger.exception("Unexpected DAG error") logger.exception("Unexpected DAG error")
await send({"type": "error", "code": "INTERNAL_ERROR", "message": str(e)}) await send({"type": "error", "code": "INTERNAL_ERROR", "message": str(e)})
finally: finally:
cleanup_pty(state) cleanup_session(state)
async def _refine_loop(state, send, recv_queue): async def _revise_loop(state, send, recv_queue):
while True: while True:
msg = await recv_queue.get() msg = await recv_queue.get()
if msg["type"] == "user_input": if msg["type"] == "user_input":
state = await refine_loop(state, send, recv_queue, msg["text"]) state = await revise_loop(state, send, recv_queue, msg["text"])
state = await screenshot(state, send) # ← checkpoint 写入点 A state = await validate(state, send) # ← checkpoint 写入点 A
state = await synthesize(state, send) # ← checkpoint 写入点 B state = await finalize(state, send) # ← checkpoint 写入点 B
await send({"type": "done"}) await send({"type": "done"})
``` ```
### Checkpoint 写入点 ### Checkpoint 写入点
`screenshot``synthesize` 节点完成后,通过推送特定消息触发 Backend 写入 DB `validate``finalize` 节点完成后,通过推送特定消息触发 Backend 写入 DB
| 写入点 | 节点 | 推送消息(Backend 监听后写 DB | | 写入点 | 节点 | 推送消息(Backend 监听后写 DB |
|--------|------|---------------------------------| |--------|------|---------------------------------|
| A | `screenshot` 完成 | `{"type": "html_ready", "html_path": "..."}` | | A | `validate` 完成 | `{"type": "validated", "result_path": "..."}` |
| B | `synthesize` 完成 | `{"type": "pptx_ready", "pptx_path": "..."}` | | B | `finalize` 完成 | `{"type": "result_ready", "result_path": "..."}` |
| — | `done` | `{"type": "done"}` → Backend 将 status 更新为 `"done"` | | — | `done` | `{"type": "done"}` → Backend 将 status 更新为 `"done"` |
**checkpoint 写入由 Backend 负责(监听 Agent 推送的消息),Agent 只负责推送正确的消息。** **checkpoint 写入由 Backend 负责(监听 Agent 推送的消息),Agent 只负责推送正确的消息。**
@@ -140,29 +135,25 @@ class AgentException(Exception):
self.message = message self.message = message
self.status_code = status_code self.status_code = status_code
class GeminiStartupError(AgentException): class LLMStartupError(AgentException):
def __init__(self): def __init__(self):
super().__init__("GEMINI_STARTUP_ERROR", "Gemini CLI 启动失败") super().__init__("LLM_STARTUP_ERROR", "LLM 启动/连接失败")
class GeminiTimeoutError(AgentException): class ExternalServiceTimeoutError(AgentException):
def __init__(self, phase: str): def __init__(self, phase: str):
super().__init__("GEMINI_TIMEOUT", f"Gemini {phase} 阶段超时") super().__init__("EXTERNAL_SERVICE_TIMEOUT", f"外部依赖{phase} 阶段超时")
class SkillNotInstalledError(AgentException): class ExternalServiceError(AgentException):
def __init__(self):
super().__init__("SKILL_NOT_INSTALLED", "skill 未安装")
class PlaywrightError(AgentException):
def __init__(self, detail: str): def __init__(self, detail: str):
super().__init__("PLAYWRIGHT_ERROR", f"截图失败: {detail}") super().__init__("EXTERNAL_SERVICE_ERROR", f"外部依赖返回错误: {detail}")
class HtmlExtractionError(AgentException): class OutputValidationError(AgentException):
def __init__(self): def __init__(self):
super().__init__("HTML_EXTRACTION_FAILED", "无法从 Gemini 输出中提取有效 HTML") super().__init__("INVALID_INPUT", "无法从输出中提取有效结果")
``` ```
### 异常传播规则 ### 异常传播规则
- DAG 节点内捕获异常 → 更新 `state.status = "error"` → 通过 `send({"type": "error", "message": ...})` 推送给前端 → 抛给 runner - DAG 节点内捕获异常 → 更新 `state.status = "error"` → 通过 `send({"type": "error", "message": ...})` 推送给前端 → 抛给 runner
- Runner 捕获 → 记录日志 → 关闭 PTY → 清理资源 - Runner 捕获 → 记录日志 → 关闭 LLM 会话 → 清理资源
- **禁止在节点内静默 swallow 异常** - **禁止在节点内静默 swallow 异常**
@@ -11,33 +11,35 @@ providers / export / storage / llm 四个扩展点的统一模式:端口(Pro
四个扩展点(含 llm)都遵循同一模式:**端口(Protocol,统一声明于 `domain/ports.py`+ 适配器多实现 + registry 工厂**。调用方只依赖端口,按配置选实现,新增能力不改调用方。 四个扩展点(含 llm)都遵循同一模式:**端口(Protocol,统一声明于 `domain/ports.py`+ 适配器多实现 + registry 工厂**。调用方只依赖端口,按配置选实现,新增能力不改调用方。
下面端口为**示范**,按项目实际定义:
```python ```python
# domain/ports.py —— 所有端口在此声明(六边形架构:端口属领域核心) # domain/ports.py —— 所有端口在此声明(六边形架构:端口属领域核心)
class Exporter(Protocol): class Exporter(Protocol):
async def export(self, screenshots: list[Path], out_path: Path) -> Path: ... async def export(self, data: object, out_path: Path) -> Path: ...
class StorageBackend(Protocol): class StorageBackend(Protocol):
async def save(self, key: str, data: bytes) -> None: ... async def save(self, key: str, data: bytes) -> None: ...
async def read(self, key: str) -> bytes: ... async def read(self, key: str) -> bytes: ...
def url_for(self, key: str) -> str: ... def url_for(self, key: str) -> str: ...
class ImageProvider(Protocol): class ExternalProvider(Protocol):
def build_prompt_context(self) -> str: ... # 注入 gemini prompt 的图片来源说明 def build_context(self) -> str: ... # 注入 LLM prompt 的外部上下文
``` ```
**节点必须通过接口调用,禁止硬编码具体实现:** **节点必须通过接口调用,禁止硬编码具体实现:**
- `synthesize` 节点调用 `exporter.export(...)`,不直接调 python-pptx - `finalize` 节点调用 `exporter.export(...)`,不直接调具体导出库
- 所有文件读写经 `StorageBackend`,不直接碰 `Path.write_bytes`(本地实现内部才碰 FS - 所有文件读写经 `StorageBackend`,不直接碰 `Path.write_bytes`(本地实现内部才碰 FS
- PDF 导出 = 新增 `pdf_exporter.py` 注册到 registry`synthesize` 一行不改 -新导出格式 = 新增 `xxx_exporter.py` 注册到 registry`finalize` 一行不改
## registry 工厂 ## registry 工厂
每个扩展点目录提供 `registry.py`,按 `Settings` 配置选择具体实现: 每个扩展点目录提供 `registry.py`,按 `Settings` 配置选择具体实现:
- `llm/`:按 `gemini_cmd` / 厂商配置选 `GeminiCliSession``LLMSession` 实现 - `llm/`:按厂商配置选 `GeminiCliSession``LLMSession` 实现
- `providers/`:按 `image_source``unsplash` / `pexels` / `none``ImageProvider` 实现 - `providers/`:按配置选具体 `ExternalProvider` 实现(含 `none` 退化态)
- `export/`:按导出格式选 `pptx_exporter`(未来 `pdf_exporter`)等 `Exporter` 实现 - `export/`:按导出格式选具体 `Exporter` 实现
- `storage/`:按存储后端选 `local_storage`(未来 `s3_storage`)等 `StorageBackend` 实现 - `storage/`:按存储后端选 `local_storage`(未来 `s3_storage`)等 `StorageBackend` 实现
新增能力 = 新增一个实现文件 + 在对应 `registry.py` 注册,调用方(DAG 节点)零改动。 新增能力 = 新增一个实现文件 + 在对应 `registry.py` 注册,调用方(DAG 节点)零改动。
+10 -11
View File
@@ -75,33 +75,32 @@ Anthropic 适配器同理,将 `AsyncOpenAI` 换为 `AsyncAnthropic`,流式 A
## DAG 节点中的调用模式 ## DAG 节点中的调用模式
```python ```python
async def phase3_generate(state, send, recv_queue): async def process(state, send, recv_queue):
try: try:
async with asyncio.timeout(settings.generate_timeout_seconds): async with asyncio.timeout(settings.process_timeout_seconds):
chunks: list[str] = [] chunks: list[str] = []
async for token in llm_session.generate(state.messages): async for token in llm_session.generate(state.messages):
chunks.append(token) chunks.append(token)
await send({"type": "llm_token", "text": token}) await send({"type": "llm_token", "text": token})
html = extract_html("".join(chunks)) result = extract_result("".join(chunks))
if not html: if not result:
raise HtmlExtractionError() raise OutputValidationError()
except TimeoutError: except TimeoutError:
raise LLMTimeoutError(phase="generate") raise ExternalServiceTimeoutError(phase="process")
``` ```
不需要 ANSI 过滤——API 响应是纯文本,无终端转义码。 不需要 ANSI 过滤——API 响应是纯文本,无终端转义码。
## 超时保护 ## 超时保护
每个 DAG 阶段用 `asyncio.timeout()` 包裹 LLM 调用: 每个 DAG 阶段用 `asyncio.timeout()` 包裹 LLM 调用(示范)
| 阶段 | 配置项 | | 阶段 | 配置项 |
|------|--------| |------|--------|
| phase1_dialog(单轮对话) | `dialog_timeout_seconds`(默认 60s | | intake(收集输入) | `intake_timeout_seconds`(默认 60s |
| phase2_preview3 张预览 | `preview_timeout_seconds`(默认 180s | | processLLM 处理 | `process_timeout_seconds`(默认 300s |
| phase3_generate(完整 HTML | `generate_timeout_seconds`(默认 300s |
超时后抛 `LLMTimeoutError``domain/exceptions.py`),不静默挂起。 超时后抛 `ExternalServiceTimeoutError``domain/exceptions.py`),不静默挂起。
## 连接管理 ## 连接管理
+13 -14
View File
@@ -5,7 +5,7 @@ paths:
# Agent LLM 驱动层与 PTY 桥接 # Agent LLM 驱动层与 PTY 桥接
厂商无关的 LLM CLI 抽象、PTY 伪终端桥接机制、超时保护与 HTML 拦截检测。 厂商无关的 LLM CLI 抽象、PTY 伪终端桥接机制、超时保护与输出拦截检测。
## LLM CLI 驱动层(llm/ ## LLM CLI 驱动层(llm/
@@ -15,7 +15,7 @@ paths:
|------|------| |------|------|
| `domain/ports.py` | `LLMSession` 协议(端口,接口定义) | | `domain/ports.py` | `LLMSession` 协议(端口,接口定义) |
| `pty_bridge.py` | 共享 PTY 机制(所有 CLI 厂商复用) | | `pty_bridge.py` | 共享 PTY 机制(所有 CLI 厂商复用) |
| `gemini_cli.py` | `GeminiCliSession`gemini 命令 + prompt 约定 + HTML 提取 | | `gemini_cli.py` | `GeminiCliSession`gemini 命令 + prompt 约定 + 输出提取 |
**扩展新厂商**:新增 `claude_cli.py` 等实现 `LLMSession` 端口即可,PTY 机制无需重写。 **扩展新厂商**:新增 `claude_cli.py` 等实现 `LLMSession` 端口即可,PTY 机制无需重写。
@@ -74,25 +74,24 @@ os.write(master_fd, (user_text + '\n').encode('utf-8'))
### 超时保护 ### 超时保护
每个 DAG 阶段设置独立超时(`asyncio.wait_for`): 每个 DAG 阶段设置独立超时(`asyncio.wait_for`,阶段为示范
| 阶段 | 超时 | | 阶段 | 超时 |
|------|------| |------|------|
| phase1_dialog(单轮回答 | 60s | | intake(收集输入 | 60s |
| phase2_preview3 张预览 | 180s | | processLLM 处理输出 | 300s |
| phase3_generate(完整 HTML | 300s |
| screenshot | 180s |
超时后抛 `GeminiTimeoutError` 超时后抛 `ExternalServiceTimeoutError`
## HTML 拦截检测 ## 输出拦截检测
CLI 输出是流式文本,需按约定标记从中提取结构化结果。下面以提取代码块为例(按你的输出约定替换正则):
```python ```python
def detect_html(text: str) -> str | None: def detect_output(text: str) -> str | None:
m = re.search(r'```html\s*([\s\S]+?)```|(<html[\s\S]+?</html>)', text, re.IGNORECASE) m = re.search(r'```result\s*([\s\S]+?)```', text, re.IGNORECASE)
return (m.group(1) or m.group(2)).strip() if m else None return m.group(1).strip() if m else None
``` ```
根据 `state.status` 决定处理: 根据 `state.status` 决定处理:
- `"previewing"`保存 `preview_{n}.html`,推送 `style_preview` 事件 - `"processing"`提取到结果即保存产物,推送 `result_ready`,触发后续节点
- `"generating"` → 保存 `slides.html`,推送 `html_ready`,触发后续节点
+23 -25
View File
@@ -7,9 +7,11 @@ paths:
Agent 服务的职责、技术栈、目录结构、配置{% if llm_provider == 'gemini-cli' %}、Skill 安装{% endif %}与测试规范。 Agent 服务的职责、技术栈、目录结构、配置{% if llm_provider == 'gemini-cli' %}、Skill 安装{% endif %}与测试规范。
> 以下以一条「会话→LLM 处理→结果」的通用流水线作示范;按你的业务替换节点与扩展点,架构不变。
## 概览(职责) ## 概览(职责)
FastAPI 服务(port {{ agent_port }})。核心职责:{% if llm_provider == 'gemini-cli' %}PTY 桥接 LLM CLI 交互式会话{% else %}通过 SDK 调用 LLM API{% endif %} + DAG 编排(对话 → 风格预览 → 生成 → 截图 → 合成)+ 事件流式推送给 Backend。 FastAPI 服务(port {{ agent_port }})。核心职责:{% if llm_provider == 'gemini-cli' %}PTY 桥接 LLM CLI 交互式会话{% else %}通过 SDK 调用 LLM API{% endif %} + DAG 编排(intake → process → validate → finalize)+ 事件流式推送给 Backend。
## 技术栈 ## 技术栈
@@ -28,27 +30,24 @@ FastAPI 服务(port {{ agent_port }})。核心职责:{% if llm_provider ==
{% else -%} {% else -%}
| asyncio | 异步 I/O,协调 LLM 调用与 WebSocket | | asyncio | 异步 I/O,协调 LLM 调用与 WebSocket |
{% endif -%} {% endif -%}
| playwrightasync | 截取每页幻灯片截图 |
| python-pptx | 合成 PPTX |
| loguru | 结构化日志 | | loguru | 结构化日志 |
| pytest + pytest-asyncio | 测试 | | pytest + pytest-asyncio | 测试 |
> 业务相关的外部能力(导出、第三方 API 等)按需经扩展点接入,对应依赖用 `uv add` 自行添加。
## 包管理(uv ## 包管理(uv
```bash ```bash
# 添加依赖 # 添加依赖
{% if llm_provider == 'gemini-cli' -%} {% if llm_provider == 'gemini-cli' -%}
uv add fastapi uvicorn playwright python-pptx loguru pydantic-settings pytest pytest-asyncio uv add fastapi uvicorn loguru pydantic-settings pytest pytest-asyncio
{% elif llm_provider == 'openai-api' -%} {% elif llm_provider == 'openai-api' -%}
uv add fastapi uvicorn openai playwright python-pptx loguru pydantic-settings pytest pytest-asyncio uv add fastapi uvicorn openai loguru pydantic-settings pytest pytest-asyncio
{% elif llm_provider == 'anthropic-api' -%} {% elif llm_provider == 'anthropic-api' -%}
uv add fastapi uvicorn anthropic playwright python-pptx loguru pydantic-settings pytest pytest-asyncio uv add fastapi uvicorn anthropic loguru pydantic-settings pytest pytest-asyncio
{% else -%} {% else -%}
uv add fastapi uvicorn playwright python-pptx loguru pydantic-settings pytest pytest-asyncio uv add fastapi uvicorn loguru pydantic-settings pytest pytest-asyncio
{% endif %} {% endif %}
# 安装 Playwright 浏览器(首次)
uv run playwright install chromium
# 运行服务 # 运行服务
uv run python -m src.main uv run python -m src.main
@@ -93,15 +92,15 @@ agent/
│ │ └── custom_llm.py # 自定义 LLMSession 实现 │ │ └── custom_llm.py # 自定义 LLMSession 实现
{% endif -%} {% endif -%}
│ │ └── registry.py │ │ └── registry.py
│ ├── providers/ # 扩展点②:ImageProvider 适配器 │ ├── providers/ # 扩展点②:外部能力 Provider 适配器(示范)
│ │ └── registry.py
│ ├── export/ # 扩展点③:Exporter 适配器(如需导出)
│ │ └── registry.py │ │ └── registry.py
│ ├── export/ # 扩展点③:Exporter 适配器
│ │ └── pptx_exporter.py
│ ├── storage/ # 扩展点④:StorageBackend 适配器 │ ├── storage/ # 扩展点④:StorageBackend 适配器
│ │ └── local_storage.py │ │ └── local_storage.py
{% if llm_provider == 'gemini-cli' -%} {% if llm_provider == 'gemini-cli' -%}
│ ├── skills/ │ ├── skills/
│ │ └── installer.py # CLI skill 自动安装 │ │ └── installer.py # CLI skill 自动安装(可选)
{% endif -%} {% endif -%}
│ ├── utils/ │ ├── utils/
{% if llm_provider == 'gemini-cli' -%} {% if llm_provider == 'gemini-cli' -%}
@@ -137,7 +136,7 @@ class Settings(BaseSettings):
log_level: str = "DEBUG" log_level: str = "DEBUG"
{% if llm_provider == 'gemini-cli' %} {% if llm_provider == 'gemini-cli' %}
llm_cmd: str = "gemini" llm_cmd: str = "gemini"
skill_name: str = "frontend-slides" skill_name: str = "" # 可选:要加载的 CLI skill 名
skill_repo: str = "" # 从 .env 注入 skill_repo: str = "" # 从 .env 注入
pty_read_buffer_size: int = 4096 pty_read_buffer_size: int = 4096
{% elif llm_provider == 'openai-api' %} {% elif llm_provider == 'openai-api' %}
@@ -153,33 +152,32 @@ class Settings(BaseSettings):
llm_model: str = "" llm_model: str = ""
llm_base_url: str = "" llm_base_url: str = ""
{% endif %} {% endif %}
generate_timeout_seconds: int = 300 process_timeout_seconds: int = 300
preview_timeout_seconds: int = 180 intake_timeout_seconds: int = 60
dialog_timeout_seconds: int = 60
max_concurrent_sessions: int = 4 max_concurrent_sessions: int = 4
process_pool_workers: int = os.cpu_count() or 4 process_pool_workers: int = os.cpu_count() or 4
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8") model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
``` ```
{% if llm_provider == 'gemini-cli' %} {% if llm_provider == 'gemini-cli' %}
## Skill 安装(skills/installer.py ## Skill 安装(skills/installer.py,可选
Agent 启动时自动确保配置指定的 skill 已安装。`skill_name` 与 `skill_repo` 均来自 `Settings`,换 skill 只改 `.env`,不改代码。 若 LLM CLI 需要预装某个 skillAgent 启动时自动确保配置指定的 skill 已安装。`skill_name` 与 `skill_repo` 均来自 `Settings`,换 skill 只改 `.env`,不改代码。
> 注意:安装目的地路径(`~/.gemini/skills/`)是 Gemini CLI 约定;换 CLI 时 `installer.py` 需同步修改。 > 注意:安装目的地路径(`~/.gemini/skills/`)是 Gemini CLI 约定;换 CLI 时 `installer.py` 需同步修改。
{% endif %} {% endif %}
## 测试规范 ## 测试规范
{% if llm_provider == 'gemini-cli' -%} {% if llm_provider == 'gemini-cli' -%}
- PTY 测试:mock `pty.openpty`、`os.read/write`,验证 ANSI 过滤和 HTML 检测 - PTY 测试:mock `pty.openpty`、`os.read/write`,验证 ANSI 过滤和输出检测
- 超时测试:注入假 PTY 不输出,验证 `LLMTimeoutError` 被抛出 - 超时测试:注入假 PTY 不输出,验证 `ExternalServiceTimeoutError` 被抛出
- skill 安装测试:mock `git clone`,验证成功/失败分支 - skill 安装测试:mock `git clone`,验证成功/失败分支
{% else -%} {% else -%}
- LLM session 测试:mock `LLMSession.generate` 返回 `AsyncIterator[str]`,验证流式处理 - LLM session 测试:mock `LLMSession.generate` 返回 `AsyncIterator[str]`,验证流式处理
- 超时测试:`generate` mock 为永不完成的迭代器,验证 `LLMTimeoutError` 被抛出 - 超时测试:`generate` mock 为永不完成的迭代器,验证 `ExternalServiceTimeoutError` 被抛出
{% endif -%} {% endif -%}
- DAG 节点测试:mock `send` 回调,验证状态转换和推送消息类型 - DAG 节点测试:mock `send` 回调,验证状态转换和推送消息类型
- checkpoint 恢复测试: - checkpoint 恢复测试:
- 注入 `checkpoint={status: "done"}` → 验证直接推送结果,不启动 LLM - 注入 `checkpoint={status: "done"}` → 验证直接推送结果,不启动 LLM
- 注入 `checkpoint={status: "screenshotting"}` → 验证跳过 LLM 直接进截图节点 - 注入 `checkpoint={status: "finalizing"}` → 验证跳过 LLM 直接进 finalize 节点
- 注入 `checkpoint={status: "dialog"}` → 验证推送 `checkpoint_lost` - 注入 `checkpoint={status: "intake"}` → 验证推送 `checkpoint_lost`
+15 -13
View File
@@ -2,15 +2,17 @@
> 三层架构、通信链路、限界上下文、六边形分层与依赖方向。 > 三层架构、通信链路、限界上下文、六边形分层与依赖方向。
> 下文以「订单」为示范业务,仅占位;新项目替换为自己的领域,分层与依赖规则不变。
## 三层架构 ## 三层架构
``` ```
frontend/ React + Vite + TypeScript + Tailwind frontend/ React + Vite + TypeScript + Tailwind
backend/ FastAPIport 8000)—— WebSocket 代理 + 会话持久化 backend/ FastAPIport 8000)—— WebSocket 代理 + 会话/状态持久化
agent/ FastAPIport 8001)—— LLM CLI PTY 桥接 + DAG 编排 agent/ FastAPIport 8001)—— LLM 服务:CLI PTY 桥接或 API 调用 + DAG 编排
``` ```
Backend 是纯代理层,不持有 AI 业务逻辑。所有交互编排在 Agent。 Backend 是纯代理/接入层,不持有核心业务逻辑。重业务编排放在领域层(backend services 或 agent
## 通信链路概览 ## 通信链路概览
@@ -18,31 +20,31 @@ Backend 是纯代理层,不持有 AI 业务逻辑。所有交互编排在 Agen
Browser Browser
↕ WebSocket ws://localhost:8000/ws/{session_id} ↕ WebSocket ws://localhost:8000/ws/{session_id}
Backend (port 8000) Backend (port 8000)
↕ WebSocket ws://localhost:8001/ws/{session_id} ↕ WebSocket ws://localhost:8001/ws/{session_id} ← 仅含 agent 时
Agent (port 8001) Agent (port 8001)
↕ pty.openpty() ↕ pty.openpty() 或 HTTP
LLM CLI(加载所配置的幻灯片生成 skill 外部 LLMCLI 适配器如 Gemini CLI,或 API SDK
``` ```
- LLM CLI(当前适配器为 Gemini CLI)与所加载的 skill 都是**可替换的外部依赖**,不在领域内硬编码。CLI 命令、skill 来源(仓库/路径)与安装路径均经配置注入(见 `config.md``agent.md`),换 CLI 或换 skill 不触动领域逻辑。 - 外部 LLMCLI 命令或 API)与所加载的能力都是**可替换的外部依赖**,不在领域内硬编码。命令、来源与密钥均经配置注入(见 `config.md``agent.md`),换提供商不触动领域逻辑。
## 限界上下文(Bounded Context ## 限界上下文(Bounded Context,示范
| 上下文 | 载体 | 子域 | | 上下文 | 载体 | 子域 |
|--------|------|------| |--------|------|------|
| 生成编排 Generation | agent | 核心域(Core | | 核心业务(示范:订单处理) | backend / agent | 核心域(Core |
| 会话管理 SessionMgmt | backend | 支撑域(Supporting | | 会话/连接管理 | backend | 支撑域(Supporting |
| 交互表现 Presentation | frontend | 通用域(Generic | | 交互表现 | frontend | 通用域(Generic |
- 上下文间**仅经消息契约集成**(即 Shared Kernel,见「通信协议与交互规约」) - 上下文间**仅经消息契约集成**(即 Shared Kernel,见「通信协议与交互规约」)
- LLM CLI 为外部系统,`agent/llm/` 适配器充当**防腐层(Anti-Corruption Layer**,把终端协议翻译为领域语言,隔离外部模型对领域的侵蚀 - 外部系统(LLM、第三方 API)由适配器充当**防腐层(Anti-Corruption Layer**,把外部协议翻译为领域语言,隔离外部对领域的侵蚀
## 分层(六边形,依赖恒向内) ## 分层(六边形,依赖恒向内)
领域层不依赖任何框架/IO 领域层不依赖任何框架/IO
``` ```
infrastructure 适配器:pty / playwright / pptx / db / ws infrastructure 适配器:pty / 外部 API / 导出 / db / ws
│ 实现端口(依赖倒置) │ 实现端口(依赖倒置)
application 用例编排:DAG runner+nodes、backend services application 用例编排:DAG runner+nodes、backend services
│ 依赖 │ 依赖
+31 -41
View File
@@ -4,7 +4,7 @@ paths: ["backend/**"]
# backend-db # backend-db
数据库({% if database == 'sqlite' %}SQLite{% else %}PostgreSQL{% endif %} + SQLAlchemy async)、Session 模型、Alembic 迁移、Checkpoint 断点恢复与会话保留。 数据库({% if database == 'sqlite' %}SQLite{% else %}PostgreSQL{% endif %} + SQLAlchemy async)、Session 模型、Alembic 迁移、Checkpoint 断点恢复与会话保留。下文实体字段以「订单」作示范。
## 数据库配置 ## 数据库配置
@@ -72,6 +72,8 @@ AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False)
`Session` 是领域核心实体,**纯 Python,零框架/IO 依赖**。业务规则(状态迁移、恢复判断)封装在实体方法内,不外泄到 service 层。 `Session` 是领域核心实体,**纯 Python,零框架/IO 依赖**。业务规则(状态迁移、恢复判断)封装在实体方法内,不外泄到 service 层。
> 字段与状态为**示范**(订单域),按你的业务替换;状态机/恢复模式不变。
```python ```python
from dataclasses import dataclass from dataclasses import dataclass
from .value_objects import SessionStatus from .value_objects import SessionStatus
@@ -79,25 +81,20 @@ from .value_objects import SessionStatus
@dataclass @dataclass
class Session: class Session:
id: str id: str
topic: str input_summary: str = "" # 输入摘要(示范)
language: str = "中文"
style: str | None = None
image_source: str | None = None
work_dir: str = "" work_dir: str = ""
status: SessionStatus = SessionStatus.IDLE status: SessionStatus = SessionStatus.IDLE
html_path: str | None = None result_path: str | None = None
pptx_path: str | None = None
error_message: str | None = None error_message: str | None = None
def mark_screenshotting(self, html_path: str) -> None: def mark_validating(self, result_path: str) -> None:
if self.status != SessionStatus.GENERATING: if self.status != SessionStatus.PROCESSING:
raise InvalidTransitionError(self.status, SessionStatus.SCREENSHOTTING) raise InvalidTransitionError(self.status, SessionStatus.VALIDATING)
self.html_path = html_path self.result_path = result_path
self.status = SessionStatus.SCREENSHOTTING self.status = SessionStatus.VALIDATING
def mark_synthesizing(self, pptx_path: str) -> None: def mark_finalizing(self) -> None:
self.pptx_path = pptx_path self.status = SessionStatus.FINALIZING
self.status = SessionStatus.SYNTHESIZING
def mark_done(self) -> None: def mark_done(self) -> None:
self.status = SessionStatus.DONE self.status = SessionStatus.DONE
@@ -116,16 +113,16 @@ class Session:
from enum import StrEnum from enum import StrEnum
class SessionStatus(StrEnum): class SessionStatus(StrEnum):
IDLE = "idle" IDLE = "idle"
DIALOG = "dialog" INTAKE = "intake"
GENERATING = "generating" PROCESSING = "processing"
SCREENSHOTTING = "screenshotting" VALIDATING = "validating"
SYNTHESIZING = "synthesizing" FINALIZING = "finalizing"
DONE = "done" DONE = "done"
ERROR = "error" ERROR = "error"
def can_recover(self) -> bool: def can_recover(self) -> bool:
return self in (self.SCREENSHOTTING, self.SYNTHESIZING, self.DONE) return self in (self.VALIDATING, self.FINALIZING, self.DONE)
``` ```
## SessionRecord ORM 映射(db/models.py ## SessionRecord ORM 映射(db/models.py
@@ -137,15 +134,11 @@ class SessionRecord(Base):
__tablename__ = "sessions" __tablename__ = "sessions"
id: Mapped[str] = mapped_column(String(36), primary_key=True) id: Mapped[str] = mapped_column(String(36), primary_key=True)
topic: Mapped[str] = mapped_column(String(500)) input_summary: 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)) work_dir: Mapped[str] = mapped_column(String(500))
status: Mapped[str] = mapped_column(String(30), default="idle") status: Mapped[str] = mapped_column(String(30), default="idle")
error_message: Mapped[str | None] = mapped_column(Text) error_message: Mapped[str | None] = mapped_column(Text)
html_path: Mapped[str | None] = mapped_column(String(500)) result_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) checkpoint_at: Mapped[datetime | None] = mapped_column(DateTime)
created_at: Mapped[datetime] = mapped_column(DateTime, default=func.now()) created_at: Mapped[datetime] = mapped_column(DateTime, default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime, default=func.now(), onupdate=func.now()) updated_at: Mapped[datetime] = mapped_column(DateTime, default=func.now(), onupdate=func.now())
@@ -175,8 +168,7 @@ class SQLAlchemySessionRepository(SessionRepository):
async def save(self, session: Session) -> None: async def save(self, session: Session) -> None:
record = await self._db.get(SessionRecord, session.id) record = await self._db.get(SessionRecord, session.id)
record.status = session.status record.status = session.status
record.html_path = session.html_path record.result_path = session.result_path
record.pptx_path = session.pptx_path
record.error_message = session.error_message record.error_message = session.error_message
record.checkpoint_at = func.now() record.checkpoint_at = func.now()
await self._db.flush() await self._db.flush()
@@ -184,8 +176,7 @@ class SQLAlchemySessionRepository(SessionRepository):
@override @override
async def create(self, session: Session) -> None: async def create(self, session: Session) -> None:
record = SessionRecord( record = SessionRecord(
id=session.id, topic=session.topic, language=session.language, id=session.id, input_summary=session.input_summary,
style=session.style, image_source=session.image_source,
work_dir=session.work_dir, status=session.status, work_dir=session.work_dir, status=session.status,
) )
self._db.add(record) self._db.add(record)
@@ -193,10 +184,9 @@ class SQLAlchemySessionRepository(SessionRepository):
def _to_entity(self, r: SessionRecord) -> Session: def _to_entity(self, r: SessionRecord) -> Session:
return Session( return Session(
id=r.id, topic=r.topic, language=r.language, id=r.id, input_summary=r.input_summary, work_dir=r.work_dir,
style=r.style, image_source=r.image_source, work_dir=r.work_dir,
status=SessionStatus(r.status), status=SessionStatus(r.status),
html_path=r.html_path, pptx_path=r.pptx_path, result_path=r.result_path,
error_message=r.error_message, error_message=r.error_message,
) )
``` ```
@@ -213,14 +203,14 @@ uv run alembic downgrade -1
## Checkpoint / 断点恢复 ## Checkpoint / 断点恢复
`slides.html` 成功落盘是 checkpoint 分界线: 中间结果成功落盘是 checkpoint 分界线:
| 断连时状态 | 能否恢复 | 恢复行为 | | 断连时状态 | 能否恢复 | 恢复行为 |
|-----------|---------|---------| |-----------|---------|---------|
| `dialog` / `generating` | ❌ | 推送 `checkpoint_lost`,提示用户重新开始 | | `intake` / `processing` | ❌ | 推送 `checkpoint_lost`,提示用户重新开始 |
| `screenshotting` | ✅ | 跳过 LLM,重跑截图 | | `validating` | ✅ | 跳过 LLM,重跑校验 |
| `synthesizing` | ✅ | 截图已在磁盘,重跑合成 | | `finalizing` | ✅ | 中间结果已在磁盘,重跑落地 |
| `done` | ✅ | 直接推送 `html_ready` + `pptx_ready` | | `done` | ✅ | 直接推送 `result_ready` |
## DB 依赖注入(core/deps.py ## DB 依赖注入(core/deps.py
+5 -5
View File
@@ -106,14 +106,14 @@ backend 是支撑域,遵循六边形架构,依赖恒向内:
| 项 | 规范 | | 项 | 规范 |
|----|------| |----|------|
| 资源 | 名词复数:`/api/v1/sessions``/api/v1/sessions/{session_id}/pptx` | | 资源 | 名词复数:`/api/v1/sessions``/api/v1/sessions/{session_id}/export` |
| 路径段 | 小写 + kebab-case,不放动词(动作用 HTTP method | | 路径段 | 小写 + kebab-case,不放动词(动作用 HTTP method |
| 方法 | GET 读 / POST 建 / PUT 全量改 / PATCH 局部改 / DELETE 删 | | 方法 | GET 读 / POST 建 / PUT 全量改 / PATCH 局部改 / DELETE 删 |
| JSON 字段 | snake_case | | JSON 字段 | snake_case |
| 版本 | `/api/v1` 前缀 | | 版本 | `/api/v1` 前缀 |
| 状态码 | 语义化 200/201/204/4xx/5xx | | 状态码 | 语义化 200/201/204/4xx/5xx |
例:`GET /api/v1/sessions/{session_id}/pptx`。路径/字段命名靠 review + 可选 spectralOpenAPI lint);HTTP 语义靠 review。 例:`GET /api/v1/sessions/{session_id}/export`。路径/字段命名靠 review + 可选 spectralOpenAPI lint);HTTP 语义靠 review。
### Swagger / OpenAPI 文档 ### Swagger / OpenAPI 文档
@@ -157,8 +157,8 @@ backend 是支撑域,遵循六边形架构,依赖恒向内:
class Settings(BaseSettings): class Settings(BaseSettings):
env: Literal["dev", "prod"] = "dev" env: Literal["dev", "prod"] = "dev"
agent_ws_url: str = "ws://localhost:8001" agent_ws_url: str = "ws://localhost:8001"
work_dir: Path = Path.home() / "ppt-gen" work_dir: Path = Path.home() / "app-data"
database_url: str = f"sqlite+aiosqlite:///{Path.home()}/ppt-gen/ppt_gen.db" database_url: str = f"sqlite+aiosqlite:///{Path.home()}/app-data/app.db"
port: int = 8000 port: int = 8000
log_level: str = "DEBUG" log_level: str = "DEBUG"
enable_api_docs: bool = True enable_api_docs: bool = True
@@ -190,7 +190,7 @@ async def ws_proxy(ws: WebSocket, session_id: str, repo: SessionRepository = Dep
## 日志规范(core/logging.py ## 日志规范(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` loguru,两个 sinkstderrdev 人类可读彩色)+ `{WORK_DIR}/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`
## 测试规范 ## 测试规范
@@ -6,22 +6,23 @@ paths: ["backend/**", "agent/**", "frontend/**"]
> 查阅型:各消息 type 的 payload 形状,外层由信封包裹(见 `communication-protocol.md`)。字段一律 snake_case。 > 查阅型:各消息 type 的 payload 形状,外层由信封包裹(见 `communication-protocol.md`)。字段一律 snake_case。
> 以下以「订单」为示范业务,仅占位;按项目实际定义自己的消息。
命令(Browser → Backend → Agent): 命令(Browser → Backend → Agent):
```json ```json
{"type": "start_session", "language": "中文", "image_source": "unsplash", "image_api_key": "xxx"} {"type": "start_session", "channel": "web"}
{"type": "user_input", "text": "深圳科技政策解读"} {"type": "create_order", "items": [{"sku": "A-1", "qty": 2}]}
{"type": "style_pick", "index": 2} {"type": "submit_order", "order_id": "..."}
``` ```
事件(Agent → Backend → Browser): 事件(Agent → Backend → Browser):
```json ```json
{"type": "gemini_message", "text": "请问你的 PPT 主旨是什么?"} {"type": "order_created", "order_id": "...", "status": "pending"}
{"type": "style_preview", "index": 1, "preview_url": "/api/v1/preview/{sid}/preview_1.html"} {"type": "item_priced", "sku": "A-1", "amount": {"currency": "CNY", "value": 1990}}
{"type": "log", "message": "📸 截图第 3/10 页"} {"type": "log", "message": "正在校验库存 2/3"}
{"type": "html_ready", "session_id": "..."} {"type": "order_confirmed", "order_id": "..."}
{"type": "pptx_ready", "session_id": "..."}
{"type": "done"} {"type": "done"}
{"type": "error", "code": "GEMINI_TIMEOUT", "message": "..."} {"type": "error", "code": "EXTERNAL_SERVICE_TIMEOUT", "message": "..."}
``` ```
@@ -11,8 +11,8 @@
## 交互范式:异步事件驱动 + 命令/事件分离(CQRS 式消息) ## 交互范式:异步事件驱动 + 命令/事件分离(CQRS 式消息)
- **命令(Command)**:客户端→服务端,表达*意图*,可被拒绝(`start_session` / `user_input` / `style_pick` - **命令(Command)**:客户端→服务端,表达*意图*,可被拒绝(示范:`start_session` / `create_order` / `submit_order`
- **事件(Event)**:服务端→客户端,陈述*已发生的事实*,单向广播(`gemini_message` / `style_preview` / `html_ready` / `done` / `error` - **事件(Event)**:服务端→客户端,陈述*已发生的事实*,单向广播(示范:`order_created` / `item_priced` / `order_confirmed` / `done` / `error`
- 命令与事件语义不混用 - 命令与事件语义不混用
### 命令幂等(Idempotency,强制) ### 命令幂等(Idempotency,强制)
@@ -20,7 +20,7 @@
投递是 at-most-once,但断连/重连/前端重发会让同一命令到达多次,故**所有命令必须可安全重复执行**——重复执行的副作用等价于执行一次。这是命令的**后置条件契约**(属 DbC,见 `design-discipline.md`),非可选优化。 投递是 at-most-once,但断连/重连/前端重发会让同一命令到达多次,故**所有命令必须可安全重复执行**——重复执行的副作用等价于执行一次。这是命令的**后置条件契约**(属 DbC,见 `design-discipline.md`),非可选优化。
- **幂等键**:命令以 `correlation_id` 作幂等键去重;服务端对已处理的 `correlation_id` 直接回放上次结果,不重复触发副作用(不重复起会话 / 不重复扣资源 / 不重复落库)。 - **幂等键**:命令以 `correlation_id` 作幂等键去重;服务端对已处理的 `correlation_id` 直接回放上次结果,不重复触发副作用(不重复起会话 / 不重复扣资源 / 不重复落库)。
- **天然幂等优先**:能用「设置为目标态」就不用「增量变更」。`style_pick` 是选定某态(幂等✅;任何"追加一次""+1"式语义需显式去重。 - **天然幂等优先**:能用「设置为目标态」就不用「增量变更」。`submit_order`(置为已提交态)是幂等 ✅;任何"追加一次""+1"式语义需显式去重。
- **REST 幂等**`/download` `/preview` 为只读 GET,天然幂等可重试可缓存(见传输层选型)。 - **REST 幂等**`/download` `/preview` 为只读 GET,天然幂等可重试可缓存(见传输层选型)。
- **校验点**:命令处理入口先查幂等键,再执行;新增命令时在 PR 说明其幂等策略(天然幂等 / 键去重)。 - **校验点**:命令处理入口先查幂等键,再执行;新增命令时在 PR 说明其幂等策略(天然幂等 / 键去重)。
@@ -31,7 +31,7 @@
```json ```json
{ {
"v": 1, // 协议版本 "v": 1, // 协议版本
"type": "style_preview", // 判别式 "type": "order_created", // 判别式
"correlation_id": "uuid", // 全链路追踪 id "correlation_id": "uuid", // 全链路追踪 id
"ts": "2026-05-31T08:00:00Z", // 发出时间(ISO-8601 "ts": "2026-05-31T08:00:00Z", // 发出时间(ISO-8601
"payload": { ... } // 与 type 对应的强类型体 "payload": { ... } // 与 type 对应的强类型体
+1 -1
View File
@@ -27,7 +27,7 @@
## 其他约定 ## 其他约定
- 每次生成请求使用独立 UUID 工作目录:`~/ppt-gen/{uuid}/` - 每次会话/请求使用独立 UUID 工作目录:`{WORK_DIR}/{uuid}/``WORK_DIR` 经 .env 注入)
## 包管理概览 ## 包管理概览
+6 -6
View File
@@ -10,18 +10,18 @@ paths: ["backend/**", "agent/**"]
| 数据 | 保留策略 | 执行方 | | 数据 | 保留策略 | 执行方 |
|------|---------|--------| |------|---------|--------|
| `~/ppt-gen/{uuid}/`HTML+截图+PPTX) | TTL 默认 24h;总磁盘超阈值(默认 5GB)按 LRU 清理 | Agent 后台定时任务(每小时扫一次) | | `{WORK_DIR}/{uuid}/`会话产出的中间/结果文件) | TTL 默认 24h;总磁盘超阈值(默认 5GB)按 LRU 清理 | Agent 后台定时任务(每小时扫一次) |
| SQLite 会话记录 | 保留 30 天,过期软删除→定期硬删 | Backend 定时任务 | | SQLite 会话记录 | 保留 30 天,过期软删除→定期硬删 | Backend 定时任务 |
| 日志文件 | loguru `rotation="100 MB"` + `retention="14 days"` + `compression="zip"` | 各服务 logging 配置 | | 日志文件 | loguru `rotation="100 MB"` + `retention="14 days"` + `compression="zip"` | 各服务 logging 配置 |
- 清理任务幂等、可重入;删除前校验路径在 workDir 内(防穿越) - 清理任务幂等、可重入;删除前校验路径在 workDir 内(防穿越)
- 配置项:`SESSION_TTL_HOURS``MAX_DISK_GB``DB_RETENTION_DAYS``LOG_RETENTION_DAYS` - 配置项:`SESSION_TTL_HOURS``MAX_DISK_GB``DB_RETENTION_DAYS``LOG_RETENTION_DAYS`
- 会话结束(done/error)后,临时中间文件preview_*.html)可立即清理,仅保留 slides.html + result.pptx - 会话结束(done/error)后,临时中间文件可立即清理,仅保留最终结果文件
## 数据存储概览 ## 数据存储概览(示范)
| 层 | 存储 | 内容 | | 层 | 存储 | 内容 |
|----|------|------| |----|------|------|
| Backend | SQLite`~/ppt-gen/ppt_gen.db` | 会话记录(id、topic、style、status、时间戳) | | Backend | SQLite`{WORK_DIR}/app.db` | 会话记录(id、状态、时间戳 |
| Agent | 文件系统(`~/ppt-gen/{session_id}/` | slides.html、preview_*.html、截图、result.pptx | | Agent | 文件系统(`{WORK_DIR}/{session_id}/` | 会话产出的中间文件与最终结果 |
| Frontend | localStorage | 图片源配置(Unsplash/Pexels API Key | | Frontend | localStorage | 客户端本地配置(如第三方 API Key |
+12 -11
View File
@@ -6,19 +6,20 @@
## 统一语言(Ubiquitous Language ## 统一语言(Ubiquitous Language
代码、注释、提交、文档、对话一律使用同一套领域术语,禁止同义异名 代码、注释、提交、文档、对话一律使用同一套领域术语,禁止同义异名
**下表用「订单」作示范领域,仅为占位**——新项目应替换为自己的业务术语;结构和约束保持不变:
| 术语 | 定义 | | 术语 | 定义 |
|------|------| |------|------|
| 会话 Session | 一次端到端生成交互的生命周期聚合根 | | 订单 Order | 一次交易的生命周期聚合根 |
| 演示文稿 Deck | 生成的 HTML 演示文稿,领域核心产物 | | 订单项 OrderItem | Order 内的单条商品明细 |
| 幻灯片 Slide | Deck 内的单页 | | 金额 Money | 币种 + 数值的值对象(构造即自校验) |
| 主旨 Intent | 用户表达的演示意图 | | 收货地址 ShippingAddress | 带校验规则的值对象 |
| 风格 Style | 视觉美学配方(Aesthetic Recipe | | 订单状态 OrderStatus | 状态机(待支付/已支付/已发货/已完成/已取消 |
| 风格预览 StylePreview | 供选择的候选样张 | | 修订 Revision | 对已存在 Order 的一次受控修改 |
| 精修 Refinement | 对已生成 Deck 的迭代修改 | | 检查点 Checkpoint | 可恢复的状态快照 |
| 检查点 Checkpoint | 可恢复的会话状态快照 | | 端口 Port | 领域对外部能力的抽象接口(支付 / 通知 / 导出 / 存储) |
| 端口 Port | 领域对外部能力的抽象接口(LLM / 图片 / 导出 / 存储) |
术语变更须同步更新本表、代码标识符与契约,三者恒一致。 术语变更须同步更新本表、代码标识符与契约,三者恒一致。
@@ -27,7 +28,7 @@
目标:**让后续未预见的变化只改一处**(变化局部化)。不追求初期设计周全,追求封装良好——围绕「易变的决策」隐藏信息(Parnas Information Hiding)。以下为硬约束: 目标:**让后续未预见的变化只改一处**(变化局部化)。不追求初期设计周全,追求封装良好——围绕「易变的决策」隐藏信息(Parnas Information Hiding)。以下为硬约束:
1. **按意图建模,不按 CRUD 建模** 1. **按意图建模,不按 CRUD 建模**
领域对象暴露的是*领域操作*`change_password` / `authenticate` / `refine`),不是数据表的增删改查。先问"这个对象能做什么",而非"它有哪些字段的 getter/setter"。 领域对象暴露的是*领域操作*`place` / `cancel` / `add_item`),不是数据表的增删改查。先问"这个对象能做什么",而非"它有哪些字段的 getter/setter"。
2. **易变规则收拢为值对象(Value Object** 2. **易变规则收拢为值对象(Value Object**
有规则、有约束的概念不用裸标量表示。规则全封进值对象,**一处定义、全局复用**。 有规则、有约束的概念不用裸标量表示。规则全封进值对象,**一处定义、全局复用**。
+11 -9
View File
@@ -7,7 +7,7 @@
## 六边形架构(Ports & Adapters ## 六边形架构(Ports & Adapters
- **端口(Port)**:领域定义的接口(`LLMSession` / `ImageProvider` / `Exporter` / `StorageBackend`),表达"领域需要什么能力" - **端口(Port)**:领域定义的接口(`LLMSession` / `ImageProvider` / `Exporter` / `StorageBackend`),表达"领域需要什么能力"
- **适配器(Adapter)**:端口的具体实现(当前 LLM CLI 适配器 / `pptx_exporter` …),位于最外层,可替换 - **适配器(Adapter)**:端口的具体实现(当前 LLM CLI 适配器 / 示范导出器 …),位于最外层,可替换
- 依赖方向恒指向领域;替换外层实现不触动内层 - 依赖方向恒指向领域;替换外层实现不触动内层
## SOLID(类/模块级强制) ## SOLID(类/模块级强制)
@@ -55,11 +55,13 @@
系统预留以下接口,保证可扩展性。新增同类能力 = 新增一个实现,**不改调用方**。端口统一声明于 `agent/domain/ports.py`(六边形架构:端口属领域核心);适配器实现散于对应目录。 系统预留以下接口,保证可扩展性。新增同类能力 = 新增一个实现,**不改调用方**。端口统一声明于 `agent/domain/ports.py`(六边形架构:端口属领域核心);适配器实现散于对应目录。
下表端口为**示范**(含一个 LLM 端口 + 三个通用外部能力端口);按项目实际需要增删:
| 扩展点(端口) | 当前适配器 | 适配器位置 | 未来扩展 | | 扩展点(端口) | 当前适配器 | 适配器位置 | 未来扩展 |
|--------|---------|---------|---------| |--------|---------|---------|---------|
| `LLMSession` | 当前 CLI 厂商适配器 | `agent/llm/` | 其他 CLI / API SDK | | `LLMSession` | 当前 CLI 厂商适配器 | `agent/llm/` | 其他 CLI / API SDK |
| `ImageProvider` | unsplash / pexels / none | `agent/providers/` | 自建图库 / AI 生图 | | `PaymentGateway` | 示范支付适配器 / none | `agent/providers/` | 接入真实支付网关 |
| `Exporter` | `pptx_exporter` | `agent/export/` | pdf_exporter / 长图 | | `Exporter` | 示范导出器(如 PDF | `agent/export/` | 其他格式 |
| `StorageBackend` | `local_storage`(本地 FS | `agent/storage/` | S3 / OSS | | `StorageBackend` | `local_storage`(本地 FS | `agent/storage/` | S3 / OSS |
每个端口都是 `Protocol`,新适配器注册到工厂(`registry`)即生效,调用方按配置选择。具体端口签名与注册规范见 `agent.md` 每个端口都是 `Protocol`,新适配器注册到工厂(`registry`)即生效,调用方按配置选择。具体端口签名与注册规范见 `agent.md`
@@ -93,28 +95,28 @@ class BadSession:
## 可靠性纪律(Resilience,强制) ## 可靠性纪律(Resilience,强制)
核心链路全是易抖动的外部依赖(LLM CLI 进程 / Chromium 截图 / 图床 HTTP),故跨边界调用必须假定**会超时、会瞬时失败、会被重复触发**。以下与「命令幂等」(见 `communication-protocol.md`)同属可靠性约束,从设计之初遵循,不允许"先实现再加兜底"。 核心链路常含易抖动的外部依赖(LLM CLI 进程 / 第三方 API / 外部 HTTP 服务),故跨边界调用必须假定**会超时、会瞬时失败、会被重复触发**。以下与「命令幂等」(见 `communication-protocol.md`)同属可靠性约束,从设计之初遵循,不允许"先实现再加兜底"。
1. **超时无处不在(Ubiquitous Timeout** 1. **超时无处不在(Ubiquitous Timeout**
任何跨进程/网络/IO 的等待**必须设显式超时**,禁止裸 `await` 无界等待。 任何跨进程/网络/IO 的等待**必须设显式超时**,禁止裸 `await` 无界等待。
- 外部调用统一用 `asyncio.timeout(...)` 或客户端原生 timeout 包裹;超时即抛领域错误(如 `GEMINI_TIMEOUT`),不静默挂起。 - 外部调用统一用 `asyncio.timeout(...)` 或客户端原生 timeout 包裹;超时即抛领域错误(如 `EXTERNAL_SERVICE_TIMEOUT`),不静默挂起。
- 超时值经 `.env` 注入、不硬编码(见 `config.md`);每类外部依赖一个超时配置项。 - 超时值经 `.env` 注入、不硬编码(见 `config.md`);每类外部依赖一个超时配置项。
- 反例:`await proc.read()` 无超时 → 进程卡死则会话永久僵死。 - 反例:`await proc.read()` 无超时 → 进程卡死则会话永久僵死。
2. **重试 + 指数退避 + 抖动(Retry / Backoff + Jitter** 2. **重试 + 指数退避 + 抖动(Retry / Backoff + Jitter**
对**幂等**操作的瞬时失败(网络抖动、图床 5xx、截图偶发失败)才允许重试;非幂等操作禁止盲目重试。 对**幂等**操作的瞬时失败(网络抖动、第三方 5xx、外部调用偶发失败)才允许重试;非幂等操作禁止盲目重试。
- 重试须有**上限**(次数 + 总时长双封顶),退避用指数 + 随机抖动,禁固定间隔猛刷。 - 重试须有**上限**(次数 + 总时长双封顶),退避用指数 + 随机抖动,禁固定间隔猛刷。
- 重试逻辑收拢为一处可复用策略(如 `utils/retry.py`),不在各调用点散写循环。 - 重试逻辑收拢为一处可复用策略(如 `utils/retry.py`),不在各调用点散写循环。
- 用户输入触发的命令依赖幂等键去重(见 `communication-protocol.md`),保证重试安全。 - 用户输入触发的命令依赖幂等键去重(见 `communication-protocol.md`),保证重试安全。
3. **降级而非整体失败(Graceful Degradation** 3. **降级而非整体失败(Graceful Degradation**
单个可选能力失败时,**降级到退化态**而非让整条生成链崩溃。 单个可选能力失败时,**降级到退化态**而非让整条链崩溃。
- 经端口的退化实现承接:`ImageProvider` 失败/超额 → 降级到 `none`无图)继续生成,而非中断 Deck - 经端口的退化实现承接:某可选外部能力失败/超额 → 降级到 `none`跳过该能力)继续主流程,而非中断整笔业务
- 降级须**可观测**:记日志 + 必要时以事件告知前端(区别于 `error` 致命错误)。 - 降级须**可观测**:记日志 + 必要时以事件告知前端(区别于 `error` 致命错误)。
- 哪些能力可降级、降级到何种退化态,在端口契约里显式声明。 - 哪些能力可降级、降级到何种退化态,在端口契约里显式声明。
4. **边界校验,内部信任(Validate at Boundary** 4. **边界校验,内部信任(Validate at Boundary**
不可信输入(前端命令、上传 `.md`、外部响应)在**适配器/路由边界一次性校验**(Pydantic / 类型 / 业务规则),校验失败回 `INVALID_INPUT`;越过边界进入领域后**信任已校验**,领域内不重复防御性判空。 不可信输入(前端命令、上传文件、外部响应)在**适配器/路由边界一次性校验**(Pydantic / 类型 / 业务规则),校验失败回 `INVALID_INPUT`;越过边界进入领域后**信任已校验**,领域内不重复防御性判空。
- 校验是边界职责,不是领域职责——领域用前置条件断言不变量,而非兜外部脏数据。 - 校验是边界职责,不是领域职责——领域用前置条件断言不变量,而非兜外部脏数据。
5. **资源管理强制(RAII / Context Manager** 5. **资源管理强制(RAII / Context Manager**
+8 -6
View File
@@ -8,15 +8,17 @@ paths: ["backend/**", "agent/**", "frontend/**"]
错误码是契约的一部分,前端按 `code` 分支处理,不依赖 `message` 文本。**集中登记,禁止散落定义。** 错误码是契约的一部分,前端按 `code` 分支处理,不依赖 `message` 文本。**集中登记,禁止散落定义。**
下表为**示范**(订单域 + 通用基础设施码),按项目实际增删:
| code | 含义 | 来源 | | code | 含义 | 来源 |
|------|------|------| |------|------|------|
| `SESSION_NOT_FOUND` | 会话不存在 | Backend | | `SESSION_NOT_FOUND` | 会话不存在 | Backend |
| `AGENT_UNAVAILABLE` | Agent 不可用 | Backend | | `AGENT_UNAVAILABLE` | Agent 不可用 | Backend |
| `GEMINI_STARTUP_ERROR` | LLM CLI 启动失败 | Agent | | `ORDER_NOT_FOUND` | 订单不存在 | Backend |
| `GEMINI_TIMEOUT` | LLM CLI 阶段超时 | Agent | | `ORDER_STATE_INVALID` | 订单状态不允许此操作 | Backend/Agent |
| `SKILL_NOT_INSTALLED` | 幻灯片生成 skill 未装 | Agent | | `LLM_STARTUP_ERROR` | LLM 启动/连接失败 | Agent |
| `HTML_EXTRACTION_FAILED` | 无法提取有效 HTML | Agent | | `EXTERNAL_SERVICE_TIMEOUT` | 外部依赖超时 | Agent |
| `PLAYWRIGHT_ERROR` | 截图失败 | Agent | | `EXTERNAL_SERVICE_ERROR` | 外部依赖返回错误 | Agent |
| `CHECKPOINT_LOST` | 断点不可恢复 | Agent | | `CHECKPOINT_LOST` | 断点不可恢复 | Agent |
| `CAPACITY_EXCEEDED` | 并发超额(排队) | Agent | | `CAPACITY_EXCEEDED` | 并发超额(排队) | Agent |
| `INVALID_INPUT` | 输入校验失败 | Backend/Agent | | `INVALID_INPUT` | 输入校验失败 | Backend/Agent |
@@ -24,4 +26,4 @@ paths: ["backend/**", "agent/**", "frontend/**"]
- 命名:`大类_具体`,全大写蛇形;新增错误码必须先登记到此表 - 命名:`大类_具体`,全大写蛇形;新增错误码必须先登记到此表
- code 字面值散落在各服务的异常类(backend `src/exceptions/base.py``AppException` 子类、agent `src/domain/exceptions.py``AgentException` 子类)。**本表是单一来源**,异常类的 `code` 与本表对齐**靠 review 把关**,不做代码生成(多数 code 服务私有、跨线共享少,生成机制收益不抵复杂度)。改 code 先改本表,再改对应异常类。 - code 字面值散落在各服务的异常类(backend `src/exceptions/base.py``AppException` 子类、agent `src/domain/exceptions.py``AgentException` 子类)。**本表是单一来源**,异常类的 `code` 与本表对齐**靠 review 把关**,不做代码生成(多数 code 服务私有、跨线共享少,生成机制收益不抵复杂度)。改 code 先改本表,再改对应异常类。
- `GEMINI_*` 等历史命名沿用现有 code 字面值(契约稳定性优先),其语义已泛化为"LLM CLI",不随当前厂商变化 - 外部依赖类错误码(`LLM_*` / `EXTERNAL_SERVICE_*`)与具体厂商解耦,换提供商不改 code。
+12 -11
View File
@@ -51,7 +51,7 @@ window.addEventListener('unhandledrejection', (event) => {
| 错误类型 | 展示方式 | | 错误类型 | 展示方式 |
|---------|---------| |---------|---------|
| WebSocket 连接失败 | Toasterror+ 重连提示 | | WebSocket 连接失败 | Toasterror+ 重连提示 |
| Gemini 超时/失败 | 聊天气泡(系统消息+ Toast | | 外部服务超时/失败 | 系统消息气泡 + Toast |
| 文件下载失败 | Toasterror | | 文件下载失败 | Toasterror |
| 组件渲染崩溃 | ErrorBoundary fallback UI | | 组件渲染崩溃 | ErrorBoundary fallback UI |
@@ -92,18 +92,19 @@ dispatch(msg.data)
## 页面交互状态流 ## 页面交互状态流
下面是**示范**状态流(订单),按你的业务替换状态与展示:
``` ```
idle ──► dialog ──► previewing ──► generating ──► screenshotting ──► done idle ──► editing ──► submitting ──► processing ──► done
refining ◄┘ revising ◄┘
``` ```
| 状态 | 展示内容 | | 状态 | 展示内容 |
|------|---------| |------|---------|
| `idle` | 初始引导(语言/图片源选择) | | `idle` | 初始引导 |
| `dialog` | 聊天气泡 + ChatInput | | `editing` | 表单录入(OrderForm |
| `previewing` | StylePicker3 个 iframe 预览卡片 | | `submitting` | 提交中(ProgressBar |
| `generating` | ProgressBar + 日志气泡 | | `processing` | 服务端处理中(ProgressBar + 日志 |
| `screenshotting` | ProgressBar(逐页进度 | | `done` | 结果展示 + 操作入口(可触发 revising |
| `done` | PreviewFrame + DownloadBar + ChatInputrefine | | `error` | 错误提示 + 重试按钮 |
| `error` | 错误气泡 + 重试按钮 |
+11 -14
View File
@@ -6,7 +6,7 @@ paths: ["frontend/**"]
React + Vite + TypeScript + Tailwind 前端的技术栈、目录、各层职责边界、TS 规范与测试规范。 React + Vite + TypeScript + Tailwind 前端的技术栈、目录、各层职责边界、TS 规范与测试规范。
聊天式交互界面,实时展示 Gemini 对话、风格预览、HTML 演示文稿和下载入口 > 下文以「订单」交互为示范,仅占位;组件/状态命名替换为你的业务,分层与边界规则不变。实时交互界面经 WebSocket 展示服务端推送的状态变化
## 技术栈 ## 技术栈
@@ -51,30 +51,27 @@ const wsUrl = import.meta.env.VITE_BACKEND_WS_URL // ws://localhost:8000
frontend/ frontend/
├── src/ ├── src/
│ ├── components/ # 纯 UI 组件(无业务逻辑,无直接 store 读写) │ ├── components/ # 纯 UI 组件(无业务逻辑,无直接 store 读写)
│ │ ├── ChatBubble.tsx # 单条对话气泡(Gemini / 用户 │ │ ├── OrderForm.tsx # 录入/编辑表单(示范
│ │ ├── ChatInput.tsx # 输入框 + 发送按钮 │ │ ├── OrderList.tsx # 列表展示
│ │ ├── StylePicker.tsx # 3 张风格预览卡片,点击选择 │ │ ├── StatusBadge.tsx # 状态标识
│ │ ├── PreviewFrame.tsx # iframe 展示完整 HTML 演示文稿 │ │ ├── ProgressBar.tsx # 处理进度条
│ │ ├── ProgressBar.tsx # 截图/合成进度条
│ │ ├── DownloadBar.tsx # HTML + PPTX 下载按钮
│ │ ├── ErrorBoundary.tsx # React 错误边界(组件级异常) │ │ ├── ErrorBoundary.tsx # React 错误边界(组件级异常)
│ │ └── Toast.tsx # 全局 Toast 容器(sonner Toaster │ │ └── Toast.tsx # 全局 Toast 容器(sonner Toaster
│ ├── pages/ # 页面级组件(组合 components,接入 hooks │ ├── pages/ # 页面级组件(组合 components,接入 hooks
│ │ └── GeneratorPage.tsx │ │ └── OrderPage.tsx
│ ├── hooks/ # 自定义 Hook(业务逻辑与副作用) │ ├── hooks/ # 自定义 Hook(业务逻辑与副作用)
│ │ ├── useWebSocket.ts # WebSocket 生命周期管理 │ │ ├── useWebSocket.ts # WebSocket 生命周期管理
│ │ ── usePresentation.ts # 会话状态机(dispatch + 状态衍生) │ │ ── useOrder.ts # 会话状态机(dispatch + 状态衍生)
│ │ └── useImageSource.ts # 图片源配置读写(localStorage
│ ├── services/ # 外部通信封装(纯函数,无 React 依赖) │ ├── services/ # 外部通信封装(纯函数,无 React 依赖)
│ │ └── wsClient.ts # WebSocket send/receive,消息序列化 │ │ └── wsClient.ts # WebSocket send/receive,消息序列化
│ ├── store/ # Zustand store │ ├── store/ # Zustand store
│ │ └── presentationStore.ts # session、messages、previewUrls、status │ │ └── orderStore.ts # session、messages、orders、status
│ ├── types/ # TypeScript 类型(只定义,不实现) │ ├── types/ # TypeScript 类型(只定义,不实现)
│ │ └── messages.ts # WsMessage、PreviewItem、SessionStatus 等 │ │ └── messages.ts # WsMessage、OrderDTO、SessionStatus 等
│ ├── schemas/ # Zod schema(运行时验证,与 types 一一对应) │ ├── schemas/ # Zod schema(运行时验证,与 types 一一对应)
│ │ └── messages.ts # wsMessageSchema、stylePickSchema 等 │ │ └── messages.ts # wsMessageSchema、createOrderSchema 等
│ ├── utils/ # 纯函数工具(无副作用) │ ├── utils/ # 纯函数工具(无副作用)
│ │ └── ansiStrip.ts # 过滤 ANSI 转义码 │ │ └── format.ts # 纯函数格式化(金额/日期等)
│ └── main.tsx # 入口:挂载 ErrorBoundary + Toaster │ └── main.tsx # 入口:挂载 ErrorBoundary + Toaster
└── tests/ └── tests/
├── components/ ├── components/
+3 -3
View File
@@ -11,9 +11,9 @@ paths: ["backend/**", "agent/**", "frontend/**"]
| 层 | 文件 | 格式 | | 层 | 文件 | 格式 |
|----|------|------| |----|------|------|
| Backend | `~/ppt-gen/logs/backend.log` | JSON,每行一条,含 request_id | | Backend | `{WORK_DIR}/logs/backend.log` | JSON,每行一条,含 request_id |
| Agent | `~/ppt-gen/logs/agent.log` | JSON,每行一条,含 session_id | | Agent | `{WORK_DIR}/logs/agent.log` | JSON,每行一条,含 session_id |
| Frontend | consoledev/ 可接 Sentryprod | 结构化对象 | | Frontend | consoledev/ 可接 Sentryprod | 结构化对象 |
- **探针**:每个服务暴露 `GET /health`(存活)与 `GET /ready`(依赖就绪,如 Agent 检查 skill 已装、Chromium 可启动 - **探针**:每个服务暴露 `GET /health`(存活)与 `GET /ready`(依赖就绪,如 Agent 检查外部依赖可用
- 禁止 `print()` / `console.log()`,统一用各层 logger(见 `conventions.md` - 禁止 `print()` / `console.log()`,统一用各层 logger(见 `conventions.md`
+2 -2
View File
@@ -23,9 +23,9 @@
|------|------|------|------| |------|------|------|------|
| 单元 | 纯函数、节点、service、hookmock 边界) | ~70% | pytest / vitest | | 单元 | 纯函数、节点、service、hookmock 边界) | ~70% | pytest / vitest |
| 集成 | 路由+DB、WS 代理转发、DAG 串联 | ~25% | httpx / Testing Library | | 集成 | 路由+DB、WS 代理转发、DAG 串联 | ~25% | httpx / Testing Library |
| E2E | 一条龙:输入→生成→下载mock LLM CLI 输出) | ~5% | Playwright | | E2E | 一条龙:输入→处理→结果mock 外部依赖输出) | ~5% | Playwright |
- **覆盖率门禁**:行覆盖 ≥ 80%(核心 hooks/schemas/dag 节点 100%),CI `--cov-fail-under=80` / vitest `coverage.thresholds` - **覆盖率门禁**:行覆盖 ≥ 80%(核心 hooks/schemas/dag 节点 100%),CI `--cov-fail-under=80` / vitest `coverage.thresholds`
- **mock 边界清晰**:只 mock 外部依赖(LLM CLI 子进程、Chromium、网络),不 mock 被测对象内部 - **mock 边界清晰**:只 mock 外部依赖(LLM CLI 子进程、第三方 API、网络),不 mock 被测对象内部
- **测试命名**`test_<被测>_<场景>_<期望>`;一个测试只断言一件事 - **测试命名**`test_<被测>_<场景>_<期望>`;一个测试只断言一件事
- **确定性**:禁止依赖真实时间/网络/随机;用 fixture 注入 - **确定性**:禁止依赖真实时间/网络/随机;用 fixture 注入
-1
View File
@@ -29,6 +29,5 @@ dist/
Thumbs.db Thumbs.db
# 生成产物 # 生成产物
*.pptx
*.db *.db
logs/ logs/
-9
View File
@@ -4,17 +4,8 @@ WORKDIR /app
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv 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 ./ COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev RUN uv sync --frozen --no-dev
{%- if llm_provider == 'gemini-cli' %}
RUN uv run playwright install chromium
{%- endif %}
COPY src ./src COPY src ./src
+3 -5
View File
@@ -8,14 +8,12 @@ dependencies = [
"uvicorn[standard]", "uvicorn[standard]",
"loguru", "loguru",
"pydantic-settings", "pydantic-settings",
{%- if llm_provider == 'gemini-cli' %} {%- if llm_provider == 'openai-api' %}
"playwright",
"python-pptx",
{%- elif llm_provider == 'openai-api' %}
"openai", "openai",
{%- elif llm_provider == 'anthropic-api' %} {%- elif llm_provider == 'anthropic-api' %}
"anthropic", "anthropic",
{%- endif %} {%- endif %}
# 业务相关外部能力(导出、第三方 SDK 等)按需自行 uv add
] ]
[dependency-groups] [dependency-groups]
@@ -50,4 +48,4 @@ root_packages = ["src"]
name = "Domain zero IO" name = "Domain zero IO"
type = "forbidden" type = "forbidden"
source_modules = ["src.domain"] source_modules = ["src.domain"]
forbidden_modules = ["fastapi", "httpx", "subprocess", "playwright"] forbidden_modules = ["fastapi", "httpx", "subprocess"]