- 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%
348 lines
19 KiB
Markdown
348 lines
19 KiB
Markdown
# 前端画面审计与修复方案(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`(738–744, 864)→ 其余 4 种上传入口消失,与标签矛盾。
|
||
- 项目配置抽屉用"目录路径"配置 rules/code/design,上传栏用"文件"——两套输入模型互相隐藏、不一致。
|
||
|
||
### 2.4 前后端契约错误:预览返回 JSON
|
||
- 前端 `send()` 预览链接 `href="/api/sessions/{sid}/result/preview"`(line 827)期望打开 HTML;
|
||
- 后端 `result_preview`(app.py:269–272)返回 `{"html": …}`,浏览器直接显示原始 JSON 文本而非文档预览。
|
||
- `service.result_preview` 本身返回的是 HTML 字符串(service.py:291–306),被错误包裹了一层 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 760–769):
|
||
```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 — 预览契约修复
|
||
现状(269–272):
|
||
```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`(约 782–784):**
|
||
```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`(369–380)中 `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 前端核心逻辑单测(Node,F3)
|
||
- 文件:`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。
|