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
+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,
};
});