Files
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

19 KiB
Raw Permalink Blame History

前端画面审计与修复方案(Genesis 概要设计书生成 Agent

  • 文档类型:实施方案(Implementation Plan
  • 关联画面:src/genesis/server/static/chat.html(唯一前端单页)
  • 关联后端:src/genesis/server/app.pysrc/genesis/server/service.pysrc/genesis/server/store.pysrc/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 声明块(456464只声明了 draftProject,从未声明 currentProject。 非严格模式下它退化为隐式全局变量,初始值为 undefined

注释 line 457draftProject = null; // 下一空白会话将绑定/已绑的项目(原 currentProject 说明这是一次未完成的重命名重构遗留。

后果链:

  1. loadProjects 启动自动绑定 if (currentProject === null …)line 545)因 undefined !== null 永不触发 → 首个项目永不自动选中。
  2. updateUploadBarcurrentProject(line 738),但建会话/上传用 draftProject/activeProject800,802,864)→ UI 状态与实际上传/绑定项目不一致。
  3. newSession 无条件 draftProject = nullline 761),而顶栏 currentProject 未被同步 → 选了项目再点"新会话",顶栏显示已选项目、新会话却未绑定、侧边栏会话列表被清空(497)。

2.2 顶栏项目与真实会话脱节

  • 顶栏 psName 显示 currentProjectsid-badge 显示会话真实项目,二者可不一致。
  • 上传提示(line 740)用 currentProject,而实际上传用 activeProject/draftProject → 提示可能显示错误项目名。

2.3 上传入口与项目模式互斥、标签误导

  • 上传栏有 5 种类型(requirements/template/write_instruction/rules/existing_system,部分标"(必需)"), 但一旦 activeProject || currentProject 为真即隐藏整个下拉并强制 requirements738744, 864)→ 其余 4 种上传入口消失,与标签矛盾。
  • 项目配置抽屉用"目录路径"配置 rules/code/design,上传栏用"文件"——两套输入模型互相隐藏、不一致。

2.4 前后端契约错误:预览返回 JSON

  • 前端 send() 预览链接 href="/api/sessions/{sid}/result/preview"line 827)期望打开 HTML
  • 后端 result_previewapp.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 持久化 userassistant 消息(agent.py:24,35,55,63…)。
  • progress 列表仅作为 API 响应返回,从不写入存储;错误回复以 assistant 角色入库。
  • 结果:list_messages 返回结构为 {role, content, action, created_at}type 字段,历史中永不出现 progress/error
  • 因此重载会话时进度/错误既无数据也无样式——这是本次要补的缺口。

2.6 次要/死代码

  • CSS header h1line 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_agentprogress 条目以 role='progress' 入库、错误回复以 role='error' 入库;loadSessionrole 渲染进度/错误样式。
  7. 抽取核心状态逻辑到 chat_state.jsF3),由 chat.html 引入,配套 Node 单测。
  8. (可选)header<h1> 标题。

4. 详细改动(文件/函数级)

4.1 chat.html — 变量统一(合并原 §4.3)

  • 删除所有 currentProject 引用,替换为 draftProject,逐处核对语义(注意 activeProject 语义不同,不可混淆)。
  • 受影响位置(逐个替换,保持语义):
    • line 545if (draftProject === null && projects.length > 0) draftProject = projects[0].name;
    • line 547else if (draftProject && !projects.find(p => p.name === draftProject))
    • line 549draftProject = projects.length > 0 ? projects[0].name : null;
    • line 558psName.textContent = draftProject || '未选择';
    • line 562(draftProject === null ? ' active' : '')
    • line 565(p.name === draftProject ? ' active' : '')
    • line 574draftProject = it.dataset.name === '' ? null : it.dataset.name;
    • line 697draftProject = cfg.name;saveDrawerProject
    • line 718if (draftProject === drawerSelected)
    • line 720draftProject = null;
    • line 738if (activeProject || draftProject)
    • line 740:提示文案用 activeProject || draftProject

4.2 chat.html — newSession 保留项目(F2

现状(line 760769):

async function newSession() {
  sid = null;
  activeProject = null;
  draftProject = null;              // ← 问题:无条件清空
  ...
}

改为:保留 draftProject(当前顶栏已选项目),使新会话继承项目绑定:

async function newSession() {
  sid = null;
  activeProject = null;
  // 保留 draftProject(当前顶栏已选项目),使新会话继承项目绑定
  badge.textContent = '未创建会话';
  updateUploadBar();
  await refreshSessions();
  renderEmptyOrWelcome();
}

"不选择项目"入口:在 renderProjectSwitcher 的"不选择项目"点击处理中,调用 newSession 前先 draftProject = null,从而显式获得无项目会话。

4.3 app.py — 预览契约修复

现状(269272):

@app.get("/api/sessions/{sid}/result/preview")
def result_preview(sid: str):
    html = service.result_preview(sid)
    return {"html": html}

改为(HTMLResponse 已在 app.py:18 导入):

@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.jsF3

UMD 形式,浏览器挂 window.GenesisState、Node 可 require,导出纯函数(不触碰 DOM):

(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 静态路由

@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)
    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_messagerole 列本为 TEXT,已可存 'progress'/'error'无需改表结构

前端 chat.html loadSession(约 782784):

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 — 顶栏标题(可选)

header369380)中 proj-switcher 之前插入:

<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 命中当前 sidnewSession(),继承 draftProject,不丢失项目上下文。
  • 删除已绑定项目deleteDrawerProjectdraftProject 置 null(与"不选择项目"等价),UI 回到无项目态。
  • 抽屉未保存守卫guardDrawerDirty() 在切换/关闭前拦截,已有,保持不变。
  • 预览无结果:后端 result_previewresult_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.jsNode 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)falseshouldHideUploadSelect(null, 'A')trueshouldHideUploadSelect('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-typetext/html 且响应体以 <!DOCTYPE html> 开头(非 {"html":)。
  • test_result_preview_missing:无结果文档时返回 404RESULT_NOT_FOUND)。
  • test_persist_progress_and_error:用 TestClient 走 建会话→上传要件定义→generatefake engine),断言 list_messagesrole='progress' 的条目;另测一个错误路径(如无模板时 generate)断言含 role='error'

7.3 集成(端到端冒烟,pytest + TestClient

  • 建会话→上传要件定义(fake 样本)→start-parseconfirm-parsegeneratefake engine)→result/preview 返回 HTML。
  • 断言 preview 链接可达且为 HTML;断言历史消息含 progress 角色。

7.4 运行要求

  • 7.1 需要 Node 运行时;若 CI 无 Node,则该层测试跳过并在文档标注(不阻塞主流程)。

8. 性能与风险

  • 改动均为客户端状态变量、单端点响应类型、少量消息持久化,无新增计算/网络开销。
  • 风险点:全局替换 currentProjectdraftProject 需逐处核对语义,避免误改 activeProject
  • 持久化进度消息会增加少量 DB 写入(每条进度 1 行),规模小,可忽略。
  • 回滚:纯前端 + 单端点 + 消息角色扩展(TEXT 列兼容),可逐文件 revert,不影响表结构。

9. 验收标准

  1. 启动有项目时顶栏自动显示项目名,且欢迎语进入"已选项目"分支。
  2. 选/建项目后新建会话,会话正确绑定该项目,侧边栏列表按该项目过滤;点"+新会话"继承已选项目(F2)。
  3. 上传栏在绑定项目时仅显示要件定义入口;无项目时显示全部 5 种。
  4. 点击"预览"在新标签页渲染文档 HTML(不再显示 JSON)。
  5. 重新载入会话时进度/错误消息按样式正确显示(F1)。
  6. 上述 7.17.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_messagerole 为 TEXT 列,可直接存 'progress'/'error',无需改表。

剩余关注

  • 7.1 需 Node 运行时;若目标部署环境无 Node,前端单测层需在 CI 中跳过并标注(不影响后端 pytest)。
  • chat_state.js 必须由 app.py 正确以 /chat_state.js 提供,否则主脚本依赖缺失;测试需覆盖该路由 200。