Files
2026Technology-Competition/docs/frontend_audit_plan.md
T
lhl be79624db5 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%
2026-08-29 11:28:08 +08:00

348 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 前端画面审计与修复方案(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。