# 聊天前端 v4(DeepSeek 模式会话)实现计划 > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 将会话交互改为 DeepSeek 模式(进入即空白草稿、首条消息才落库、顶栏切项目仅影响下次新建),并修复 5 条高严重/低风险的前后端不整合点(A1/B5/C1/B6/F1)。 **Architecture:** 后端仅微调 `store.py`(`list_sessions` 增加 project 过滤 + `delete_session` 级联删消息)与 `app.py`(`/api/sessions` 增加 `project` 查询参数);前端 `chat.html` 内嵌 JS 完成状态机重塑(变量重命名 `currentProject`→`draftProject` + 新增 `lastTriggerEl`/`drawerDirty`/`drawerSnapshot`)与 5 处修复。无 schema 迁移、无新依赖。 **Tech Stack:** Python 3.11+ / FastAPI / SQLite(json_extract,带 Python 兜底)/ pytest(覆盖率 `>=99%`);前端原生 JS(内嵌于 chat.html,无构建步骤、无前端测试框架)。 ## Global Constraints - 文件名 ASCII 小写(本计划文件:`2026-08-28-chat-v4-deepseek-session.md`)。 - 代码不含硬编码绝对路径;项目目录经 `ProjectConfig` 解析。 - `.env` / 密钥不入库;API Key 走环境变量。 - 测试命令:`python -m pytest`;覆盖率门槛 `fail_under=99`,全绿方可提交。 - 提交信息沿用仓库风格(中文 + 范围,如 `feat(server): ...`)。 - 文档存 `docs/` 下;AI 交流用中文。 - 前端无自动化测试框架:每个前端任务的「测试」步骤为**具体手动浏览器验收**(取自 spec §5.2),非占位符。 --- ## File Structure - Modify: `src/genesis/server/store.py` — `list_sessions` 增加 `project` 过滤(json_extract + Python 兜底)、`delete_session` 事务内先删 `chat_messages` 再删 `sessions`。 - Modify: `src/genesis/server/app.py` — `GET /api/sessions` 增加 `project: str | None = None` 查询参数。 - Modify: `src/genesis/server/static/chat.html` — 状态变量、`refreshSessions`/`newSession`/`loadSession`/`send`/上传回调/抽屉/`Esc` 处理。 - Test: `tests/test_server_store.py` — 新增 `list_sessions` 过滤与 `delete_session` 级联用例。 - Test: `tests/test_server_api.py` — 新增 `GET /api/sessions?project=` 用例。 约定:会话创建后端返回 `{"session_id","status","name"}`(无 `project` 字段),前端用本地 `draftProject` 作为新建会话的 `activeProject`。 --- ### Task 1: store.list_sessions 支持 project 过滤(后端) **Files:** - Modify: `src/genesis/server/store.py:153-162`(`list_sessions`) - Test: `tests/test_server_store.py` **Interfaces:** - Consumes: `SessionStore._conn()`、`SessionRecord.project`、`_from_dict` - Produces: `list_sessions(user_id, project: str | None = None) -> list[SessionRecord]`(project 非 None 时仅返匹配项;为 None 时返全部,兼容 legacy) - [ ] **Step 1: 写失败测试** ```python # tests/test_server_store.py (追加) import os, tempfile from genesis.server.store import SessionStore def _tmp_store(): fd, p = tempfile.mkstemp(suffix=".db") os.close(fd) os.remove(p) # SessionStore 自行创建 return SessionStore(db_path=p), p def test_list_sessions_filter_by_project(): store, p = _tmp_store() try: a = store.create_session("u", name="A", project="stock") b = store.create_session("u", name="B", project="other") recs = store.list_sessions("u", project="stock") ids = {r.session_id for r in recs} assert a.session_id in ids and b.session_id not in ids finally: os.remove(p) def test_list_sessions_filter_empty_project(): store, p = _tmp_store() try: e = store.create_session("u", name="E") # project 默认 "" store.create_session("u", name="X", project="stock") recs = store.list_sessions("u", project="") assert all(r.project == "" for r in recs) assert e.session_id in {r.session_id for r in recs} finally: os.remove(p) def test_list_sessions_no_project_returns_all(): store, p = _tmp_store() try: store.create_session("u", name="A", project="stock") store.create_session("u", name="B", project="other") assert len(store.list_sessions("u")) == 2 finally: os.remove(p) ``` - [ ] **Step 2: 运行测试确认失败** Run: `python -m pytest tests/test_server_store.py::test_list_sessions_filter_by_project tests/test_server_store.py::test_list_sessions_filter_empty_project tests/test_server_store.py::test_list_sessions_no_project_returns_all -v` Expected: FAIL(`list_sessions() takes 2 positional arguments but 3 were given` 或过滤未生效) - [ ] **Step 3: 最小实现** ```python # src/genesis/server/store.py — 替换 list_sessions(153-162) def list_sessions(self, user_id: str, project: str | None = None) -> list[SessionRecord]: if project is None: with self._conn() as c: rows = c.execute( "SELECT data FROM sessions WHERE user_id = ?", (user_id,) ).fetchall() recs = [self._from_dict(json.loads(r["data"])) for r in rows] else: try: with self._conn() as c: rows = c.execute( "SELECT data FROM sessions WHERE user_id = ?" " AND json_extract(data, '$.project') = ?", (user_id, project), ).fetchall() recs = [self._from_dict(json.loads(r["data"])) for r in rows] except Exception: # json_extract 不可用(旧 SQLite)→ 全量取回后 Python 过滤兜底 with self._conn() as c: rows = c.execute( "SELECT data FROM sessions WHERE user_id = ?", (user_id,) ).fetchall() recs = [self._from_dict(json.loads(r["data"])) for r in rows] recs = [r for r in recs if r.project == project] recs.sort(key=lambda r: r.updated_at, reverse=True) return recs ``` - [ ] **Step 4: 运行测试确认通过** Run: `python -m pytest tests/test_server_store.py -v` Expected: PASS(含 3 个新用例);其余 store 用例仍绿 - [ ] **Step 5: 提交** ```bash git add src/genesis/server/store.py tests/test_server_store.py git commit -m "feat(server): list_sessions 支持 project 过滤(json_extract + Python 兜底)" ``` --- ### Task 2: store.delete_session 级联删除消息(A1) **Files:** - Modify: `src/genesis/server/store.py:180-183`(`delete_session`) - Test: `tests/test_server_store.py` **Interfaces:** - Consumes: `add_message(session_id, role, content, action=None)`、`list_messages(session_id)` - Produces: `delete_session(session_id) -> bool`(先删 `chat_messages` 再删 `sessions`,原子) - [ ] **Step 1: 写失败测试** ```python # tests/test_server_store.py (追加) def test_delete_session_cascades_messages(): store, p = _tmp_store() try: rec = store.create_session("u", name="A", project="stock") store.add_message(rec.session_id, "user", "你好") store.add_message(rec.session_id, "assistant", "收到") assert len(store.list_messages(rec.session_id)) == 2 ok = store.delete_session(rec.session_id) assert ok is True assert store.list_messages(rec.session_id) == [] # 消息已级联删除 finally: os.remove(p) ``` - [ ] **Step 2: 运行测试确认失败** Run: `python -m pytest tests/test_server_store.py::test_delete_session_cascades_messages -v` Expected: FAIL(删除后 `list_messages` 仍返回 2 条) - [ ] **Step 3: 最小实现** ```python # src/genesis/server/store.py — 替换 delete_session(180-183) def delete_session(self, session_id: str) -> bool: with self._conn() as c: c.execute("DELETE FROM chat_messages WHERE session_id = ?", (session_id,)) cur = c.execute("DELETE FROM sessions WHERE session_id = ?", (session_id,)) return cur.rowcount > 0 ``` - [ ] **Step 4: 运行测试确认通过** Run: `python -m pytest tests/test_server_store.py::test_delete_session_cascades_messages -v` Expected: PASS - [ ] **Step 5: 提交** ```bash git add src/genesis/server/store.py tests/test_server_store.py git commit -m "fix(server): delete_session 级联删除 chat_messages(A1)" ``` --- ### Task 3: app.py GET /api/sessions 增加 project 参数 **Files:** - Modify: `src/genesis/server/app.py:156-162`(`list_sessions` 端点) - Test: `tests/test_server_api.py` **Interfaces:** - Consumes: `service.store.list_sessions(user_id, project)` - Produces: `GET /api/sessions?user_id=&project=` 返回按 project 过滤的会话列表 - [ ] **Step 1: 写失败测试** ```python # tests/test_server_api.py (追加,复用现有 client fixture) def test_api_list_sessions_filter_by_project(client): r0 = client.post("/api/sessions", json={"user_id": "default", "project": "stock"}) r1 = client.post("/api/sessions", json={"user_id": "default", "project": "other"}) res = client.get("/api/sessions?user_id=default&project=stock") assert res.status_code == 200 ids = {x["session_id"] for x in res.json()} assert r0.json()["session_id"] in ids assert r1.json()["session_id"] not in ids ``` - [ ] **Step 2: 运行测试确认失败** Run: `python -m pytest tests/test_server_api.py::test_api_list_sessions_filter_by_project -v` Expected: FAIL(`project` 参数被忽略,两个会话都返回) - [ ] **Step 3: 最小实现** ```python # src/genesis/server/app.py — 替换 list_sessions 端点(156-162) @app.get("/api/sessions") def list_sessions(user_id: str = "default", project: str | None = None): return [ {"session_id": r.session_id, "name": r.name, "project": r.project, "status": r.status, "updated_at": r.updated_at} for r in service.store.list_sessions(user_id, project=project) ] ``` - [ ] **Step 4: 运行测试确认通过** Run: `python -m pytest tests/test_server_api.py::test_api_list_sessions_filter_by_project -v` Expected: PASS - [ ] **Step 5: 提交** ```bash git add src/genesis/server/app.py tests/test_server_api.py git commit -m "feat(server): GET /api/sessions 支持 project 查询参数" ``` --- ### Task 4: 前端状态机重塑 — 变量重命名 + 空白草稿 + 空态提示 **Files:** - Modify: `src/genesis/server/static/chat.html`(状态变量声明、新增 `renderEmptyOrWelcome`、`newSession` 重置、`loadSession` 同步 `draftProject`、启动逻辑) **Interfaces:** - Consumes: `api()`、`addMsg()`、`refreshSessions()`(Task 5 改造)、`updateUploadBar()`、`loadProjects()` - Produces: `draftProject`(替代 `currentProject`);空白草稿态不创建会话;`draftProject===null` 时内容区显示固定文案「请新建项目或者选择项目」 > 前端无自动化测试;本任务验收 = 手动浏览器核对 spec §5.2 第 1、4 条。 - [ ] **Step 1: 写失败验收(手动)** 打开 `scripts/serve.py --fake`,浏览器访问 `/`: - 现状:进入即 `POST /api/sessions` 创建会话,顶栏显示「未创建会话」被替换。 - 期望(改后):进入显示空白聊天;`draftProject` 为具体项目时显示欢迎语;`draftProject===null` 时内容区显示「请新建项目或者选择项目」。 - [ ] **Step 2: 实现 — 状态变量(448-453)** ```javascript // 替换 let sid = null; let currentProject = null; let activeProject = null; let projects = []; let drawerOpen = false; let drawerSelected = null; // 当前抽屉中编辑的项目名(null = 新建模式) // 为 let sid = null; let draftProject = null; // 下一空白会话将绑定/已绑的项目(原 currentProject) let activeProject = null; // 当前已落库会话绑定的项目(来自后端) let projects = []; let drawerOpen = false; let drawerSelected = null; let lastTriggerEl = null; // 打开抽屉/下拉前的焦点元素(C1) let drawerDirty = false; // 抽屉表单是否有未保存改动(B6) let drawerSnapshot = null; // loadDrawerForm 时的字段快照(B6) ``` - [ ] **Step 3: 实现 — 新增 renderEmptyOrWelcome + 改造 newSession** 在 `addMsg` 附近新增: ```javascript function renderEmptyOrWelcome() { chatEl.innerHTML = ''; if (draftProject === null) { addMsg('assistant', '请新建项目或者选择项目'); } else { addMsg('assistant', '你好!我是 Genesis 概要设计书生成 Agent。
请先在上方📎上传要件定义 xlsx' + (draftProject ? '(模板/规则/代码库已由项目「' + esc(draftProject) + '」提供)' : '与模板 docx') + ',然后告诉我:
· 「生成概要设计书」(自动解析→影响→生成→QA)
· 「用中文生成」 / 「现在什么状态?」'); } } ``` 替换 `newSession`(706-718)为: ```javascript async function newSession() { sid = null; activeProject = null; try { localStorage.removeItem('genesis_session'); } catch (e) { /* 忽略 */ } chatEl.innerHTML = ''; badge.textContent = '未创建会话'; updateUploadBar(); await refreshSessions(); renderEmptyOrWelcome(); } ``` - [ ] **Step 4: 实现 — loadSession 同步 draftProject(720-739)** 在 `activeProject = rec.project || null;` 之后增加一行: ```javascript activeProject = rec.project || null; draftProject = rec.project || null; // 载入既有会话时,顶栏与侧边栏过滤对齐该会话项目 applyProjectContext(); ``` - [ ] **Step 5: 实现 — 启动逻辑(821-833)** 替换启动 IIFE 为: ```javascript (async () => { try { const collapsed = localStorage.getItem('genesis_sidebar_collapsed') === '1'; if (collapsed) document.getElementById('sidebar').classList.add('collapsed'); } catch (e) { /* 忽略 */ } await loadProjects(); const saved = (() => { try { return localStorage.getItem('genesis_session'); } catch (e) { return null; } })(); if (saved) { try { await loadSession(saved); return; } catch (e) { /* 忽略,新建 */ } } newSession().catch(e => addMsg('error', esc(String(e)))); })(); ``` - [ ] **Step 6: 运行手动验收** Run: `python -m pytest tests/test_server_*.py -q`(确认后端未回归) 浏览器核对:启动无会话 → 选具体项目显示欢迎语;选「未选择项目」显示「请新建项目或者选择项目」;点「+ 新会话」回到空白草稿且不创建会话(Network 无 `POST /api/sessions`)。 - [ ] **Step 7: 提交** ```bash git add src/genesis/server/static/chat.html git commit -m "feat(chat): 会话状态机重塑——空白草稿态 + draftProject 重命名 + 空态提示" ``` --- ### Task 5: 侧边栏按项目过滤(refreshSessions 改造) **Files:** - Modify: `src/genesis/server/static/chat.html`(`refreshSessions` 484-505) **Interfaces:** - Consumes: `draftProject`、`api()` - Produces: `draftProject===null` 时侧边栏为空;否则 `GET /api/sessions?user_id=default&project=`(带 `encodeURIComponent`) - [ ] **Step 1: 写失败验收(手动)** 有两个项目各 1 个历史会话时,切换顶栏项目 → 侧边栏只显示当前项目会话。 - [ ] **Step 2: 实现 — refreshSessions** 替换 `refreshSessions`(484-505)开头部分为: ```javascript async function refreshSessions() { const box = document.getElementById('session-list'); if (draftProject === null) { box.innerHTML = ''; return; } // 未选项目 → 无历史 const list = await api('GET', '/api/sessions?user_id=default&project=' + encodeURIComponent(draftProject)); box.innerHTML = ''; list.sort((a, b) => (b.updated_at || '').localeCompare(a.updated_at || '')); for (const s of list) { ``` (后续 `for (const s of list) { ... }` 渲染逻辑保持不变,仅循环源从 `list` 改为已排序 `list`——原代码已在循环前 sort,结构一致,直接沿用原 489 行起的渲染体。) - [ ] **Step 3: 运行手动验收** Run: `python -m pytest tests/test_server_*.py -q` 浏览器核对 spec §5.2 第 2 条:顶栏切项目 → 侧边栏过滤、localStorage 清。 - [ ] **Step 4: 提交** ```bash git add src/genesis/server/static/chat.html git commit -m "feat(chat): 侧边栏按 draftProject 过滤会话列表" ``` --- ### Task 6: 首条消息创建会话(send 改造,D-v4 核心) **Files:** - Modify: `src/genesis/server/static/chat.html`(`send` 741-772) **Interfaces:** - Consumes: `sid`、`draftProject`、`api()`、`showTyping()`、`addMsg()` - Produces: `sid===null` 时先 `POST /api/sessions`(带 `draftProject`)再发消息;`activeProject = draftProject` - [ ] **Step 1: 写失败验收(手动)** 空白草稿态发首条消息 → Network 先出现 `POST /api/sessions`,随后 `POST /api/chat/{sid}/messages`;侧边栏出现该会话;`localStorage['genesis_session']` 写入。 - [ ] **Step 2: 实现 — 完整替换 send** 替换 `send`(741-772)为: ```javascript async function send() { const text = inputEl.value.trim(); if (!text) return; if (!sid) { // 首条消息:先落库会话(D-v4) try { const d = await api('POST', '/api/sessions', { user_id: 'default', project: draftProject || null }); sid = d.session_id; activeProject = draftProject || null; badge.textContent = '会话: ' + (d.name || '新会话'); try { localStorage.setItem('genesis_session', sid); } catch (e) { /* 忽略 */ } await refreshSessions(); } catch (e) { addMsg('error', '创建会话失败:' + esc(String(e))); return; } } inputEl.value = ''; addMsg('user', esc(text)); const typing = showTyping(); sendBtn.disabled = true; try { const res = await api('POST', '/api/chat/' + sid + '/messages', { content: text }); typing.remove(); if (res.progress && res.progress.length) { const items = res.progress.map(p => '' + esc(p.step) + ': ' + esc(p.detail || '') + '').join(''); addMsg('progress', items); } let replyHtml = esc(res.reply || ''); const s = res.status; if (s === 'done' || s === 'writing') { replyHtml += '
' + '下载 docx' + '预览' + '影响调查书' + 'QA 报告
'; } addMsg('assistant', replyHtml); } catch (e) { typing.remove(); addMsg('error', '请求失败:' + esc(String(e))); } finally { sendBtn.disabled = false; inputEl.focus(); } } ``` - [ ] **Step 3: 运行手动验收** Run: `python -m pytest tests/test_server_*.py -q` 浏览器核对 spec §5.2 第 4 条。 - [ ] **Step 4: 提交** ```bash git add src/genesis/server/static/chat.html git commit -m 'feat(chat): 首条消息才创建会话(DeepSeek 模式核心)' ``` --- ### Task 7: 上传门禁 + B5 状态同步 + F1 失败保留文件 **Files:** - Modify: `src/genesis/server/static/chat.html`(上传回调 793-819) **Interfaces:** - Consumes: `sid`、`activeProject`、`api()`、`refreshSessions()`、`updateUploadBar()` - Produces: `sid===null` 时拒绝上传并提示;成功后 `activeProject = rec.project || null` + `refreshSessions()`(B5);失败后不清空 `file-input.value` 且展示 `detail.message`(F1) - [ ] **Step 1: 写失败验收(手动)** 空白草稿态点「上传」→ 提示「请先发送一条消息以创建会话」,不上传。已建会话上传成功 → 状态行/徽章正确。上传失败(如断网)→ 文件仍选中可重试。 - [ ] **Step 2: 实现 — 上传回调** 替换上传 `click` 处理器(793-819)为: ```javascript 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 file = document.getElementById('file-input').files[0]; if (!file) { addMsg('error', '请选择文件'); return; } const fd = new FormData(); fd.append('file_type', ft); fd.append('file', file); const statusEl = document.getElementById('upload-status'); statusEl.textContent = '上传中…'; try { const r = await fetch('/api/sessions/' + sid + '/files', { method: 'POST', body: fd }); const d = await r.json(); if (!r.ok) throw new Error(d.detail?.message || r.status); statusEl.textContent = ''; // B5:上传成功后同步 activeProject 并刷新侧边栏状态 const rec = await api('GET', '/api/sessions/' + sid); activeProject = rec.project || null; badge.textContent = '会话: ' + (rec.name || '新会话'); await refreshSessions(); document.getElementById('file-input').value = ''; // 仅成功时清空 addMsg('user', '[上传] ' + ft + ':' + d.file_name); addMsg('progress', '上传完成:' + esc(d.file_name) + '(' + d.size + 'B)'); } catch (e) { statusEl.textContent = ''; // F1:失败保留 file-input 值,展示后端明细 addMsg('error', '上传失败:' + esc(String(e))); } }); ``` - [ ] **Step 3: 运行手动验收** Run: `python -m pytest tests/test_server_*.py -q` 浏览器核对 spec §5.2 第 3、5、6 条。 - [ ] **Step 4: 提交** ```bash git add src/genesis/server/static/chat.html git commit -m 'fix(chat): 上传门禁(sid 门禁) + B5 状态同步 + F1 失败保留文件' ``` --- ### Task 8: Esc 关闭后焦点还原(C1) **Files:** - Modify: `src/genesis/server/static/chat.html`(`ps-current` 点击、`ps-manage` 点击、`Esc` 处理 782-791) **Interfaces:** - Consumes: `lastTriggerEl`、`toggleProjectSwitcher()`、`closeProjectDrawer()` - Produces: 打开抽屉/下拉前记录 `lastTriggerEl`;`Esc` 关闭后 `lastTriggerEl.focus()` - [ ] **Step 1: 写失败验收(手动)** 打开项目抽屉(点「项目管理」)→ `Esc` 关闭 → 焦点回到「项目管理」触发按钮。打开顶栏下拉 → `Esc` → 焦点回到「项目」按钮。 - [ ] **Step 2: 实现 — 记录触发元素** `ps-current` 点击(778)改为: ```javascript document.getElementById('ps-current').addEventListener('click', (e) => { e.stopPropagation(); lastTriggerEl = e.currentTarget; toggleProjectSwitcher(); }); ``` `ps-manage` 点击(568-573 区域)在 `openProjectDrawer` 前记录: ```javascript psMenu.querySelectorAll('.ps-item[data-manage]').forEach(it => { it.onclick = () => { lastTriggerEl = it; toggleProjectSwitcher(false); openProjectDrawer(drawerSelected); }; }); ``` - [ ] **Step 3: 实现 — Esc 还原焦点** 替换 `Esc` 处理(786-791)为: ```javascript document.addEventListener('keydown', (e) => { if (e.key === 'Escape') { if (psMenu.classList.contains('open')) toggleProjectSwitcher(false); else if (drawerOpen) closeProjectDrawer(); if (lastTriggerEl) { lastTriggerEl.focus(); lastTriggerEl = null; } } }); ``` - [ ] **Step 4: 运行手动验收** Run: `python -m pytest tests/test_server_*.py -q` 浏览器核对 spec §5.2 第 8 条。 - [ ] **Step 5: 提交** ```bash git add src/genesis/server/static/chat.html git commit -m 'fix(chat): Esc 关闭抽屉/下拉后焦点还原到触发元素(C1)' ``` --- ### Task 9: 抽屉未保存变更提示(B6) **Files:** - Modify: `src/genesis/server/static/chat.html`(`loadDrawerForm` 快照、`renderDrawerList`/`ps-manage`/`closeProjectDrawer`/`renderProjectSwitcher` 守卫) **Interfaces:** - Consumes: `drawerOpen`、`drawerSnapshot`、`isDrawerDirty()`(新增) - Produces: 切换抽屉项/关闭抽屉/切项目/切管理前若有未保存改动 → `confirm('有未保存的修改,确定放弃?')` - [ ] **Step 1: 写失败验收(手动)** 打开项目抽屉 → 改某字段 → 点另一个项目 / 点「项目管理」/ `Esc` 关闭 → 弹出「有未保存的修改,确定放弃?」;点「确定」才离开,点「取消」停留。 - [ ] **Step 2: 实现 — 快照与脏检测** 在 `loadDrawerForm` 末尾(`renderDrawerList();` 之后)追加快照采集,并新增三个辅助函数(放在 `loadDrawerForm` 附近): ```javascript 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(''); } function isDrawerDirty() { if (!drawerOpen || drawerSnapshot === null) return false; return drawerSnapshotNow() !== drawerSnapshot; } function guardDrawerDirty() { if (isDrawerDirty()) return confirm('有未保存的修改,确定放弃?'); return true; } ``` 在 `loadDrawerForm` 函数体最后追加: ```javascript drawerSnapshot = drawerSnapshotNow(); ``` - [ ] **Step 3: 实现 — 守卫插入** `renderDrawerList` 项点击(634-639)加守卫: ```javascript it.onclick = () => { if (!guardDrawerDirty()) return; const n = it.dataset.name === '' ? null : it.dataset.name; loadDrawerForm(n); }; ``` `ps-manage` 点击(Task 8 已改)加守卫: ```javascript it.onclick = () => { if (!guardDrawerDirty()) return; lastTriggerEl = it; toggleProjectSwitcher(false); openProjectDrawer(drawerSelected); }; ``` `closeProjectDrawer`(589-592)加守卫: ```javascript function closeProjectDrawer() { if (!guardDrawerDirty()) return; drawerOpen = false; drawer.classList.remove('open'); } ``` `renderProjectSwitcher` 项点击(559-567)加守卫: ```javascript psMenu.querySelectorAll('.ps-item[data-name]').forEach(it => { it.onclick = () => { if (!guardDrawerDirty()) return; currentProject = it.dataset.name === '' ? null : it.dataset.name; applyProjectContext(); renderProjectSwitcher(); updateUploadBar(); toggleProjectSwitcher(false); }; }); ``` - [ ] **Step 4: 运行手动验收** Run: `python -m pytest tests/test_server_*.py -q` 浏览器核对 spec §5.2 第 7 条。 - [ ] **Step 5: 提交** ```bash git add src/genesis/server/static/chat.html git commit -m 'fix(chat): 抽屉未保存变更守卫提示(B6)' ``` --- ## Self-Review(写毕自查) **1. Spec 覆盖**: - §1.1 生命周期(进入空白/切项目/首条消息/继续/新会话/删除)→ Task 4/5/6/7 覆盖。 - §1.3 空态文案「请新建项目或者选择项目」→ Task 4 `renderEmptyOrWelcome` 硬编码。 - §1.5 上传门禁(禁只上传不发消息)→ Task 7 `if (!sid)` 守卫。 - §2.2 `list_sessions` project 过滤 → Task 1。 - §2.3 级联删除 → Task 2。 - §2.4 端点参数 → Task 3。 - §3 前端状态机(变量/启动/切项目/新会话/首消息/上传/侧边栏/删除)→ Task 4-7。 - §4 A1/B5/C1/B6/F1 → Task 2/7/8/9/7。 - 无遗漏。 **2. 占位符扫描**:无 TBD/TODO;前端「测试」步骤均为具体手动验收(环境无前端测试框架,已在 Global Constraints 声明)。无任何笔误占位行。 **3. 类型/命名一致性**:`draftProject` 在 Task 4 声明、Task 5/6/7 引用一致;`lastTriggerEl` Task 4 声明、Task 8 使用;`drawerSnapshot`/`isDrawerDirty`/`guardDrawerDirty` 在 Task 9 内定义并使用;后端 `list_sessions(user_id, project)` 签名在 Task 1 定义、Task 3 端点透传一致。无命名漂移。 **4. 与 spec 的一处澄清**:spec §2.4 原文「project 为空(None)时不加过滤条件……返回空历史」存在内部张力。本计划采用:**后端 `list_sessions(project=None)` 返回全部(legacy 兼容);前端 `draftProject===null` 时 `refreshSessions` 直接渲染空列表且不发请求**,从而满足用户裁定「未选择项目 → 侧边栏无历史」。对应测试 `test_list_sessions_no_project_returns_all`(返全部)取代 spec 原 `test_list_sessions_no_project_returns_empty_for_v4`(空态为前端行为)。