From a1956deb638bba5bdbc01d12480e55f80692269d Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Sun, 6 Sep 2026 14:34:30 +0800 Subject: [PATCH] fix(web): summarize session titles, sort by activity, and paginate history (BUG-553) Co-authored-by: Cursor --- CHANGELOG.md | 4 + docs/BUG_HISTORY.md | 16 ++ ...S-session-list-title-and-order-20260906.md | 52 +++++++ .../session-list-title-and-order-20260906.md | 67 +++++++++ frontend/DESIGN.md | 6 +- frontend/docs/VOICE.md | 4 + frontend/src/app/api/consult/route.ts | 34 ++++- frontend/src/app/api/consult/status/route.ts | 8 + frontend/src/app/api/sessions/[id]/route.ts | 11 +- frontend/src/app/api/sessions/route.ts | 69 ++++++++- frontend/src/app/globals.css | 4 +- frontend/src/app/page.tsx | 43 +++--- frontend/src/components/app-sidebar.tsx | 25 +++- .../src/components/sidebar-session-row.tsx | 5 + frontend/src/hooks/use-consultation-run.ts | 21 ++- frontend/src/hooks/use-session-management.ts | 55 ++++++- frontend/src/lib/consultation-agent-events.ts | 6 +- frontend/src/lib/home-cloud-sync.ts | 28 +++- frontend/src/lib/home-profile.ts | 9 +- frontend/src/lib/home-types.ts | 1 + frontend/src/lib/session-cursor.ts | 67 +++++++++ frontend/src/lib/session-groups.ts | 90 ++++++++++++ frontend/src/lib/session-metadata-update.ts | 10 ++ frontend/src/lib/session-title-agent.ts | 139 ++++++++++++++++++ frontend/src/lib/session-title.ts | 49 ++++++ frontend/src/lib/stream-agent-response.ts | 8 + .../application-billing-contract.test.ts | 6 +- frontend/tests/chart-library-session.test.ts | 41 ++++++ frontend/tests/chat-session-authority.test.ts | 10 ++ frontend/tests/chat-session-write.test.ts | 9 ++ frontend/tests/consultation-recovery.test.ts | 5 +- frontend/tests/session-cursor.test.ts | 57 +++++++ frontend/tests/session-groups.test.ts | 80 ++++++++++ frontend/tests/session-title-agent.test.ts | 84 +++++++++++ frontend/tests/sidebar-contract.test.ts | 11 +- frontend/tests/sidebar-state.test.ts | 19 +++ 36 files changed, 1089 insertions(+), 64 deletions(-) create mode 100644 docs/tasks/PROGRESS-session-list-title-and-order-20260906.md create mode 100644 docs/testing/session-list-title-and-order-20260906.md create mode 100644 frontend/src/lib/session-cursor.ts create mode 100644 frontend/src/lib/session-groups.ts create mode 100644 frontend/src/lib/session-metadata-update.ts create mode 100644 frontend/src/lib/session-title-agent.ts create mode 100644 frontend/src/lib/session-title.ts create mode 100644 frontend/tests/session-cursor.test.ts create mode 100644 frontend/tests/session-groups.test.ts create mode 100644 frontend/tests/session-title-agent.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d1e45179..6ea4e3da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # 印度占星 Skill 更新日志 +## 2026-09-06 — 历史对话改成主题标题,按活动时间排,并分页加载 + +咨询第一轮会用当前模型起一个 6–12 字的主题标题,不扣点数;起名失败就留着原来的标题。只有发问和回答会改变列表顺序,改名、收藏、换模型不会把旧会话顶上去。历史按今天 / 昨天 / 最近 7 天 / 最近 30 天 / 更早分段,侧栏只显示标题,别人的盘才在下面加一行资料名。列表每页 40 条,滚到底静默续取。Skill 版本未变。 + ## 2026-09-06 — 生成中仍可打字,停止不再像报错 回答生成时输入框不再变灰,回车会排成一条待发送,当前回答正常结束后自动发出,停止或失败则放回输入框。生时校正里点停止会留下已生成的内容,并用灰字说明已停止、不扣点,不再出现红色告警。Skill 版本未变。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 6aece918..9297845c 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -8549,4 +8549,20 @@ - 复发自:BUG-329(停止按钮只绑了 `send()` 的 abort,没覆盖选择题与采用) - 修复版本:`055b7adc` +## BUG-553 | 历史列表只按置顶排,改名收藏后旧会话跳到最顶 + +- 状态:resolved +- 首次发现:2026-09-06 +- 最近更新:2026-09-06 +- 影响面:侧栏历史列表、`GET /api/sessions`、`PATCH /api/sessions/[id]`、`use-session-management` +- 用户现象:刚聊过的会话不在最上面;改名、收藏、换模型或换资料后刷新,这条会话反而排到最顶。 +- 触发条件:登录后打开侧栏历史;对已有会话做元数据 PATCH,或不发新消息就刷新。 +- 根因:`visibleSessions` 只按 `pinned` 排,开着页面时 `updatedAt` 变化不改位置。元数据 PATCH 和服务端 `metadataUpdateValues` 一律写 `updated_at = now()`,非对话操作也会把会话顶到列表头。真正该 bump 的是发问 / 回答落库。 +- 修复:`sortSessions` 置顶优先、组内 `updatedAt` 倒序且稳定。元数据 PATCH 不再写 `updated_at`;客户端改名 / 收藏 / 归档 / 换模型 / 换资料不再 `timestamp()`。列表改为游标分页,历史按本地日期分组,侧栏标题去掉资料名前缀。 +- 验证:`frontend/tests/session-groups.test.ts`、`session-cursor.test.ts`、`chat-session-write.test.ts`(`metadataUpdateValues` 不含 `updated_at`)、`sidebar-state.test.ts`(改名不 bump `updatedAt`)、`sidebar-contract.test.ts`、`chart-library-session.test.ts`。 +- 防复发:元数据 PATCH 不得写 `updated_at`。`updatedAt` 只由对话活动推进。侧栏历史排序必须置顶 + `updatedAt` 倒序,不得只按置顶。 +- 相关记录:BUG-024 +- 复发自:无 +- 修复版本:待提交 + diff --git a/docs/tasks/PROGRESS-session-list-title-and-order-20260906.md b/docs/tasks/PROGRESS-session-list-title-and-order-20260906.md new file mode 100644 index 00000000..ba0d44d2 --- /dev/null +++ b/docs/tasks/PROGRESS-session-list-title-and-order-20260906.md @@ -0,0 +1,52 @@ +# PROGRESS · 历史对话标题、排序、分组与分页(2026-09-06) + +工作树:`.worktrees/session-list-title-and-order-20260906` +分支:`codex/session-list-title-and-order-20260906` +基线:任务书写 `origin/staging` @ `985c3258`;开工时 `origin/staging` 已到 `d04990fc`(含 composer BUG-551/552),以当时远端为准。 + +`page.tsx` 收尾 **2035** 行(上限 2041)。未改迁移、未 bump Skill。5.1 起名走 `generateText` 可 mock;真实模型调用在本机测试里不打外部 API。客户端只引用 `session-title.ts` 纯函数,避免把 Mastra 打进 `/` 包。 + +| 任务 | 状态 | BUG | +| --- | --- | --- | +| 5.2 排序与 `updated_at` | 完成 | BUG-553 | +| 5.6 列表游标分页 | 完成 | — | +| 5.4 侧栏去资料前缀 | 完成 | — | +| 5.3 历史日期分组 | 完成 | — | +| 5.1 首轮模型起名 | 完成 | 不编 BUG | +| 5.5 BUG_HISTORY / CHANGELOG / DESIGN / VOICE | 完成 | — | + +## 实现要点 + +- `sortSessions`:置顶优先,其次 `updatedAt` 倒序,相等保持原序。 +- 元数据 PATCH 不再写 `updated_at`;客户端改名 / 收藏 / 归档 / 换模型不再 bump。 +- `GET /api/sessions`:`limit` 默认 40(1–100),`before` 游标 `(updated_at,id)` 倒序,第一页合并全部置顶,后续页只有非置顶;`archived=1` 独立游标。 +- 历史按本地午夜分成今天 / 昨天 / 最近 7 天 / 最近 30 天 / 更早;空组不渲染。滚到底哨兵静默续取,无「加载更多 / 没有更多」。 +- 侧栏标题只显示会话标题;`chartProfileRole !== "self"` 时副标题显示资料名(含「资料已删除 · 」)。 +- 咨询首轮且标题仍是自动值时,与主回答并行起名(8 秒 ref'd 超时,最多 30 token,不扣点)。合格标题经 `session.title` 事件下发,并按「标题仍等于本轮开始值」守卫写库。流结束前会等该事件,避免客户端完成 PATCH 把模型标题盖回去。 + +## 既有断言改动 + +| 文件 | 原值 | 新值 | 原因 | +| --- | --- | --- | --- | +| `home-profile.ts` / `chart-library-session` | `资料名 · 标题` | 标题单独一行;他人资料走副标题 | 侧栏前缀把主题挤没 | +| `page.tsx` `visibleSessions` | 只按 `pinned` 排 | `sortSessions`(置顶 + `updatedAt` 倒序) | BUG-553:刚聊过的不在最上面 | +| `metadataUpdateValues` | 每次 PATCH 写 `updated_at` | 返回值不含 `updated_at` | 改名收藏把旧会话顶上去 | +| `renameSession` | `{ ...session, title, updatedAt: timestamp() }` | `{ ...session, title }` | 同上 | +| `GET /api/sessions` | 一次返回全部元数据数组 | `{ sessions, nextCursor }`,默认 40 | 上千行 DOM;后续页静默续取 | +| `fetchSessions` / hydrate | `readSessions(payload)` | `readSessions(page.sessions)` + `nextCursor` | 分页响应形状 | +| `onToggleArchivedView` | 只翻转本地 `showArchivedSessions` | 重新取第一页(`archived=1`) | 归档改由服务端过滤 | +| `app-sidebar` 历史区 | `historySessions.map` 一根长列表 | `historyGroups.map` + 哨兵 | 今天 / 昨天 / 更早;无加载文案 | +| `use-consultation-run` 完成标题 | `reply.title && !isGenericSessionTitle(reply.title)` | `streamedTitle ?? reply.title` | 首轮模型标题经 `session.title` 事件到达 | +| `consult` session select | `...,session_type,messages` | 另加 `title,theme,chart_profile_role` | 起名判定需要当前标题与分析对象 | +| `agent.generate` token 上限 | 任务书写 `maxTokens` | `modelSettings.maxOutputTokens` | 当前 Mastra/AI SDK CallSettings 字段名 | + +## 测试 + +| 命令 | 结果 | +| --- | --- | +| `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 tests/session-*.test.ts tests/application-billing-contract.test.ts` | 355 pass / 0 fail | +| `./node_modules/.bin/tsc --noEmit` | 0 错 | +| 改动文件 eslint `--quiet` | 0 error | +| `page.tsx` | 2035 行(上限 2041) | +| `npm run build -- --webpack` | webpack 编译通过;类型检查仍停在既有 `dossierResponse` 路由导出(`api/rectification/cases/[caseId]`),与本单无关。本单曾误把 `metadataUpdateValues` 从 sessions `[id]` 路由再导出,已撤回。 | +| 全量 `npm test`(含 `database-*`) | 本机 Docker 库测多项 300s 超时 / 权限负例 ERROR,与本单无关;未当作本单回归。 | diff --git a/docs/testing/session-list-title-and-order-20260906.md b/docs/testing/session-list-title-and-order-20260906.md new file mode 100644 index 00000000..ac47342f --- /dev/null +++ b/docs/testing/session-list-title-and-order-20260906.md @@ -0,0 +1,67 @@ +# Staging 人肉复核 · 历史对话标题、排序、分组与分页(2026-09-06) + +给产品负责人。不要把真实出生资料、真实用户问题或对话正文写进任何记录。试用问题时用虚构句,例如「半年内换工作时机」。 + +对应 BUG-553。测之前先做第 0 条。 + +## 0. 确认测的是新版本 + +浏览器打开 `https://staging.jyotisha.chat/api/health`,看 `deployment.gitCommit` 前 8 位是否等于本单合入 staging 后的提交。不一致 = 先别测。 + +## 1. 新会话首轮标题变成主题总结(P0) + +1. 新建一次普通咨询(不要用生时校正,也不要从今日节奏入口进)。 +2. 发出一句虚构问题,例如问最近半年换工作的时机。 +3. 看侧栏这条会话的标题:回答生成期间或结束后。 + +- ✅ 预期:标题变成大约 6–12 字的主题,例如「半年内换工作时机」这类总结,而不是第一句话截断加省略号。本人资料时侧栏没有「资料名 · 」前缀。 +- ❌ 失败:一直停在「新对话」或「我想问一下…」;或标题里出现出生年月日、钟点;或界面出现「正在生成标题」之类提示。 + +## 2. 校正和今日节奏标题不变(P0) + +1. 打开生时校正会话。 +2. 从今日节奏入口开一条会话。 + +- ✅ 预期:仍是「M月D日 · 生时校正」或「M月D日 · 今日节奏」,不会被模型改成主题总结。 +- ❌ 失败:日期式标题被换成别的总结。 + +## 3. 改名 / 收藏 / 换模型后刷新不改变顺序(P0) + +1. 找两条未置顶、时间不同的历史会话,记下上下顺序。 +2. 给下面那条改名、收藏再取消收藏、或换一个模型,然后刷新。 + +- ✅ 预期:未置顶时两条的上下顺序不变。收藏后它进「收藏对话」,取消收藏后回到原来的时间位置,不会只因为改名就跳到历史最顶。 +- ❌ 失败:改个名或换个模型,刷新后这条跑到最上面。 + +## 4. 新发一条后该会话到组首(P0) + +1. 对一条未置顶历史会话再发一条虚构问题。 +2. 看「今天」分组。 + +- ✅ 预期:这条出现在「今天」组靠前(置顶仍全体在收藏区)。侧栏分组标签是今天 / 昨天 / 最近 7 天 / 最近 30 天 / 更早,空组没有。 +- ❌ 失败:发过消息仍停在原地;或出现空的分组标题。 + +## 5. 停止回答后标题没有错误提示(P0) + +1. 新会话首轮发出问题后立刻点停止。 + +- ✅ 预期:没有红色告警,也没有标题失败提示。标题要么已是总结,要么仍是原来的自动标题。 +- ❌ 失败:因为起名失败或停止而弹出错误。 + +## 6. 超过 40 条时分页(P0) + +账号历史明显超过 40 条时: + +1. 打开未归档历史。 +2. 滚到列表底部,再往下一点。 +3. 切到归档视图,同样滚到底。 + +- ✅ 预期:首屏大约是最近 40 条非置顶,加上全部收藏。滚到底没有「加载更多」或「没有更多」文案,新行直接接上。归档视图同样分页。 +- ❌ 失败:一次画出全部历史;或底部出现加载/没有更多字样;或收藏不在第一屏。 + +## 7. 他人资料副标题(P1) + +用另一份资料开咨询。 + +- ✅ 预期:侧栏主行只有标题;下面一行小字是资料名。资料已删时是「资料已删除 · 资料名」。本人资料没有这行。 +- ❌ 失败:标题仍是「资料名 · 标题」挤在一行。 diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index 8cb09c75..bfd920fd 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -288,9 +288,9 @@ The birth-time rectification session is the consultation transcript plus a house ### Navigation item -- **Structure:** title, optional metadata, current-state marker. +- **Structure:** title, optional subtitle for another person's chart, current-state marker. - **States:** default, hover, current, focus, disabled. -- **Hierarchy:** section labels stay tertiary; session titles and primary actions use ink so history rows do not collapse into the same gray as “收藏对话 / 历史对话”. +- **Hierarchy:** section labels stay tertiary; session titles and primary actions use ink so history rows do not collapse into the same gray as “收藏对话 / 历史对话”. History groups use the overline token for “今天 / 昨天 / 最近 7 天 / 最近 30 天 / 更早”. When the chart is not the account holder, a secondary line shows the chart name under the title. - **Request behavior:** existing sessions remain selectable for reading while a request is active; creating or sending another request stays locked until the active request settles. - **Surface:** translucent warm-gray sidebar; current uses a white glass surface and deep-brown marker. @@ -302,7 +302,7 @@ The birth-time rectification session is the consultation transcript plus a house - **Tablet:** 64px collapsed by default from 768px through 1023px; 240px when expanded. - **Mobile:** no icon rail; an off-canvas drawer uses `min(86vw, 320px)` and closes through its scrim or Escape. - **Collapsed content:** logo, new-chat action, reports action, one history expansion action, and account avatar. Individual sessions do not become indistinguishable repeated icons. -- **Expanded content:** new chat, my reports, chart list, favorites, then history. Nested lists indent under their section labels. Actions, section labels, and session rows share 18px icons, caption/body type, and ink/muted tokens. Empty untitled sessions stay off the history list. Pin is favorite; archive stays in the session menu, not as a history-header toggle. +- **Expanded content:** new chat, my reports, chart list, favorites, then history grouped by recency. Nested lists indent under their section labels. Actions, section labels, and session rows share 18px icons, caption/body type, and ink/muted tokens. Empty untitled sessions stay off the history list. Pin is favorite; archive stays in the session menu, not as a history-header toggle. The history list loads 40 rows at a time and silently appends the next page at the bottom. - **Scroll ownership:** header and footer remain fixed; `SidebarContent` is the sole sidebar scroll owner. - **Scrollbar:** `SidebarContent`, ordinary session `.conversation`, and the rectification house board use a quiet overlay scrollbar: transparent track, no `scrollbar-gutter`, and a 4px warm thumb mixed from `--color-ink`. The thumb stays transparent until hover or keyboard focus inside the scroller, then uses `color-mix(in srgb, var(--color-ink) 26%, transparent)`; thumb hover uses 40%. Increased contrast keeps the thumb visible; forced colors restore the system scrollbar. - **Motion:** Sidebar state changes are immediate on desktop, tablet, and mobile. The 44px trigger keeps one stable 18px sidebar glyph and never enters an intermediate scale or opacity state. diff --git a/frontend/docs/VOICE.md b/frontend/docs/VOICE.md index 53c986f9..f5a07dff 100644 --- a/frontend/docs/VOICE.md +++ b/frontend/docs/VOICE.md @@ -52,7 +52,11 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、 | 2019 入职 | 2019 年前后,你有没有换过工作?A. 明确发生且时间吻合 | 2019 年前后,你有没有换过工作? | 不得把选项字面写进题干。 | | 2023 感情区分 | 你有没有一段认真开始或结束的关系? | 工作这块记下了。2023 年前后,有没有一段认真开始或结束的关系? | 题干必须写出服务端给的年份;漏写年份用户看不见期间。 | +## 侧栏与会话标题 +历史分组标签只用这五个词,空组不出现:今天 / 昨天 / 最近 7 天 / 最近 30 天 / 更早。不要写成「七日内」「更早之前」。 + +会话标题是主题总结,不是资料名,也不是第一句话的截断。起名提示词只输出标题本身:6 到 12 个字,不要引号、书名号、句号或解释;不要复述出生日期、时间或地点,也不要写资料姓名。 - 不得虚构星盘事实或唯一出生分钟。 - 问哪道题、年份、选项语义仍由服务端 focus 唯一所有;Agent 用 `spokenPrompt` 写题干,不得改年份、不得改选项含义。正文不得出题、复述或改写题干——题干作为同一条消息里正文之后的独立段落出现。 diff --git a/frontend/src/app/api/consult/route.ts b/frontend/src/app/api/consult/route.ts index dc7ac291..ad7d21fa 100644 --- a/frontend/src/app/api/consult/route.ts +++ b/frontend/src/app/api/consult/route.ts @@ -73,6 +73,7 @@ import { type GeneralDailyPanchangaContext, } from "@/lib/general-daily-panchanga"; import { consultationHistoryFromStoredMessages } from "@/lib/consultation-session-history"; +import { generateSessionTitle, shouldGenerateSessionTitle } from "@/lib/session-title-agent"; import { z } from "zod"; export const runtime = "nodejs"; @@ -278,7 +279,7 @@ export async function POST(request: Request) { const { data: chatSession, error: chatSessionError } = await supabase .from("chat_sessions") - .select("id,model_id,model_config_version,session_type,messages") + .select("id,model_id,model_config_version,session_type,messages,title,theme,chart_profile_role") .eq("id", parsed.data.sessionId) .eq("user_id", user.id) .maybeSingle(); @@ -637,6 +638,34 @@ export async function POST(request: Request) { // its settle-and-log entry point here so the request-level catch below can // still emit it. const agenticFailure: { report?: (error: unknown) => Promise } = {}; + const expectedTitle = typeof chatSession.title === "string" ? chatSession.title : ""; + const titleSideEvent = shouldGenerateSessionTitle({ + title: expectedTitle, + sessionType: chatSession.session_type, + }, storedHistory) + ? generateSessionTitle({ + model: selectedModel, + question: parsed.data.question, + theme: consultationTheme, + chartRole: chatSession.chart_profile_role === "other" ? "other" : "self", + signal: request.signal, + }).then(async (title) => { + if (!title) return null; + try { + const { error } = await supabase.from("chat_sessions").update({ title }) + .eq("id", sessionId) + .eq("user_id", userId) + .eq("title", expectedTitle); + if (error) console.warn("session title persist failed", error); + } catch (error) { + console.warn("session title persist failed", error); + } + return { type: "session.title" as const, title }; + }).catch((error) => { + console.warn("session title failed", error); + return null; + }) + : Promise.resolve(null); async function runAgenticConsultation( consultationMode: ConsultationBirthTimeMode, @@ -837,6 +866,7 @@ export async function POST(request: Request) { return streamAgentResponse({ runId: requestId, requestId, + sideEvent: titleSideEvent, state, stream: result.fullStream, requireTool: false, @@ -939,6 +969,7 @@ export async function POST(request: Request) { return streamAgentResponse({ runId: requestId, requestId, + sideEvent: titleSideEvent, state, stream: result.fullStream, requireTool: true, @@ -1054,6 +1085,7 @@ export async function POST(request: Request) { return streamAgentResponse({ runId: requestId, requestId, + sideEvent: titleSideEvent, state, stream: result.fullStream, requireTool: true, diff --git a/frontend/src/app/api/consult/status/route.ts b/frontend/src/app/api/consult/status/route.ts index 33432f7b..d270b58c 100644 --- a/frontend/src/app/api/consult/status/route.ts +++ b/frontend/src/app/api/consult/status/route.ts @@ -101,11 +101,19 @@ export async function GET(request: Request) { statusData = settledData; } + const { data: sessionRow } = await supabase + .from("chat_sessions") + .select("title") + .eq("id", statusData.session_id) + .eq("user_id", user.id) + .maybeSingle(); + return NextResponse.json({ requestId: statusData.request_id, sessionId: statusData.session_id, status: statusData.status, responseMessage: statusData.response_message, updatedAt: statusData.updated_at, + ...(typeof sessionRow?.title === "string" ? { title: sessionRow.title } : {}), }); } diff --git a/frontend/src/app/api/sessions/[id]/route.ts b/frontend/src/app/api/sessions/[id]/route.ts index 551416a1..304b6aae 100644 --- a/frontend/src/app/api/sessions/[id]/route.ts +++ b/frontend/src/app/api/sessions/[id]/route.ts @@ -3,14 +3,13 @@ import { createServerSupabaseClient } from "@/lib/supabase/server"; import { isSupabaseConfigurationError } from "@/lib/supabase/config"; import { ChatSessionBodyTooLargeError, - chatSessionMetadataPatchSchema, chatSessionModelPatchSchema, chatSessionWriteSchema, - extractChatSessionMetadataPatch, readChatSessionJson, } from "@/lib/chat-session-write-contract"; import { logIgnoredSessionMessages } from "@/lib/chat-session-observability"; import { consumeUserRequestRateLimit } from "@/lib/request-rate-limit"; +import { metadataUpdateValues } from "@/lib/session-metadata-update"; type RouteContext = { params: Promise<{ id: string }> }; @@ -25,12 +24,6 @@ function ignoredMessageCount(payload: { messages: unknown }) { return Array.isArray(payload.messages) ? payload.messages.length : 0; } -function metadataUpdateValues(payload: unknown): Record | null { - const parsed = chatSessionMetadataPatchSchema.safeParse(extractChatSessionMetadataPatch(payload)); - if (!parsed.success) return null; - return { ...parsed.data, updated_at: new Date().toISOString() }; -} - export async function GET(_request: Request, context: RouteContext) { try { const { id } = await context.params; @@ -86,7 +79,7 @@ export async function PATCH(request: Request, context: RouteContext) { values = metadataUpdateValues(fullWrite.data); } if (!values && modelPatch.success) { - values = { ...modelPatch.data, updated_at: new Date().toISOString() }; + values = { ...modelPatch.data }; } if (!values && payloadHasMessages(payload)) { return NextResponse.json({ ok: true }); diff --git a/frontend/src/app/api/sessions/route.ts b/frontend/src/app/api/sessions/route.ts index 77c24b6d..16f60452 100644 --- a/frontend/src/app/api/sessions/route.ts +++ b/frontend/src/app/api/sessions/route.ts @@ -3,21 +3,74 @@ import { chatSessionCreateSchema, ChatSessionBodyTooLargeError, readChatSessionJ import { consumeUserRequestRateLimit } from "@/lib/request-rate-limit"; import { isSupabaseConfigurationError } from "@/lib/supabase/config"; import { createServerSupabaseClient } from "@/lib/supabase/server"; +import { + clampSessionLimit, + isArchivedSessionQuery, + nextSessionCursor, + parseSessionCursor, + sessionCursorFilter, +} from "@/lib/session-cursor"; const SESSION_LIST_COLUMNS = "id,title,theme,model_id,session_type,rectification_case_id,chart_profile_id,chart_profile_name,chart_profile_role,updated_at,pinned,archived_at"; -export async function GET() { +function applyArchiveFilter Query; + not: (column: string, operator: string, value: null) => Query; +}>(query: Query, archived: boolean): Query { + return archived ? query.not("archived_at", "is", null) : query.is("archived_at", null); +} + +export async function GET(request: Request) { try { + const url = new URL(request.url); + const limit = clampSessionLimit(url.searchParams.get("limit")); + const archived = isArchivedSessionQuery(url.searchParams.get("archived")); + const beforeRaw = url.searchParams.get("before"); + const cursor = parseSessionCursor(beforeRaw); + if (beforeRaw && !cursor) { + return NextResponse.json({ error: "聊天记录请求无效" }, { status: 400 }); + } const supabase = await createServerSupabaseClient(); const { data: { user }, error: authError } = await supabase.auth.getUser(); if (authError || !user) return NextResponse.json({ error: "请先登录" }, { status: 401 }); - const { data, error } = await supabase - .from("chat_sessions") - .select(SESSION_LIST_COLUMNS) - .eq("user_id", user.id) - .order("updated_at", { ascending: false }); - if (error) return NextResponse.json({ error: "聊天记录暂时无法读取" }, { status: 500 }); - return NextResponse.json({ sessions: data ?? [] }); + + let pageQuery = applyArchiveFilter( + supabase + .from("chat_sessions") + .select(SESSION_LIST_COLUMNS) + .eq("user_id", user.id) + .eq("pinned", false), + archived, + ) + .order("updated_at", { ascending: false }) + .order("id", { ascending: false }) + .limit(limit + 1); + if (cursor) { + pageQuery = pageQuery.or(sessionCursorFilter(cursor)); + } + const { data: pageRows, error: pageError } = await pageQuery; + if (pageError) return NextResponse.json({ error: "聊天记录暂时无法读取" }, { status: 500 }); + + let pinnedRows: typeof pageRows = []; + if (!cursor) { + const { data: pinnedData, error: pinnedError } = await applyArchiveFilter( + supabase + .from("chat_sessions") + .select(SESSION_LIST_COLUMNS) + .eq("user_id", user.id) + .eq("pinned", true), + archived, + ) + .order("updated_at", { ascending: false }) + .order("id", { ascending: false }); + if (pinnedError) return NextResponse.json({ error: "聊天记录暂时无法读取" }, { status: 500 }); + pinnedRows = pinnedData ?? []; + } + + const nextCursor = nextSessionCursor(pageRows ?? [], limit); + const page = (pageRows ?? []).slice(0, limit); + const sessions = cursor ? page : [...(pinnedRows ?? []), ...page]; + return NextResponse.json({ sessions, nextCursor }); } catch (error) { if (isSupabaseConfigurationError(error)) { return NextResponse.json({ error: "数据库尚未配置", code: "DATABASE_NOT_CONFIGURED" }, { status: 503 }); diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index a3b62fd0..3f3557dd 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -764,7 +764,9 @@ button:disabled { cursor: default; opacity: .45; } font-weight: 500; letter-spacing: .02em; } -.session-main small { color: var(--color-ink-tertiary); line-height: 1.3; font-size: var(--type-overline); } +.session-main small, .session-subtitle { color: var(--color-ink-tertiary); line-height: 1.3; font-size: var(--type-overline); } +.sidebar-group-label { margin: var(--space-2) 0 var(--space-1); color: var(--color-ink-tertiary); font-size: var(--type-overline); font-weight: 500; letter-spacing: .02em; } +.session-list-sentinel { height: 1px; } .session-menu-trigger { width: 44px; height: 44px; display: grid; place-items: center; justify-self: center; padding: 0; border: 0; border-radius: 0; background: transparent; color: inherit; cursor: pointer; opacity: .64; transition: background-color 120ms ease-out, color 120ms ease-out, opacity 120ms ease-out, transform 120ms ease-out; } .session-menu-trigger > svg { width: 18px; height: 18px; } .session-row:hover .session-menu-trigger, .session-row:focus-within .session-menu-trigger, .session-menu-trigger[aria-expanded="true"] { opacity: 1; } diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 3af2a05b..f64c39ed 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -77,6 +77,7 @@ import { useConversationScrollAnchor } from "@/hooks/use-conversation-scroll-anc import { useProfileOnboarding } from "@/hooks/use-profile-onboarding"; import { useRectificationSurface } from "@/hooks/use-rectification-surface"; import { useSessionManagement } from "@/hooks/use-session-management"; +import { sortSessions } from "@/lib/session-groups"; import { showChatNotice as setComposerNotice } from "@/lib/chat-notice"; import { chatReplyAnnouncement, type ChatReplyPhase } from "@/lib/chat-reply-announcement"; import { @@ -196,9 +197,7 @@ import { placeQuestion, readProfile, selectedBirthPlace, - sessionChartLabel, - sessionSidebarTitle, - upsertSelfChart, + sessionChartLabel, sessionSidebarSubtitle, sessionSidebarTitle, upsertSelfChart, } from "@/lib/home-profile"; import { activeChartStorageKey, @@ -272,6 +271,7 @@ export default function Home() { const [signingOut, setSigningOut] = useState(false); const [sessions, setSessions] = useState([]); const [showArchivedSessions, setShowArchivedSessions] = useState(false); + const [sessionsCursor, setSessionsCursor] = useState(null); const [sessionMenuId, setSessionMenuId] = useState(null); const [pendingSessionDeletion, setPendingSessionDeletion] = useState(null); const [modelCatalog, setModelCatalog] = useState(null); @@ -379,13 +379,11 @@ export default function Home() { const activeRectificationSession = activeSession?.sessionType === "birth_time_rectification"; const rectificationSurfaceOpen = activeRectificationSession && activeSession.id === rectificationSessionId; - const visibleSessions = sessions - .filter((session) => showArchivedSessions ? Boolean(session.archivedAt) : !session.archivedAt) - .filter((session) => session.sessionType === "birth_time_rectification" - || session.messages.length > 0 - || !session.messagesHydrated - || session.id === activeSessionId) - .sort((left, right) => Number(right.pinned) - Number(left.pinned)); + const visibleSessions = sortSessions(sessions.filter((session) => (showArchivedSessions ? session.archivedAt : !session.archivedAt) + && (session.sessionType === "birth_time_rectification" + || session.messages.length > 0 + || !session.messagesHydrated + || session.id === activeSessionId))); const activeError = requestError && requestError.sessionId === activeSession?.id ? requestError.message : ""; const isLoading = pendingSessionId === activeSession?.id; const productEntrypointsDisabled = !hydrated @@ -442,8 +440,7 @@ export default function Home() { toggleArchivedSession, shareSession, startNewChat, - selectSession, - selectSessionModel, + selectSession, selectSessionModel, loadMoreSessions, toggleArchivedView, } = useSessionManagement({ account, accountId, activeChartId, activeSession, activeSessionId, activeSessionIdRef, applySessionPopStateRef, cancellationPending, chartLibrary, creatingSession, modelCatalog, @@ -451,7 +448,8 @@ export default function Home() { rectificationSessionId, sessionDetailInFlight, sessionSelectionSource, sessions, sessionsRef, setActiveChartId, setActiveSessionId, setBirthTimeConsultationConsent, setCreatingSession, setDraft, setDraftEntrypoint, setDraftTheme, setRectificationError, setRequestError, - setSessionDetailLoadingId, setSessionFullPrompt, setSessions, uiPreview, visibleSessions, + setSessionDetailLoadingId, setSessionFullPrompt, setSessions, sessionsCursor, setSessionsCursor, + showArchivedSessions, setShowArchivedSessions, uiPreview, visibleSessions, openRectificationSession: (exactSessionId) => rectificationSessionOpenerRef.current(exactSessionId), }); @@ -932,7 +930,8 @@ export default function Home() { ]); const nextModelCatalog = modelCatalogResult.catalog; const nextProfile = readProfile(nextAccount.profile); - const parsedSessions = readSessions(sessionsPayload, nextModelCatalog); + const parsedSessions = readSessions(sessionsPayload.sessions, nextModelCatalog); + setSessionsCursor(sessionsPayload.nextCursor); let nextSessions = parsedSessions.sessions; if (nextSessions.length === 0) { if (controller.signal.aborted) return; @@ -1590,10 +1589,9 @@ export default function Home() { }; const sidebarSessions = visibleSessions.map((session) => ({ - id: session.id, - title: sessionSidebarTitle(session, chartLibrary), - pinned: session.pinned, - archived: Boolean(session.archivedAt), + id: session.id, title: sessionSidebarTitle(session, chartLibrary), + subtitle: sessionSidebarSubtitle(session, chartLibrary), + pinned: session.pinned, archived: Boolean(session.archivedAt), updatedAt: session.updatedAt, })); const sidebarCharts = (chartLibrary.length > 0 ? chartLibrary @@ -1744,14 +1742,11 @@ export default function Home() { newChatDisabled={!hydrated || !modelCatalog || creatingSession || Boolean(pendingSessionId) || cancellationPending} creatingSession={creatingSession} sessionControls={{ - archivedCount: sessions.filter((session) => session.archivedAt).length, - showingArchived: showArchivedSessions, + archivedCount: sessions.filter((session) => session.archivedAt).length, showingArchived: showArchivedSessions, + hasMore: Boolean(sessionsCursor), onLoadMore: loadMoreSessions, menuSessionId: sessionMenuId, disabled: Boolean(pendingSessionId) || cancellationPending, - onToggleArchivedView: () => { - setShowArchivedSessions((current) => !current); - setSessionMenuId(null); - }, + onToggleArchivedView: () => { void toggleArchivedView(); setSessionMenuId(null); }, onMenuSessionChange: setSessionMenuId, onTogglePinned: togglePinnedSession, onRename: (sessionId) => { diff --git a/frontend/src/components/app-sidebar.tsx b/frontend/src/components/app-sidebar.tsx index 008d981e..0fc7598e 100644 --- a/frontend/src/components/app-sidebar.tsx +++ b/frontend/src/components/app-sidebar.tsx @@ -40,6 +40,7 @@ import { import { ThemePreferenceMenu } from "@/components/theme-preference-menu"; import { UserAvatar } from "@/components/user-avatar"; import type { BeamAvatar } from "@/lib/beam-avatar"; +import { groupSessionsByRecency } from "@/lib/session-groups"; export type SidebarAccount = { name: string; @@ -106,12 +107,25 @@ export function AppSidebar({ const { isMobile, setOpen, setOpenMobile, state, viewport } = useSidebar(); const firstSessionRef = useRef(null); const historyHeadingRef = useRef(null); + const loadMoreRef = useRef(null); const isCollapsedDesktop = state === "collapsed" && !isMobile; const showExpandedContent = !isCollapsedDesktop; const menuPlacement = `${viewport}:${state}`; const previousMenuPlacement = useRef(menuPlacement); const favoriteSessions = sessions.filter((session) => session.pinned); const historySessions = sessions.filter((session) => !session.pinned); + const historyGroups = groupSessionsByRecency(historySessions); + + useEffect(() => { + if (!sessionControls.hasMore || !sessionControls.onLoadMore) return; + const node = loadMoreRef.current; + if (!node) return; + const observer = new IntersectionObserver((entries) => { + if (entries.some((entry) => entry.isIntersecting)) sessionControls.onLoadMore?.(); + }); + observer.observe(node); + return () => observer.disconnect(); + }, [historySessions.length, sessionControls.hasMore, sessionControls.onLoadMore]); useEffect(() => { if (previousMenuPlacement.current !== menuPlacement && accountMenuOpen) { @@ -259,7 +273,16 @@ export function AppSidebar({ {historySessions.length === 0 ?

暂无对话,点上方「新建对话」开始

: ( - {historySessions.map((session, index) => renderSession(session, favoriteSessions.length + index))} + {historyGroups.map((group) => ( +
+

{group.label}

+ {group.sessions.map((session) => renderSession( + session, + favoriteSessions.length + historySessions.findIndex((item) => item.id === session.id), + ))} +
+ ))} + {sessionControls.hasMore ?
: null} )} diff --git a/frontend/src/components/sidebar-session-row.tsx b/frontend/src/components/sidebar-session-row.tsx index 9cdcc49e..5e777e17 100644 --- a/frontend/src/components/sidebar-session-row.tsx +++ b/frontend/src/components/sidebar-session-row.tsx @@ -19,13 +19,17 @@ import { sessionMutationMenuVisible } from "@/lib/chat-session-persistence"; export type SidebarSession = { readonly id: string; readonly title: string; + readonly subtitle?: string | null; readonly pinned: boolean; readonly archived: boolean; + readonly updatedAt: number; }; export type SidebarSessionControls = { readonly archivedCount: number; readonly showingArchived: boolean; + readonly hasMore?: boolean; + readonly onLoadMore?: () => void; readonly menuSessionId: string | null; readonly disabled: boolean; readonly onToggleArchivedView: () => void; @@ -96,6 +100,7 @@ export const SidebarSessionRow = forwardRef{session.title} {opening ? {RECTIFICATION_SIDEBAR_OPENING_NOTE} : null} + {session.subtitle ? {session.subtitle} : null} { if (status?.status !== "reserved") return; const session = sessions.find((item) => item.id === status.sessionId); + if (typeof status.title === "string" && status.title && session && isAutoDerivedSessionTitle(session.title)) { + updateSession(session.id, (current) => ({ ...current, title: status.title! })); + } if (session) restoreConsultationRecovery(session, status.requestId); }) .catch(() => undefined); @@ -381,6 +385,11 @@ export function useConsultationRun(params: ConsultationRunParams) { setComposerNotice("回答已完成,正在恢复服务端完整内容。"); try { const status = await fetchConsultationStatus(pending.sessionId, pending.requestId); + if (typeof status.title === "string" && status.title) { + updateSession(pending.sessionId, (current) => ( + isAutoDerivedSessionTitle(current.title) ? { ...current, title: status.title! } : current + )); + } if (status.status === "completed") { const detailed = await fetchSessionDetail(pending.sessionId, modelCatalog); if (detailed) { @@ -767,6 +776,7 @@ export function useConsultationRun(params: ConsultationRunParams) { const reader = response.body.getReader(); const decoder = new TextDecoder(); let answer = ""; + let streamedTitle: string | undefined; const updateActivity = (event: ConsultationAgentPublicEvent) => { let activity: AgentActivityView | undefined; if (event.type === "skill.started") { @@ -823,6 +833,12 @@ export function useConsultationRun(params: ConsultationRunParams) { parseAgentReply(answer).text, ); } + if (event.type === "session.title") { + streamedTitle = event.title; + updateSession(sessionId, (current) => ( + isAutoDerivedSessionTitle(current.title) ? { ...current, title: event.title } : current + )); + } if (event.type === "run.completed") { runCompleted = true; agentExecutionReceipt = event.receipt; @@ -902,8 +918,9 @@ export function useConsultationRun(params: ConsultationRunParams) { : new Error("Agent 没有返回可显示的回答,请重试。"); } - const completedTitle = reply.title && !isGenericSessionTitle(reply.title) - ? resolveSessionTitle(question, reply.title, { + const modelTitle = streamedTitle ?? reply.title; + const completedTitle = modelTitle && !isGenericSessionTitle(modelTitle) + ? resolveSessionTitle(question, modelTitle, { entrypoint: consultEntrypoint, theme, existingTitles: sessions.filter((item) => item.id !== sessionId).map((item) => item.title), diff --git a/frontend/src/hooks/use-session-management.ts b/frontend/src/hooks/use-session-management.ts index 87b7b663..fc912098 100644 --- a/frontend/src/hooks/use-session-management.ts +++ b/frontend/src/hooks/use-session-management.ts @@ -1,6 +1,7 @@ "use client"; import type { Dispatch, MutableRefObject, SetStateAction } from "react"; +import { useRef } from "react"; import { showChatNotice as setComposerNotice } from "@/lib/chat-notice"; import { writeChatSession } from "@/lib/chat-session-write-contract"; @@ -18,11 +19,14 @@ import { activeChartStorageKey, createSession, fetchSessionDetail, + fetchSessions, LoginRedirectError, mergeHydratedSession, patchSessionModel, + readSessions, } from "@/lib/home-cloud-sync"; import { chartSnapshotForSession } from "@/lib/home-profile"; +import { beginSessionPageLoad, mergeSessionPage } from "@/lib/session-groups"; import type { ConsultationEntrypoint } from "@/lib/consultation-entrypoint"; import { persistSessionModelSelection, @@ -30,7 +34,6 @@ import { } from "@/lib/session-model-persistence"; import type { PublicLanguageModelCatalog } from "@/lib/public-models"; import { - timestamp, type Account, type ChartLibraryRecord, type ChatSession, @@ -73,6 +76,10 @@ export type SessionManagementParams = { setSessionDetailLoadingId: Dispatch>; setSessionFullPrompt: Dispatch>; setSessions: Dispatch>; + sessionsCursor: string | null; + setSessionsCursor: Dispatch>; + showArchivedSessions: boolean; + setShowArchivedSessions: Dispatch>; uiPreview: MutableRefObject; visibleSessions: ChatSession[]; openRectificationSession: (exactSessionId: string) => Promise | void; @@ -113,11 +120,16 @@ export function useSessionManagement(params: SessionManagementParams) { setSessionDetailLoadingId, setSessionFullPrompt, setSessions, + sessionsCursor, + setSessionsCursor, + showArchivedSessions, + setShowArchivedSessions, uiPreview, visibleSessions, openRectificationSession, } = params; sessionsRef.current = sessions; + const loadMoreInFlight = useRef(false); function updateSession(sessionId: string, change: (session: ChatSession) => ChatSession) { setSessions((current) => current.map((session) => (session.id === sessionId ? change(session) : session))); @@ -185,7 +197,7 @@ export function useSessionManagement(params: SessionManagementParams) { async function renameSession(session: ChatSession) { const title = window.prompt("重命名聊天记录", session.title)?.trim(); if (!title || title === session.title) return; - const nextSession = { ...session, title, updatedAt: timestamp() }; + const nextSession = { ...session, title }; updateSession(session.id, () => nextSession); try { await persistSession(nextSession); @@ -348,6 +360,41 @@ export function useSessionManagement(params: SessionManagementParams) { sessionSelectionSource.current = "user"; } + async function loadMoreSessions() { + if (!beginSessionPageLoad(loadMoreInFlight, sessionsCursor)) return; + try { + const page = await fetchSessions(undefined, { + before: sessionsCursor, + archived: showArchivedSessions, + }); + const incoming = readSessions(page.sessions, modelCatalog).sessions; + setSessions((current) => mergeSessionPage(current, incoming)); + setSessionsCursor(page.nextCursor); + } catch (caught) { + if (caught instanceof LoginRedirectError) return; + setComposerNotice(caught instanceof Error ? caught.message : "暂时无法读取聊天记录"); + } finally { + loadMoreInFlight.current = false; + } + } + + async function toggleArchivedView() { + const nextArchived = !showArchivedSessions; + setShowArchivedSessions(nextArchived); + try { + const page = await fetchSessions(undefined, { archived: nextArchived }); + const parsed = readSessions(page.sessions, modelCatalog).sessions; + const active = sessionsRef.current.find((session) => session.id === activeSessionId); + setSessions(active && !parsed.some((session) => session.id === active.id) + ? mergeHydratedSession(parsed, active) + : parsed); + setSessionsCursor(page.nextCursor); + } catch (caught) { + if (caught instanceof LoginRedirectError) return; + setComposerNotice(caught instanceof Error ? caught.message : "暂时无法读取聊天记录"); + } + } + applySessionPopStateRef.current = (search: string) => { if (uiPreview.current) return; const listed = sessionsRef.current; @@ -384,7 +431,7 @@ export function useSessionManagement(params: SessionManagementParams) { const nextSession: ChatSession = retryingFailedSync ? activeSession - : { ...activeSession, modelId, updatedAt: timestamp() }; + : { ...activeSession, modelId }; const selectionVersion = (modelSelectionVersions.current.get(nextSession.id) ?? 0) + 1; modelSelectionVersions.current.set(nextSession.id, selectionVersion); if (!retryingFailedSync) updateSession(activeSession.id, () => nextSession); @@ -440,5 +487,7 @@ export function useSessionManagement(params: SessionManagementParams) { startNewChat, selectSession, selectSessionModel, + loadMoreSessions, + toggleArchivedView, }; } diff --git a/frontend/src/lib/consultation-agent-events.ts b/frontend/src/lib/consultation-agent-events.ts index f93ea89e..0fb0500c 100644 --- a/frontend/src/lib/consultation-agent-events.ts +++ b/frontend/src/lib/consultation-agent-events.ts @@ -98,6 +98,10 @@ const thinkingDeltaSchema = z.object({ type: z.literal("thinking.delta"), text: const thinkingSectionEventSchema = publicThinkingSectionSchema.extend({ type: z.literal("thinking.section"), }).strict(); +const sessionTitleSchema = z.object({ + type: z.literal("session.title"), + title: z.string().trim().min(1).max(48), +}).strict(); const runCompletedSchema = z.object({ type: z.literal("run.completed"), receipt: agentExecutionReceiptSchema }).strict(); // A failure is the case the receipt is most needed for, so it carries the same // allowlisted receipt a completed run does. It stays optional because the @@ -113,7 +117,7 @@ const runFailedSchema = z.object({ export const consultationAgentPublicEventSchema = z.discriminatedUnion("type", [ runStartedSchema, skillStartedSchema, skillCompletedSchema, toolStartedSchema, activitySchema, toolCompletedSchema, toolFailedSchema, answerDeltaSchema, thinkingDeltaSchema, - thinkingSectionEventSchema, runCompletedSchema, runFailedSchema, + thinkingSectionEventSchema, sessionTitleSchema, runCompletedSchema, runFailedSchema, ]); export type ConsultationAgentPublicEvent = z.infer; diff --git a/frontend/src/lib/home-cloud-sync.ts b/frontend/src/lib/home-cloud-sync.ts index 2e86ac96..7bbb8c7d 100644 --- a/frontend/src/lib/home-cloud-sync.ts +++ b/frontend/src/lib/home-cloud-sync.ts @@ -237,6 +237,21 @@ export async function fetchDailyStarlanguage(signal: AbortSignal): Promise { - const response = await fetch("/api/sessions", { signal, cache: "no-store" }); +export async function fetchSessions( + signal?: AbortSignal, + options?: { before?: string | null; archived?: boolean }, +): Promise { + const params = new URLSearchParams(); + if (options?.before) params.set("before", options.before); + if (options?.archived) params.set("archived", "1"); + const query = params.toString(); + const response = await fetch("/api/sessions" + (query ? `?${query}` : ""), { signal, cache: "no-store" }); if (response.status === 401) redirectToLogin(); const payload = await response.json().catch(() => null); if (!response.ok) throw new Error(payloadMessage(payload, "暂时无法读取聊天记录")); - return payload && typeof payload === "object" ? (payload as { sessions?: unknown }).sessions : null; + return readSessionListPage(payload); } export async function fetchSessionDetail( diff --git a/frontend/src/lib/home-profile.ts b/frontend/src/lib/home-profile.ts index b982a227..441715bd 100644 --- a/frontend/src/lib/home-profile.ts +++ b/frontend/src/lib/home-profile.ts @@ -103,8 +103,13 @@ export function sessionChartLabel(session: ChatSession, library: readonly ChartL return current ? name : `资料已删除 · ${name}`; } -export function sessionSidebarTitle(session: ChatSession, library: readonly ChartLibraryRecord[]) { - return `${sessionChartLabel(session, library)} · ${session.title || "新对话"}`; +export function sessionSidebarTitle(session: ChatSession, _library?: readonly ChartLibraryRecord[]) { + return session.title?.trim() || "新对话"; +} + +export function sessionSidebarSubtitle(session: ChatSession, library: readonly ChartLibraryRecord[]) { + if (session.chartProfileRole === "self" || !session.chartProfileId) return null; + return sessionChartLabel(session, library); } export function upsertSelfChart(library: ChartLibraryRecord[], profile: Profile) { diff --git a/frontend/src/lib/home-types.ts b/frontend/src/lib/home-types.ts index b16c4e18..f3e6736e 100644 --- a/frontend/src/lib/home-types.ts +++ b/frontend/src/lib/home-types.ts @@ -152,6 +152,7 @@ export type ConsultationStatus = { readonly status: "reserved" | "completed" | "cancelled"; readonly responseMessage?: unknown; readonly updatedAt?: string; + readonly title?: string; }; export type PendingConsultation = { readonly requestId: string; diff --git a/frontend/src/lib/session-cursor.ts b/frontend/src/lib/session-cursor.ts new file mode 100644 index 00000000..70bf93d9 --- /dev/null +++ b/frontend/src/lib/session-cursor.ts @@ -0,0 +1,67 @@ +const sessionIdPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +export const SESSION_PAGE_SIZE = 40; +export const SESSION_PAGE_LIMIT_MIN = 1; +export const SESSION_PAGE_LIMIT_MAX = 100; + +export type SessionCursor = { + readonly updatedAt: string; + readonly id: string; +}; + +export function clampSessionLimit(raw: string | null | undefined): number { + if (raw == null || raw === "") return SESSION_PAGE_SIZE; + const parsed = Number.parseInt(raw, 10); + if (!Number.isFinite(parsed)) return SESSION_PAGE_SIZE; + return Math.min(SESSION_PAGE_LIMIT_MAX, Math.max(SESSION_PAGE_LIMIT_MIN, Math.trunc(parsed))); +} + +export function isArchivedSessionQuery(raw: string | null | undefined): boolean { + return raw === "1"; +} + +export function encodeSessionCursor(updatedAt: string, id: string): string { + return `${updatedAt},${id}`; +} + +export function parseSessionCursor(raw: string | null | undefined): SessionCursor | null { + if (!raw) return null; + const comma = raw.indexOf(","); + if (comma <= 0 || comma === raw.length - 1) return null; + const updatedAt = raw.slice(0, comma); + const id = raw.slice(comma + 1); + if (!Number.isFinite(Date.parse(updatedAt))) return null; + if (!sessionIdPattern.test(id)) return null; + return { updatedAt, id }; +} + +export function sessionCursorFilter(cursor: SessionCursor): string { + const updatedAt = JSON.stringify(cursor.updatedAt); + const id = JSON.stringify(cursor.id); + return `updated_at.lt.${updatedAt},and(updated_at.eq.${updatedAt},id.lt.${id})`; +} + +export function compareSessionCursor( + left: SessionCursor, + right: SessionCursor, +): number { + const time = Date.parse(right.updatedAt) - Date.parse(left.updatedAt); + if (time !== 0) return time; + return left.id < right.id ? 1 : left.id > right.id ? -1 : 0; +} + +export function nextSessionCursor( + rows: readonly { id: string; updated_at?: string; updatedAt?: string }[], + limit: number, +): string | null { + if (rows.length <= limit) return null; + const last = rows[limit - 1]; + if (!last) return null; + const updatedAt = typeof last.updated_at === "string" + ? last.updated_at + : typeof last.updatedAt === "string" + ? last.updatedAt + : ""; + if (!updatedAt) return null; + return encodeSessionCursor(updatedAt, last.id); +} diff --git a/frontend/src/lib/session-groups.ts b/frontend/src/lib/session-groups.ts new file mode 100644 index 00000000..78149d95 --- /dev/null +++ b/frontend/src/lib/session-groups.ts @@ -0,0 +1,90 @@ +import type { ChatSession } from "@/lib/home-types"; + +export const SESSION_RECENCY_LABELS = { + today: "今天", + yesterday: "昨天", + week: "最近 7 天", + month: "最近 30 天", + older: "更早", +} as const; + +export type SessionRecencyKey = keyof typeof SESSION_RECENCY_LABELS; + +export type SessionRecencyGroup = { + readonly key: SessionRecencyKey; + readonly label: string; + readonly sessions: readonly T[]; +}; + +function startOfLocalDay(now: Date): number { + return new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); +} + +export function sortSessions(sessions: readonly T[]): T[] { + return sessions + .map((session, index) => ({ session, index })) + .sort((left, right) => { + const pinned = Number(right.session.pinned) - Number(left.session.pinned); + if (pinned !== 0) return pinned; + const time = right.session.updatedAt - left.session.updatedAt; + if (time !== 0) return time; + return left.index - right.index; + }) + .map((item) => item.session); +} + +export function recencyKeyFor(updatedAt: number, now = Date.now()): SessionRecencyKey { + const todayStart = startOfLocalDay(new Date(now)); + const dayMs = 24 * 60 * 60 * 1000; + if (updatedAt >= todayStart) return "today"; + if (updatedAt >= todayStart - dayMs) return "yesterday"; + if (updatedAt >= todayStart - 6 * dayMs) return "week"; + if (updatedAt >= todayStart - 29 * dayMs) return "month"; + return "older"; +} + +export function groupSessionsByRecency( + sessions: readonly T[], + now = Date.now(), +): SessionRecencyGroup[] { + const buckets: Record = { + today: [], + yesterday: [], + week: [], + month: [], + older: [], + }; + for (const session of sessions) { + buckets[recencyKeyFor(session.updatedAt, now)].push(session); + } + const order: SessionRecencyKey[] = ["today", "yesterday", "week", "month", "older"]; + return order.flatMap((key) => { + const group = buckets[key]; + if (group.length === 0) return []; + return [{ key, label: SESSION_RECENCY_LABELS[key], sessions: group }]; + }); +} + +export function mergeSessionPage( + existing: readonly T[], + incoming: readonly T[], +): T[] { + const seen = new Map(existing.map((session) => [session.id, session])); + const next = [...existing]; + for (const row of incoming) { + const current = seen.get(row.id); + if (current) continue; + seen.set(row.id, row); + next.push(row); + } + return next; +} + +export function beginSessionPageLoad( + inFlight: { current: boolean }, + cursor: string | null, +): boolean { + if (inFlight.current || !cursor) return false; + inFlight.current = true; + return true; +} diff --git a/frontend/src/lib/session-metadata-update.ts b/frontend/src/lib/session-metadata-update.ts new file mode 100644 index 00000000..e01a0624 --- /dev/null +++ b/frontend/src/lib/session-metadata-update.ts @@ -0,0 +1,10 @@ +import { + chatSessionMetadataPatchSchema, + extractChatSessionMetadataPatch, +} from "@/lib/chat-session-write-contract"; + +export function metadataUpdateValues(payload: unknown): Record | null { + const parsed = chatSessionMetadataPatchSchema.safeParse(extractChatSessionMetadataPatch(payload)); + if (!parsed.success) return null; + return { ...parsed.data }; +} diff --git a/frontend/src/lib/session-title-agent.ts b/frontend/src/lib/session-title-agent.ts new file mode 100644 index 00000000..79801646 --- /dev/null +++ b/frontend/src/lib/session-title-agent.ts @@ -0,0 +1,139 @@ +import { Agent } from "@mastra/core/agent"; + +import { consultationDomainDefinition, type ConsultationDomain } from "@/lib/consultation-domain-registry"; +import { + sanitizeSessionTitle, + shouldGenerateSessionTitle, + isAutoDerivedSessionTitle, +} from "@/lib/session-title"; +import type { ResolvedLanguageModel } from "@/mastra/model"; + +export { + sanitizeSessionTitle, + shouldGenerateSessionTitle, + isAutoDerivedSessionTitle, +}; + +export const SESSION_TITLE_TIMEOUT_MS = 8_000; +export const SESSION_TITLE_MAX_TOKENS = 30; + +export const SESSION_TITLE_INSTRUCTIONS = `你只给这段咨询起一个中文标题。 +6 到 12 个字,直接写主题,不要引号、书名号、句号或解释。 +不要复述出生日期、时间或地点,也不要写资料姓名。 +只输出标题本身。`; + +type DisposableAbort = Readonly<{ + signal: AbortSignal; + dispose: () => void; +}>; + +function composedAbortSignal(signal: AbortSignal | undefined, timeoutMs: number): DisposableAbort { + const controller = new AbortController(); + // Must stay ref'd. The platform timeout helper uses an unref timer (BUG-523). + const timeoutId = globalThis.setTimeout(() => { + if (!controller.signal.aborted) { + controller.abort(new DOMException("session title timed out", "TimeoutError")); + } + }, timeoutMs); + const onExternalAbort = () => { + if (!controller.signal.aborted) { + controller.abort(signal?.reason ?? new DOMException("aborted", "AbortError")); + } + }; + if (signal) { + if (signal.aborted) onExternalAbort(); + else signal.addEventListener("abort", onExternalAbort); + } + return { + signal: controller.signal, + dispose: () => { + globalThis.clearTimeout(timeoutId); + signal?.removeEventListener("abort", onExternalAbort); + }, + }; +} + +function whenAborted(signal: AbortSignal): { promise: Promise; dispose: () => void } { + let onAbort: (() => void) | undefined; + const promise = new Promise((_, reject) => { + const fail = () => { + reject(signal.reason ?? new Error("aborted")); + }; + if (signal.aborted) { + fail(); + return; + } + onAbort = fail; + signal.addEventListener("abort", fail, { once: true }); + }); + return { + promise, + dispose: () => { + if (onAbort) signal.removeEventListener("abort", onAbort); + }, + }; +} + +export async function generateSessionTitleText( + model: ResolvedLanguageModel, + input: { + question: string; + theme?: ConsultationDomain | null; + chartRole?: "self" | "other" | null; + }, + signal?: AbortSignal, +): Promise { + const agent = new Agent({ + id: `session-title-${model.id}`, + name: "Session Title", + model: model.model, + instructions: SESSION_TITLE_INSTRUCTIONS, + }); + const themeLabel = input.theme && input.theme !== "general" + ? consultationDomainDefinition(input.theme).label + : "综合"; + const subject = input.chartRole === "other" ? "他人" : "本人"; + const result = await agent.generate([{ + role: "user", + content: [ + `主题:${themeLabel}`, + `分析对象:${subject}`, + `问题:${input.question.trim()}`, + ].join("\n"), + }], { + abortSignal: signal, + modelSettings: { maxOutputTokens: SESSION_TITLE_MAX_TOKENS }, + }); + const text = typeof result.text === "string" ? result.text : ""; + return text.trim(); +} + +export async function generateSessionTitle(input: { + model?: ResolvedLanguageModel | null; + question: string; + theme?: ConsultationDomain | null; + chartRole?: "self" | "other" | null; + signal?: AbortSignal; + timeoutMs?: number; + generateText?: (signal?: AbortSignal) => Promise; +}): Promise { + const generate = input.generateText ?? (input.model + ? (signal?: AbortSignal) => generateSessionTitleText(input.model as ResolvedLanguageModel, { + question: input.question, + theme: input.theme, + chartRole: input.chartRole, + }, signal) + : null); + if (!generate) return null; + const composed = composedAbortSignal(input.signal, input.timeoutMs ?? SESSION_TITLE_TIMEOUT_MS); + const aborted = whenAborted(composed.signal); + try { + const raw = await Promise.race([generate(composed.signal), aborted.promise]); + return sanitizeSessionTitle(raw); + } catch { + return null; + } finally { + aborted.dispose(); + composed.dispose(); + } +} diff --git a/frontend/src/lib/session-title.ts b/frontend/src/lib/session-title.ts new file mode 100644 index 00000000..a589763f --- /dev/null +++ b/frontend/src/lib/session-title.ts @@ -0,0 +1,49 @@ +import { isGenericSessionTitle } from "@/lib/agent-reply"; + +function hanLength(value: string): number { + return Array.from(value.replace(/\s+/g, "")).length; +} + +function clipHan(value: string, maxChars: number): string { + const characters = Array.from(value); + return characters.length > maxChars ? characters.slice(0, maxChars).join("") : value; +} + +const BIRTH_STAMP = /\d{4}年\d{1,2}月(?:\d{1,2}日)?|\d{1,2}:\d{2}/; +const DATED_ENTRY_TITLE = /^\d{1,2}月\d{1,2}日\s*·\s*(?:今日节奏|生时校正)$/; + +export function sanitizeSessionTitle(raw: string): string | null { + if (/[\r\n]/.test(raw)) return null; + const stripped = raw + .replace(/[“”"‘’'「」『』《》]/g, "") + .replace(/[。..!?!?、,,;;::]+$/u, "") + .replace(/\s+/g, " ") + .trim(); + if (!stripped) return null; + if (hanLength(stripped) < 2) return null; + if (isGenericSessionTitle(stripped)) return null; + if (BIRTH_STAMP.test(stripped)) return null; + const clipped = clipHan(stripped, 14); + if (hanLength(clipped) < 6) return null; + return clipped; +} + +export function isAutoDerivedSessionTitle(title: string): boolean { + const text = title.replace(/\s+/g, " ").trim(); + if (!text) return true; + if (isGenericSessionTitle(text)) return true; + if (DATED_ENTRY_TITLE.test(text)) return true; + if (/…$|\.{3}$/.test(text)) return true; + return false; +} + +export function shouldGenerateSessionTitle( + session: { title?: string | null; sessionType?: string | null }, + storedHistory: readonly unknown[], +): boolean { + if (storedHistory.length > 0) return false; + if (session.sessionType === "birth_time_rectification") return false; + const title = typeof session.title === "string" ? session.title : ""; + if (DATED_ENTRY_TITLE.test(title.replace(/\s+/g, " ").trim())) return false; + return isAutoDerivedSessionTitle(title); +} diff --git a/frontend/src/lib/stream-agent-response.ts b/frontend/src/lib/stream-agent-response.ts index f30e0f34..aa9b3516 100644 --- a/frontend/src/lib/stream-agent-response.ts +++ b/frontend/src/lib/stream-agent-response.ts @@ -285,6 +285,7 @@ type StreamAgentResponseOptions = EventOptions & { ) => void | Promise; onError?: (error: unknown, emitted: boolean, output: string) => void | Promise; onCancel?: (emitted: boolean) => void | Promise; + sideEvent?: Promise; }; // Failed attempts are retried by the model against the same request-scoped @@ -449,6 +450,12 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) { const body = new ReadableStream({ start(controller) { + const sideEvent = options.sideEvent + ? options.sideEvent.then((event) => { + if (event) send(controller, event); + return event; + }).catch(() => null) + : Promise.resolve(null); void (async () => { send(controller, { type: "run.started", runId: options.runId, requestId: options.requestId }); for (const event of skillBoundEvents) send(controller, event); @@ -496,6 +503,7 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) { ); settled = true; settling = false; + await sideEvent; send(controller, { type: "run.completed", receipt }); if (!disconnected) controller.close(); } catch (error) { diff --git a/frontend/tests/application-billing-contract.test.ts b/frontend/tests/application-billing-contract.test.ts index 9fccb558..5a47b468 100644 --- a/frontend/tests/application-billing-contract.test.ts +++ b/frontend/tests/application-billing-contract.test.ts @@ -71,9 +71,9 @@ test("free Agentic rectification turns bypass reservation, completion, and cance test("standard consultation resolves and settles the session-pinned model version", () => { assert.match(consultRoute, /sessionId: z\.string\(\)\.uuid\(\)/); - // Former value: select("id,model_id,model_config_version,session_type") without messages. - // Task 2 reads the last 12 stored messages as model history, so this select now includes messages. - assert.match(consultRoute, /select\("id,model_id,model_config_version,session_type,messages"\)/); + // Former value: select("id,model_id,model_config_version,session_type,messages"). + // First-round session titles need the current title, theme, and chart role. + assert.match(consultRoute, /select\("id,model_id,model_config_version,session_type,messages,title,theme,chart_profile_role"\)/); assert.match(consultRoute, /resolveSessionLanguageModel\(\s*chatSession\.model_id,\s*chatSession\.model_config_version,?\s*\)/); assert.match(consultRoute, /actualModelId: selectedModel\.id/); assert.match(consultRoute, /modelConfigVersion: selectedModel\.configVersion/); diff --git a/frontend/tests/chart-library-session.test.ts b/frontend/tests/chart-library-session.test.ts index 3ad333bc..7c0a22a3 100644 --- a/frontend/tests/chart-library-session.test.ts +++ b/frontend/tests/chart-library-session.test.ts @@ -6,6 +6,8 @@ import { chartLibraryOnCloudFailure, chartLibrarySessionBranch, } from "../src/lib/chart-library-session.ts"; +import { sessionSidebarSubtitle, sessionSidebarTitle } from "../src/lib/home-profile.ts"; +import { emptyProfile, type ChartLibraryRecord, type ChatSession } from "../src/lib/home-types.ts"; type RecordShape = { id: string; role: "self" | "other" }; @@ -53,3 +55,42 @@ test("a failed cloud read keeps only the profile-derived self chart", () => { { id: "self", role: "self" }, ]); }); + +test("sidebar titles drop the chart-name prefix and only subtitle others", () => { + const selfRecord: ChartLibraryRecord = { + id: "self", + role: "self", + relationship: "self", + updatedAt: 1, + profile: { ...emptyProfile, name: "本人" }, + }; + const library: ChartLibraryRecord[] = [selfRecord]; + const base: ChatSession = { + id: "11111111-1111-4111-8111-111111111111", + title: "半年内换工作时机", + theme: "career", + modelId: "m", + messages: [], + updatedAt: 1, + sessionType: "consultation", + rectificationCaseId: null, + chartProfileId: "self", + chartProfileName: "本人", + chartProfileRole: "self", + pinned: false, + archivedAt: null, + messagesHydrated: true, + }; + assert.equal(sessionSidebarTitle(base, library), "半年内换工作时机"); + assert.equal(sessionSidebarSubtitle(base, library), null); + const other = { ...base, chartProfileId: "other-1", chartProfileName: "对方", chartProfileRole: "other" as const }; + assert.equal(sessionSidebarSubtitle(other, library), "资料已删除 · 对方"); + const liveOtherLibrary: ChartLibraryRecord[] = [{ + ...selfRecord, + id: "other-1", + role: "other", + relationship: "partner", + profile: { ...emptyProfile, name: "对方" }, + }]; + assert.equal(sessionSidebarSubtitle(other, liveOtherLibrary), "对方"); +}); diff --git a/frontend/tests/chat-session-authority.test.ts b/frontend/tests/chat-session-authority.test.ts index a509338e..eaf50a75 100644 --- a/frontend/tests/chat-session-authority.test.ts +++ b/frontend/tests/chat-session-authority.test.ts @@ -77,3 +77,13 @@ test("pin and archive flags are session metadata, not localStorage", () => { assert.doesNotMatch(page, /localStorage\.setItem\(`\$\{prefix\}pinned`/); assert.doesNotMatch(page, /writeSynastryHistory\(/); }); + +test("session list GET pages by cursor and returns pinned on the first page", () => { + assert.match(listRoute, /clampSessionLimit/); + assert.match(listRoute, /parseSessionCursor/); + assert.match(listRoute, /eq\("pinned", false\)/); + assert.match(listRoute, /eq\("pinned", true\)/); + assert.match(listRoute, /isArchivedSessionQuery/); + assert.match(listRoute, /nextCursor/); + assert.match(listRoute, /limit \+ 1/); +}); diff --git a/frontend/tests/chat-session-write.test.ts b/frontend/tests/chat-session-write.test.ts index 2ce56952..f674c0f2 100644 --- a/frontend/tests/chat-session-write.test.ts +++ b/frontend/tests/chat-session-write.test.ts @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; import { chatSessionCreateSchema, chatSessionMetadataPatchSchema, chatSessionWriteSchema, writeChatSession, type ChatSessionWrite } from "../src/lib/chat-session-write-contract.ts"; +import { metadataUpdateValues } from "../src/lib/session-metadata-update.ts"; import { homeSurface } from "./home-surface.ts"; const sessionId = "11111111-1111-4111-8111-111111111111"; @@ -230,3 +231,11 @@ test("self-hosted staging bootstrap reads profile and sessions through same-orig assert.match(accountRoute, /profile,/); assert.doesNotMatch(accountRoute, /rectificationCase/); }); + +test("metadata PATCH no longer writes updated_at", () => { + const values = metadataUpdateValues({ title: "半年内换工作时机" }); + assert.ok(values); + assert.equal("updated_at" in values, false); + const itemRoute = readFileSync(new URL("../src/app/api/sessions/[id]/route.ts", import.meta.url), "utf8"); + assert.doesNotMatch(itemRoute, /updated_at: new Date\(\)\.toISOString\(\)/); +}); diff --git a/frontend/tests/consultation-recovery.test.ts b/frontend/tests/consultation-recovery.test.ts index 6ef9b441..5dbc8dfa 100644 --- a/frontend/tests/consultation-recovery.test.ts +++ b/frontend/tests/consultation-recovery.test.ts @@ -136,8 +136,9 @@ test("the first default consultation title is persisted with the user question", // completed metadata patch only updates title/theme/model/chart binding. assert.equal(sendSource.indexOf("await persistSession(userSession)"), -1); assert.match(sendSource, /await persistSession\(completedSession\)/); - assert.match(sendSource, /const completedTitle = reply\.title && !isGenericSessionTitle\(reply\.title\)/); - assert.match(sendSource, /resolveSessionTitle\(question, reply\.title/); + assert.match(sendSource, /const modelTitle = streamedTitle \?\? reply\.title/); + assert.match(sendSource, /const completedTitle = modelTitle && !isGenericSessionTitle\(modelTitle\)/); + assert.match(sendSource, /resolveSessionTitle\(question, modelTitle/); }); test("a truncated generation keeps the partial answer and does not wait for a successful run", () => { diff --git a/frontend/tests/session-cursor.test.ts b/frontend/tests/session-cursor.test.ts new file mode 100644 index 00000000..b414ecdd --- /dev/null +++ b/frontend/tests/session-cursor.test.ts @@ -0,0 +1,57 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + clampSessionLimit, + compareSessionCursor, + encodeSessionCursor, + nextSessionCursor, + parseSessionCursor, + SESSION_PAGE_SIZE, +} from "../src/lib/session-cursor.ts"; + +const leftId = "11111111-1111-4111-8111-111111111111"; +const rightId = "22222222-2222-4222-8222-222222222222"; +const stamp = "2026-09-06T04:00:00.000Z"; + +test("encodeSessionCursor round-trips a valid pair", () => { + const encoded = encodeSessionCursor(stamp, leftId); + assert.deepEqual(parseSessionCursor(encoded), { updatedAt: stamp, id: leftId }); +}); + +test("parseSessionCursor returns null for illegal strings", () => { + assert.equal(parseSessionCursor(null), null); + assert.equal(parseSessionCursor(""), null); + assert.equal(parseSessionCursor("not-a-date,11111111-1111-4111-8111-111111111111"), null); + assert.equal(parseSessionCursor(`${stamp},not-a-uuid`), null); + assert.equal(parseSessionCursor(stamp), null); +}); + +test("compareSessionCursor uses id when the timestamp is the same", () => { + const sameTime = compareSessionCursor( + { updatedAt: stamp, id: leftId }, + { updatedAt: stamp, id: rightId }, + ); + assert.ok(sameTime > 0); + const newerTime = compareSessionCursor( + { updatedAt: "2026-09-06T05:00:00.000Z", id: leftId }, + { updatedAt: stamp, id: rightId }, + ); + assert.ok(newerTime < 0); +}); + +test("clampSessionLimit defaults to 40 and clamps 1–100", () => { + assert.equal(clampSessionLimit(null), SESSION_PAGE_SIZE); + assert.equal(clampSessionLimit("0"), 1); + assert.equal(clampSessionLimit("101"), 100); + assert.equal(clampSessionLimit("40"), 40); +}); + +test("nextSessionCursor is null when the page is short, else encodes the limit-th row", () => { + const rows = [ + { id: leftId, updated_at: stamp }, + { id: rightId, updated_at: stamp }, + ]; + assert.equal(nextSessionCursor(rows, 40), null); + assert.equal(nextSessionCursor(rows, 1), encodeSessionCursor(stamp, leftId)); +}); diff --git a/frontend/tests/session-groups.test.ts b/frontend/tests/session-groups.test.ts new file mode 100644 index 00000000..aa9d0475 --- /dev/null +++ b/frontend/tests/session-groups.test.ts @@ -0,0 +1,80 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + beginSessionPageLoad, + groupSessionsByRecency, + mergeSessionPage, + recencyKeyFor, + sortSessions, +} from "../src/lib/session-groups.ts"; + +function session(id: string, updatedAt: number, pinned = false) { + return { id, pinned, updatedAt }; +} + +test("sortSessions puts pinned ahead of a newer unpinned row", () => { + const olderPinned = session("pin", 100, true); + const newer = session("new", 500); + assert.deepEqual(sortSessions([newer, olderPinned]).map((item) => item.id), ["pin", "new"]); +}); + +test("sortSessions orders same pin state by updatedAt descending", () => { + const early = session("early", 100); + const late = session("late", 400); + assert.deepEqual(sortSessions([early, late]).map((item) => item.id), ["late", "early"]); +}); + +test("sortSessions keeps the original order when pin and time are equal", () => { + const first = session("a", 200); + const second = session("b", 200); + assert.deepEqual(sortSessions([first, second]).map((item) => item.id), ["a", "b"]); +}); + +test("sortSessions does not drop archived rows", () => { + const archived = { ...session("arc", 50), archivedAt: "2026-09-01T00:00:00.000Z" }; + const live = session("live", 80); + assert.deepEqual(sortSessions([archived, live]).map((item) => item.id), ["live", "arc"]); +}); + +test("groupSessionsByRecency uses local midnight for yesterday across 23:59 to 00:01", () => { + const now = new Date(2026, 8, 6, 0, 1, 0).getTime(); + const justYesterday = new Date(2026, 8, 5, 23, 59, 0).getTime(); + const justToday = new Date(2026, 8, 6, 0, 0, 0).getTime(); + assert.equal(recencyKeyFor(justYesterday, now), "yesterday"); + assert.equal(recencyKeyFor(justToday, now), "today"); +}); + +test("groupSessionsByRecency splits day 7 and day 8, and day 30 and day 31", () => { + const now = new Date(2026, 8, 6, 12, 0, 0).getTime(); + const todayStart = new Date(2026, 8, 6).getTime(); + const dayMs = 24 * 60 * 60 * 1000; + const day7 = todayStart - 6 * dayMs + 12 * 60 * 60 * 1000; + const day8 = todayStart - 7 * dayMs + 12 * 60 * 60 * 1000; + const day30 = todayStart - 29 * dayMs + 12 * 60 * 60 * 1000; + const day31 = todayStart - 30 * dayMs + 12 * 60 * 60 * 1000; + assert.equal(recencyKeyFor(day7, now), "week"); + assert.equal(recencyKeyFor(day8, now), "month"); + assert.equal(recencyKeyFor(day30, now), "month"); + assert.equal(recencyKeyFor(day31, now), "older"); + const groups = groupSessionsByRecency([ + session("today", now), + session("week", day7), + session("older", day31), + ], now); + assert.deepEqual(groups.map((group) => group.label), ["今天", "最近 7 天", "更早"]); +}); + +test("mergeSessionPage keeps the local row and skips a duplicate id", () => { + const local = session("keep", 900); + const incoming = [session("keep", 100), session("next", 80)]; + assert.deepEqual(mergeSessionPage([local], incoming).map((item) => item.id), ["keep", "next"]); + assert.equal(mergeSessionPage([local], incoming)[0]?.updatedAt, 900); +}); + +test("beginSessionPageLoad only starts one in-flight request", () => { + const inFlight = { current: false }; + assert.equal(beginSessionPageLoad(inFlight, "cursor"), true); + assert.equal(beginSessionPageLoad(inFlight, "cursor"), false); + assert.equal(beginSessionPageLoad({ current: false }, null), false); +}); diff --git a/frontend/tests/session-title-agent.test.ts b/frontend/tests/session-title-agent.test.ts new file mode 100644 index 00000000..c5bfc603 --- /dev/null +++ b/frontend/tests/session-title-agent.test.ts @@ -0,0 +1,84 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +import { + generateSessionTitle, + sanitizeSessionTitle, + shouldGenerateSessionTitle, +} from "../src/lib/session-title-agent.ts"; +import { consultationAgentPublicEventSchema } from "../src/lib/consultation-agent-events.ts"; + +test("sanitizeSessionTitle strips quotes and trailing punctuation", () => { + assert.equal(sanitizeSessionTitle("「半年内换工作时机」。"), "半年内换工作时机"); + assert.equal(sanitizeSessionTitle("\"半年内换工作时机\""), "半年内换工作时机"); +}); + +test("sanitizeSessionTitle clips titles longer than 14 characters", () => { + assert.equal(sanitizeSessionTitle("今年下半年要不要换工作以及去哪座城市"), "今年下半年要不要换工作以及去"); +}); + +test("sanitizeSessionTitle rejects short, wrapped, generic, or birth-stamped titles", () => { + assert.equal(sanitizeSessionTitle("问"), null); + assert.equal(sanitizeSessionTitle("换工作\n时机"), null); + assert.equal(sanitizeSessionTitle("新对话"), null); + assert.equal(sanitizeSessionTitle("1990年3月换工作"), null); + assert.equal(sanitizeSessionTitle("凌晨 04:50 的节奏"), null); +}); + +test("sanitizeSessionTitle keeps a valid summary", () => { + assert.equal(sanitizeSessionTitle("半年内换工作时机"), "半年内换工作时机"); +}); + +test("shouldGenerateSessionTitle only runs on a first-round auto title", () => { + assert.equal(shouldGenerateSessionTitle({ title: "新对话", sessionType: "consultation" }, []), true); + assert.equal(shouldGenerateSessionTitle({ title: "我想问一下最近半年换工…", sessionType: "consultation" }, []), true); + assert.equal(shouldGenerateSessionTitle({ title: "新对话", sessionType: "consultation" }, [{ role: "user" }]), false); + assert.equal(shouldGenerateSessionTitle({ title: "新对话", sessionType: "birth_time_rectification" }, []), false); + assert.equal(shouldGenerateSessionTitle({ title: "9月6日 · 今日节奏", sessionType: "consultation" }, []), false); + assert.equal(shouldGenerateSessionTitle({ title: "半年内换工作时机", sessionType: "consultation" }, []), false); +}); + +test("generateSessionTitle times out to null and keeps the timer ref'd", async () => { + const source = readFileSync(new URL("../src/lib/session-title-agent.ts", import.meta.url), "utf8"); + assert.doesNotMatch(source, /AbortSignal\.timeout/); + assert.doesNotMatch(source, /\.unref\(/); + assert.match(source, /clearTimeout/); + + const pending = new Set(); + let created = 0; + const realSetTimeout = globalThis.setTimeout; + const realClearTimeout = globalThis.clearTimeout; + globalThis.setTimeout = ((handler: TimerHandler, delay?: number, ...args: unknown[]) => { + created += 1; + const id = realSetTimeout(handler, delay, ...args); + pending.add(id); + return id; + }) as typeof setTimeout; + globalThis.clearTimeout = ((id?: ReturnType) => { + pending.delete(id); + realClearTimeout(id); + }) as typeof clearTimeout; + try { + const title = await generateSessionTitle({ + question: "我想问一下最近半年换工作的时机", + timeoutMs: 20, + generateText: () => new Promise(() => {}), + }); + assert.equal(title, null); + assert.ok(created >= 1); + assert.equal(pending.size, 0); + } finally { + globalThis.setTimeout = realSetTimeout; + globalThis.clearTimeout = realClearTimeout; + } +}); + +test("consultation public events include session.title", () => { + const parsed = consultationAgentPublicEventSchema.parse({ + type: "session.title", + title: "半年内换工作时机", + }); + assert.equal(parsed.type, "session.title"); + assert.equal(parsed.title, "半年内换工作时机"); +}); diff --git a/frontend/tests/sidebar-contract.test.ts b/frontend/tests/sidebar-contract.test.ts index b49de341..33292cd4 100644 --- a/frontend/tests/sidebar-contract.test.ts +++ b/frontend/tests/sidebar-contract.test.ts @@ -190,7 +190,7 @@ test("uses one collapsed history action instead of icon-only session rows", () = assert.match(appSidebar, /MessageSquareText/); assert.match(appSidebar, /state === "collapsed" && !isMobile/); assert.match(appSidebar, /favoriteSessions\.map/); - assert.match(appSidebar, /historySessions\.map/); + assert.match(appSidebar, /historyGroups\.map/); assert.match(appSidebar, /星盘列表/); assert.match(appSidebar, /收藏对话/); assert.match(appSidebar, /历史对话/); @@ -449,3 +449,12 @@ test("removes class-owned drawer state and obsolete sidebar anchoring", () => { assert.doesNotMatch(globalStyles, /\.account-menu\s*\{/); assert.doesNotMatch(globalStyles, /\.session-list\s*\{[^}]*overflow(?:-y)?:\s*auto/); }); + +test("history renders recency group labels and a silent load-more sentinel", () => { + const appSidebar = readProjectFile("src/components/app-sidebar.tsx"); + assert.match(appSidebar, /groupSessionsByRecency/); + assert.match(appSidebar, /sidebar-group-label/); + assert.match(appSidebar, /session-list-sentinel/); + assert.match(appSidebar, /sessionControls\.hasMore \?
{ assert.equal(sidebarViewportForWidth(0), "mobile"); @@ -28,3 +30,20 @@ test("accepts Command or Control B only outside editable controls", () => { assert.equal(shouldHandleSidebarShortcut({ ...shortcut, target: { isContentEditable: true } }), false); assert.equal(shouldHandleSidebarShortcut({ ...shortcut, key: "k", target: null }), false); }); + +test("renaming a session does not bump updatedAt", () => { + assert.match(homeSurface, /const nextSession = \{ \.\.\.session, title \};/); + assert.doesNotMatch(homeSurface, /const nextSession = \{ \.\.\.session, title, updatedAt: timestamp\(\) \};/); +}); + +test("loadMoreSessions merges by id and only starts one in-flight request", () => { + const inFlight = { current: false }; + assert.equal(beginSessionPageLoad(inFlight, "cursor-1"), true); + assert.equal(beginSessionPageLoad(inFlight, "cursor-1"), false); + const merged = mergeSessionPage( + [{ id: "keep", updatedAt: 900 }], + [{ id: "keep", updatedAt: 1 }, { id: "next", updatedAt: 2 }], + ); + assert.deepEqual(merged.map((item) => item.id), ["keep", "next"]); + assert.equal(merged[0]?.updatedAt, 900); +});