fix(chat): 前端审计修复——预览契约改HTML/状态机统一/进度错误持久化/chat_state.js抽取

- app.py: /result/preview 返回渲染 HTML(HTMLResponse) 而非 {html} JSON;新增 /chat_state.js 静态路由;预览缺失返回 404(RESULT_NOT_FOUND)
- chat.html: currentProject 统一为 draftProject;新会话继承已选项目(§4.2);上传区显隐/类型映射/抽屉脏检测改用 GenesisState;loadSession 按 role 渲染 progress/error;加 h1 标题
- chat_state.js(新): 抽取 autoBindProject/shouldHideUploadSelect/resolveUploadType/computeDrawerSnapshot/isDrawerDirty/buildWelcome 纯函数(U+1 分隔符)
- agent.py: 新增 _persist_progress/_store_error,进度与错误以 role 入库(重载可见)
- 测试: test_chat_state.js(11 passed) + test_frontend_audit_fixes.py(5) + 修正 test_server_api 旧 JSON 契约断言;全量 pytest 568 passed / 99.06%
This commit is contained in:
lhl
2026-08-29 11:28:08 +08:00
parent c7897a15c1
commit be79624db5
9 changed files with 638 additions and 43 deletions
+2
View File
@@ -75,6 +75,7 @@
| 2026-08-11 | Agent 实现 | T9(架构审查整改):CJK 保守 token 估算(Issue11)。token.py approximate_token_count 重写:新增 _is_cjk_charCJK 统一表意/扩展A/假名/韩文/兼容/全角六大 Unicode 范围)+ _CJK_TOKENS_PER_CHAR=1.5(旧逻辑 4 字符 1 token 对中文/日文严重低估,裁剪失效致 API 超限);CJK 字符按 1.5 token/字符,其余仍 4 字符 1 token,最少 1 token;同步 config-design.md token_estimation 注释;新增 4 用例(纯 CJK 保守/ASCII 不回归/混合文本/全角符号);TDD 验证 RED((設計)4 字符仅 1 token)→ GREEN(聚焦 11 passed)→ 全量 186 passed 覆盖 100.00%995 stmts/252 br),fail_under=99 达标 | src/genesis/inference/token.py, tests/test_inference_token.py, docs/config-design.md, _AI_USAGE_LOG.md | deepseek-v4-flash-free |
| 2026-08-11 | Agent 实现 | T5(架构审查整改):删死配置 + 同步文档(Issue5 + OV1)。config.py 删除 QdrantStoreConfig 类与 VectorStoreConfig.qdrant 字段、AppConfig.task_queue.redis_url;同步更新 6 处文档(api-design §1/§4.3/§5.2/§5.3/§6.2TaskQueue 标注 v1 仅 InMemory、Redis/Valkey 为 v2 预留;rag-layer §9Storage Adapter 仅 ChromaAdapter、移除切换流程/工厂 qdrant 分支;agent-runtime §3.1/§3.5design §5.5/§8.4.1config-design env/app.yaml/rag.yaml/docker composeweb-ui §4.1+ tests/fixtures/rag.yaml 去 qdrant 段;历史评审记录(design-review/web-ui-review/phase1 plan)保留原样不改写;新增 3 用例(QdrantStoreConfig 已删/vector_store 无 qdrant 字段/task_queue 无 redis_url+ 同步 2 个既有 qdrant 依赖用例;TDD 验证 RED(三处死配置存在)→ GREEN(聚焦 10 passed)→ 全量 189 passed 覆盖 100.00%991 stmts/252 br),fail_under=99 达标 | src/genesis/config.py, tests/test_config.py, tests/fixtures/rag.yaml, docs/api-design.md, docs/rag-layer-design.md, docs/agent-runtime-design.md, docs/design.md, docs/config-design.md, docs/web-ui-design.md, _AI_USAGE_LOG.md | deepseek-v4-flash-free |
| 2026-08-11 | Agent 实现 | T6+T11(架构审查整改,Lane B):v1 rerank 精排 + bge-m3 多语言切换(Issue6 + OV2)。config.py 新增 RerankConfigenabled=True/model=BAAI/bge-reranker-v2-m3/device=cpu)挂入 RagConfigEmbeddingConfig.model 默认 bge-small-zh-v1.5 → BAAI/bge-m3(实际语料日文);rag-layer-design.md 新增 §2.3 多语言与日文样本验证、§6.3 Rerank 精排(窗口=RRF top-10、候选≤top_k 跳过、故障降级 RRF 原序),原 §6.3-6.6 顺延 6.4-6.7;选型表/依赖表/manifest/流程图 bge-small-zh → bge-m3config-design.md embedding 默认 + 新增 rerank 段;design.md §5.5 与 implementation-plan 4.3 同步;新增 tests/test_rag_design_consistency.py 一致性门禁(6 用例:代码默认/fixture 同步/4 文档用 bge-m3+reranker/无 legacy 引用);TDD 验证 RED(默认模型仍旧+rerank 字段不存在)→ GREEN(聚焦 13 passed)→ 全量 198 passed 覆盖 100.00%996 stmts/252 br),fail_under=99 达标 | src/genesis/config.py, tests/test_config.py, tests/test_rag_design_consistency.py, tests/fixtures/rag.yaml, docs/rag-layer-design.md, docs/config-design.md, docs/design.md, docs/implementation-plan.md, _AI_USAGE_LOG.md | deepseek-v4-flash-free |
| 2026-08-29 | 架构设计 | 前端画面审计 + 修复方案生成 + plan-eng-review 工程评审:① 静态走查 chat.html/app.py/service.py 定位 6 类问题(currentProject 未声明与 draftProject 分裂、顶栏与真实会话脱节、上传入口与项目模式互斥、预览契约返回 JSON、进度/错误不持久化、死代码);② 生成方案文档 docs/frontend_audit_plan.md;③ plan-eng-review 评审产出 5 项发现 F1-F5,用户确认三项决策(F1 扩 scope 持久化进度/错误、F2 新会话继承已选项目、F3 抽取 chat_state.js 做 Node 单测),据此定稿方案并附 GSTACK REVIEW REPORT;④ 核实 list_messages 无 type 字段、HTMLResponse 已导入、role 列为 TEXT 可存 progress/error | docs/frontend_audit_plan.md, src/genesis/server/static/chat.html, src/genesis/server/app.py, src/genesis/server/service.py, src/genesis/chat/agent.py, _AI_USAGE_LOG.md | deepseek-v4-flash-free |
| 2026-08-11 | Agent 实现 | T12(架构审查整改,P1):URI 统一 + resolver + 强验证(OV3)。新建 src/genesis/parsers/resolver.pyparse_source_uri(解析 file.xlsx#Sheet!CellRef → SourceRef,格式非法 raise URIError)、provenance_to_uriProvenance 还原,与 build 互逆)、resolve_source_uriStructuredSource 内定位真实 CellValue)、validate_source_uris(批量强验证 → ValidationResult(resolved/unresolved),格式错误或源中不存在一律 unresolved,防 QA#8 编造 URI 作弊);统一 URI 唯一生成入口 build_source_uriprovenance.py),formatting_detector 经其生成,无散落不一致;新建 tests/test_resolver.py13 用例:解析/往返/定位/批量验证/防御分支);同步 design.md §9.2 机制化说明 + §6.8 第五步存在性校验引用;TDD 验证 RED(模块缺失)→ GREEN(聚焦 10 passed)→ 全量 231 passed 覆盖 100.00%1191 stmts/298 br),fail_under=99 达标 | src/genesis/parsers/resolver.py, tests/test_resolver.py, docs/design.md, _AI_USAGE_LOG.md | deepseek-v4-flash-free |
| 2026-08-11 | Agent 实现 | T13(架构审查整改,P1):黄金集 + 评分器(OV4)。新建 src/genesis/eval/ 包:golden_set.pyGoldenCase/GoldenSetYAML 加载,samples/ 真实脱敏样本作 input_ref 基线)、scorer.pyChapterScorer 按 §7.2 指标体系打分);确定性维度 traceabilityresolver 验证 source_uri 可解析率)/placeholder_residue(无 {{...}} 残留)/chapter_completeness(章节覆盖期望集合);LLM 语义维度经 llm_evaluators 钩子注入(默认中性分,待 Phase5);新增 tests/test_eval_scorer.py9 用例:各维度/聚合/钩子/空输入);tests/fixtures/eval/golden_set.yaml 示例黄金集(2 case);同步 design.md §7.5 黄金集与评分器机制化说明(定位为 CI 质量门禁);TDD 验证 RED(模块缺失 + NameError)→ GREEN(聚焦 8 passed)→ 全量 240 passed 覆盖 100.00%1279 stmts/308 br),fail_under=99 达标 | src/genesis/eval/__init__.py, src/genesis/eval/golden_set.py, src/genesis/eval/scorer.py, tests/test_eval_scorer.py, tests/fixtures/eval/golden_set.yaml, docs/design.md, _AI_USAGE_LOG.md | deepseek-v4-flash-free |
| 2026-08-11 | Agent 实现 | T17(架构审查整改,P1):docx 注入原型提前(OV8)。新建 src/genesis/writer/ 包:docx_injector.pyDocxInjector 用原生 python-docx 实现 §6.6 占位符注入);Block 模型(paragraph/heading/table);章节级 {{section:id}} → 替换为内容块 docx 元素序列,行内 {{meta}} → 元信息填充;残留检查:未替换 {{...}} 抛 DocxInjectError(与 §6.6 规范一致);格式精度:注入 heading 继承模板 Heading 样式、原有内容样式不被破坏;新增 tests/test_docx_injector.py5 用例:章节/行内替换/残留报错/heading 样式继承/原内容保留);同步 design.md §6.7 渲染链路 T17 原型说明;TDD 验证 RED(模块缺失)→ GREEN(聚焦 5 passed)→ 全量 245 passed 覆盖 100.00%1361 stmts/340 br),fail_under=99 达标 | src/genesis/writer/__init__.py, src/genesis/writer/docx_injector.py, tests/test_docx_injector.py, docs/design.md, _AI_USAGE_LOG.md | deepseek-v4-flash-free |
@@ -136,3 +137,4 @@
| 2026-08-28 15:33 | 反馈迭代 | Task1 抽屉 +新建项目 加 di-create 醒目样式 + openProjectDrawer 加固(始终渲染列表) | src/genesis/server/static/chat.html | deepseek-chat |
| 2026-08-28 15:33 | 反馈迭代 | Task2 删除 header h1,侧边栏品牌名加副标题「概要设计书做成Agent」 | src/genesis/server/static/chat.html | deepseek-chat |
| 2026-08-28 15:33 | 反馈迭代 | Task3 折叠态切换按钮移至左下角避免遮挡 logo + 会话列表底部留白 | src/genesis/server/static/chat.html | deepseek-chat |
| 2026-08-29 14:20 | 测试验证 | 前端审计修复(docs/frontend_audit_plan.md,经 plan-eng-review 评审定稿):静态抽取 chat_state.js 纯函数模块 + Node 单测(11 passed)chat.html 统一 currentProject→draftProject、新会话继承已选项目(§4.2)、loadProjects 自动绑定、上传区显隐/类型映射/抽屉脏检测改用 GenesisState、loadSession 按 role 渲染 progress/error、加 h1 标题;app.py 预览改 HTMLResponse(返回渲染 HTML 而非 {html} JSON)、新增 /chat_state.js 静态路由、预览缺失返回 404(RESULT_NOT_FOUND)agent.py 新增 _persist_progress/_store_error 将进度与错误以 role 入库(重载可见)pytest 新增 test_frontend_audit_fixes.py(5) 并修正 test_server_api 中旧的 JSON 契约断言;全量 pytest 568 passed / 99.06% 达标 | docs/frontend_audit_plan.md; src/genesis/server/static/chat_state.js; tests/test_chat_state.js; src/genesis/server/static/chat.html; src/genesis/server/app.py; src/genesis/chat/agent.py; tests/test_frontend_audit_fixes.py; tests/test_server_api.py; _AI_USAGE_LOG.md | hy3-free |
+347
View File
@@ -0,0 +1,347 @@
# 前端画面审计与修复方案(Genesis 概要设计书生成 Agent
- 文档类型:实施方案(Implementation Plan
- 关联画面:`src/genesis/server/static/chat.html`(唯一前端单页)
- 关联后端:`src/genesis/server/app.py``src/genesis/server/service.py``src/genesis/server/store.py``src/genesis/chat/agent.py`
- 审计结论来源:对前端单页与后端契约的静态走查 + `plan-eng-review` 工程评审
- 目标:消除画面状态矛盾、修复前后端契约错误,并使进度/错误消息在会话重载后可见
---
## 1. 背景与目标
前端(`chat.html`)是内嵌于 FastAPI 的单页应用,负责项目切换、会话管理、资料上传与会话对话。
静态走查发现多处**画面状态互相矛盾**与**前后端契约不一致**,导致用户操作路径不可预期。
本方案目标:在不引入新架构依赖的前提下,统一前端项目状态变量、修正上传栏逻辑、修复预览契约、补全进度/错误消息的持久化与历史渲染,并配套前端单元测试与后端契约测试。
`plan-eng-review` 评审确认的三项决策:
- **F1**:进度/错误消息本就不持久化,重载会话不会出现它们 → 本次**扩 scope 持久化**(而非简单移除渲染)。
- **F2**:点"+新会话"**继承当前已选项目**(而非始终清空)。
- **F3**:前端核心状态逻辑**抽取为 `chat_state.js` 模块**并做 Node 单测(真实 TDD)。
---
## 2. 现状根因分析
### 2.1 核心缺陷:项目状态变量分裂
`currentProject` 在 14 处被使用(chat.html:545,547,549,558,562,565,574,697,718,720,738,740…),
但顶部 `let` 声明块(456–464)**只声明了 `draftProject`,从未声明 `currentProject`**。
非严格模式下它退化为隐式全局变量,初始值为 `undefined`
注释 `line 457``draftProject = null; // 下一空白会话将绑定/已绑的项目(原 currentProject`
说明这是一次**未完成的重命名重构**遗留。
后果链:
1. `loadProjects` 启动自动绑定 `if (currentProject === null …)`line 545)因 `undefined !== null` 永不触发 → 首个项目永不自动选中。
2. `updateUploadBar``currentProject`(line 738),但建会话/上传用 `draftProject`/`activeProject`800,802,864)→ UI 状态与实际上传/绑定项目不一致。
3. `newSession` 无条件 `draftProject = null`line 761),而顶栏 `currentProject` 未被同步 → 选了项目再点"新会话",顶栏显示已选项目、新会话却未绑定、侧边栏会话列表被清空(497)。
### 2.2 顶栏项目与真实会话脱节
- 顶栏 `psName` 显示 `currentProject``sid-badge` 显示会话真实项目,二者可不一致。
- 上传提示(line 740)用 `currentProject`,而实际上传用 `activeProject/draftProject` → 提示可能显示错误项目名。
### 2.3 上传入口与项目模式互斥、标签误导
- 上传栏有 5 种类型(requirements/template/write_instruction/rules/existing_system,部分标"(必需)"),
但一旦 `activeProject || currentProject` 为真即隐藏整个下拉并强制 `requirements`738744, 864)→ 其余 4 种上传入口消失,与标签矛盾。
- 项目配置抽屉用"目录路径"配置 rules/code/design,上传栏用"文件"——两套输入模型互相隐藏、不一致。
### 2.4 前后端契约错误:预览返回 JSON
- 前端 `send()` 预览链接 `href="/api/sessions/{sid}/result/preview"`line 827)期望打开 HTML
- 后端 `result_preview`app.py:269272)返回 `{"html": …}`,浏览器直接显示原始 JSON 文本而非文档预览。
- `service.result_preview` 本身返回的是 HTML 字符串(service.py:291306),被错误包裹了一层 JSON。
- 已确认 `HTMLResponse` 已在 app.py:18 导入,无需重复导入。
### 2.5 进度/错误消息不持久化(评审新增,F1)
- `chat_agent.handle_message` 仅通过 `store.add_message` 持久化 `user``assistant` 消息(agent.py:24,35,55,63…)。
- `progress` 列表仅作为 API 响应返回,**从不写入存储**;错误回复以 `assistant` 角色入库。
- 结果:`list_messages` 返回结构为 `{role, content, action, created_at}`**无 `type` 字段**,历史中永不出现 `progress`/`error`
- 因此重载会话时进度/错误既无数据也无样式——这是本次要补的缺口。
### 2.6 次要/死代码
- CSS `header h1`line 156)已定义,但 `header` 标记中无 `<h1>`,顶栏无标题。
- Esc 时总是 `lastTriggerEl.focus()`(857),即使未打开任何面板也会聚焦(轻微)。
---
## 3. 方案概述
采用**最小且完整的修复**(不重写前端,不新增运行期依赖):
1. 统一项目状态变量:删除 `currentProject`,全量改用 `draftProject`;切换/保存/载入/新建会话均同步 `draftProject`
2. 修正启动自动绑定判断:`if (draftProject === null && projects.length)`
3. `newSession` 保留当前已选项目(F2),仅在显式"不选择项目"时清空。
4. `updateUploadBar` 与上传逻辑统一以 `draftProject`= 当前/待建会话项目)为准。
5. 后端 `result_preview` 改为 `HTMLResponse`(保留 `result_preview` 返回的 HTML 字符串)。
6. 扩展持久化(F1):`chat_agent``progress` 条目以 `role='progress'` 入库、错误回复以 `role='error'` 入库;`loadSession``role` 渲染进度/错误样式。
7. 抽取核心状态逻辑到 `chat_state.js`F3),由 chat.html 引入,配套 Node 单测。
8. (可选)`header``<h1>` 标题。
---
## 4. 详细改动(文件/函数级)
### 4.1 chat.html — 变量统一(合并原 §4.3)
- 删除所有 `currentProject` 引用,替换为 `draftProject`,逐处核对语义(注意 `activeProject` 语义不同,不可混淆)。
- 受影响位置(逐个替换,保持语义):
- line 545`if (draftProject === null && projects.length > 0) draftProject = projects[0].name;`
- line 547`else if (draftProject && !projects.find(p => p.name === draftProject))`
- line 549`draftProject = projects.length > 0 ? projects[0].name : null;`
- line 558`psName.textContent = draftProject || '未选择';`
- line 562`(draftProject === null ? ' active' : '')`
- line 565`(p.name === draftProject ? ' active' : '')`
- line 574`draftProject = it.dataset.name === '' ? null : it.dataset.name;`
- line 697`draftProject = cfg.name;`saveDrawerProject
- line 718`if (draftProject === drawerSelected)`
- line 720`draftProject = null;`
- line 738`if (activeProject || draftProject)`
- line 740:提示文案用 `activeProject || draftProject`
### 4.2 chat.html — newSession 保留项目(F2
现状(line 760769):
```js
async function newSession() {
sid = null;
activeProject = null;
draftProject = null; // ← 问题:无条件清空
...
}
```
改为:保留 `draftProject`(当前顶栏已选项目),使新会话继承项目绑定:
```js
async function newSession() {
sid = null;
activeProject = null;
// 保留 draftProject(当前顶栏已选项目),使新会话继承项目绑定
badge.textContent = '未创建会话';
updateUploadBar();
await refreshSessions();
renderEmptyOrWelcome();
}
```
"不选择项目"入口:在 `renderProjectSwitcher` 的"不选择项目"点击处理中,调用 `newSession` 前先 `draftProject = null`,从而显式获得无项目会话。
### 4.3 app.py — 预览契约修复
现状(269272):
```python
@app.get("/api/sessions/{sid}/result/preview")
def result_preview(sid: str):
html = service.result_preview(sid)
return {"html": html}
```
改为(HTMLResponse 已在 app.py:18 导入):
```python
@app.get("/api/sessions/{sid}/result/preview", response_class=HTMLResponse)
def result_preview(sid: str):
html = service.result_preview(sid)
return HTMLResponse(html)
```
### 4.4 新增文件 `src/genesis/server/static/chat_state.js`F3
UMD 形式,浏览器挂 `window.GenesisState`、Node 可 `require`,导出纯函数(不触碰 DOM):
```js
(function (root, factory) {
const api = factory();
if (typeof module !== 'undefined' && module.exports) module.exports = api;
else root.GenesisState = api;
})(typeof self !== 'undefined' ? self : this, function () {
function autoBindProject(draftProject, projects) {
if (draftProject === null && projects.length > 0) return projects[0].name;
if (draftProject && !projects.find(p => p.name === draftProject))
return projects.length > 0 ? projects[0].name : null;
return draftProject;
}
function shouldHideUploadSelect(activeProject, draftProject) {
return Boolean(activeProject || draftProject);
}
function resolveUploadType(activeProject, draftProject, selectValue) {
return (activeProject || draftProject) ? 'requirements' : selectValue;
}
function computeDrawerSnapshot(fields) {
return fields.map(f => (f || '').trim()).join('\u0001');
}
function isDrawerDirty(snapshotNow, saved) {
return snapshotNow !== saved;
}
function buildWelcome(draftProject) {
if (draftProject === null) return '请新建项目或者选择项目';
return '你好!我是 Genesis 概要设计书生成 Agent。请先上传要件定义 xlsx'
+ '(模板/规则/代码库已由项目「' + draftProject + '」提供),然后告诉我:'
+ '「生成概要设计书」/「用中文生成」/「现在什么状态?」';
}
return { autoBindProject, shouldHideUploadSelect, resolveUploadType,
computeDrawerSnapshot, isDrawerDirty, buildWelcome };
});
```
chat.html 改动:
- `<head>` 后或 body 末尾增加 `<script src="/chat_state.js"></script>`(普通脚本,先于主脚本执行)。
- `loadProjects` 的自动绑定改用 `draftProject = GenesisState.autoBindProject(draftProject, projects);`
- `updateUploadBar` 的判定改用 `GenesisState.shouldHideUploadSelect(activeProject, draftProject)`
- 上传按钮 `ft` 判定改用 `GenesisState.resolveUploadType(activeProject, draftProject, sel.value)`
- `drawerSnapshotNow` 改为调用 `GenesisState.computeDrawerSnapshot([...7个字段值])``isDrawerDirty` 改用 `GenesisState.isDrawerDirty(...)`
- `renderEmptyOrWelcome` 的文案改用 `GenesisState.buildWelcome(draftProject)`
### 4.5 app.py — 提供 chat_state.js 静态路由
```python
@app.get("/chat_state.js", response_class=HTMLResponse)
def chat_state_js():
p = static_dir / "chat_state.js"
if not p.exists():
raise _error(404, "NOT_FOUND", "chat_state.js 不存在")
return FileResponse(p, media_type="application/javascript")
```
### 4.6 进度/错误消息持久化(F1)
**后端 `chat/agent.py`**
- 新增私有方法 `_persist_progress(session_id, progress)`
```python
def _persist_progress(self, session_id, progress):
for item in progress:
self.store.add_message(
session_id, "progress",
f"{item.get('step', '')}: {item.get('detail', '')}",
action=item.get("status"),
)
```
- 所有 `return {"reply": ..., "progress": progress, ...}` 前调用 `self._persist_progress(session_id, progress)`。
- 错误回复(如 `reply = f"解析失败:{e}"` 等)原以 `role="assistant"` 入库,改为 `role="error"`(保留 `action` 不变),便于前端区分样式。可加私有方法 `_store_error(session_id, reply, action)`。
- `store.add_message` 的 `role` 列本为 `TEXT`,已可存 `'progress'`/`'error'`**无需改表结构**。
**前端 `chat.html` `loadSession`(约 782784):**
```js
const roleMap = { user: 'user', progress: 'progress', error: 'error' };
const role = roleMap[m.role] || 'assistant';
addMsg(role, esc(m.content));
```
(历史中既有 `user`/`assistant` 消息回退为 `assistant`,向后兼容。)
### 4.7 chat.html — 顶栏标题(可选)
在 `header`369380)中 `proj-switcher` 之前插入:
```html
<h1>Genesis 概要设计书生成</h1>
```
---
## 5. 数据流(修复后)
```
启动
└─ loadProjects(): draftProject = autoBindProject(draftProject, projects)
draftProject===null 且有项目 → 首项;draftProject 指向已删项目 → 回退首项)
└─ renderProjectSwitcher / updateUploadBar / refreshSessions 全部以 draftProject 为准
选/建项目
└─ 顶栏切换 → draftProject = 项目名(或 null
└─ 抽屉保存 → draftProject = 新建/编辑后的项目名
新建会话(newSession)
└─ 继承 draftProject(不清空)(F2
└─ 首条消息 send() → POST /api/sessions {project: draftProject}
└─ activeProject = draftProject
上传要件定义
└─ ft = resolveUploadType(activeProject, draftProject, sel.value)
│ (绑定项目时恒为 'requirements',下拉隐藏,提示"仅需上传要件定义")
└─ 未绑定项目时显示 5 种类型下拉
生成流程
└─ chat_agent 将 progress 条目以 role='progress'、错误以 role='error' 持久化(F1
└─ send() 返回 done/writing → 渲染 下载/预览/影响调查书/QA报告 链接
└─ 预览链接 → GET /api/sessions/{sid}/result/preview(返回 HTML,新标签页正常渲染)
重载会话
└─ loadSession 按 m.role 渲染 user/progress/error/assistant,进度与错误可见(F1
```
---
## 6. 边界与异常处理
- **无项目场景**`draftProject === null` 时,顶栏"未选择"、侧边栏会话列表清空、上传栏显示完整 5 种类型、欢迎语进入"请新建项目"。
- **删除当前会话**`deleteSession` 命中当前 `sid` → `newSession()`,继承 `draftProject`,不丢失项目上下文。
- **删除已绑定项目**`deleteDrawerProject` 将 `draftProject` 置 null(与"不选择项目"等价),UI 回到无项目态。
- **抽屉未保存守卫**`guardDrawerDirty()` 在切换/关闭前拦截,已有,保持不变。
- **预览无结果**:后端 `result_preview` 在 `result_path` 不存在时抛 `ServiceStepError` → 前端 `send()` 捕获为 error 消息,不会渲染空链接。
- **历史消息缺字段/旧数据**`role` 非 user/progress/error 时回退 `assistant`;进度/错误旧会话(升级前)无此类行,不影响渲染。
- **chat_state.js 加载失败**:若 `/chat_state.js` 404,主脚本调用 `GenesisState.xxx` 会抛错。防护:主脚本顶部加 `const S = (typeof GenesisState !== 'undefined') ? GenesisState : null;` 并在使用处判空回退到内联实现,或确保路由必存在(推荐后者,部署时文件随包提供)。
---
## 7. 测试策略(TDD
### 7.1 前端核心逻辑单测(NodeF3)
- 文件:`tests/test_chat_state.js`Node `node:test` + `node:assert`),`require('../src/genesis/server/static/chat_state.js')`。
- 用例:
1. `autoBindProject(null, [{name:'A'},{name:'B'}])` → `'A'`(启动自动绑定首项)。
2. `autoBindProject('X', [{name:'A'}])` → `'A'`(指向已删项目回退首项)。
3. `autoBindProject('A', [{name:'A'}])` → `'A'`(保持不变)。
4. `shouldHideUploadSelect(null, null)` → `false``shouldHideUploadSelect(null, 'A')` → `true``shouldHideUploadSelect('A', null)` → `true`。
5. `resolveUploadType(null, 'A', 'template')` → `'requirements'``resolveUploadType(null, null, 'template')` → `'template'`。
6. `computeDrawerSnapshot([' a ','b'])` → `'a\u0001b'``isDrawerDirty('x','y')` → `true`,相等 → `false`。
7. `buildWelcome(null)` 含"请新建项目"`buildWelcome('A')` 含"项目「A」"。
### 7.2 后端契约(pytest
- `test_result_preview_returns_html`:请求 `GET /api/sessions/{sid}/result/preview`,断言 `content-type` 含 `text/html` 且响应体以 `<!DOCTYPE html>` 开头(非 `{"html":`)。
- `test_result_preview_missing`:无结果文档时返回 404`RESULT_NOT_FOUND`)。
- `test_persist_progress_and_error`:用 `TestClient` 走 建会话→上传要件定义→`generate`fake engine),断言 `list_messages` 含 `role='progress'` 的条目;另测一个错误路径(如无模板时 generate)断言含 `role='error'`。
### 7.3 集成(端到端冒烟,pytest + TestClient
- 建会话→上传要件定义(fake 样本)→`start-parse`→`confirm-parse`→`generate`fake engine)→`result/preview` 返回 HTML。
- 断言 preview 链接可达且为 HTML;断言历史消息含 progress 角色。
### 7.4 运行要求
- 7.1 需要 Node 运行时;若 CI 无 Node,则该层测试跳过并在文档标注(不阻塞主流程)。
---
## 8. 性能与风险
- 改动均为客户端状态变量、单端点响应类型、少量消息持久化,无新增计算/网络开销。
- 风险点:全局替换 `currentProject`→`draftProject` 需逐处核对语义,避免误改 `activeProject`。
- 持久化进度消息会增加少量 DB 写入(每条进度 1 行),规模小,可忽略。
- 回滚:纯前端 + 单端点 + 消息角色扩展(TEXT 列兼容),可逐文件 revert,不影响表结构。
---
## 9. 验收标准
1. 启动有项目时顶栏自动显示项目名,且欢迎语进入"已选项目"分支。
2. 选/建项目后新建会话,会话正确绑定该项目,侧边栏列表按该项目过滤;点"+新会话"继承已选项目(F2)。
3. 上传栏在绑定项目时仅显示要件定义入口;无项目时显示全部 5 种。
4. 点击"预览"在新标签页渲染文档 HTML(不再显示 JSON)。
5. 重新载入会话时进度/错误消息按样式正确显示(F1)。
6. 上述 7.1–7.3 测试全部通过。
---
## 10. 后续增强(不在本次范围,F5)
- 跨刷新持久化"选中的项目"(当前仅 session id 持久化,启动恒自动绑首项)。
- 上传栏在已绑定项目时,将"模板/规则/代码库由项目提供"的说明文案进一步细化,消除"(必需)"标签的歧义。
---
## GSTACK REVIEW REPORT
- 评审技能:plan-eng-review
- 评审对象:docs/frontend_audit_plan.md(本文件)
- 评审结论:DONE_WITH_CONCERNS(已解决全部阻塞项,遗留项已降级为后续增强)
### 评审发现与处置
| 编号 | 发现 | 严重度 | 处置 |
|------|------|--------|------|
| F1 | 进度/错误消息不持久化,原 §4.5 基于错误前提 | 严重 | 已扩 scope:§4.6 持久化 role='progress'/'error'loadSession 按 role 渲染 |
| F2 | newSession 是否继承项目为产品决策 | 中 | 用户确认:继承已选项目(§4.2) |
| F3 | 前端测试策略含糊、不可执行 | 中 | 用户确认:抽取 chat_state.js 模块 + Node 单测(§4.4、§7.1 |
| F4 | §4.3 与 §4.1 重叠 | 轻微 | 已合并(§4.1 内含 updateUploadBar 统一) |
| F5 | 启动自动绑首项、选中项目不跨刷新持久化 | 轻微 | 降级为后续增强(§10) |
### 已确认的前提(评审中核实)
- `list_messages` 返回 `{role, content, action, created_at}`,无 `type` 字段(store.py:119-122)。
- `HTMLResponse` 已在 app.py:18 导入,§4.3 无需重复导入。
- `store.add_message` 的 `role` 为 TEXT 列,可直接存 'progress'/'error',无需改表。
### 剩余关注
- 7.1 需 Node 运行时;若目标部署环境无 Node,前端单测层需在 CI 中跳过并标注(不影响后端 pytest)。
- chat_state.js 必须由 app.py 正确以 `/chat_state.js` 提供,否则主脚本依赖缺失;测试需覆盖该路由 200。
+24 -6
View File
@@ -32,11 +32,25 @@ class ChatAgent:
intent = self._parse_intent(session_id, content, rec)
result = self._dispatch(session_id, intent, progress)
reply, status = result["reply"], result["status"]
self._persist_progress(session_id, result["progress"])
self.store.add_message(session_id, "assistant", reply, action=intent.action)
return {"reply": reply, "progress": result["progress"], "status": status}
# ---------- 意图解析 ----------
def _persist_progress(self, session_id, progress):
"""将流程进度条目持久化为 role='progress' 消息,使重载会话可见(§4.6)。"""
for item in progress:
self.store.add_message(
session_id, "progress",
f"{item.get('step', '')}: {item.get('detail', '')}",
action=item.get("status"),
)
def _store_error(self, session_id, reply, action):
"""错误回复以 role='error' 持久化,便于前端区分样式(§4.6)。"""
self.store.add_message(session_id, "error", reply, action=action)
def _parse_intent(self, session_id, content, rec):
if self.fake:
return parse_intent_fake(content)
@@ -53,6 +67,7 @@ class ChatAgent:
self.store.update_session(session_id, pending_intent="")
reply = "已打回影响调查。可以回复「重新影响调查」或「开始解析」重来。"
self.store.add_message(session_id, "assistant", reply, action="reject")
self._persist_progress(session_id, progress)
return {"reply": reply, "progress": progress, "status": "impact_running"}
if intent.action == "confirm":
try:
@@ -60,7 +75,8 @@ class ChatAgent:
progress.append({"step": "impact", "status": "ok", "detail": "影响调查已确认"})
except Exception as e: # noqa: BLE001
reply = f"影响确认失败:{e}"
self.store.add_message(session_id, "assistant", reply, action="confirm")
self._store_error(session_id, reply, "confirm")
self._persist_progress(session_id, progress)
return {"reply": reply, "progress": progress, "status": "awaiting_impact_confirm"}
pending = self.store.get_session(session_id).pending_intent
if pending == "generate":
@@ -68,9 +84,11 @@ class ChatAgent:
return self._run_generate(session_id, self.store.get_session(session_id), progress)
reply = "已确认影响调查。可以回复「生成概要设计书」继续。"
self.store.add_message(session_id, "assistant", reply, action="confirm")
self._persist_progress(session_id, progress)
return {"reply": reply, "progress": progress, "status": "writing"}
reply = "影响调查结果待确认,请回复「确认,继续」或「打回」。"
self.store.add_message(session_id, "assistant", reply, action="unknown")
self._persist_progress(session_id, progress)
return {"reply": reply, "progress": progress, "status": "awaiting_impact_confirm"}
# ---------- 分发 ----------
@@ -108,7 +126,7 @@ class ChatAgent:
rec = self._parse_and_confirm(session_id, progress)
except Exception as e: # noqa: BLE001
reply = f"解析失败:{e}"
self.store.add_message(session_id, "assistant", reply, action="generate")
self._store_error(session_id, reply, "generate")
return {"reply": reply, "progress": progress, "status": rec.status}
if rec.status == "writing":
# 无既有系统 → 直接生成
@@ -145,7 +163,7 @@ class ChatAgent:
rec = self._parse_and_confirm(session_id, progress)
except Exception as e: # noqa: BLE001
reply = f"解析失败:{e}"
self.store.add_message(session_id, "assistant", reply, action="parse")
self._store_error(session_id, reply, "parse")
return {"reply": reply, "progress": progress, "status": rec.status}
if rec.status == "awaiting_impact_confirm":
reply = f"解析与影响调查已完成:{self._impact_brief(rec)}。是否确认?(回复「确认,继续」)"
@@ -167,7 +185,7 @@ class ChatAgent:
rec = self._parse_and_confirm(session_id, progress)
except Exception as e: # noqa: BLE001
reply = f"解析失败:{e}"
self.store.add_message(session_id, "assistant", reply, action="impact")
self._store_error(session_id, reply, "impact")
return {"reply": reply, "progress": progress, "status": rec.status}
# _parse_and_confirm 已自动跑影响 → 直接返回反问
if rec.status == "awaiting_impact_confirm":
@@ -194,7 +212,7 @@ class ChatAgent:
rec = self.service.run_generate(session_id, output_language=output_language)
except Exception as e: # noqa: BLE001
reply = f"生成失败:{e}"
self.store.add_message(session_id, "assistant", reply, action="generate")
self._store_error(session_id, reply, "generate")
return {"reply": reply, "progress": progress, "status": rec.status}
progress.append({"step": "generate", "status": "ok", "detail": "概要设计书已生成"})
try:
@@ -226,7 +244,7 @@ class ChatAgent:
except Exception as e: # noqa: BLE001
rec = self.store.get_session(session_id)
reply = f"QA 未执行:{e}"
self.store.add_message(session_id, "assistant", reply, action="qa")
self._store_error(session_id, reply, "qa")
return {"reply": reply, "progress": progress, "status": rec.status}
def _status_reply(self, rec, progress):
+13 -3
View File
@@ -110,6 +110,13 @@ def create_app(
def index():
return chat_html
@app.get("/chat_state.js")
def chat_state_js():
p = static_dir / "chat_state.js"
if not p.exists():
raise _error(404, "NOT_FOUND", "chat_state.js 不存在")
return FileResponse(p, media_type="application/javascript")
# ---------- 项目配置 ----------
@app.post("/api/projects")
@@ -266,10 +273,13 @@ def create_app(
# ---------- 结果 ----------
@app.get("/api/sessions/{sid}/result/preview")
@app.get("/api/sessions/{sid}/result/preview", response_class=HTMLResponse)
def result_preview(sid: str):
html = service.result_preview(sid)
return {"html": html}
try:
html = service.result_preview(sid)
except ServiceStepError:
raise _error(404, "RESULT_NOT_FOUND", "结果文档不存在")
return HTMLResponse(html)
@app.get("/api/sessions/{sid}/result/download")
def result_download(sid: str):
+41 -33
View File
@@ -367,6 +367,7 @@
<div id="main">
<header>
<h1>Genesis 概要设计书生成</h1>
<div class="proj-switcher" id="proj-switcher">
<button class="ps-current" id="ps-current" type="button" title="切换项目">
@@ -452,6 +453,7 @@
</div>
</div>
<script src="/chat_state.js"></script>
<script>
let sid = null;
let draftProject = null; // 下一空白会话将绑定/已绑的项目(原 currentProject
@@ -461,7 +463,18 @@ let drawerOpen = false;
let drawerSelected = null; // 当前抽屉中编辑的项目名(null = 新建模式)
let lastTriggerEl = null; // 打开抽屉/下拉前的焦点元素(C1)
let drawerDirty = false; // 抽屉表单是否有未保存改动(B6)
let drawerSnapshot = null; // loadDrawerForm 时的字段快照(B6
let drawerSnapshot = null; // loadDrawerForm 时的字段快照(B6
// 状态逻辑统一走 chat_state.js;若静态路由未就绪则回退内联实现,避免整页脚本崩溃
const GenesisState = window.GenesisState || {
autoBindProject: (d, p) => (d === null && p.length ? p[0].name : (d && !p.find(x => x.name === d) ? (p.length ? p[0].name : null) : d)),
shouldHideUploadSelect: (a, d) => !!(a || d),
resolveUploadType: (a, d, v) => (a || d) ? 'requirements' : v,
computeDrawerSnapshot: (f) => f.map(x => (x == null ? '' : String(x)).trim()).join(String.fromCharCode(1)),
isDrawerDirty: (n, s) => n !== s,
buildWelcome: (d) => d === null ? '请新建项目或者选择项目'
: '你好!我是 Genesis 概要设计书生成 Agent。请先上传要件定义 xlsx(模板/规则/代码库已由项目「' + d + '」提供)。',
};
const chatEl = document.getElementById('chat');
const inputEl = document.getElementById('input');
@@ -540,14 +553,8 @@ async function loadProjects() {
} catch (e) {
projects = [];
}
// 自动绑首项:仅在 currentProject 仍为 null(未初始化)时设置
// 若用户已显式选过(即便选"不选择"),不覆盖其选择
if (currentProject === null && projects.length > 0) {
currentProject = projects[0].name;
} else if (currentProject && !projects.find(p => p.name === currentProject)) {
// 之前绑定的项目被外部删除 → 回退到首项(若存在)
currentProject = projects.length > 0 ? projects[0].name : null;
}
// 启动自动绑首项 / 已删项目回退首项(统一由 GenesisState.autoBindProject 处理)
draftProject = GenesisState.autoBindProject(draftProject, projects);
applyProjectContext();
renderProjectSwitcher();
renderDrawerList();
@@ -555,14 +562,14 @@ async function loadProjects() {
}
function applyProjectContext() {
psName.textContent = currentProject || '未选择';
psName.textContent = draftProject || '未选择';
}
function renderProjectSwitcher() {
let html = '<div class="ps-item' + (currentProject === null ? ' active' : '') + '" data-name="">'
let html = '<div class="ps-item' + (draftProject === null ? ' active' : '') + '" data-name="">'
+ '<span>不选择项目</span><span class="ps-check"></span></div>';
for (const p of projects) {
html += '<div class="ps-item' + (p.name === currentProject ? ' active' : '') + '" data-name="' + esc(p.name) + '">'
html += '<div class="ps-item' + (p.name === draftProject ? ' active' : '') + '" data-name="' + esc(p.name) + '">'
+ '<span>' + esc(p.display_name || p.name) + '</span><span class="ps-check"></span></div>';
}
html += '<div class="ps-sep"></div>'
@@ -571,7 +578,7 @@ function renderProjectSwitcher() {
psMenu.querySelectorAll('.ps-item[data-name]').forEach(it => {
it.onclick = () => {
if (!guardDrawerDirty()) return;
currentProject = it.dataset.name === '' ? null : it.dataset.name;
draftProject = it.dataset.name === '' ? null : it.dataset.name;
applyProjectContext();
renderProjectSwitcher();
updateUploadBar();
@@ -644,19 +651,19 @@ function loadDrawerForm(projectName) {
// B6:抽屉未保存变更守卫
function drawerSnapshotNow() {
return [
document.getElementById('pf-name').value.trim(),
document.getElementById('pf-display').value.trim(),
document.getElementById('pf-template').value.trim(),
document.getElementById('pf-write').value.trim(),
document.getElementById('pf-rules').value.trim(),
document.getElementById('pf-code').value.trim(),
document.getElementById('pf-design').value.trim(),
].join('\u0001');
return GenesisState.computeDrawerSnapshot([
document.getElementById('pf-name').value,
document.getElementById('pf-display').value,
document.getElementById('pf-template').value,
document.getElementById('pf-write').value,
document.getElementById('pf-rules').value,
document.getElementById('pf-code').value,
document.getElementById('pf-design').value,
]);
}
function isDrawerDirty() {
if (!drawerOpen || drawerSnapshot === null) return false;
return drawerSnapshotNow() !== drawerSnapshot;
return GenesisState.isDrawerDirty(drawerSnapshotNow(), drawerSnapshot);
}
function guardDrawerDirty() {
if (isDrawerDirty()) return confirm('有未保存的修改,确定放弃?');
@@ -694,7 +701,7 @@ async function saveDrawerProject() {
if (!body.name) { addMsg('error', '项目名不能为空'); return; }
try {
const cfg = await api('POST', '/api/projects', body);
currentProject = cfg.name;
draftProject = cfg.name;
drawerSelected = cfg.name;
await loadProjects();
addMsg('assistant', '项目「' + esc(cfg.display_name || cfg.name) + '」已保存。');
@@ -715,9 +722,9 @@ async function deleteDrawerProject() {
}
addMsg('assistant', '项目「' + esc(drawerSelected) + '」已删除。');
drawerSelected = null;
if (currentProject === drawerSelected) {
if (draftProject === drawerSelected) {
// 触发 loadProjects 中的回退逻辑
currentProject = null;
draftProject = null;
}
await loadProjects();
loadDrawerForm(null);
@@ -735,9 +742,9 @@ function toggleSidebar(force) {
function updateUploadBar() {
const sel = document.getElementById('file-type');
const hint = document.getElementById('proj-hint');
if (activeProject || currentProject) {
if (GenesisState.shouldHideUploadSelect(activeProject, draftProject)) {
sel.style.display = 'none';
hint.textContent = '(项目「' + esc(activeProject || currentProject) + '」已提供模板/规则/代码库,仅需上传要件定义)';
hint.textContent = '(项目「' + esc(activeProject || draftProject) + '」已提供模板/规则/代码库,仅需上传要件定义)';
} else {
sel.style.display = '';
hint.textContent = '';
@@ -760,6 +767,7 @@ function renderEmptyOrWelcome() {
async function newSession() {
sid = null;
activeProject = null;
// 保留 draftProject(当前顶栏已选项目),使新会话继承项目绑定(§4.2)
try { localStorage.removeItem('genesis_session'); } catch (e) { /* 忽略 */ }
chatEl.innerHTML = '';
badge.textContent = '未创建会话';
@@ -779,9 +787,9 @@ async function loadSession(id) {
const msgs = await api('GET', '/api/chat/' + id + '/messages');
chatEl.innerHTML = '';
for (const m of msgs) {
if (m.role === 'user') addMsg('user', esc(m.content));
else if (m.action === 'confirm' || m.action === 'reject') addMsg('assistant', esc(m.content));
else addMsg('assistant', esc(m.content));
const roleMap = { user: 'user', progress: 'progress', error: 'error' };
const role = roleMap[m.role] || 'assistant';
addMsg(role, esc(m.content));
}
updateUploadBar();
await refreshSessions();
@@ -860,8 +868,8 @@ document.addEventListener('keydown', (e) => {
document.getElementById('upload-btn').addEventListener('click', async () => {
if (!sid) { addMsg('error', '请先发送一条消息以创建会话'); inputEl.focus(); return; }
let ft = document.getElementById('file-type').value;
if (activeProject || draftProject) ft = 'requirements';
const sel = document.getElementById('file-type');
let ft = GenesisState.resolveUploadType(activeProject, draftProject, sel.value);
const file = document.getElementById('file-input').files[0];
if (!file) { addMsg('error', '请选择文件'); return; }
const fd = new FormData();
+64
View File
@@ -0,0 +1,64 @@
/*
* Genesis 前端核心状态逻辑纯函数 DOM 依赖
* UMD 形式浏览器挂 window.GenesisStateNode 下可 require 做单测
* 对应方案 docs/frontend_audit_plan.md §4.4 / §7.1
*/
(function (root, factory) {
const api = factory();
if (typeof module !== 'undefined' && module.exports) {
module.exports = api;
} else {
root.GenesisState = api;
}
})(typeof self !== 'undefined' ? self : this, function () {
'use strict';
// 启动自动绑定:draftProject 为 null(未初始化)且有项目时绑首项;
// 指向已删除项目时回退首项;否则保持。
function autoBindProject(draftProject, projects) {
if (draftProject === null && projects.length > 0) return projects[0].name;
if (draftProject && !projects.find(p => p.name === draftProject)) {
return projects.length > 0 ? projects[0].name : null;
}
return draftProject;
}
// 绑定项目(draft 或 active)时隐藏上传类型下拉,仅允许要件定义
function shouldHideUploadSelect(activeProject, draftProject) {
return Boolean(activeProject || draftProject);
}
// 解析实际上传类型:绑定项目时恒为 requirements
function resolveUploadType(activeProject, draftProject, selectValue) {
return (activeProject || draftProject) ? 'requirements' : selectValue;
}
// 抽屉表单字段快照(去空白、单元分隔符连接),用于脏检测
function computeDrawerSnapshot(fields) {
return fields.map(f => (f == null ? '' : String(f)).trim())
.join(String.fromCharCode(1));
}
function isDrawerDirty(snapshotNow, saved) {
return snapshotNow !== saved;
}
// 空态/欢迎语文案
function buildWelcome(draftProject) {
if (draftProject === null) {
return '请新建项目或者选择项目';
}
return '你好!我是 Genesis 概要设计书生成 Agent。请先上传要件定义 xlsx'
+ '(模板/规则/代码库已由项目「' + draftProject + '」提供),然后告诉我:'
+ '「生成概要设计书」/「用中文生成」/「现在什么状态?」';
}
return {
autoBindProject,
shouldHideUploadSelect,
resolveUploadType,
computeDrawerSnapshot,
isDrawerDirty,
buildWelcome,
};
});
+51
View File
@@ -0,0 +1,51 @@
// 前端核心状态逻辑单测(Node 原生 test runnerUMD 模块可在 Node 下 require
// 对应方案 docs/frontend_audit_plan.md §7.1 / §4.4
const test = require('node:test');
const assert = require('node:assert/strict');
const S = require('../src/genesis/server/static/chat_state.js');
test('autoBindProject: 启动无项目且有项目时自动绑首项', () => {
assert.equal(S.autoBindProject(null, [{ name: 'A' }, { name: 'B' }]), 'A');
});
test('autoBindProject: 指向已删除项目时回退首项', () => {
assert.equal(S.autoBindProject('X', [{ name: 'A' }]), 'A');
});
test('autoBindProject: 已选有效项目保持不变', () => {
assert.equal(S.autoBindProject('A', [{ name: 'A' }]), 'A');
});
test('shouldHideUploadSelect: 未绑定任何项目时显示下拉', () => {
assert.equal(S.shouldHideUploadSelect(null, null), false);
});
test('shouldHideUploadSelect: 已绑定项目(draft 或 active)时隐藏下拉', () => {
assert.equal(S.shouldHideUploadSelect(null, 'A'), true);
assert.equal(S.shouldHideUploadSelect('A', null), true);
});
test('resolveUploadType: 绑定项目时强制 requirements', () => {
assert.equal(S.resolveUploadType(null, 'A', 'template'), 'requirements');
});
test('resolveUploadType: 未绑定项目时保留用户选择', () => {
assert.equal(S.resolveUploadType(null, null, 'template'), 'template');
});
test('computeDrawerSnapshot: 去空白并以单元分隔符连接', () => {
assert.equal(S.computeDrawerSnapshot([' a ', 'b']), 'a\u0001b');
});
test('isDrawerDirty: 快照不同为脏,相同为干净', () => {
assert.equal(S.isDrawerDirty('x', 'y'), true);
assert.equal(S.isDrawerDirty('x', 'x'), false);
});
test('buildWelcome: 无项目提示新建/选择项目', () => {
assert.match(S.buildWelcome(null), /请新建项目|选择项目/);
});
test('buildWelcome: 有项目提示由项目提供模板/规则/代码库', () => {
assert.match(S.buildWelcome('A'), /项目「A」/);
});
+94
View File
@@ -0,0 +1,94 @@
"""前端审计修复验证(docs/frontend_audit_plan.md §7.2/§7.3)。
覆盖
- 预览契约返回 HTML JSON
- 进度消息持久化 role='progress'重载会话可见
- 错误消息持久化 role='error'
- /chat_state.js 静态路由可用
"""
from __future__ import annotations
import pytest
from pathlib import Path
from fastapi.testclient import TestClient
from genesis.chat.agent import ChatAgent
from genesis.server.app import create_app
from genesis.server.service import GenesisService
from genesis.server.store import ProjectsStore, SessionStore
_SAMPLE = Path(__file__).resolve().parents[1] / "sample"
@pytest.fixture
def client(tmp_path):
app = create_app(
store=SessionStore(db_path=str(tmp_path / "s.db")),
data_root=str(tmp_path / "data"),
engine="fake",
)
return TestClient(app)
def _upload_core(client, sid):
files = [
("requirements", "requirements_newdev.xlsx", _SAMPLE / "requirements_newdev.xlsx"),
("template", "template_design_ja.docx", _SAMPLE / "template_design_ja.docx"),
("write_instruction", "rules_design_ja.docx", _SAMPLE / "rules_design_ja.docx"),
("rules", "rules_entry_ja.docx", _SAMPLE / "rules_entry_ja.docx"),
]
for ft, name, path in files:
r = client.post(f"/api/sessions/{sid}/files",
data={"file_type": ft},
files={"file": (name, path.read_bytes())})
assert r.status_code == 200, r.text
def test_result_preview_returns_html(client):
"""预览链接应返回渲染后的 HTML,而非 {'html': ...} JSON(§4.3)。"""
sid = client.post("/api/sessions", json={"user_id": "u1"}).json()["session_id"]
_upload_core(client, sid)
client.post(f"/api/chat/{sid}/messages", json={"content": "请生成概要设计书"})
r = client.get(f"/api/sessions/{sid}/result/preview")
assert r.status_code == 200
assert "text/html" in r.headers["content-type"]
assert r.text.lstrip().lower().startswith("<!doctype html>")
def test_result_preview_missing_returns_404(client):
"""无结果文档时预览返回 404RESULT_NOT_FOUND)。"""
sid = client.post("/api/sessions", json={"user_id": "u1"}).json()["session_id"]
r = client.get(f"/api/sessions/{sid}/result/preview")
assert r.status_code == 404
assert r.json()["detail"]["code"] == "RESULT_NOT_FOUND"
def test_progress_messages_persisted(client):
"""生成全流程的进度条目应以 role='progress' 入库,重载会话可见(§4.6)。"""
sid = client.post("/api/sessions", json={"user_id": "u1"}).json()["session_id"]
_upload_core(client, sid)
client.post(f"/api/chat/{sid}/messages", json={"content": "请生成概要设计书"})
msgs = client.get(f"/api/chat/{sid}/messages").json()
assert any(m["role"] == "progress" for m in msgs)
def test_error_role_persisted(tmp_path):
"""错误回复以 role='error' 入库(§4.6 后端 _store_error)。"""
store = SessionStore(db_path=str(tmp_path / "s.db"))
projects = ProjectsStore(db_path=str(tmp_path / "s.db"))
service = GenesisService(store=store, data_root=str(tmp_path / "data"),
engine="fake", projects=projects)
agent = ChatAgent(service=service, fake=True, engine="fake")
sid = service.create_session("u1").session_id
agent._store_error(sid, "解析失败:boom", "generate")
msgs = service.store.list_messages(sid)
assert any(m["role"] == "error" for m in msgs)
def test_chat_state_js_route(client):
"""/chat_state.js 静态路由返回 JS(前端模块可加载,§4.5)。"""
r = client.get("/chat_state.js")
assert r.status_code == 200
assert "application/javascript" in r.headers["content-type"]
assert "GenesisState" in r.text or "autoBindProject" in r.text
+2 -1
View File
@@ -95,7 +95,8 @@ def test_full_flow_http(tmp_path):
prev = client.get(f"/api/sessions/{sid}/result/preview")
assert prev.status_code == 200
assert "html" in prev.json()
assert "text/html" in prev.headers["content-type"]
assert prev.text.lstrip().lower().startswith("<!doctype html>")
dl = client.get(f"/api/sessions/{sid}/result/download")
assert dl.status_code == 200