Files
2026Technology-Competition/docs/plans/2026-08-28-chat-v4-deepseek-session.md
T

746 lines
28 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.
# 聊天前端 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 / SQLitejson_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_sessions153-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_session180-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_messagesA1"
```
---
### 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。<br>请先在上方📎上传<strong>要件定义 xlsx</strong>'
+ (draftProject ? '(模板/规则/代码库已由项目「' + esc(draftProject) + '」提供)' : '与<strong>模板 docx</strong>')
+ ',然后告诉我:<br>· 「生成概要设计书」(自动解析→影响→生成→QA)<br>· 「用中文生成」 / 「现在什么状态?」');
}
}
```
替换 `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 同步 draftProject720-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=<draftProject>`(带 `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 => '<span class="progress-item ' + (p.status || 'ok') + '">' + esc(p.step) + ': ' + esc(p.detail || '') + '</span>').join('');
addMsg('progress', items);
}
let replyHtml = esc(res.reply || '');
const s = res.status;
if (s === 'done' || s === 'writing') {
replyHtml += '<div class="actions">'
+ '<a href="/api/sessions/' + sid + '/result/download">下载 docx</a>'
+ '<a href="/api/sessions/' + sid + '/result/preview">预览</a>'
+ '<a href="/api/sessions/' + sid + '/result/impact-report">影响调查书</a>'
+ '<a href="/api/sessions/' + sid + '/result/qa-report">QA 报告</a></div>';
}
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', '<span class="progress-item ok">上传完成:' + esc(d.file_name) + '' + d.size + 'B</span>');
} 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`(空态为前端行为)。