13 KiB
13 KiB
概要设计书生成 Agent — 聊天前端 v4(DeepSeek 模式会话)设计文档
- 文档类型:架构/功能设计(Superpowers brainstorming 产物)
- 创建日期:2026-08-28
- 状态:设计已确认,待实现
- 范式步骤:架构设计 → Agent 实现(本轮仅前端聊天 + 后端会话端点微调)
- 关联文档:
docs/design-web-chat-v3.md(前一轮 v3 设计) - 不在本轮范围:RAG 化(项目配置目录预存向量库 + 检索)— 列为下一轮独立子项目
0. 背景与目标
v3 已实现"Web 服务化 + 聊天式交互 + 项目级配置 + 前端美化"。但在前端页面(chat.html)与后端实现之间存在若干不整合点,且会话交互模型与常见产品(DeepSeek 式"进入即空白、首条消息才落库")有体验落差。
本轮目标:
- 会话生命周期改为 DeepSeek 模式:进入页面为空白草稿;首次用户动作(消息)才创建会话并落库;顶栏切换项目仅影响"下一次新建会话",不改动已存在会话。
- 修复 5 条高严重/低风险的不整合点:A1(删会话不级联消息)、B5(上传后状态不同步)、C1(Esc 关闭后焦点不还原)、B6(抽屉未保存变更静默丢失)、F1(上传失败清空文件输入)。
- 维持 Python 测试
>=99%覆盖率门槛与提交规范(ASCII 文件名、无硬编码绝对路径、.env 不入库等)。
1. 业务语义(核心契约)
1.1 会话生命周期 v4
| 阶段 | 触发 | 后端动作 | 前端动作 |
|---|---|---|---|
| 进入空白态 | 启动 / 刷新 / 顶栏切项目 / 点"+ 新会话" | 不调 | 欢迎语(或空态提示)+ 空白聊天;localStorage 不写;侧边栏显示当前 draftProject 下的历史会话 |
| 切项目 | 顶栏下拉选项目 | 不调 | 重置聊天区为空白;展示欢迎语或空态提示;draftProject 更新;侧边栏列表刷新(按新项目过滤);sid=null、清 localStorage |
| 首条消息 | 用户在空白态发首条消息 | POST /api/sessions(带 draftProject)→ 拿 sid |
写 localStorage;该会话才进入侧边栏历史;随后 POST /api/chat/{sid}/messages |
| 继续对话 | 已有 sid 的会话中发消息 |
POST /api/chat/{sid}/messages |
正常聊天 |
| 新建空白 | 点"+ 新会话" | 不调 | 清空聊天区 + 欢迎语;sid=null、清 localStorage;顶栏项目不重置(保持 draftProject) |
| 删除会话 | hover 会话 × → confirm | DELETE /api/sessions/{sid}(级联 chat_messages) |
删的是当前 sid → 进入空白态;删的不是当前 → 仅刷侧边栏 |
1.2 项目归属
SessionRecord.project在首条消息时落库,之后 locked(不可改)。draftProject(前currentProject):仅作用于下一次空白态→首消息时绑定的项目。- 切换
draftProject不影响已存在的会话(包括当前已问答的sid)。
1.3 空态提示文案(固定)
-
当
draftProject为null("未选择项目")且处于空白态时,内容区不展示欢迎语,改为展示固定文案:请新建项目或者选择项目
-
当
draftProject为具体项目时,展示欢迎语(沿用 v3 文案)。 -
侧边栏在
draftProject=null时无历史会话(后端GET /api/sessions不传project,v4 语义返回空)。
1.4 兼容 v3 行为
- 已存在的 v3
localStorage['genesis_session']值:启动时仍尝试加载;如该sid不存在则忽略并进入空白态;如存在则进入"已问答态"。 - 旧 v3
currentProject命名:本次重命名为draftProject(更精确),不与"已问答会话的activeProject"混淆。
1.5 上传与发消息的约束(D-v4 裁定)
- 允许:只发消息不上传(纯对话 / 让后端返回需要件定义提示)。
- 允许:上传 + 发消息(先发消息建会话 → 上传要件 → 再发"生成")。
- 禁止:只上传不发消息。
- 实现:空白草稿态(
sid===null)下点击上传 → 不执行上传,提示「请先发送一条消息以创建会话」并聚焦输入框;sid存在后上传照常。
2. 后端变更
2.1 端点改动一览
| 端点 | 现状 | v4 变更 | 原因 |
|---|---|---|---|
POST /api/sessions |
body name/project |
不变 | 仍接收 project;前端仅在首条消息时调用 |
GET /api/sessions?user_id= |
无 project 过滤 | 新增 ?project= 可选参数 |
侧边栏按项目过滤 |
GET /api/sessions/{sid} |
完整 record | 不变 | 加载时仍可用 |
DELETE /api/sessions/{sid} |
只删 sessions 行 | 级联删 chat_messages(A1) |
防数据泄漏 |
POST /api/sessions/{sid}/files 等其余端点 |
— | 0 改动 | 不在本轮 spec 范围 |
2.2 GET /api/sessions?project= 过滤
store.py list_sessions 改为支持可选 project 参数:
- 现状:
SELECT data FROM sessions WHERE user_id = ? - 改为:
WHERE user_id = ?+ 可选AND json_extract(data, '$.project') = ? data字段是 JSON 字符串(store.py写入整条 JSON)。SQLite 3.38+ 支持json_extract。- Fallback 策略:启动时一次性探测
SELECT json_extract('{"a":1}', '$.a');若抛错则走 Python 端过滤(先取全部user_id行,json.loads比对project字段)。一次只几条~几十条,无性能问题。 - 默认排序(
updated_at desc)保留。 project为空字符串(''或None)的处理:v4 语义下,前端在draftProject=null时不传project参数,后端按"查全部但仅返project为空的历史遗留会话"实现——实际即project IS NULL OR json_extract(...) = ''。此分支仅在兼容 legacy 时命中,新 UI 不主动引导。
2.3 DELETE 级联(A1)
store.py delete_session 改为单连接事务内两步:
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
- 单个
with self._conn() as c:上下文内执行两条 SQL,保证原子性。 - 不引入
FOREIGN KEY ... ON DELETE CASCADE:历史数据无 FK,引入需迁移;应用层手动级联已足够。 - 文档标注"删除会话必须经由
delete_session"。
2.4 app.py 改动
@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, ...}
for r in service.store.list_sessions(user_id, project=project)
]
project为空(None)时不加过滤条件(兼容 legacy;v4 前端在draftProject=null时本就不传,返回空历史)。project为具体值(如stock)时,后端走json_extract或 Python fallback 过滤。
2.5 测试(后端,Python)
| 用例 | 断言 |
|---|---|
test_delete_session_cascades_messages |
创建会话→发 2 条消息→删会话→list_messages 返空 |
test_list_sessions_filter_by_project |
创建项目 A、B 下各一会话→?project=stock 只返 stock 那条 |
test_list_sessions_no_project_returns_empty_for_v4 |
draftProject=null(不传 project)→ 返空(v4 语义) |
test_list_sessions_python_fallback |
mock json_extract 抛错→走 Python 过滤分支仍正确 |
旧 test_delete_session / test_list_sessions |
仍绿 |
3. 前端状态机(chat.html v4)
3.1 状态变量(重命名 + 新增)
| 变量 | 含义 | 变更 |
|---|---|---|
draftProject |
下一空白会话将绑定/已绑的项目 | 重命名(原 currentProject) |
activeProject |
当前已落库会话绑定的项目(来自 GET /api/sessions/{sid}) |
不变 |
sid |
当前会话 id;null = 空白草稿态 |
不变;新增"null 时不写 localStorage" |
lastTriggerEl |
打开抽屉/下拉前的 document.activeElement |
新增(C1 用) |
drawerDirty |
抽屉表单相对初始快照是否改动 | 新增(B6 用) |
drawerSnapshot |
loadDrawerForm 时的字段快照 |
新增(B6 用) |
3.2 关键流程
启动
loadProjects()→draftProject = 首项或 null。- 若
localStorage['genesis_session']存在且GET /api/sessions/{sid}成功 → 进入"已问答态"(加载消息、设sid/activeProject)。 - 否则进入空白草稿态:清空聊天区,按
draftProject决定展示:- 有值 → 欢迎语;
null→ 空态提示文案「请新建项目或者选择项目」(不放欢迎语)。
顶栏切换项目(核心)
- 更新
draftProject,顶栏名同步。 - 重置聊天区为空白草稿态(欢迎语或空态提示),
sid = null,localStorage清genesis_session。 - 不调任何后端(静默)。
- 侧边栏按新
draftProject刷新(GET /api/sessions?project=)。 - 原会话(无论是否已问答)保留在历史中,仅被过滤隐藏。
「+ 新会话」按钮
- 同"顶栏切换"的"重置"动作,但不改
draftProject(保持当前项目)。 sid=null、清localStorage。
首条消息 → 会话落库(D-v4)
send():若sid === null:POST /api/sessions(带{user_id, project: draftProject || null})→ 拿sid/name/activeProject。- 写
localStorage['genesis_session'] = sid。 - 再
POST /api/chat/{sid}/messages(发送该首条内容)。
- 若
sid !== null:正常POST /api/chat/{sid}/messages。
上传(沿用 v3 强制 requirements 逻辑)
ft = activeProject || draftProject ? 'requirements' : 用户选。- 若
sid === null→ 不执行上传,提示「请先发送一条消息以创建会话」并inputEl.focus()。 - 上传成功 → 执行 B5 修复(见节 4)。
侧边栏列表
refreshSessions():GET /api/sessions?project=<draftProject>;draftProject=null时不传参(后端返空)→ 侧边栏空。- 仅显示当前项目会话。
删除会话
- 删当前
sid→DELETE成功后进入空白草稿态(sid=null、清localStorage、欢迎语/空态提示)。 - 删其他 → 仅
refreshSessions()。
4. 5 条不整合点修复落地
| 编号 | 修复内容 | 落点 |
|---|---|---|
| A1 | 后端 delete_session 事务内先删 chat_messages 再删 sessions(节 2.3) |
后端 store.py |
| B5 | 上传成功后:activeProject = rec.project || null 并 refreshSessions() 重新渲染状态行/徽章(补全 v3 漏设的 activeProject) |
chat.html 上传回调 |
| C1 | 打开抽屉/下拉前记 lastTriggerEl = document.activeElement;Esc 关闭时 lastTriggerEl.focus() |
chat.html Esc 处理 |
| B6 | loadDrawerForm 时存 drawerSnapshot;切换抽屉项/关闭抽屉/切项目前若 drawerDirty → confirm('有未保存的修改,确定放弃?') |
chat.html 抽屉逻辑 |
| F1 | 上传失败时不清空 file-input.value(成功时才清);错误展示后端 detail.message 明细 |
chat.html 上传回调 |
5. 测试与验收
5.1 后端(Python,维持 >=99% 覆盖门槛)
见节 2.5。新增 4 个用例 + 旧用例仍绿。
5.2 前端(chat.html,无覆盖门槛;手动 + 轻量自测)
手动验收清单(实现后逐项核对):
- 启动无会话 → 选具体项目 → 欢迎语;选"未选择" → 显示「请新建项目或者选择项目」。
- 顶栏切项目 → 聊天区重置、侧边栏过滤、localStorage 清。
- 空白态上传要件定义 → 提示「请先发送一条消息以创建会话」,不上传。
- 首条消息 → 会话落库、侧边栏出现、localStorage 写入。
- 已建会话后上传 → 成功,上传后状态行/徽章正确(B5)。
- 上传失败 → 文件仍选中可重试(F1)。
- 抽屉改字段未保存切项目/切抽屉项 → 弹"有未保存的修改"(B6)。
- Esc 关抽屉 → 焦点回到打开按钮(C1)。
- 删除会话 → 历史无孤儿消息(A1);删当前 → 回空白态。
5.3 验收门槛
python -m pytest全绿、覆盖率>=99%。- 前端手动清单全过。
6. 风险与回滚
| 风险 | 缓解 |
|---|---|
json_extract 在个别部署环境不可用 |
提供 Python 端过滤 fallback(节 2.2) |
重命名 currentProject→draftProject 漏改导致功能回退 |
实现时全局替换并补前端手动清单核对 |
旧 v3 localStorage 会话加载异常 |
加载失败 catch 后进入空白态,不影响新流程 |
| 删除会话级联误删 | 单连接事务内两步,先 messages 后 sessions;加回归测试 |
回滚:本轮为独立提交,可整体 revert;数据库 schema 不变(仅 SQL 语句变更),无迁移风险。
7. 提交与规范遵循
- 文件命名 ASCII 小写(本 spec 文件名
2026-08-28-chat-v4-deepseek-session-design.md)。 - 代码不含硬编码绝对路径;项目配置目录经
ProjectConfig解析,不写死。 .env/ 密钥不入库;API Key 走环境变量。- 单一职责:前端状态机改动集中在 chat.html 内相关函数,不跨模块扩散。
- 测试先行(TDD):后端 4 个新用例先红后绿;前端按节 5.2 手动清单验收。
- 日志:在
_AI_USAGE_LOG.md追加一条"架构设计 / Agent 实现"记录。