fix(web): summarize session titles, sort by activity, and paginate history (BUG-553)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,5 +1,9 @@
|
||||
# 印度占星 Skill 更新日志
|
||||
|
||||
## 2026-09-06 — 历史对话改成主题标题,按活动时间排,并分页加载
|
||||
|
||||
咨询第一轮会用当前模型起一个 6–12 字的主题标题,不扣点数;起名失败就留着原来的标题。只有发问和回答会改变列表顺序,改名、收藏、换模型不会把旧会话顶上去。历史按今天 / 昨天 / 最近 7 天 / 最近 30 天 / 更早分段,侧栏只显示标题,别人的盘才在下面加一行资料名。列表每页 40 条,滚到底静默续取。Skill 版本未变。
|
||||
|
||||
## 2026-09-06 — 生成中仍可打字,停止不再像报错
|
||||
|
||||
回答生成时输入框不再变灰,回车会排成一条待发送,当前回答正常结束后自动发出,停止或失败则放回输入框。生时校正里点停止会留下已生成的内容,并用灰字说明已停止、不扣点,不再出现红色告警。Skill 版本未变。
|
||||
|
||||
@@ -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
|
||||
- 复发自:无
|
||||
- 修复版本:待提交
|
||||
|
||||
|
||||
|
||||
@@ -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,与本单无关;未当作本单回归。 |
|
||||
@@ -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)
|
||||
|
||||
用另一份资料开咨询。
|
||||
|
||||
- ✅ 预期:侧栏主行只有标题;下面一行小字是资料名。资料已删时是「资料已删除 · 资料名」。本人资料没有这行。
|
||||
- ❌ 失败:标题仍是「资料名 · 标题」挤在一行。
|
||||
+3
-3
@@ -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.
|
||||
|
||||
@@ -52,7 +52,11 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
|
||||
| 2019 入职 | 2019 年前后,你有没有换过工作?A. 明确发生且时间吻合 | 2019 年前后,你有没有换过工作? | 不得把选项字面写进题干。 |
|
||||
| 2023 感情区分 | 你有没有一段认真开始或结束的关系? | 工作这块记下了。2023 年前后,有没有一段认真开始或结束的关系? | 题干必须写出服务端给的年份;漏写年份用户看不见期间。 |
|
||||
|
||||
## 侧栏与会话标题
|
||||
|
||||
历史分组标签只用这五个词,空组不出现:今天 / 昨天 / 最近 7 天 / 最近 30 天 / 更早。不要写成「七日内」「更早之前」。
|
||||
|
||||
会话标题是主题总结,不是资料名,也不是第一句话的截断。起名提示词只输出标题本身:6 到 12 个字,不要引号、书名号、句号或解释;不要复述出生日期、时间或地点,也不要写资料姓名。
|
||||
|
||||
- 不得虚构星盘事实或唯一出生分钟。
|
||||
- 问哪道题、年份、选项语义仍由服务端 focus 唯一所有;Agent 用 `spokenPrompt` 写题干,不得改年份、不得改选项含义。正文不得出题、复述或改写题干——题干作为同一条消息里正文之后的独立段落出现。
|
||||
|
||||
@@ -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<void> } = {};
|
||||
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,
|
||||
|
||||
@@ -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 } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<string, unknown> | 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 });
|
||||
|
||||
@@ -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 extends {
|
||||
is: (column: string, value: null) => 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 });
|
||||
|
||||
@@ -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; }
|
||||
|
||||
+19
-24
@@ -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<ChatSession[]>([]);
|
||||
const [showArchivedSessions, setShowArchivedSessions] = useState(false);
|
||||
const [sessionsCursor, setSessionsCursor] = useState<string | null>(null);
|
||||
const [sessionMenuId, setSessionMenuId] = useState<string | null>(null);
|
||||
const [pendingSessionDeletion, setPendingSessionDeletion] = useState<ChatSession | null>(null);
|
||||
const [modelCatalog, setModelCatalog] = useState<PublicLanguageModelCatalog | null>(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) => {
|
||||
|
||||
@@ -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<HTMLButtonElement>(null);
|
||||
const historyHeadingRef = useRef<HTMLElement>(null);
|
||||
const loadMoreRef = useRef<HTMLDivElement>(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({
|
||||
<SidebarGroupContent className="sidebar-nested">
|
||||
{historySessions.length === 0 ? <p className="sidebar-empty">暂无对话,点上方「新建对话」开始</p> : (
|
||||
<SidebarMenu className="session-list">
|
||||
{historySessions.map((session, index) => renderSession(session, favoriteSessions.length + index))}
|
||||
{historyGroups.map((group) => (
|
||||
<div key={group.key}>
|
||||
<p className="sidebar-group-label">{group.label}</p>
|
||||
{group.sessions.map((session) => renderSession(
|
||||
session,
|
||||
favoriteSessions.length + historySessions.findIndex((item) => item.id === session.id),
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
{sessionControls.hasMore ? <div ref={loadMoreRef} aria-hidden className="session-list-sentinel" /> : null}
|
||||
</SidebarMenu>
|
||||
)}
|
||||
</SidebarGroupContent>
|
||||
|
||||
@@ -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<HTMLButtonElement, SidebarSessionRow
|
||||
<span className="truncate">{session.title}</span>
|
||||
{opening ? <span className="session-opening-note">{RECTIFICATION_SIDEBAR_OPENING_NOTE}</span> : null}
|
||||
</span>
|
||||
{session.subtitle ? <small className="session-subtitle">{session.subtitle}</small> : null}
|
||||
</SidebarMenuButton>
|
||||
<Menu.Trigger
|
||||
className="session-menu-trigger"
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useQueuedMessage } from "@/hooks/use-queued-message";
|
||||
|
||||
import { showChatNotice as setComposerNotice } from "@/lib/chat-notice";
|
||||
import { parseAgentReply, isGenericSessionTitle, resolveSessionTitle } from "@/lib/agent-reply";
|
||||
import { isAutoDerivedSessionTitle } from "@/lib/session-title";
|
||||
import type { ConsultationBirthTimeMode } from "@/lib/consultation-birth-time-mode";
|
||||
import {
|
||||
GENERAL_NO_MINUTE_DAILY_FORTUNE_QUESTION,
|
||||
@@ -273,6 +274,9 @@ export function useConsultationRun(params: ConsultationRunParams) {
|
||||
.then((status) => {
|
||||
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),
|
||||
|
||||
@@ -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<SetStateAction<string | null>>;
|
||||
setSessionFullPrompt: Dispatch<SetStateAction<{ question: string; theme: Theme } | null>>;
|
||||
setSessions: Dispatch<SetStateAction<ChatSession[]>>;
|
||||
sessionsCursor: string | null;
|
||||
setSessionsCursor: Dispatch<SetStateAction<string | null>>;
|
||||
showArchivedSessions: boolean;
|
||||
setShowArchivedSessions: Dispatch<SetStateAction<boolean>>;
|
||||
uiPreview: MutableRefObject<boolean>;
|
||||
visibleSessions: ChatSession[];
|
||||
openRectificationSession: (exactSessionId: string) => Promise<void> | 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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<typeof consultationAgentPublicEventSchema>;
|
||||
|
||||
|
||||
@@ -237,6 +237,21 @@ export async function fetchDailyStarlanguage(signal: AbortSignal): Promise<Daily
|
||||
if (payload?.status !== "ok" || !payload.card) return { kind: "unavailable" };
|
||||
return { kind: "ready", card: payload.card };
|
||||
}
|
||||
export type SessionListPage = {
|
||||
readonly sessions: unknown;
|
||||
readonly nextCursor: string | null;
|
||||
};
|
||||
|
||||
export function readSessionListPage(value: unknown): SessionListPage {
|
||||
if (Array.isArray(value)) return { sessions: value, nextCursor: null };
|
||||
if (!value || typeof value !== "object") return { sessions: [], nextCursor: null };
|
||||
const page = value as { sessions?: unknown; nextCursor?: unknown };
|
||||
return {
|
||||
sessions: page.sessions,
|
||||
nextCursor: typeof page.nextCursor === "string" && page.nextCursor ? page.nextCursor : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function readSessions(value: unknown, catalog: PublicLanguageModelCatalog | null): SessionReadResult {
|
||||
if (!Array.isArray(value)) return { sessions: [], fallbackSessionIds: [] };
|
||||
const fallbackSessionIds: string[] = [];
|
||||
@@ -417,12 +432,19 @@ export async function fetchModelCatalog(signal?: AbortSignal) {
|
||||
return parsePublicModelCatalog(payload);
|
||||
}
|
||||
|
||||
export async function fetchSessions(signal?: AbortSignal): Promise<unknown> {
|
||||
const response = await fetch("/api/sessions", { signal, cache: "no-store" });
|
||||
export async function fetchSessions(
|
||||
signal?: AbortSignal,
|
||||
options?: { before?: string | null; archived?: boolean },
|
||||
): Promise<SessionListPage> {
|
||||
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(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<T extends { updatedAt: number } = ChatSession> = {
|
||||
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<T extends { pinned: boolean; updatedAt: number }>(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<T extends { updatedAt: number }>(
|
||||
sessions: readonly T[],
|
||||
now = Date.now(),
|
||||
): SessionRecencyGroup<T>[] {
|
||||
const buckets: Record<SessionRecencyKey, T[]> = {
|
||||
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<T extends { id: string; updatedAt: number }>(
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import {
|
||||
chatSessionMetadataPatchSchema,
|
||||
extractChatSessionMetadataPatch,
|
||||
} from "@/lib/chat-session-write-contract";
|
||||
|
||||
export function metadataUpdateValues(payload: unknown): Record<string, unknown> | null {
|
||||
const parsed = chatSessionMetadataPatchSchema.safeParse(extractChatSessionMetadataPatch(payload));
|
||||
if (!parsed.success) return null;
|
||||
return { ...parsed.data };
|
||||
}
|
||||
@@ -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<never>; dispose: () => void } {
|
||||
let onAbort: (() => void) | undefined;
|
||||
const promise = new Promise<never>((_, 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<string> {
|
||||
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<string>;
|
||||
}): Promise<string | null> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -285,6 +285,7 @@ type StreamAgentResponseOptions = EventOptions & {
|
||||
) => void | Promise<void>;
|
||||
onError?: (error: unknown, emitted: boolean, output: string) => void | Promise<void>;
|
||||
onCancel?: (emitted: boolean) => void | Promise<void>;
|
||||
sideEvent?: Promise<ConsultationAgentPublicEvent | null>;
|
||||
};
|
||||
|
||||
// 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<Uint8Array>({
|
||||
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) {
|
||||
|
||||
@@ -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/);
|
||||
|
||||
@@ -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), "对方");
|
||||
});
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
@@ -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\(\)/);
|
||||
});
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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<unknown>();
|
||||
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<typeof setTimeout>) => {
|
||||
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, "半年内换工作时机");
|
||||
});
|
||||
@@ -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 \? <div ref=\{loadMoreRef\}/);
|
||||
assert.doesNotMatch(appSidebar, /加载更多|没有更多/);
|
||||
});
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
shouldHandleSidebarShortcut,
|
||||
sidebarViewportForWidth,
|
||||
} from "../src/lib/sidebar-state.ts";
|
||||
import { beginSessionPageLoad, mergeSessionPage } from "../src/lib/session-groups.ts";
|
||||
import { homeSurface } from "./home-surface.ts";
|
||||
|
||||
test("classifies the exact sidebar breakpoints", () => {
|
||||
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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user