fix(web): keep rectification sessions listed and persist chats on first send
Empty-consultation filtering is now session_type scoped so birth-time rows stay in the sidebar. Subtitles use updatedAt with sort/group/cursor. New chats stay local until the first send.
This commit is contained in:
+4
-4
@@ -117,11 +117,11 @@
|
||||
- **现状:** 四个页面共用一份列表、切页不重拉已落地。星盘 / 星历 / 报告页不能改名或删除。
|
||||
- **解除需要:** 把只依赖 `sessions` + fetch 的元数据操作搬进 `SessionListProvider`,四页都能用。另立单。
|
||||
|
||||
## 会话列表:空咨询延迟落库未做(2026-09-17,BUG-928)
|
||||
## ~~会话列表:空咨询延迟落库未做(2026-09-17,BUG-928)~~
|
||||
|
||||
- **让步:** 任务书 T3 要把 `startNewChat` / 启动落点改成本地创建、第一问前才 `POST /api/sessions`。牵动 `?c=` 深链和刷新恢复,本轮只做服务端过滤 + 复用已有空咨询。
|
||||
- **替代:** `GET /api/sessions` 排除 `messages = []`,响应带 `draft`;启动和「新建对话」优先用这份空咨询,不再连点就堆新行。
|
||||
- **解除需要:** 给未落库会话一个刷新可恢复的身份,且 `send()` 在咨询 POST 前补 create。另立单。
|
||||
- ~~**让步:** 任务书 T3 要把 `startNewChat` / 启动落点改成本地创建、第一问前才 `POST /api/sessions`。牵动 `?c=` 深链和刷新恢复,本轮只做服务端过滤 + 复用已有空咨询。~~
|
||||
- ~~**替代:** `GET /api/sessions` 排除 `messages = []`,响应带 `draft`;启动和「新建对话」优先用这份空咨询,不再连点就堆新行。~~
|
||||
- **解除(2026-09-21,BUG-989):** `startNewChat` 只在本地开一条,第一问 `send()` 才 `POST /api/sessions`;未落库不写 `?c=`。列表过滤收窄到咨询空行,校正会话不再被 `messages = []` 误删(BUG-987)。刷新丢掉未开口的本地空会话是可接受的。
|
||||
|
||||
## 会话列表多键排序:无 Docker 未跑翻页重叠(2026-09-17,BUG-926)
|
||||
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# 印度占星 Skill 更新日志
|
||||
|
||||
## 2026-09-21 — 侧栏重新列出生时校正,时间按最后活动,新建对话开口前不落库
|
||||
|
||||
侧栏不再把生时校正会话藏起来。每条下面的时间改成最后一次说话或校正操作的时间,和列表排序、分组一致。点「新建对话」先只在这一页开着,真正发出第一句话才存到云端;连点不会堆出一排空的「新对话」。Skill 版本不变。
|
||||
|
||||
## 2026-09-20 — 校正窗口保留真实日期,跨午夜采用不再丢日期(本地实现,未发布)
|
||||
|
||||
凌晨申报的前半窗口落在前一天;只记得夜里时先问午夜前后,不知道或跳过则仅保留填报当天凌晨与深夜两段。候选排序和宽度使用日期序号、实际分钟与声明分段,不把间隙填成全天。交付显示实际日期及「前一天 / 后一天」;本命上升分段也保留完整日期。扩窗沿用已持久日期与用户选侧,未知/跳过的允许两段不因后续缩窄被改写,无法满足约束时不保存。经 D4 授权新增兼容迁移,独立保留采用日期及实际计算 offset,原填报日期不变,排盘、咨询和报告同源读取。固定 offset 不代表已修复 IANA/DST。算法身份更新,历史结果只读不重标;评分、权重、确认阈值与 Skill 版本不变。最终全量、受控真人与部署验收尚待完成。
|
||||
|
||||
@@ -13055,3 +13055,51 @@
|
||||
- 相关记录:BUG-142
|
||||
- 复发自:BUG-142(publish 超时预算;本次是缓存被过早丢掉后的冷构建)
|
||||
- 修复版本:待本修复合入 staging 的门禁 run
|
||||
|
||||
## BUG-987 | 列表用 messages=[] 过滤,生时校正会话全部从侧栏消失
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-09-21
|
||||
- 最近更新:2026-09-21
|
||||
- 影响面:`GET /api/sessions`、`excludeEmptyConsultations`、侧栏历史
|
||||
- 用户现象:staging 侧栏一条生时校正都没有;深链 `?c=` 仍能打开同一会话。
|
||||
- 触发条件:打开 `/` 或次级页拉会话列表。校正会话的 `chat_sessions.messages` 永远是 `[]`。
|
||||
- 根因:`e4e73f56` 用全表 `messages <> '[]'` 当「有没有内容」。该判据只对咨询成立。客户端 `isListedSidebarSession` 放行校正,服务端先把行拿走。源码正则 `not("messages", "eq", [])` 从未拿真实行验证。
|
||||
- 修复:过滤收窄为 `session_type.neq.consultation,messages.neq.[]`。归档视图同一条规则。校正内容在 case 表,不在 `messages` 列。
|
||||
- 验证:`database-session-list-visibility.test.ts`(真实 Postgres,无 Docker 时 skip)、`session-list-filter.test.ts` 跨层对断、`chat-session-authority.test.ts`。
|
||||
- 防复发:不得用「某列为空」推断会话有没有内容。列表过滤必须带 `session_type` 限定。不得用源码正则代替真实行测试。
|
||||
- 相关记录:BUG-928、BUG-704
|
||||
- 复发自:BUG-928(空咨询过滤写成全表 `messages <> '[]'`)
|
||||
- 修复版本:待本修复合入 staging
|
||||
|
||||
## BUG-988 | 侧栏副标题用创建时间,排序用最后活动时间,列表读起来是乱的
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-09-21
|
||||
- 最近更新:2026-09-21
|
||||
- 影响面:`sessionSidebarSubtitle`、`SESSION_LIST_COLUMNS`
|
||||
- 用户现象:侧栏时间不单调(9/18 → 9/17 → 9/7 → 9/16),9/7 创建、9/16 仍在用的会话落在「最近 7 天」却显示 9/7。
|
||||
- 触发条件:会话创建后隔天继续使用。
|
||||
- 根因:`e4e73f56` 把 `created_at` 加进列表列,副标题读创建时间;排序、分组、游标仍用 `updated_at`。产品 2026-09-21 推翻 BUG-929 的「副标题为创建时间」。
|
||||
- 修复:副标题改 `updatedAt`。列表列去掉 `created_at`。详情接口仍返回 `created_at`。
|
||||
- 验证:`session-groups.test.ts`(早创建晚活动:位置按活动时间,副标题显示活动时间)、`chat-session-authority.test.ts` 列合同。
|
||||
- 防复发:显示、排序、分组、游标必须用同一个时间字段(`updated_at`)。`SESSION_LIST_COLUMNS` 不得再含 `created_at`。
|
||||
- 相关记录:BUG-929、BUG-553
|
||||
- 复发自:BUG-929(副标题改创建时间后与排序键分叉)
|
||||
- 修复版本:待本修复合入 staging
|
||||
|
||||
## BUG-989 | 点「新建对话」复用旧空会话,第一问之前就落库
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-09-21
|
||||
- 最近更新:2026-09-21
|
||||
- 影响面:`startNewChat`、`send()`、`GET /api/sessions` 的 `draft`、首页启动
|
||||
- 用户现象:点新建落到 9/17 的旧「新对话」;连点不会开新的。空咨询在开口前就已经在库里。
|
||||
- 触发条件:点「新建对话」,或启动时并入 `draft`。
|
||||
- 根因:BUG-928 让步成「复用空咨询 + 服务端 draft」。复用既不 bump 时间也不改创建时间。产品 2026-09-21 收掉该让步。
|
||||
- 修复:`startNewChat` 只在本地创建,不 `POST`、不写 `?c=`。第一问 `send()` 先 `POST /api/sessions` 再走既有 append。落库失败撤销本地会话并保留输入框草稿。删除 `draft` 字段、`draftRow`、`findReusableEmptyConsultation`。
|
||||
- 验证:`session-list-filter.test.ts` 源码合同(新建无 POST / 无 URL,send 时 create,失败不 `setDraft`)、`chat-session-url.test.ts`、`session-list-lifecycle.test.tsx` 仍覆盖无 draft 的列表响应。
|
||||
- 防复发:第一问之前不得 `POST /api/sessions`。未落库会话不得写入 `?c=`。列表响应不得再带 `draft`。
|
||||
- 相关记录:BUG-928、BUG-987
|
||||
- 复发自:BUG-928(延迟落库让步未收口)
|
||||
- 修复版本:待本修复合入 staging
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
# PROGRESS · 会话列表重建(2026-09-21)
|
||||
|
||||
工作树:`.worktrees/session-list-rebuild-20260921`
|
||||
分支:`codex/session-list-rebuild-20260921`
|
||||
基线:`origin/staging` @ `f8d65e48`(任务书;代码基线 `0c3c9d6b`)
|
||||
|
||||
## 任务状态
|
||||
|
||||
| 任务 | 状态 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| T1 列表不得吞掉生时校正 BUG-987 | 完成 | 过滤改为 `session_type.neq.consultation,messages.neq.[]`;归档同一规则 |
|
||||
| T2 显示时间与排序同源 BUG-988 | 完成 | 副标题改 `updatedAt`;`SESSION_LIST_COLUMNS` 去掉 `created_at` |
|
||||
| T3 第一问之前不落库 BUG-989 | 完成 | 删除复用与 `draft`;本地新建;`send()` 先 POST 再建问 |
|
||||
| 记录 | 完成 | BUG-987/988/989、CHANGELOG、DESIGN、BLOCKED 划掉 120 行那条 |
|
||||
|
||||
## 开工基线
|
||||
|
||||
| 项 | 值 |
|
||||
| --- | --- |
|
||||
| 分支 | `codex/session-list-rebuild-20260921` 跟踪 `origin/staging` @ `f8d65e48` |
|
||||
| `page.tsx` 行数 | 1826 |
|
||||
| `Home()` useState / useRef | 未改装配;帽 36 / 37 |
|
||||
|
||||
## 实现要点
|
||||
|
||||
- 服务端与客户端共用 `excludeEmptyConsultations` / `isListedSidebarSession` / `cloudListIncludesSession`。校正会话 `messages` 永远是 `[]`,不能当空咨询删。
|
||||
- 副标题、排序、分组、游标都用 `updated_at`。详情接口仍返回 `created_at`(`home-cloud-sync` 回落链未动)。
|
||||
- `startNewChat` 只替换本地未开口咨询,不 POST、不写 `?c=`。`persistSession("create")` 成功后才 push URL。落库失败撤销本地会话,且发生在 `setDraft("")` 之前,输入框草稿仍在。
|
||||
- 已成功建行的 id 记入 `cloudCreatedIds`,避免第一问失败后再发时重复 POST。
|
||||
|
||||
## 测试
|
||||
|
||||
| 项 | 结果 |
|
||||
| --- | --- |
|
||||
| `page.tsx` 行数 | **1791**(开工 1826,未增长) |
|
||||
| `tsc --noEmit` | 0 错 |
|
||||
| `npm run lint` | 0 error(既有 warning 未动;去掉新建路径里未再使用的 `setCreatingSession` 解构,避免新增 warning) |
|
||||
| 定向 | chat-session-authority / session-list-filter / session-groups / chat-session-url / local-postgres-or / chart-library-session / session-list-lifecycle / composer-isolation / home-shell-growth / chat-session-write **全部通过** |
|
||||
| `npm test` | **3598** 条,**3509** 通过 / **89** 失败。失败几乎全是本机 Windows:`database-*.test.ts` 因 Docker 网段耗尽(`all predefined address pools have been fully subnetted`,其它 worktree 留下的 postgres 网络),以及 symlink / chmod / shell 脚本类用例。与本单相关的源码合同失败 1 条已修(`chat-session-write.test.ts` 启动不再 `writeChatSession(initialSession)`)。未在本机重跑 `0c3c9d6b` 全量作逐条基线。 |
|
||||
| `npm run test:db` | Docker 在 PATH 里,但创建 compose 网络失败(同上网段耗尽)。**不得写成通过。** `database-session-list-visibility.test.ts` 照提交。 |
|
||||
| `next build` / `--webpack` | 编译与 tsc 通过;收集 `/api/consult` 等页数据时 `symlink ... EPERM`(Windows 无开发者模式)。本机看不到 `/` Static 与 gzip。 |
|
||||
|
||||
## 让步
|
||||
|
||||
T1–T3 都做了,功能上无让步。
|
||||
|
||||
环境缺口(替代证据):
|
||||
|
||||
1. **真实 Postgres 列表可见性**:本机 Docker 网段被其它会话占满,`test:db` 跑不起来。替代:`local-postgres-or.test.ts` 编译 `session_type.neq.consultation,messages.neq.[]`;`session-list-filter.test.ts` 跨层对断;`chat-session-authority.test.ts` 断言路由走 `excludeEmptyConsultations`。
|
||||
2. **next build 静态与 gzip**:Windows symlink EPERM。未量首屏 gzip。
|
||||
@@ -152,7 +152,7 @@
|
||||
| `TASK-consultation-answer-start-anchor-20260917.md` | `PROGRESS-consultation-answer-start-anchor-20260917.md` | 主会话回答落在结尾:`useConversationScrollAnchor` 是贴底跟随,流式期间视口钉在最后一个字,回答开头滚出视口;改为发送后问题钉顶、回答向下长、长出视口显示「跳到最新」、末尾动态留白;产品追加拍板:校正面同一语义(推翻 BUG-041/048 贴底),本轮开头 = 用户行或新助手行。BUG 段 930 起 | 已验收(经修复单) | `worktree/green-harbor-5be3` |
|
||||
| `TASK-consultation-answer-start-anchor-fix-20260917.md` | `PROGRESS-consultation-answer-start-anchor-fix-20260917.md` | 验收修复单:F1 头就是留白行时留白按整视口算(BUG-931);F2 留白只在钉住期间存在(BUG-932);前置:先修 e4e73f56 的两处 TS 错否则门禁不过 | 已验收 | `cc1a8980`(Claude 验收:tsc 0 / lint 0 error / npm test 3457 条 39 红与 11c0028d 逐条一致、新增 2 条绿 / `next build --webpack` 通过、`/` Static、首屏 gzip 591,242(较 09-16 基线 582,800 +1.45%,含会话列表单)/ Chrome 真实布局 S1–S6 全部通过,S6 新助手行距顶 16px 且增高不动,S5 不再写留白);真机六条欠 |
|
||||
| — | `PROGRESS-starter-greeting-20260917.md` | 首页开场语改成 claude.ai 式单行问候:`starter-greeting.ts` 的「称呼 + 追问句」五时段十五条收成三个池子(时段 / 星期 / 回访,按 `variantSelection` 确定性取一条),副标题行与 `.starter-salutation` 下线,h1 降到 `clamp(24px, 2.8vw, 30px)`;追问移到输入框占位符「想聊什么都可以」。产品直接拍板,非 Bug,不占 BUG 号 | 待验收 | `codex/starter-greeting-20260917` |
|
||||
| `TASK-session-list-rebuild-20260921.md` | — | **P0 会话列表重建(`e4e73f56` 的返工)**:服务端 `GET /api/sessions` 用 `messages <> '[]'` 排除空咨询,但校正会话的 `messages` 永远是 `[]`(建行写死空数组、创建合同只收空数组、PATCH 忽略、`touch_chat_session_from_rectification_case` 只 bump `updated_at`)→ **全部生时校正会话从侧栏消失**,而客户端 `isListedSidebarSession` 明确放行校正会话,两层规则相反且无跨层对断(BUG-987);同一提交把副标题换成 `created_at`,排序/分组/游标仍是 `updated_at`,列表可见时间不单调(9/18→9/17→9/7→9/16)(BUG-988);BUG-928 让步下来的「复用空会话」不 bump 时间,点新建变成跳回 9/17 的旧草稿(BUG-989)。**产品 09-21 拍板**:D1 副标题改最后活动时间(推翻 BUG-929 该条,其余保留)、D2 收掉 `BLOCKED.md:120` 的让步,第一问前不落库、删复用与 `draft`。T1 不得让步,T3 可拆轮。BUG 段 987 起 | 待领取 | — |
|
||||
| `TASK-session-list-rebuild-20260921.md` | `PROGRESS-session-list-rebuild-20260921.md` | **P0 会话列表重建(`e4e73f56` 的返工)**:服务端 `GET /api/sessions` 用 `messages <> '[]'` 排除空咨询,但校正会话的 `messages` 永远是 `[]`(建行写死空数组、创建合同只收空数组、PATCH 忽略、`touch_chat_session_from_rectification_case` 只 bump `updated_at`)→ **全部生时校正会话从侧栏消失**,而客户端 `isListedSidebarSession` 明确放行校正会话,两层规则相反且无跨层对断(BUG-987);同一提交把副标题换成 `created_at`,排序/分组/游标仍是 `updated_at`,列表可见时间不单调(9/18→9/17→9/7→9/16)(BUG-988);BUG-928 让步下来的「复用空会话」不 bump 时间,点新建变成跳回 9/17 的旧草稿(BUG-989)。**产品 09-21 拍板**:D1 副标题改最后活动时间(推翻 BUG-929 该条,其余保留)、D2 收掉 `BLOCKED.md:120` 的让步,第一问前不落库、删复用与 `draft`。T1 不得让步,T3 可拆轮。BUG 段 987 起 | 待验收 | `codex/session-list-rebuild-20260921` |
|
||||
|
||||
### 个人报告
|
||||
|
||||
|
||||
+1
-1
@@ -353,7 +353,7 @@ The birth-time rectification session is the consultation transcript plus a house
|
||||
|
||||
### Navigation item
|
||||
|
||||
- **Structure:** title is the session name. New dated titles are `生时校正 · M月D日` / `今日节奏 · M月D日` (category first). Subtitle is always `M月D日 HH:MM` in Asia/Shanghai from `created_at`, plus ` · <chart name>` when the chart is not the account holder. Same-day duplicates are not uniquified with wall-clock `HH:MM`.
|
||||
- **Structure:** title is the session name. New dated titles are `生时校正 · M月D日` / `今日节奏 · M月D日` (category first). Subtitle is always `M月D日 HH:MM` in Asia/Shanghai from `updated_at` (last activity), plus ` · <chart name>` when the chart is not the account holder. Same-day duplicates are not uniquified with wall-clock `HH:MM`. Display, sort, grouping, and cursor all use `updated_at`.
|
||||
- **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 “收藏对话 / 历史对话”. 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. A session title is named when it is created; opening a stored session must not rename it. `updatedAt` advances only on conversation activity, including rectification turns, choice, adopt, and stop. Opening, refresh, and metadata PATCH do not bump it. A `?c=` that is not on the loaded page is looked up with `GET /api/sessions/{id}` before anyone may say it was deleted. Locked by `frontend/tests/session-open-preserves-identity.test.ts` and `frontend/tests/session-lookup-unlisted.test.ts`.
|
||||
- **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.
|
||||
|
||||
@@ -127,7 +127,6 @@ import {
|
||||
CONSULTATION_EVIDENCE_VALIDATION_LABEL,
|
||||
CONSULTATION_LOADING_METHOD_LABEL,
|
||||
} from "@/lib/consultation-activity-labels";
|
||||
import { writeChatSession } from "@/lib/chat-session-write-contract";
|
||||
import {
|
||||
SESSION_MISSING_NOTICE,
|
||||
clearLoginSessionReturn,
|
||||
@@ -210,7 +209,6 @@ import {
|
||||
resolveLookupBootstrap,
|
||||
payloadCode,
|
||||
payloadMessage,
|
||||
readDraftConsultation,
|
||||
readSessions,
|
||||
readStoredDailyStarlanguage,
|
||||
readStoredPendingConsultation,
|
||||
@@ -228,7 +226,7 @@ import {
|
||||
starterHomeLandingNeedsConsultation,
|
||||
type BootstrapPhase,
|
||||
} from "@/lib/home-bootstrap";
|
||||
import { findReusableEmptyConsultation } from "@/lib/session-list-filter";
|
||||
import { isUnsavedEmptyConsultation } from "@/lib/session-list-filter";
|
||||
|
||||
const BirthTimeRectification = dynamic(
|
||||
() => import("@/components/birth-time-rectification").then((module) => module.BirthTimeRectification),
|
||||
@@ -931,31 +929,12 @@ export default function Home() {
|
||||
const parsedSessions = readSessions(listBoot.rawRows, nextModelCatalog);
|
||||
setSessionsCursor(listBoot.cursor);
|
||||
let nextSessions = parsedSessions.sessions.length > 0 ? parsedSessions.sessions : listBoot.sessions;
|
||||
const draftSession = readDraftConsultation(listBoot.draftRow, nextModelCatalog);
|
||||
if (draftSession && !nextSessions.some((session) => session.id === draftSession.id)) {
|
||||
nextSessions = [...nextSessions, draftSession];
|
||||
}
|
||||
if (nextSessions.length === 0) {
|
||||
if (controller.signal.aborted) return;
|
||||
const initialSession = createSession(
|
||||
nextSessions = [createSession(
|
||||
nextModelCatalog?.defaultModelId ?? "",
|
||||
"consultation",
|
||||
chartSnapshotForSession("self", [], nextProfile),
|
||||
);
|
||||
if (nextModelCatalog) {
|
||||
await writeChatSession(initialSession.id, {
|
||||
title: initialSession.title,
|
||||
theme: initialSession.theme,
|
||||
model_id: initialSession.modelId,
|
||||
messages: [],
|
||||
session_type: initialSession.sessionType,
|
||||
rectification_case_id: initialSession.rectificationCaseId,
|
||||
chart_profile_id: initialSession.chartProfileId,
|
||||
chart_profile_name: initialSession.chartProfileName,
|
||||
chart_profile_role: initialSession.chartProfileRole,
|
||||
}, "create");
|
||||
}
|
||||
nextSessions = [initialSession];
|
||||
)];
|
||||
}
|
||||
nextSessions = applyLegacySessionControls(nextAccount.user.id, nextSessions);
|
||||
|
||||
@@ -1010,32 +989,13 @@ export default function Home() {
|
||||
bootstrapSelection.urlAction,
|
||||
);
|
||||
if (starterHomeLandingNeedsConsultation(nextSessions, landingSessionId, bootstrapSelection.urlAction)) {
|
||||
const reusable = findReusableEmptyConsultation(nextSessions);
|
||||
if (reusable) {
|
||||
landingSessionId = reusable.id;
|
||||
} else {
|
||||
if (controller.signal.aborted) return;
|
||||
const homeSession = createSession(
|
||||
nextModelCatalog?.defaultModelId ?? "",
|
||||
"consultation",
|
||||
chartSnapshotForSession("self", [], nextProfile),
|
||||
);
|
||||
if (nextModelCatalog) {
|
||||
await writeChatSession(homeSession.id, {
|
||||
title: homeSession.title,
|
||||
theme: homeSession.theme,
|
||||
model_id: homeSession.modelId,
|
||||
messages: [],
|
||||
session_type: homeSession.sessionType,
|
||||
rectification_case_id: homeSession.rectificationCaseId,
|
||||
chart_profile_id: homeSession.chartProfileId,
|
||||
chart_profile_name: homeSession.chartProfileName,
|
||||
chart_profile_role: homeSession.chartProfileRole,
|
||||
}, "create");
|
||||
}
|
||||
nextSessions = [homeSession, ...nextSessions];
|
||||
landingSessionId = homeSession.id;
|
||||
}
|
||||
const homeSession = createSession(
|
||||
nextModelCatalog?.defaultModelId ?? "",
|
||||
"consultation",
|
||||
chartSnapshotForSession("self", [], nextProfile),
|
||||
);
|
||||
nextSessions = [homeSession, ...nextSessions];
|
||||
landingSessionId = homeSession.id;
|
||||
}
|
||||
const activeListed = nextSessions.find((session) => session.id === landingSessionId)
|
||||
?? nextSessions[0];
|
||||
@@ -1062,7 +1022,12 @@ export default function Home() {
|
||||
setActiveSessionId(landingSessionId);
|
||||
if (bootstrapSelection.clearStoredReturn) clearLoginSessionReturn();
|
||||
if (bootstrapSelection.urlAction === "replace-clear") writeSessionUrl(null, "replace");
|
||||
if (bootstrapSelection.urlAction === "replace-selected") writeSessionUrl(bootstrapSelection.sessionId, "replace");
|
||||
if (bootstrapSelection.urlAction === "replace-selected") {
|
||||
const selected = nextSessions.find((session) => session.id === bootstrapSelection.sessionId);
|
||||
if (!selected || !isUnsavedEmptyConsultation(selected)) {
|
||||
writeSessionUrl(bootstrapSelection.sessionId, "replace");
|
||||
}
|
||||
}
|
||||
if (reservedConsultation?.status === "reserved") {
|
||||
const recoverySession = nextSessions.find((session) => session.id === reservedConsultation.sessionId);
|
||||
if (recoverySession) restoreConsultationRecovery(recoverySession, reservedConsultation.requestId, storedPending);
|
||||
|
||||
@@ -16,22 +16,9 @@ import {
|
||||
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,created_at,updated_at,pinned,archived_at";
|
||||
import { applyArchiveFilter, excludeEmptyConsultations } from "@/lib/session-list-filter";
|
||||
|
||||
function excludeEmptyConsultations<Query extends {
|
||||
not: (column: string, operator: string, value: unknown) => Query;
|
||||
}>(query: Query): Query {
|
||||
// Empty consultations are `messages = []`. Rectification rows keep an opening
|
||||
// turn, so excluding empty arrays leaves them in the list (BUG-928).
|
||||
return query.not("messages", "eq", []);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
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(request: Request) {
|
||||
try {
|
||||
@@ -67,7 +54,6 @@ export async function GET(request: Request) {
|
||||
if (pageError) return NextResponse.json({ error: "聊天记录暂时无法读取" }, { status: 500 });
|
||||
|
||||
let pinnedRows: typeof pageRows = [];
|
||||
let draft: NonNullable<typeof pageRows>[number] | null = null;
|
||||
if (!cursor) {
|
||||
const { data: pinnedData, error: pinnedError } = await excludeEmptyConsultations(
|
||||
applyArchiveFilter(
|
||||
@@ -83,26 +69,12 @@ export async function GET(request: Request) {
|
||||
.order("id", { ascending: false });
|
||||
if (pinnedError) return NextResponse.json({ error: "聊天记录暂时无法读取" }, { status: 500 });
|
||||
pinnedRows = pinnedData ?? [];
|
||||
if (!archived) {
|
||||
const { data: draftData, error: draftError } = await supabase
|
||||
.from("chat_sessions")
|
||||
.select(SESSION_LIST_COLUMNS)
|
||||
.eq("user_id", user.id)
|
||||
.eq("session_type", "consultation")
|
||||
.eq("messages", [])
|
||||
.is("archived_at", null)
|
||||
.order("updated_at", { ascending: false })
|
||||
.order("id", { ascending: false })
|
||||
.limit(1);
|
||||
if (draftError) return NextResponse.json({ error: "聊天记录暂时无法读取" }, { status: 500 });
|
||||
draft = draftData?.[0] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
const nextCursor = nextSessionCursor(pageRows ?? [], limit);
|
||||
const page = (pageRows ?? []).slice(0, limit);
|
||||
const sessions = cursor ? page : [...(pinnedRows ?? []), ...page];
|
||||
return NextResponse.json({ sessions, nextCursor, draft });
|
||||
return NextResponse.json({ sessions, nextCursor });
|
||||
} catch (error) {
|
||||
if (isSupabaseConfigurationError(error)) {
|
||||
return NextResponse.json({ error: "数据库尚未配置", code: "DATABASE_NOT_CONFIGURED" }, { status: 503 });
|
||||
|
||||
@@ -64,9 +64,11 @@ import { completedOnboardingTranscript, isProfileComplete, selectedBirthPlace }
|
||||
import { pendingConsultationStorageKey, timestamp } from "@/lib/home-types";
|
||||
import {
|
||||
consultSendBlockedByRectificationSession,
|
||||
fallbackSessionId,
|
||||
missingActiveSessionSendAction,
|
||||
SESSION_NOT_CONSULTATION_CODE,
|
||||
} from "@/lib/rectification-session-composer-guard";
|
||||
import { isUnsavedEmptyConsultation } from "@/lib/session-list-filter";
|
||||
import type {
|
||||
Account,
|
||||
AccountDialog,
|
||||
@@ -606,6 +608,22 @@ export function useConsultationRun(params: ConsultationRunParams) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isUnsavedEmptyConsultation(currentSession)) {
|
||||
try {
|
||||
await persistSession(currentSession, "create");
|
||||
} catch (caught) {
|
||||
const remaining = sessions.filter((session) => session.id !== currentSession.id);
|
||||
setSessions(remaining);
|
||||
const fallbackId = fallbackSessionId(remaining);
|
||||
setActiveSessionId(fallbackId);
|
||||
setRequestError({
|
||||
sessionId: fallbackId,
|
||||
message: caught instanceof Error ? caught.message : "新对话未能保存到云端。",
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const [year, month, day] = profile.date.split("-").map(Number);
|
||||
const [hour, minute] = consultationRoute.time?.split(":").map(Number) ?? [];
|
||||
|
||||
|
||||
@@ -29,7 +29,11 @@ import {
|
||||
patchSessionModel,
|
||||
readSessions,
|
||||
} from "@/lib/home-cloud-sync";
|
||||
import { findReusableEmptyConsultation } from "@/lib/session-list-filter";
|
||||
import {
|
||||
isListedSidebarSession,
|
||||
isUnsavedEmptyConsultation,
|
||||
replaceUnsavedEmptyConsultations,
|
||||
} from "@/lib/session-list-filter";
|
||||
import { chartSnapshotForSession } from "@/lib/home-profile";
|
||||
import { beginSessionPageLoad, mergeSessionPage } from "@/lib/session-groups";
|
||||
import type { ConsultationEntrypoint } from "@/lib/consultation-entrypoint";
|
||||
@@ -110,7 +114,6 @@ export function useSessionManagement(params: SessionManagementParams) {
|
||||
setActiveChartId,
|
||||
setActiveSessionId,
|
||||
setBirthTimeConsultationConsent,
|
||||
setCreatingSession,
|
||||
setDraft,
|
||||
setDraftEntrypoint,
|
||||
setDraftTheme,
|
||||
@@ -131,12 +134,21 @@ export function useSessionManagement(params: SessionManagementParams) {
|
||||
const [sessionFullPrompt, setSessionFullPrompt] = useState<{ question: string; theme: Theme } | null>(null);
|
||||
sessionsRef.current = sessions;
|
||||
const loadMoreInFlight = useRef(false);
|
||||
const pendingCreateById = useRef(new Map<string, { continuedFromSessionId?: string }>());
|
||||
const cloudCreatedIds = useRef(new Set<string>());
|
||||
for (const session of sessions) {
|
||||
if (!isUnsavedEmptyConsultation(session)) cloudCreatedIds.current.add(session.id);
|
||||
}
|
||||
|
||||
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 visibleSessions = sortSessions(sessions.filter((session) => {
|
||||
if (showArchivedSessions) {
|
||||
return Boolean(session.archivedAt)
|
||||
&& (session.sessionType === "birth_time_rectification"
|
||||
|| session.messages.length > 0
|
||||
|| !session.messagesHydrated);
|
||||
}
|
||||
return isListedSidebarSession(session);
|
||||
}));
|
||||
|
||||
function updateSession(sessionId: string, change: (session: ChatSession) => ChatSession) {
|
||||
setSessions((current) => current.map((session) => (session.id === sessionId ? change(session) : session)));
|
||||
@@ -149,6 +161,9 @@ export function useSessionManagement(params: SessionManagementParams) {
|
||||
) {
|
||||
if (!account) throw new Error("账户尚未加载完成");
|
||||
if (process.env.NODE_ENV === "development" && uiPreview.current) return;
|
||||
if (mode === "create" && cloudCreatedIds.current.has(session.id)) return;
|
||||
const continuedFromSessionId = options?.continuedFromSessionId
|
||||
?? pendingCreateById.current.get(session.id)?.continuedFromSessionId;
|
||||
const values = mode === "create"
|
||||
? {
|
||||
title: session.title,
|
||||
@@ -160,8 +175,8 @@ export function useSessionManagement(params: SessionManagementParams) {
|
||||
chart_profile_id: session.chartProfileId,
|
||||
chart_profile_name: session.chartProfileName,
|
||||
chart_profile_role: session.chartProfileRole,
|
||||
...(options?.continuedFromSessionId
|
||||
? { continued_from_session_id: options.continuedFromSessionId }
|
||||
...(continuedFromSessionId
|
||||
? { continued_from_session_id: continuedFromSessionId }
|
||||
: {}),
|
||||
}
|
||||
: {
|
||||
@@ -173,6 +188,11 @@ export function useSessionManagement(params: SessionManagementParams) {
|
||||
chart_profile_role: session.chartProfileRole,
|
||||
};
|
||||
await writeChatSession(session.id, values, mode);
|
||||
if (mode === "create") {
|
||||
pendingCreateById.current.delete(session.id);
|
||||
cloudCreatedIds.current.add(session.id);
|
||||
if (!uiPreview.current) writeSessionUrl(session.id, "push");
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureSessionMessages(sessionId: string) {
|
||||
@@ -305,55 +325,24 @@ export function useSessionManagement(params: SessionManagementParams) {
|
||||
|
||||
async function startNewChat(options?: { continuedFromSessionId?: string }): Promise<ChatSession | null> {
|
||||
if (!account || !modelCatalog || creatingSession) return null;
|
||||
if (!options?.continuedFromSessionId) {
|
||||
const reusable = findReusableEmptyConsultation(sessions);
|
||||
if (reusable) {
|
||||
setActiveSessionId(reusable.id);
|
||||
if (!uiPreview.current) writeSessionUrl(reusable.id, "push");
|
||||
setDraft("");
|
||||
setDraftTheme(null);
|
||||
setDraftEntrypoint(null);
|
||||
setComposerNotice("");
|
||||
setRequestError(null);
|
||||
return reusable;
|
||||
}
|
||||
}
|
||||
const nextSession = {
|
||||
...createSession(modelCatalog.defaultModelId),
|
||||
...chartSnapshotForSession(activeChartId, chartLibrary, profile),
|
||||
};
|
||||
const previousSessionId = activeSession?.id ?? "";
|
||||
const previousHref = `${window.location.pathname}${window.location.search}`;
|
||||
setCreatingSession(true);
|
||||
setSessions((current) => [nextSession, ...current]);
|
||||
for (const session of sessions) {
|
||||
if (isUnsavedEmptyConsultation(session)) pendingCreateById.current.delete(session.id);
|
||||
}
|
||||
pendingCreateById.current.set(nextSession.id, {
|
||||
continuedFromSessionId: options?.continuedFromSessionId,
|
||||
});
|
||||
setSessions((current) => replaceUnsavedEmptyConsultations(current, nextSession));
|
||||
setActiveSessionId(nextSession.id);
|
||||
if (!uiPreview.current) writeSessionUrl(nextSession.id, "push");
|
||||
setDraft("");
|
||||
setDraftTheme(null);
|
||||
setDraftEntrypoint(null);
|
||||
setComposerNotice("");
|
||||
setRequestError(null);
|
||||
try {
|
||||
await persistSession(
|
||||
nextSession,
|
||||
"create",
|
||||
options?.continuedFromSessionId
|
||||
? { continuedFromSessionId: options.continuedFromSessionId }
|
||||
: undefined,
|
||||
);
|
||||
return nextSession;
|
||||
} catch (caught) {
|
||||
setSessions((current) => current.filter((session) => session.id !== nextSession.id));
|
||||
setActiveSessionId(previousSessionId);
|
||||
if (!uiPreview.current) window.history.replaceState(null, "", previousHref);
|
||||
setRequestError({
|
||||
sessionId: previousSessionId,
|
||||
message: caught instanceof Error ? caught.message : "新对话未能保存到云端。",
|
||||
});
|
||||
return null;
|
||||
} finally {
|
||||
setCreatingSession(false);
|
||||
}
|
||||
return nextSession;
|
||||
}
|
||||
|
||||
function selectSession(sessionId: string) {
|
||||
@@ -418,6 +407,7 @@ export function useSessionManagement(params: SessionManagementParams) {
|
||||
archived: showArchivedSessions,
|
||||
});
|
||||
const incoming = readSessions(page.sessions, modelCatalog).sessions;
|
||||
for (const session of incoming) cloudCreatedIds.current.add(session.id);
|
||||
setSessions((current) => mergeSessionPage(current, incoming));
|
||||
setSessionsCursor(page.nextCursor);
|
||||
} catch (caught) {
|
||||
@@ -434,6 +424,7 @@ export function useSessionManagement(params: SessionManagementParams) {
|
||||
try {
|
||||
const page = await fetchSessions(undefined, { archived: nextArchived });
|
||||
const parsed = readSessions(page.sessions, modelCatalog).sessions;
|
||||
for (const session of parsed) cloudCreatedIds.current.add(session.id);
|
||||
const active = sessionsRef.current.find((session) => session.id === activeSessionId);
|
||||
setSessions(active && !parsed.some((session) => session.id === active.id)
|
||||
? mergeHydratedSession(parsed, active)
|
||||
@@ -462,6 +453,7 @@ export function useSessionManagement(params: SessionManagementParams) {
|
||||
if (query.present && requestedId && !listed.some((session) => session.id === requestedId)) {
|
||||
void lookupSessionById(requestedId, modelCatalog).then((looked) => {
|
||||
if (looked.status === "found") {
|
||||
cloudCreatedIds.current.add(looked.session.id);
|
||||
setSessions((current) => mergeHydratedSession(current, looked.session));
|
||||
sessionSelectionSource.current = "history";
|
||||
selectSession(looked.session.id);
|
||||
|
||||
@@ -248,30 +248,18 @@ export async function fetchDailyStarlanguage(signal: AbortSignal): Promise<Daily
|
||||
export type SessionListPage = {
|
||||
readonly sessions: unknown;
|
||||
readonly nextCursor: string | null;
|
||||
readonly draft: unknown;
|
||||
};
|
||||
|
||||
export function readSessionListPage(value: unknown): SessionListPage {
|
||||
if (Array.isArray(value)) return { sessions: value, nextCursor: null, draft: null };
|
||||
if (!value || typeof value !== "object") return { sessions: [], nextCursor: null, draft: null };
|
||||
const page = value as { sessions?: unknown; nextCursor?: unknown; draft?: unknown };
|
||||
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,
|
||||
draft: page.draft ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function readDraftConsultation(
|
||||
value: unknown,
|
||||
catalog: PublicLanguageModelCatalog | null,
|
||||
): ChatSession | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const parsed = readSessions([{ ...(value as object), messages: [] }], catalog).sessions[0];
|
||||
if (!parsed || parsed.sessionType !== "consultation") return null;
|
||||
return { ...parsed, messages: [], messagesHydrated: true };
|
||||
}
|
||||
|
||||
export function readSessions(value: unknown, catalog: PublicLanguageModelCatalog | null): SessionReadResult {
|
||||
if (!Array.isArray(value)) return { sessions: [], fallbackSessionIds: [] };
|
||||
const fallbackSessionIds: string[] = [];
|
||||
|
||||
@@ -24,7 +24,6 @@ import { toSidebarSessionRow } from "@/lib/session-sidebar-row";
|
||||
export type SessionListBoot = {
|
||||
readonly sessions: ChatSession[];
|
||||
readonly rawRows: unknown;
|
||||
readonly draftRow: unknown;
|
||||
readonly cursor: string | null;
|
||||
readonly account: Account | null;
|
||||
readonly signedOut: boolean;
|
||||
@@ -96,16 +95,15 @@ async function loadSessionList(signal: AbortSignal): Promise<SessionListBoot> {
|
||||
fetch("/api/account", { signal, cache: "no-store" }),
|
||||
]);
|
||||
if (sessionResponse.status === 401 || accountResponse.status === 401) {
|
||||
return { sessions: [], rawRows: [], draftRow: null, cursor: null, account: null, signedOut: true };
|
||||
return { sessions: [], rawRows: [], cursor: null, account: null, signedOut: true };
|
||||
}
|
||||
const sessionPayload = await sessionResponse.json().catch(() => null) as {
|
||||
sessions?: unknown;
|
||||
nextCursor?: unknown;
|
||||
draft?: unknown;
|
||||
} | null;
|
||||
const accountPayload = await accountResponse.json().catch(() => null);
|
||||
if (!sessionResponse.ok || !accountResponse.ok) {
|
||||
return { sessions: [], rawRows: [], draftRow: null, cursor: null, account: null, signedOut: false };
|
||||
return { sessions: [], rawRows: [], cursor: null, account: null, signedOut: false };
|
||||
}
|
||||
const rawRows = Array.isArray(sessionPayload?.sessions) ? sessionPayload.sessions : [];
|
||||
const parsed = readSessions(rawRows, null);
|
||||
@@ -113,7 +111,6 @@ async function loadSessionList(signal: AbortSignal): Promise<SessionListBoot> {
|
||||
return {
|
||||
sessions: parsed.sessions,
|
||||
rawRows,
|
||||
draftRow: sessionPayload?.draft ?? null,
|
||||
cursor,
|
||||
account: accountPayload as Account,
|
||||
signedOut: false,
|
||||
@@ -145,7 +142,7 @@ export function SessionListProvider({ children }: { children: ReactNode }) {
|
||||
})
|
||||
.catch(() => {
|
||||
if (controller.signal.aborted) return;
|
||||
bootRef.current = { sessions: [], rawRows: [], draftRow: null, cursor: null, account: null, signedOut: false };
|
||||
bootRef.current = { sessions: [], rawRows: [], cursor: null, account: null, signedOut: false };
|
||||
setSettled(true);
|
||||
readyPack.resolve();
|
||||
});
|
||||
|
||||
@@ -1,14 +1,35 @@
|
||||
import type { ChatSession } from "@/lib/home-types";
|
||||
|
||||
export function findReusableEmptyConsultation(
|
||||
sessions: readonly ChatSession[],
|
||||
): ChatSession | undefined {
|
||||
return sessions.find((session) => (
|
||||
session.sessionType === "consultation"
|
||||
/** PostgREST `or()` that keeps rectification rows and non-empty consultations. */
|
||||
export const EMPTY_CONSULTATION_LIST_FILTER = "session_type.neq.consultation,messages.neq.[]";
|
||||
|
||||
export function excludeEmptyConsultations<Query extends {
|
||||
or: (expression: string) => Query;
|
||||
}>(query: Query): Query {
|
||||
// Rectification content lives on the case table; chat_sessions.messages is
|
||||
// always []. Only unpublished consultation drafts are `messages = []`.
|
||||
return query.or(EMPTY_CONSULTATION_LIST_FILTER);
|
||||
}
|
||||
|
||||
export 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 function isUnsavedEmptyConsultation(session: ChatSession): boolean {
|
||||
return session.sessionType === "consultation"
|
||||
&& !session.archivedAt
|
||||
&& session.messagesHydrated
|
||||
&& session.messages.length === 0
|
||||
));
|
||||
&& session.messages.length === 0;
|
||||
}
|
||||
|
||||
export function replaceUnsavedEmptyConsultations(
|
||||
sessions: readonly ChatSession[],
|
||||
nextSession: ChatSession,
|
||||
): ChatSession[] {
|
||||
return [nextSession, ...sessions.filter((session) => !isUnsavedEmptyConsultation(session))];
|
||||
}
|
||||
|
||||
export function isListedSidebarSession(session: ChatSession): boolean {
|
||||
@@ -17,3 +38,13 @@ export function isListedSidebarSession(session: ChatSession): boolean {
|
||||
if (!session.messagesHydrated) return true;
|
||||
return session.messages.length > 0;
|
||||
}
|
||||
|
||||
export function cloudListIncludesSession(input: {
|
||||
sessionType: ChatSession["sessionType"];
|
||||
messagesEmpty: boolean;
|
||||
archived: boolean;
|
||||
archivedView: boolean;
|
||||
}): boolean {
|
||||
if (input.sessionType === "consultation" && input.messagesEmpty) return false;
|
||||
return input.archivedView ? input.archived : !input.archived;
|
||||
}
|
||||
|
||||
@@ -11,8 +11,7 @@ export function sessionSidebarSubtitle(
|
||||
session: ChatSession,
|
||||
library: readonly ChartLibraryRecord[] = [],
|
||||
): string {
|
||||
const created = session.createdAt || session.updatedAt;
|
||||
const clock = Number.isFinite(created) ? shanghaiDateTimeLabel(new Date(created)) : "";
|
||||
const clock = Number.isFinite(session.updatedAt) ? shanghaiDateTimeLabel(new Date(session.updatedAt)) : "";
|
||||
const parts: string[] = [];
|
||||
if (clock) parts.push(clock);
|
||||
if (session.chartProfileRole && session.chartProfileRole !== "self" && session.chartProfileId) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import test from "node:test";
|
||||
|
||||
import { homeSurface as page } from "./home-surface.ts";
|
||||
const listRoute = readFileSync(new URL("../src/app/api/sessions/route.ts", import.meta.url), "utf8");
|
||||
const listFilter = readFileSync(new URL("../src/lib/session-list-filter.ts", import.meta.url), "utf8");
|
||||
const itemRoute = readFileSync(new URL("../src/app/api/sessions/[id]/route.ts", import.meta.url), "utf8");
|
||||
const consultRoute = readFileSync(new URL("../src/app/api/consult/route.ts", import.meta.url), "utf8");
|
||||
const sql = readFileSync(
|
||||
@@ -12,26 +13,35 @@ const sql = readFileSync(
|
||||
);
|
||||
const sendSource = page.slice(page.indexOf(" async function send("), page.indexOf("\n\n consultationReplay.current"));
|
||||
|
||||
test("session list GET drops empty consultations and still returns a reusable draft", () => {
|
||||
assert.match(listRoute, /\.not\("messages", "eq", \[\]\)/);
|
||||
assert.match(listRoute, /\.eq\("session_type", "consultation"\)[\s\S]*\.eq\("messages", \[\]\)/);
|
||||
assert.match(listRoute, /return NextResponse\.json\(\{ sessions, nextCursor, draft \}\)/);
|
||||
test("session list GET drops empty consultations without swallowing rectification rows", () => {
|
||||
// 原值:.not("messages", "eq", []) 全表过滤,响应带 draft
|
||||
// 新值:or(session_type.neq.consultation,messages.neq.[]),响应只有 sessions/nextCursor
|
||||
// 原因:校正会话 messages 永远是 [](BUG-987);第一问前不落库后不再返回 draft(BUG-989)
|
||||
assert.match(listFilter, /session_type\.neq\.consultation,messages\.neq\.\[\]/);
|
||||
assert.match(listRoute, /excludeEmptyConsultations/);
|
||||
assert.match(listRoute, /session_type/);
|
||||
assert.doesNotMatch(listRoute, /\.not\("messages", "eq", \[\]\)/);
|
||||
assert.doesNotMatch(listFilter, /\.not\("messages", "eq", \[\]\)/);
|
||||
assert.match(listRoute, /return NextResponse\.json\(\{ sessions, nextCursor \}\)/);
|
||||
assert.doesNotMatch(listRoute, /draft/);
|
||||
assert.doesNotMatch(itemRoute, /\.or\("session_type\.neq\.consultation,messages\.neq\.\[\]"\)/);
|
||||
assert.doesNotMatch(itemRoute, /\.not\("messages", "eq", \[\]\)/);
|
||||
});
|
||||
|
||||
test("session list GET omits messages while detail GET returns them", () => {
|
||||
// Former list/detail column strings ended at updated_at; pinned and archived_at
|
||||
// were added when those flags moved off localStorage.
|
||||
// 原值:列表列到 updated_at,pinned,archived_at,不选 created_at
|
||||
// 新值:加上 created_at,供侧栏副标题用会话创建时间
|
||||
// 原因:任务书 T4 副标题是 M月D日 HH:MM(创建时间,Asia/Shanghai)
|
||||
// 原值:列表列含 created_at,供副标题用创建时间
|
||||
// 新值:SESSION_LIST_COLUMNS 不含 created_at
|
||||
// 原因:副标题改最后活动时间,与排序/分组/游标同源(BUG-988)
|
||||
assert.match(
|
||||
listRoute,
|
||||
/SESSION_LIST_COLUMNS = "id,title,theme,model_id,session_type,rectification_case_id,chart_profile_id,chart_profile_name,chart_profile_role,created_at,updated_at,pinned,archived_at"/,
|
||||
/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"/,
|
||||
);
|
||||
// 原值:select(SESSION_LIST_COLUMNS) 之后不得出现 messages
|
||||
// 新值:WHERE 用 .not("messages", "eq", []),SELECT 列仍不含 messages
|
||||
// 原因:T3 空咨询过滤要看 messages,但不把正文带回列表
|
||||
assert.doesNotMatch(listRoute, /SESSION_LIST_COLUMNS = "[^"]*created_at/);
|
||||
// 原值:WHERE 用 .not("messages", "eq", []),SELECT 列仍不含 messages
|
||||
// 新值:WHERE 用 session_type 限定的 or(),SELECT 列仍不含 messages
|
||||
// 原因:T1 空咨询过滤要看 messages,但不把正文带回列表
|
||||
assert.doesNotMatch(listRoute, /SESSION_LIST_COLUMNS = "[^"]*messages/);
|
||||
assert.doesNotMatch(
|
||||
listRoute,
|
||||
|
||||
@@ -174,8 +174,14 @@ test("popstate to a missing session query reuses selectSession side effects for
|
||||
|
||||
test("creating and leaving a session keep the address bar in sync", () => {
|
||||
const startNewChat = sourceBetween(page, "async function startNewChat(", "function selectSession(");
|
||||
assert.match(startNewChat, /writeSessionUrl\(nextSession\.id, "push"\)/);
|
||||
assert.match(startNewChat, /window\.history\.replaceState\(null, "", previousHref\)/);
|
||||
// 原值:新建立刻 writeSessionUrl(nextSession.id, "push"),失败再 replaceState 回旧地址
|
||||
// 新值:本地创建不写 ?c=;POST 成功后 persistSession(create) 才 push
|
||||
// 原因:第一问前不落库(BUG-989),未落库会话没有服务端身份
|
||||
assert.doesNotMatch(startNewChat, /writeSessionUrl\(nextSession\.id, "push"\)/);
|
||||
assert.doesNotMatch(startNewChat, /persistSession\(/);
|
||||
const persistSession = sourceBetween(page, "async function persistSession(", "async function ensureSessionMessages(");
|
||||
assert.match(persistSession, /if \(mode === "create"\) \{/);
|
||||
assert.match(persistSession, /writeSessionUrl\(session\.id, "push"\)/);
|
||||
|
||||
// 原值:delete/archive 函数体内直接 `writeSessionUrl(fallbackId || null, "replace")`
|
||||
// 新值:删/归档当前会话走 `activateFallbackSession`,由它 `writeSessionUrl(null|fallbackId, "replace")`
|
||||
|
||||
@@ -313,7 +313,11 @@ test("self-hosted staging bootstrap reads profile and sessions through same-orig
|
||||
assert.doesNotMatch(page, /createBrowserSupabaseClient/);
|
||||
assert.match(page, /fetch\("\/api\/account"/);
|
||||
assert.match(page, /fetch\("\/api\/sessions"/);
|
||||
assert.match(page, /writeChatSession\(initialSession\.id,[\s\S]*?"create"\)/);
|
||||
// 原值:启动时空列表立刻 writeChatSession(initialSession.id, ..., "create")
|
||||
// 新值:本地 createSession,第一问 send() 才 persistSession(..., "create")
|
||||
// 原因:BUG-989 第一问之前不落库
|
||||
assert.doesNotMatch(page, /writeChatSession\(initialSession\.id,[\s\S]*?"create"\)/);
|
||||
assert.match(page, /await persistSession\(currentSession, "create"\)/);
|
||||
assert.match(page, /fetch\(`\/api\/sessions\/\$\{encodeURIComponent\(sessionId\)\}`/);
|
||||
assert.match(accountRoute, /AUTH_PROVIDER\?\.trim\(\) === "self-hosted"/);
|
||||
assert.match(accountRoute, /profile,/);
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
closeLocalPostgresDataPools,
|
||||
createLocalPostgresDataClient,
|
||||
} from "../src/lib/db/local-postgres-client-core.ts";
|
||||
import { applyArchiveFilter, excludeEmptyConsultations } from "../src/lib/session-list-filter.ts";
|
||||
import { startPostgresFixture } from "./helpers/postgres-fixture.ts";
|
||||
|
||||
const runner = fileURLToPath(new URL("../scripts/db-migrate.mjs", import.meta.url));
|
||||
const docker = spawnSync("docker", ["version", "--format", "{{.Server.Version}}"], { stdio: "ignore" }).status === 0;
|
||||
|
||||
const emptyConsultation = "11111111-1111-4111-8111-111111111111";
|
||||
const emptyRectification = "22222222-2222-4222-8222-222222222222";
|
||||
const filledConsultation = "33333333-3333-4333-8333-333333333333";
|
||||
const archivedRectification = "44444444-4444-4444-8444-444444444444";
|
||||
|
||||
test("session list query keeps empty rectification rows and drops empty consultations", {
|
||||
skip: docker ? false : "docker unavailable",
|
||||
}, async () => {
|
||||
const fixture = startPostgresFixture();
|
||||
try {
|
||||
const migration = spawnSync(process.execPath, [runner], {
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
...process.env,
|
||||
SCHEMA_DATABASE_URL: fixture.connectionUrl("schema_owner", "schema-owner-test-password"),
|
||||
},
|
||||
});
|
||||
assert.equal(migration.status, 0, migration.stderr);
|
||||
fixture.psqlAs(
|
||||
"identity_runtime",
|
||||
"identity-runtime-test-password",
|
||||
`insert into identity.users(name,email,email_verified,email_verified_at) values ('Fictional List','list-visibility@example.com',true,now());`,
|
||||
);
|
||||
const userId = fixture.psql("select id from identity.users where email='list-visibility@example.com'");
|
||||
fixture.psql(`
|
||||
insert into public.chat_sessions (id, user_id, title, theme, model_id, messages, session_type, pinned, archived_at, updated_at)
|
||||
values
|
||||
('${emptyConsultation}', '${userId}', 'Empty consult', 'general', 'test-model', '[]', 'consultation', false, null, now()),
|
||||
('${emptyRectification}', '${userId}', 'Empty rectification', 'general', 'test-model', '[]', 'birth_time_rectification', false, null, now()),
|
||||
('${filledConsultation}', '${userId}', 'Filled consult', 'general', 'test-model', '[{"role":"user","text":"问一句"}]', 'consultation', false, null, now()),
|
||||
('${archivedRectification}', '${userId}', 'Archived rectification', 'general', 'test-model', '[]', 'birth_time_rectification', false, '2026-09-08T00:00:00Z', now());
|
||||
`);
|
||||
const local = createLocalPostgresDataClient(
|
||||
fixture.connectionUrl("app_runtime", "app-runtime-test-password"),
|
||||
{ id: userId, email: "list-visibility@example.com" },
|
||||
);
|
||||
const live = await excludeEmptyConsultations(
|
||||
applyArchiveFilter(
|
||||
local.from("chat_sessions").select("id").eq("user_id", userId).eq("pinned", false),
|
||||
false,
|
||||
),
|
||||
);
|
||||
assert.equal(live.error, null, live.error?.message);
|
||||
const liveIds = (live.data as { id: string }[]).map((row) => row.id).sort();
|
||||
assert.deepEqual(liveIds, [emptyRectification, filledConsultation].sort());
|
||||
const archived = await excludeEmptyConsultations(
|
||||
applyArchiveFilter(
|
||||
local.from("chat_sessions").select("id").eq("user_id", userId).eq("pinned", false),
|
||||
true,
|
||||
),
|
||||
);
|
||||
assert.equal(archived.error, null, archived.error?.message);
|
||||
assert.deepEqual(
|
||||
(archived.data as { id: string }[]).map((row) => row.id),
|
||||
[archivedRectification],
|
||||
);
|
||||
} finally {
|
||||
await closeLocalPostgresDataPools();
|
||||
fixture.stop();
|
||||
}
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import { sessionCursorFilter } from "../src/lib/session-cursor.ts";
|
||||
|
||||
const core = readFileSync(new URL("../src/lib/db/local-postgres-client-core.ts", import.meta.url), "utf8");
|
||||
const listRoute = readFileSync(new URL("../src/app/api/sessions/route.ts", import.meta.url), "utf8");
|
||||
const listFilter = readFileSync(new URL("../src/lib/session-list-filter.ts", import.meta.url), "utf8");
|
||||
|
||||
test("or() compiles a comma-separated PostgREST filter", () => {
|
||||
const parameters: unknown[] = [];
|
||||
@@ -48,3 +49,10 @@ test("or() compiles the session cursor filter with a nested and", () => {
|
||||
]);
|
||||
assert.match(listRoute, /pageQuery = pageQuery\.or\(sessionCursorFilter\(cursor\)\)/);
|
||||
});
|
||||
|
||||
test("session list empty-consultation filter is session_type scoped", () => {
|
||||
assert.match(listFilter, /session_type\.neq\.consultation,messages\.neq\.\[\]/);
|
||||
assert.match(listRoute, /excludeEmptyConsultations/);
|
||||
assert.doesNotMatch(listRoute, /\.not\("messages", "eq", \[\]\)/);
|
||||
assert.doesNotMatch(listFilter, /\.not\("messages", "eq", \[\]\)/);
|
||||
});
|
||||
|
||||
@@ -8,6 +8,9 @@ import {
|
||||
recencyKeyFor,
|
||||
sortSessions,
|
||||
} from "../src/lib/session-groups.ts";
|
||||
import { sessionSidebarSubtitle } from "../src/lib/session-sidebar-row.ts";
|
||||
import { shanghaiDateTimeLabel } from "../src/lib/session-shanghai-clock.ts";
|
||||
import type { ChatSession } from "../src/lib/home-types.ts";
|
||||
|
||||
function session(id: string, updatedAt: number, pinned = false) {
|
||||
return { id, pinned, updatedAt };
|
||||
@@ -72,6 +75,44 @@ test("mergeSessionPage keeps the local row and skips a duplicate id", () => {
|
||||
assert.equal(mergeSessionPage([local], incoming)[0]?.updatedAt, 900);
|
||||
});
|
||||
|
||||
test("sidebar subtitle follows updatedAt so the visible clock matches sortSessions", () => {
|
||||
const createdEarly = Date.parse("2026-09-07T05:42:00.000Z");
|
||||
const activeLate = Date.parse("2026-09-16T10:01:00.000Z");
|
||||
const mid = Date.parse("2026-09-17T14:53:00.000Z");
|
||||
const newest = Date.parse("2026-09-18T00:01:00.000Z");
|
||||
function row(id: string, createdAt: number, updatedAt: number): ChatSession {
|
||||
return {
|
||||
id,
|
||||
title: id,
|
||||
theme: "general",
|
||||
modelId: "m",
|
||||
messages: [{ role: "user", text: "问一句" }],
|
||||
createdAt,
|
||||
updatedAt,
|
||||
sessionType: "consultation",
|
||||
rectificationCaseId: null,
|
||||
chartProfileId: null,
|
||||
chartProfileName: null,
|
||||
chartProfileRole: null,
|
||||
pinned: false,
|
||||
archivedAt: null,
|
||||
messagesHydrated: true,
|
||||
};
|
||||
}
|
||||
const earlyCreatedLateActive = row("early-created", createdEarly, activeLate);
|
||||
const sessions = [
|
||||
earlyCreatedLateActive,
|
||||
row("mid", mid, mid),
|
||||
row("newest", newest, newest),
|
||||
];
|
||||
assert.deepEqual(sortSessions(sessions).map((item) => item.id), ["newest", "mid", "early-created"]);
|
||||
const clocks = sortSessions(sessions).map((item) => sessionSidebarSubtitle(item));
|
||||
const expected = sortSessions(sessions).map((item) => shanghaiDateTimeLabel(new Date(item.updatedAt)));
|
||||
assert.deepEqual(clocks, expected);
|
||||
assert.equal(sessionSidebarSubtitle(earlyCreatedLateActive), shanghaiDateTimeLabel(new Date(activeLate)));
|
||||
assert.notEqual(sessionSidebarSubtitle(earlyCreatedLateActive), shanghaiDateTimeLabel(new Date(createdEarly)));
|
||||
});
|
||||
|
||||
test("beginSessionPageLoad only starts one in-flight request", () => {
|
||||
const inFlight = { current: false };
|
||||
assert.equal(beginSessionPageLoad(inFlight, "cursor"), true);
|
||||
|
||||
@@ -3,13 +3,19 @@ import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
findReusableEmptyConsultation,
|
||||
cloudListIncludesSession,
|
||||
EMPTY_CONSULTATION_LIST_FILTER,
|
||||
isListedSidebarSession,
|
||||
isUnsavedEmptyConsultation,
|
||||
replaceUnsavedEmptyConsultations,
|
||||
} from "../src/lib/session-list-filter.ts";
|
||||
import type { ChatSession } from "../src/lib/home-types.ts";
|
||||
|
||||
const management = readFileSync(new URL("../src/hooks/use-session-management.ts", import.meta.url), "utf8");
|
||||
const page = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8");
|
||||
const listRoute = readFileSync(new URL("../src/app/api/sessions/route.ts", import.meta.url), "utf8");
|
||||
const listContext = readFileSync(new URL("../src/lib/session-list-context.tsx", import.meta.url), "utf8");
|
||||
const consultRun = readFileSync(new URL("../src/hooks/use-consultation-run.ts", import.meta.url), "utf8");
|
||||
|
||||
function session(change: Partial<ChatSession>): ChatSession {
|
||||
return {
|
||||
@@ -31,7 +37,7 @@ function session(change: Partial<ChatSession>): ChatSession {
|
||||
};
|
||||
}
|
||||
|
||||
test("an empty hydrated consultation can be reused instead of creating another", () => {
|
||||
test("empty consultations stay off the sidebar while rectification rows stay on it", () => {
|
||||
const empty = session({ id: "empty" });
|
||||
const listed = session({
|
||||
id: "listed",
|
||||
@@ -43,11 +49,71 @@ test("an empty hydrated consultation can be reused instead of creating another",
|
||||
sessionType: "birth_time_rectification",
|
||||
title: "生时校正 · 9月14日",
|
||||
});
|
||||
assert.equal(findReusableEmptyConsultation([listed, empty, rectification])?.id, "empty");
|
||||
assert.equal(findReusableEmptyConsultation([listed, rectification]), undefined);
|
||||
const archivedRectification = session({
|
||||
id: "archived-rect",
|
||||
sessionType: "birth_time_rectification",
|
||||
archivedAt: "2026-09-08T00:00:00.000Z",
|
||||
});
|
||||
assert.equal(isListedSidebarSession(empty), false);
|
||||
assert.equal(isListedSidebarSession(listed), true);
|
||||
assert.equal(isListedSidebarSession(rectification), true);
|
||||
assert.match(management, /findReusableEmptyConsultation\(sessions\)/);
|
||||
assert.match(page, /readDraftConsultation\(listBoot\.draftRow/);
|
||||
assert.equal(isListedSidebarSession(archivedRectification), false);
|
||||
assert.equal(isUnsavedEmptyConsultation(empty), true);
|
||||
assert.equal(isUnsavedEmptyConsultation(rectification), false);
|
||||
});
|
||||
|
||||
test("server list filter and sidebar listing agree on empty consultation, rectification, and archive", () => {
|
||||
const cases = [
|
||||
{ sessionType: "consultation" as const, messagesEmpty: true, archived: false, archivedView: false, listed: false },
|
||||
{ sessionType: "birth_time_rectification" as const, messagesEmpty: true, archived: false, archivedView: false, listed: true },
|
||||
{ sessionType: "consultation" as const, messagesEmpty: false, archived: false, archivedView: false, listed: true },
|
||||
{ sessionType: "birth_time_rectification" as const, messagesEmpty: true, archived: true, archivedView: false, listed: false },
|
||||
{ sessionType: "birth_time_rectification" as const, messagesEmpty: true, archived: true, archivedView: true, listed: true },
|
||||
{ sessionType: "consultation" as const, messagesEmpty: true, archived: true, archivedView: true, listed: false },
|
||||
];
|
||||
for (const row of cases) {
|
||||
const cloud = cloudListIncludesSession(row);
|
||||
const client = isListedSidebarSession(session({
|
||||
sessionType: row.sessionType,
|
||||
messages: row.messagesEmpty ? [] : [{ role: "user", text: "问一句" }],
|
||||
archivedAt: row.archived ? "2026-09-08T00:00:00.000Z" : null,
|
||||
messagesHydrated: true,
|
||||
}));
|
||||
assert.equal(cloud, row.listed, JSON.stringify(row));
|
||||
if (!row.archivedView) assert.equal(client, row.listed, JSON.stringify(row));
|
||||
}
|
||||
assert.equal(EMPTY_CONSULTATION_LIST_FILTER, "session_type.neq.consultation,messages.neq.[]");
|
||||
assert.match(listRoute, /excludeEmptyConsultations/);
|
||||
assert.match(listRoute, /from "@\/lib\/session-list-filter"/);
|
||||
});
|
||||
|
||||
test("new chat stays local until the first send and does not reuse a draft row", () => {
|
||||
const empty = session({ id: "empty" });
|
||||
const listed = session({
|
||||
id: "listed",
|
||||
messages: [{ role: "user", text: "问一句" }],
|
||||
title: "半年内换工作时机",
|
||||
});
|
||||
const next = session({ id: "next" });
|
||||
assert.deepEqual(
|
||||
replaceUnsavedEmptyConsultations([listed, empty], next).map((item) => item.id),
|
||||
["next", "listed"],
|
||||
);
|
||||
assert.doesNotMatch(management, /findReusableEmptyConsultation/);
|
||||
assert.doesNotMatch(page, /readDraftConsultation|findReusableEmptyConsultation|writeChatSession/);
|
||||
assert.doesNotMatch(listContext, /draftRow/);
|
||||
assert.match(management, /replaceUnsavedEmptyConsultations\(current, nextSession\)/);
|
||||
assert.doesNotMatch(management, /writeSessionUrl\(nextSession\.id, "push"\)/);
|
||||
assert.match(consultRun, /isUnsavedEmptyConsultation\(currentSession\)/);
|
||||
assert.match(consultRun, /await persistSession\(currentSession, "create"\)/);
|
||||
assert.match(management, /if \(mode === "create"\) \{/);
|
||||
assert.match(management, /cloudCreatedIds\.current\.add\(session\.id\)/);
|
||||
assert.match(management, /writeSessionUrl\(session\.id, "push"\)/);
|
||||
const persistBlockStart = consultRun.indexOf("if (isUnsavedEmptyConsultation(currentSession))");
|
||||
const persistBlockEnd = consultRun.indexOf("const [year, month, day]");
|
||||
assert.ok(persistBlockStart >= 0 && persistBlockEnd > persistBlockStart);
|
||||
const persistBlock = consultRun.slice(persistBlockStart, persistBlockEnd);
|
||||
assert.match(persistBlock, /setSessions\(remaining\)/);
|
||||
assert.match(persistBlock, /setRequestError/);
|
||||
assert.doesNotMatch(persistBlock, /setDraft\(/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user