Files
Jyotisha/docs/tasks/TASK-session-list-title-and-order-20260906.md
T

111 lines
17 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.
# TASK · 历史对话:模型总结式标题、按活动时间排序、日期分组(2026-09-06)
- 基线:`origin/staging` @ `985c3258`
- 分支:`codex/session-list-title-and-order-20260906`
- 执行方:coding agent;验收:Claude
- 涉及文件:`frontend/src/app/api/consult/route.ts`(只加一次调用与一个事件,新逻辑进独立模块)、新建 `frontend/src/lib/session-title-agent.ts`、新建 `frontend/src/lib/session-groups.ts``frontend/src/hooks/use-consultation-run.ts``frontend/src/hooks/use-session-management.ts``frontend/src/app/api/sessions/[id]/route.ts``frontend/src/app/api/sessions/route.ts`GET 加分页参数)、`frontend/src/lib/home-cloud-sync.ts``frontend/src/lib/home-profile.ts``frontend/src/components/app-sidebar.tsx``frontend/src/app/page.tsx`(只改 `visibleSessions` 的排序一行,**不得增行**)、`frontend/DESIGN.md``frontend/docs/VOICE.md`
- BUG 编号起点:**BUG-553**(开工时 `grep -o "^## BUG-5[0-9][0-9]" docs/BUG_HISTORY.md | tail -1` 复核;BUG-551/552 已由 `TASK-composer-live-input-and-stop-20260906.md` 预留,BUG-542 由 `TASK-api-not-configured-mislabel-20260904.md` 预留)
- 串行:本单改 `page.tsx``use-consultation-run.ts`。同日三份单的顺序是 **composer551/552)→ 本单 → 设置弹窗单**;本单开工前先把已合入 staging 的 composer 分支拉进来,不得并行改同一文件。
- 不改数据库结构;不动迁移(分页不加索引,见 5.6)。
## 1. 事故实证(staging2026-09-06,产品实测)
| # | 用户看到 | 期望(对标 Claude 的历史列表) |
| --- | --- | --- |
| 1 | 标题就是第一句话的前 14 个字加省略号(如「我想问一下最近半年换工…」),侧栏里再前缀一个资料名,一行根本读不出主题 | 一个 6–12 字的主题总结(「半年内换工作时机」),一眼能分辨 |
| 2 | 刚聊过的会话不在最上面;改个名、收藏一下、换个模型,刷新后旧会话反而跳到最顶 | 只有真正的对话活动改变顺序;置顶始终在前 |
| 3 | 历史列表是一根长条,没有今天 / 昨天 / 更早的分段 | 按时间分组 |
| 4 | (产品追问)会话多了怎么办:现在 `/api/sessions` 一次返回该用户**全部**会话元数据,侧栏一次渲染全部行 | 首屏只取最近一页,滚到底再取下一页 |
## 2. 根因(按符号定位,行号以基线为准)
**标题**
1. 服务端 `append_consultation_question` RPC(迁移 `20260901010000_append_consultation_question.sql` L84–90)在标题为空或「新对话」时直接 `left(question, 14) || '…'``consultation-reply-metadata.ts::safeQuestionTitle` 是同一规则的 TS 版,作为 `parseAgentReply` 的兜底元数据。
2. 客户端 `use-consultation-run.ts` L883889`reply.title && !isGenericSessionTitle(reply.title) ? resolveSessionTitle(question, reply.title, …) : userSession.title``reply.title` 只来自正文里的 `<!--AYANAM_TITLE:-->` 注释(`agent-reply.ts` L42),而当前没有任何提示词让模型输出它——这条「模型起名」链路早已断掉,只剩空壳。`agent-reply.ts::resolveSessionTitle` L87110 的 `modelTitle` 分支、`clipTitle(14)``uniquifySessionTitle` 都还在,可直接复用。
3. 侧栏 `home-profile.ts::sessionSidebarTitle` L106 把每条都拼成「资料名 · 标题」,而对话页头部 `chat-header-subtitle``page.tsx` L1806)已经显示「分析对象」,侧栏这一层前缀只是把标题挤没。
**排序**
4. `/api/sessions``updated_at desc` 返回,但 `page.tsx` L380392 的 `visibleSessions``.sort((l, r) => Number(r.pinned) - Number(l.pinned))`——不按 `updatedAt` 排。页面开着期间新活动只改 `updatedAt` 不改位置;`Array.prototype.sort` 稳定,顺序冻结到下次整页加载。
5. `api/sessions/[id]/route.ts::metadataUpdateValues` L31 对任何元数据 PATCH(改名、收藏、归档、换模型、换资料)都写 `updated_at = now()``use-session-management.ts::renameSession` L188 也在客户端顺手 `updatedAt: timestamp()`。于是非对话操作会让会话在下次加载时跳到最顶。发消息真正的 bump 在 `append_consultation_question``updated_at = clock_timestamp()`,这条是对的。
6. `app-sidebar.tsx` L113–114 只分「收藏 / 历史」两组,没有时间分组。
7. `api/sessions/route.ts` L1418`select(...).eq("user_id").order("updated_at")`,无 `limit`、无游标;`home-cloud-sync.ts` L421 一次拿完;`page.tsx``showArchivedSessions` 过滤在客户端做。每行约 300 字节,元数据本身不重(消息已经按会话懒加载,`ensureSessionMessages`),真正的开销是上千行 DOM 与一次性 JSON;再往后若数据库层有 max-rows 上限还会**静默截断**。
## 3. 决策记录(产品已授权,2026-09-06)
1. **模型起名,产品同意增加模型开销**:一个会话只起一次名,用该会话当前选的模型(与 `rectification-adopt-narration-agent.ts` 一样走 `ResolvedLanguageModel`),最多 30 个输出 token,**不扣用户点数**、不进用户可见的用量;计量记录按 adopt-narration 既有做法处理,没有既有做法就不记。
2. 起名只在**咨询会话的第一轮**`storedHistory` 为空)且标题仍是自动派生值时触发;生时校正会话(「M月D日 · 生时校正」)和今日运势入口(「M月D日 · 今日节奏」)保持日期式标题,不调模型——它们是重复型会话,日期比总结更有用。
3. 起名与主回答**并行**:在首轮开始时基于用户问题(加主题标签、分析对象是本人还是他人)就起,不等回答结束,避免拖慢回答;成功后通过流事件 `session.title` 推给客户端,并由服务端写库。写库带守卫:只在标题仍等于本轮开始时读到的值时更新,用户中途改名以用户为准。失败、超时(8 秒,计时器必须 ref,见 BUG-523)、输出不合格 → 静默保留现有标题,不重试、不报错。
4. 标题合格标准(服务端清洗,纯函数):去引号、书名号、句末标点与空白;6–14 个字(Han 计数,`clipTitle(14)` 兜底);不含换行;不是 `isGenericSessionTitle`;**不含出生日期 / 时间 / 地点**(提示词禁止复述这些,清洗层再拦一次含 4 位年份+月日或 HH:MM 的输出)。不合格视同失败。
5. **侧栏只显示标题**:删除 `sessionSidebarTitle` 的「资料名 · 」前缀。分析对象不是本人(`chartProfileRole !== "self"`)时在标题下加一行小字显示资料名;是本人不显示。产品偏好是删多余入口,不是再叠一层。
6. **排序规则**:置顶在前;组内按 `updatedAt` 倒序;`updatedAt` 只由**对话活动**推进(发问、回答落库、校正会话的回合落库)。改名、收藏、归档、换模型、换资料一律不动 `updatedAt`(服务端 PATCH 不再写 `updated_at`;客户端相应操作不再 `timestamp()`)。
7. **日期分组**:只对「历史」组分段:今天 / 昨天 / 最近 7 天 / 最近 30 天 / 更早,按浏览器本地时间、以 `updatedAt` 归组;空组不渲染;「收藏」组不分段。分组标题用现有 overline 字号,不加新的视觉元素。
8. **分页(产品 2026-09-06 追加)**:列表按游标分页,每页 40 条,游标 `(updated_at, id)` 复合、倒序;**置顶会话第一页全量返回**,后续页只含非置顶;归档视图改为服务端参数(`archived=1`)、独立游标。侧栏滚到底部自动取下一页(`IntersectionObserver` 哨兵),**没有 spinner、没有「加载更多」文字**,新行直接接在「更早」组末尾;取完为止,末尾不显示「没有更多」。不加数据库索引(`user_id` 上已有索引,排序在几千行内可忽略;真慢了另开迁移单并跑 `test:db`)。通过 URL 深链打开、但不在已加载页里的会话,沿用现有按 id 单取 `/api/sessions/[id]` 后插入列表的路径。标题去重(`existingTitles`)只看已加载的会话,接受。
9. 不在本单:改名仍用 `window.prompt`(Claude 是行内改名)——产品若要,另开单;`<!--AYANAM_TITLE:-->` 注释解析留着当防御,不动。
## 4. 硬红线
- `page.tsx` 不得增行(现 2041 行);排序、分组、起名全部进 `lib/` / `hooks/`
- 起名调用不得阻塞主回答流的首个 `answer.delta`,不得共享主回答的 AbortController 之外的取消语义(用户停止回答时起名一并取消)。
- 起名失败不得在 UI 出现任何提示;不得出现 spinner / 「正在生成标题」。
- 不改 `append_consultation_question` / `complete_consultation_response`;不加迁移。
- 不放宽 `chat-session-write-contract.ts` 的 PATCH 字段集合;`title` 仍由客户端在完成后 PATCH 持久化(L906 `persistSession(completedSession)` 不变)。
- 既有测试总数不降;改动的断言写「原值 / 新值 / 原因」三栏。
- `next build``/` 仍 Static;首屏 gzip ±2%。
- 任务书 / 进度 / Bug 历史 / 测试 fixture 只用虚构问题文本,不得出现真实用户的问题、昵称、邮箱。
## 5. 任务分解
### 5.1 模型起名(CHANGELOG 项,不编 BUG
- 新建 `frontend/src/lib/session-title-agent.ts`:导出 `generateSessionTitle({ model, question, theme, chartRole, signal })` 与纯函数 `sanitizeSessionTitle(raw): string | null`(决策 4)。Agent 结构照抄 `adopt-narration-agent.ts` 的 ref'd 超时包装。
- `api/consult/route.ts`:首轮且标题为自动值时(判定放进 `session-title-agent.ts::shouldGenerateSessionTitle(session, storedHistory)`),在主回答启动的同一处并行发起;完成后 (a) 向流写事件 `{ type: "session.title", title }`(b) `update chat_sessions set title = $1 where id = $2 and user_id = $3 and title = $4``$4` = 本轮开始读到的标题)。两步任一失败只 `console.warn`。事件 schema 加进 `consultation-agent-events.ts`(或既有事件联合类型所在处)。
- `use-consultation-run.ts`:事件循环里收到 `session.title` 后记为 `streamedTitle`L883 处改为 `resolveSessionTitle(question, streamedTitle ?? reply.title, …)`,其余不动(`existingTitles` 去重、`clipTitle` 保留)。
- 恢复路径:`/api/consult/status` 响应里带当前标题;客户端恢复合并时若本地标题仍是自动值则采用(不在本单加新字段以外的逻辑)。
- 验收:`tests/session-title-agent.test.ts` 新建——`sanitizeSessionTitle` 至少 8 例(去引号 / 去句末标点 / 超 14 字裁剪 / 少于 2 字返回 null / 含换行 null / 通用标题 null / 含 `1990年3月``04:50` null / 正常值原样);`shouldGenerateSessionTitle`:首轮+自动标题 true、非首轮 false、校正会话 false、用户已改名 false;带 fake `generateText` 的超时用例(超时后 promise 以 null 结束,且 `setTimeout` 计时器不 unref)。`tests/consultation-*` 或既有 consult 路由合同测试加一条:事件类型联合含 `session.title`
### 5.2 排序与 `updated_at`BUG-553
- `page.tsx` L392 排序改为调用 `lib/session-groups.ts::sortSessions(sessions)`(置顶优先,其次 `updatedAt` 倒序,稳定)。
- `api/sessions/[id]/route.ts::metadataUpdateValues`:去掉 `updated_at`
- `use-session-management.ts``renameSession`、收藏 / 取消收藏、归档 / 取消归档、换模型、换资料,凡是只改元数据的分支不再写 `updatedAt: timestamp()`;保留发问 / 回答 / 校正回合的 bump(`use-consultation-run.ts` L552、L902 等不动)。
- 验收:`tests/session-groups.test.ts` 新建——`sortSessions` 至少 4 例(置顶胜时间;同置顶按时间;相等保持原序;归档过滤不在本函数);`tests/chat-session-write.test.ts`(或 `[id]` 路由合同测试)加一条:`metadataUpdateValues({ title })` 返回值不含 `updated_at``tests/sidebar-state.test.ts``use-session-management` 相关测试加一条:改名后 `updatedAt` 不变。
### 5.3 日期分组
- `lib/session-groups.ts::groupSessionsByRecency(sessions, now = Date.now())``Array<{ key: "today"|"yesterday"|"week"|"month"|"older"; label: string; sessions }>`,空组剔除。
- `app-sidebar.tsx`:「历史」组内按分组渲染,分组标题 `<p className="sidebar-group-label">`(复用 `.sidebar-section-summary` 的字号/颜色 token,不新造样式变量);焦点管理(`firstSessionRef` / `historyHeadingRef`)与序号(`favoriteSessions.length + index`)语义不变。
- 验收:`tests/session-groups.test.ts` 加分组用例(跨天边界用固定 `now`,至少覆盖 23:59 → 00:01 的「昨天」判定、第 7 天与第 8 天、第 30 与第 31 天);`tests/sidebar-contract.test.ts` 加一条:历史区渲染分组标签且空组不渲染。
### 5.4 侧栏标题去前缀
- `home-profile.ts::sessionSidebarTitle` 改为只返回标题;新增 `sessionSidebarSubtitle(session, library): string | null`(非本人时返回资料名,含「资料已删除 · 」前缀逻辑,本人返回 null)。`app-sidebar.tsx` 渲染副标题行。
- 验收:`tests/chart-library-session.test.ts``tests/sidebar-contract.test.ts` 中断言前缀的用例按三栏表改;新增本人 / 他人 / 已删除资料三例。
### 5.6 列表分页
- `api/sessions/route.ts` GET:读取 `limit`(默认 40,钳到 1100)、`before``<updated_at ISO>,<id>`)、`archived``0|1`,默认 0);第一页(无 `before`)额外并入该用户全部 `pinned = true` 且归档状态匹配的会话;返回 `{ sessions, nextCursor: string | null }`。游标解析与生成放 `frontend/src/lib/session-cursor.ts` 纯函数(编码 / 解码 / 非法值返回 null → 400)。
- `home-cloud-sync.ts::fetchSessions`:接受 `{ before?, archived? }`,返回 `{ sessions, nextCursor }`;旧调用点改为第一页语义。
- `use-session-management.ts`:新增 `sessionsCursor`(按归档视图各一份)、`loadMoreSessions()`(进行中去重、按 id 合并、已存在的行不覆盖本地更新的 `updatedAt`)。切换归档视图时重新取第一页。
- `app-sidebar.tsx`:历史列表末尾一个 `<div aria-hidden>` 哨兵,进入视口调用 `onLoadMore``nextCursor === null` 时不渲染哨兵。
- `page.tsx``showArchivedSessions` 过滤改由服务端承担后,L381 的客户端归档过滤改为透传(仍不得增行)。
- 验收:`tests/session-cursor.test.ts` 新建(编码往返、非法字符串 null、时间相同按 id 比较);`tests/chat-session-*`/路由合同测试加——`limit` 超界钳制、第一页含置顶且后续页不含、`archived=1` 只返回归档;`tests/sidebar-contract.test.ts` 加——有游标时渲染哨兵、无游标不渲染、源码中不存在「加载更多」「没有更多」文案;`tests/sidebar-state.test.ts` 加——`loadMoreSessions` 合并去重且并发调用只发一次请求。
### 5.5 记录
- `docs/BUG_HISTORY.md`:BUG-553(列表只按置顶排、元数据 PATCH 推进 `updated_at`;关联 BUG-024 侧栏标题记录)。
- `CHANGELOG.md`:一条写清「标题改为模型总结(首轮一次、不扣点数)、排序改活动时间、历史分组、侧栏去资料前缀、列表分页(每页 40、滚到底续取)」。
- `frontend/DESIGN.md` Sidebar shell / Navigation item:分组标签与副标题行;`frontend/docs/VOICE.md`:分组标签文案「今天 / 昨天 / 最近 7 天 / 最近 30 天 / 更早」与起名提示词的措辞边界(不复述出生资料)。
- `docs/tasks/PROGRESS-session-list-title-and-order-20260906.md``docs/testing/session-list-title-and-order-20260906.md`(真实环境:新会话首轮回答期间或结束后标题变成总结;改名 / 收藏 / 换模型后刷新不改变顺序;新发一条后该会话到组首;分组标签正确;停止回答后标题不出现错误提示;账号超过 40 个会话时首屏只见最近 40 条 + 全部收藏,滚到底静默续出,归档视图同样分页)。
## 6. 让步顺序
5.2 → 5.6 → 5.4 → 5.3 → 5.1。5.1 若模型端 `generate` 在测试环境无法 mock,可先交付 5.1 的纯函数与事件契约并在进度记录写明;5.2 不可省。
## 7. 开工前置命令
```bash
git fetch origin --prune
git worktree add -b codex/session-list-title-and-order-20260906 .worktrees/session-list-title-and-order-20260906 origin/staging
cd .worktrees/session-list-title-and-order-20260906
ln -s /workspace/Jyotisha/frontend/node_modules frontend/node_modules
cd frontend && npx tsx --test tests/sidebar-*.test.ts tests/chart-library-session.test.ts tests/chat-session-*.test.ts tests/agent-reply.test.ts tests/consultation-*.test.ts 2>&1 | grep -E "^# (tests|pass|fail)|^not ok"
```
收尾跑同一条命令 fail=0,再 `./node_modules/.bin/tsc --noEmit``npm run lint`0 error)、`npm test``npm run build``/` Static、gzip ±2%)。