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

17 KiB
Raw Blame History

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.tsfrontend/src/hooks/use-consultation-run.tsfrontend/src/hooks/use-session-management.tsfrontend/src/app/api/sessions/[id]/route.tsfrontend/src/app/api/sessions/route.tsGET 加分页参数)、frontend/src/lib/home-cloud-sync.tsfrontend/src/lib/home-profile.tsfrontend/src/components/app-sidebar.tsxfrontend/src/app/page.tsx(只改 visibleSessions 的排序一行,不得增行)、frontend/DESIGN.mdfrontend/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.tsxuse-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 L883889reply.title && !isGenericSessionTitle(reply.title) ? resolveSessionTitle(question, reply.title, …) : userSession.titlereply.title 只来自正文里的 <!--AYANAM_TITLE:--> 注释(agent-reply.ts L42),而当前没有任何提示词让模型输出它——这条「模型起名」链路早已断掉,只剩空壳。agent-reply.ts::resolveSessionTitle L87110 的 modelTitle 分支、clipTitle(14)uniquifySessionTitle 都还在,可直接复用。
  3. 侧栏 home-profile.ts::sessionSidebarTitle L106 把每条都拼成「资料名 · 标题」,而对话页头部 chat-header-subtitlepage.tsx L1806)已经显示「分析对象」,侧栏这一层前缀只是把标题挤没。

排序

  1. /api/sessionsupdated_at desc 返回,但 page.tsx L380392 的 visibleSessions.sort((l, r) => Number(r.pinned) - Number(l.pinned))——不按 updatedAt 排。页面开着期间新活动只改 updatedAt 不改位置;Array.prototype.sort 稳定,顺序冻结到下次整页加载。
  2. api/sessions/[id]/route.ts::metadataUpdateValues L31 对任何元数据 PATCH(改名、收藏、归档、换模型、换资料)都写 updated_at = now()use-session-management.ts::renameSession L188 也在客户端顺手 updatedAt: timestamp()。于是非对话操作会让会话在下次加载时跳到最顶。发消息真正的 bump 在 append_consultation_questionupdated_at = clock_timestamp(),这条是对的。
  3. app-sidebar.tsx L113–114 只分「收藏 / 历史」两组,没有时间分组。
  4. api/sessions/route.ts L1418select(...).eq("user_id").order("updated_at"),无 limit、无游标;home-cloud-sync.ts L421 一次拿完;page.tsxshowArchivedSessions 过滤在客户端做。每行约 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 后记为 streamedTitleL883 处改为 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_atBUG-553

  • page.tsx L392 排序改为调用 lib/session-groups.ts::sortSessions(sessions)(置顶优先,其次 updatedAt 倒序,稳定)。
  • api/sessions/[id]/route.ts::metadataUpdateValues:去掉 updated_at
  • use-session-management.tsrenameSession、收藏 / 取消收藏、归档 / 取消归档、换模型、换资料,凡是只改元数据的分支不再写 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_attests/sidebar-state.test.tsuse-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.tstests/sidebar-contract.test.ts 中断言前缀的用例按三栏表改;新增本人 / 他人 / 已删除资料三例。

5.6 列表分页

  • api/sessions/route.ts GET:读取 limit(默认 40,钳到 1100)、before<updated_at ISO>,<id>)、archived0|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> 哨兵,进入视口调用 onLoadMorenextCursor === null 时不渲染哨兵。
  • page.tsxshowArchivedSessions 过滤改由服务端承担后,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.mddocs/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. 开工前置命令

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 --noEmitnpm run lint0 error)、npm testnpm run build/ Static、gzip ±2%)。