docs: 新增 chat v4 DeepSeek 会话模式设计文档与实现计划 + 更新 AI 使用日志
This commit is contained in:
@@ -0,0 +1,745 @@
|
||||
# 聊天前端 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。<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 同步 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=<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`(空态为前端行为)。
|
||||
Reference in New Issue
Block a user