diff --git a/CHANGELOG.md b/CHANGELOG.md index 03c0b799..23c39e8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # 印度占星 Skill 更新日志 +## 2026-10-28 — 用链接打开对话更快 + +- 从分享链接、侧栏历史或刷新打开某条对话时,对话内容和账户同时开始读取,少等一轮(手机上约 0.15~0.4 秒)。打开别人的或已删除的对话,提示和以前一样。 +- Skill 版本不变(BUG-1477,issue #126)。 + ## 2026-10-28 — 咨询准备数据更快,含出生资料的缓存文件只有本机服务能读 - 同一张盘连着问时,每轮准备数据卡从约 0.1 秒降到几毫秒;每天第一问不再在回答路径上现算整包,问题一发出去就在后台提前算好。回答内容不变。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 6776f99e..5f018275 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -20156,3 +20156,18 @@ - 关联:BUG-1471(#122 每份报告只拷一次)、BUG-1305(保存星盘后的预热)。 - 复发自:无 - 修复版本:分支 `codex/consult-card-cache-memory-20261028`。 + +## BUG-1477 | 用 `?c=` 链接打开对话时,会话内容要排到第二轮才去读 + +- 状态:resolved(代码 + 回归测试;待部署) +- 首次发现 / 最近更新:2026-10-28 / 2026-10-28(issue #118 代码体检第 17 条 → #126) +- 影响面:分享链接、侧栏历史、在对话里刷新等所有 `?c=` 冷开首页;`frontend/src/lib/boot-early-reads.ts`、`lib/home-cloud-sync.ts`(`fetchSessionDetail`、`lookupSessionById`、`resolveLookupBootstrap`)、`lib/home-bootstrap-run.ts`。 +- 现象:HTML 一到就已经知道要打开哪条对话,但 `GET /api/sessions/` 要等账户、会话列表、模型目录、回答状态都回来以后才发,揭幕多等一轮往返(估,手机网络约 150~400 ms)。 +- 触发条件:任何带 `?c=<会话 id>` 的冷开首页。 +- 根因:BUG-1127 的早读脚本只发账户、人物、模型、会话列表四条;会话详情由启动代码在第二轮才发。 +- 修复:早读脚本在 `?c=` 带着合法会话 id 时(与页面 `parseSessionUrlQuery` 同一个 id 规则)同一轮多发一条 `/api/sessions/`,并记下 id。只有首页启动拿它:会话不在第一页时 `resolveLookupBootstrap` 的查找、在第一页但没读过内容时落地那一步的 `fetchSessionDetail`。按 id 绑定,取一次就删;id 不对就丢弃不用;早读网络失败照常重新请求;30 秒以上的早读照旧不信。之后同一条对话的刷新不会拿到这份旧响应。401 / 404 / 其他错误的处理与提示(「该对话不存在或已被删除」「这条对话暂时读不到」)不变。 +- 验证:新测试 `frontend/tests/home-early-session-detail-20261028.test.ts`:脚本在同一次同步执行里同时发出账户和该会话两条请求,路径与缓存选项和页面自己发的一致;脚本挑出的 id 与页面解析结果在 10 种查询串上一致(大小写、其他参数、非法 id、空、错误编码);早读只给对应 id、取一次;不在第一页的会话、在第一页的会话都直接用早读、不再发请求;早读 404 仍判为「不存在」、500 仍提示「暂时读不到」;早读网络失败回退到正常请求。既有 `home-first-load-20260930`、`home-bootstrap-run` 测试全过;全量前端测试与 staging 逐条比对无新增失败。tsc 0、lint 0。 +- 防复发:上述测试。启动时就能确定的读取放进早读同一轮,并按身份 / id 绑定。 +- 关联:BUG-1127(早读脚本)、BUG-1040(换账户丢弃早读)、BUG-1474(`?c=` 揭幕前等排版组件)。 +- 复发自:无 +- 修复版本:分支 `codex/home-early-session-detail-20261028`。 diff --git a/docs/testing/code-health-118-20261028.md b/docs/testing/code-health-118-20261028.md index 67b4bc5c..3b26a09f 100644 --- a/docs/testing/code-health-118-20261028.md +++ b/docs/testing/code-health-118-20261028.md @@ -35,3 +35,8 @@ 17. 侧栏某条对话「…」→ 转发(分享):提示「已复制当前聊天」,粘贴出来有对话、Markdown 报告和 JSON 分享包三段。手机 Safari 也试一次。 18. 建档或改资料时选出生日期:选得上、存得住;手动输入 2 月 30 日这类不存在的日期不被接受。 +## #126 用 `?c=` 链接打开对话更快(BUG-1477) + +19. 复制一条自己对话的链接(地址栏里带 `?c=`),新开一个无痕窗口登录后直接打开:对话一次出现、内容齐全。 +20. 打开一条已删除的对话链接:仍提示「该对话不存在或已被删除」,停在空白首页。 + diff --git a/frontend/src/lib/boot-early-reads.ts b/frontend/src/lib/boot-early-reads.ts index 0c3357ee..7d67b3d3 100644 --- a/frontend/src/lib/boot-early-reads.ts +++ b/frontend/src/lib/boot-early-reads.ts @@ -18,6 +18,7 @@ * script string. */ +import { SESSION_URL_ID_PATTERN } from "./chat-session-url.ts"; import { SESSION_PAGE_SIZE } from "./session-cursor.ts"; export const BOOT_READS_GLOBAL = "__jyotishaBootReads"; @@ -31,6 +32,11 @@ export const CURRENT_SUBJECT_KEY_PREFIX = "jyotisha_current_subject:"; /** An early read older than this is not trusted; the boot reads fresh instead. */ export const BOOT_READ_MAX_AGE_MS = 30_000; +/** Same path `fetchSessionDetail` and `lookupSessionById` read. */ +export function bootSessionDetailPath(sessionId: string): string { + return `/api/sessions/${encodeURIComponent(sessionId)}`; +} + export function bootSessionsPath(subjectId: string): string { return `/api/sessions?limit=${SESSION_PAGE_SIZE}&subject=${encodeURIComponent(subjectId)}`; } @@ -42,6 +48,8 @@ type BootReads = { models?: Promise; sessions?: Promise; sessionsSubject?: string; + sessionDetail?: Promise; + sessionDetailId?: string; }; type BootReadKey = "account" | "chartProfiles" | "models"; @@ -74,6 +82,14 @@ export function bootEarlyReadsScript(): string { sessions: read(${js(`/api/sessions?limit=${SESSION_PAGE_SIZE}&subject=`)} + encodeURIComponent(subject)), sessionsSubject: subject }; + // A \`?c=\` link: its conversation goes out in this same round (issue #126). + var detail = /[?&]c=([^&#]*)/.exec(window.location.search || ""); + var detailId = ""; + try { detailId = detail ? decodeURIComponent(detail[1].replace(/\\+/g, " ")) : ""; } catch (decodeError) { detailId = ""; } + if (new RegExp(${js(SESSION_URL_ID_PATTERN.source)}, ${js(SESSION_URL_ID_PATTERN.flags)}).test(detailId)) { + window[${js(BOOT_READS_GLOBAL)}].sessionDetail = read(${js("/api/sessions/")} + encodeURIComponent(detailId)); + window[${js(BOOT_READS_GLOBAL)}].sessionDetailId = detailId; + } } catch (error) {} })();`; } @@ -129,6 +145,21 @@ export function takeBootSessionsRead(subjectId: string): Promise | nul return asked === subjectId ? read : null; } +/** + * The early `?c=` conversation read, once, and only for `sessionId`. A read for + * another id is dropped, never returned. Only the home bootstrap takes it, so a + * later refresh of the same conversation never sees this older response. + */ +export function takeBootSessionDetailRead(sessionId: string): Promise | null { + const reads = bootReads(); + if (!reads?.sessionDetail) return null; + const read = reads.sessionDetail; + const asked = reads.sessionDetailId; + delete reads.sessionDetail; + delete reads.sessionDetailId; + return asked === sessionId ? read : null; +} + /** An early read that failed on the network falls back to the normal request. */ export function withBootFallback(early: Promise | null, fresh: () => Promise): Promise { return early ? early.catch(() => fresh()) : fresh(); diff --git a/frontend/src/lib/home-bootstrap-run.ts b/frontend/src/lib/home-bootstrap-run.ts index 9385173d..8349f45a 100644 --- a/frontend/src/lib/home-bootstrap-run.ts +++ b/frontend/src/lib/home-bootstrap-run.ts @@ -62,7 +62,7 @@ import { } from "@/lib/home-types"; import type { PublicLanguageModelCatalog } from "@/lib/public-models"; import { startRectificationEntrySummaryRead } from "@/lib/rectification-entry-summary-read"; -import { takeBootRead } from "@/lib/boot-early-reads"; +import { takeBootRead, takeBootSessionDetailRead } from "@/lib/boot-early-reads"; import { preloadHomeOnboardingShell } from "@/lib/home-onboarding-shell-loader"; import { loadChatMarkdown } from "@/lib/chat-markdown-loader"; import { dropRectificationStoredPending } from "@/lib/rectification-session-composer-guard"; @@ -157,7 +157,8 @@ const defaultIo: Omit = { fetchModelCatalog, fetchActiveConsultationStatus, fetchConsultationStatus, - fetchSessionDetail, + // The landing conversation may already be on its way from the HTML (issue #126). + fetchSessionDetail: (sessionId, catalog, signal) => fetchSessionDetail(sessionId, catalog, signal, takeBootSessionDetailRead(sessionId)), resolveLookupBootstrap, patchSessionModel, redirectToLogin, diff --git a/frontend/src/lib/home-cloud-sync.ts b/frontend/src/lib/home-cloud-sync.ts index 66f1aaa5..0677f921 100644 --- a/frontend/src/lib/home-cloud-sync.ts +++ b/frontend/src/lib/home-cloud-sync.ts @@ -1,6 +1,6 @@ import { parseRectificationOfferRecord, parseRectificationReturnRecord } from "@/lib/rectification-in-chat"; import { entrySummaryFromResponse, type RectificationEntrySummary } from "@/lib/rectification-entry"; -import { BOOT_CHART_PROFILES_PATH, BOOT_MODELS_PATH, BOOT_READ_INIT, withBootFallback } from "@/lib/boot-early-reads"; +import { BOOT_CHART_PROFILES_PATH, BOOT_MODELS_PATH, BOOT_READ_INIT, takeBootSessionDetailRead, withBootFallback } from "@/lib/boot-early-reads"; import { writeChatSession } from "@/lib/chat-session-write-contract"; import { clearHomeWarmSnapshot } from "@/lib/home-warm-snapshot"; import { @@ -526,11 +526,12 @@ export async function fetchSessionDetail( sessionId: string, catalog: PublicLanguageModelCatalog | null, signal?: AbortSignal, + early: Promise | null = null, ): Promise { - const response = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}`, { + const response = await withBootFallback(early, () => fetch(`/api/sessions/${encodeURIComponent(sessionId)}`, { signal, cache: "no-store", - }); + })); if (response.status === 401) redirectToLogin(); const payload = await response.json().catch(() => null); if (!response.ok) throw new Error(payloadMessage(payload, "暂时无法读取聊天记录")); @@ -549,11 +550,12 @@ export async function lookupSessionById( sessionId: string, catalog: PublicLanguageModelCatalog | null, signal?: AbortSignal, + early: Promise | null = null, ): Promise { - const response = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}`, { + const response = await withBootFallback(early, () => fetch(`/api/sessions/${encodeURIComponent(sessionId)}`, { signal, cache: "no-store", - }); + })); if (response.status === 401) redirectToLogin(); if (response.status === 404) return { status: "missing" }; const payload = await response.json().catch(() => null); @@ -592,7 +594,8 @@ export async function resolveLookupBootstrap(input: { if (selection.urlAction !== "lookup") { return { selection, sessions: input.sessions, notice: null }; } - const looked = await lookupSessionById(selection.sessionId, input.catalog, input.signal); + // The HTML already asked for this `?c=` conversation (issue #126). + const looked = await lookupSessionById(selection.sessionId, input.catalog, input.signal, takeBootSessionDetailRead(selection.sessionId)); throwIfAborted(input.signal); if (looked.status === "found") { return { diff --git a/frontend/tests/home-early-session-detail-20261028.test.ts b/frontend/tests/home-early-session-detail-20261028.test.ts new file mode 100644 index 00000000..70580473 --- /dev/null +++ b/frontend/tests/home-early-session-detail-20261028.test.ts @@ -0,0 +1,135 @@ +/** + * Issue #126: a `?c=` link reads its conversation in the HTML's first round, + * next to the account, instead of after the session list. + */ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import vm from "node:vm"; + +import { + BOOT_ACCOUNT_PATH, + BOOT_READ_INIT, + BOOT_READS_GLOBAL, + bootEarlyReadsScript, + bootSessionDetailPath, + clearBootReads, + takeBootSessionDetailRead, +} from "../src/lib/boot-early-reads.ts"; +import { parseSessionUrlQuery, SESSION_LOOKUP_FAILED_NOTICE } from "../src/lib/chat-session-url.ts"; +import { createSession, fetchSessionDetail, resolveLookupBootstrap } from "../src/lib/home-cloud-sync.ts"; +import { previewModelCatalog } from "../src/lib/home-types.ts"; + +const SESSION_ID = "88888888-8888-4888-8888-888888888126"; +const OTHER_SESSION = "99999999-9999-4999-8999-999999999126"; + +function runBootScript(search: string) { + const sent: { path: string; init: RequestInit }[] = []; + const window: Record = { + location: { pathname: "/", search }, + fetch: (path: string, init: RequestInit) => { sent.push({ path, init }); return Promise.resolve(new Response("{}")); }, + Promise, + localStorage: { getItem: () => null }, + }; + vm.runInNewContext(bootEarlyReadsScript(), { window, Date, encodeURIComponent }); + return { sent, reads: window[BOOT_READS_GLOBAL] as { sessionDetailId?: string } }; +} + +test("a ?c= link sends its conversation in the same round as the account, as the normal request", () => { + const { sent, reads } = runBootScript(`?c=${SESSION_ID}`); + const paths = sent.map((request) => request.path); + assert.ok(paths.includes(BOOT_ACCOUNT_PATH)); + assert.ok(paths.includes(bootSessionDetailPath(SESSION_ID)), "one synchronous run sends both"); + assert.deepEqual({ ...sent.find((request) => request.path === bootSessionDetailPath(SESSION_ID))!.init }, { ...BOOT_READ_INIT }); + assert.equal(reads.sessionDetailId, SESSION_ID); + // The page reads the same path when it asks without the early read. + const source = readFileSync(new URL("../src/lib/home-cloud-sync.ts", import.meta.url), "utf8"); + assert.match(source, /fetch\(`\/api\/sessions\/\$\{encodeURIComponent\(sessionId\)\}`, \{\n\s+signal,\n\s+cache: "no-store",/); +}); + +test("the script picks the same id the page will, and nothing for anything else", () => { + for (const search of [`?c=${SESSION_ID}`, `?new=1&c=${SESSION_ID}`, `?c=${SESSION_ID}&x=1`, `?c=${SESSION_ID.toUpperCase()}`, "?c=not-a-session", "?c=", "", "?x=1", "?c=%E4%BD%A0", "?c=%zz"]) { + const page = parseSessionUrlQuery(search).sessionId; + const { sent, reads } = runBootScript(search); + const detail = sent.filter((request) => request.path.startsWith("/api/sessions/")); + assert.equal(reads.sessionDetailId ?? null, page, search); + assert.equal(detail.length, page ? 1 : 0, search); + } +}); + +function withEarlyDetail(reads: Record, search: string, run: (fetched: string[]) => Promise) { + const host = globalThis as unknown as Record; + const originals = { window: host.window, location: host.location, fetch: globalThis.fetch }; + const fetched: string[] = []; + host.window = globalThis; + host.location = { search, pathname: "/" }; + globalThis.fetch = (async (input: RequestInfo | URL) => { fetched.push(String(input)); return new Response("{}", { status: 500 }); }) as typeof fetch; + host[BOOT_READS_GLOBAL] = { startedAt: Date.now(), ...reads }; + return run(fetched).finally(() => { + clearBootReads(); + globalThis.fetch = originals.fetch; + if (originals.window === undefined) Reflect.deleteProperty(host, "window"); else host.window = originals.window; + if (originals.location === undefined) Reflect.deleteProperty(host, "location"); else host.location = originals.location; + }); +} + +const row = (id: string) => ({ + id, title: "合成会话", theme: "general", modelId: previewModelCatalog.defaultModelId, messages: [{ role: "user", text: "问" }], + createdAt: 1, updatedAt: 1, sessionType: "consultation", rectificationCaseId: null, chartProfileId: null, + chartProfileName: null, chartProfileRole: "self", pinned: false, archivedAt: null, +}); +const json = (payload: unknown, status = 200) => Promise.resolve(new Response(JSON.stringify(payload), { status })); + +test("the early read is taken once, and only for its own id", async () => withEarlyDetail( + { sessionDetail: json({ session: row(SESSION_ID) }), sessionDetailId: SESSION_ID }, "", async () => { + assert.equal(takeBootSessionDetailRead(OTHER_SESSION), null, "another id gets nothing"); + assert.equal(takeBootSessionDetailRead(SESSION_ID), null, "and the read is dropped for good"); + }, +)); + +test("a conversation not on the first page is looked up from the early read, no second request", async () => withEarlyDetail( + { sessionDetail: json({ session: row(SESSION_ID) }), sessionDetailId: SESSION_ID }, `?c=${SESSION_ID}`, async (fetched) => { + const listed = createSession(previewModelCatalog.defaultModelId); + const result = await resolveLookupBootstrap({ sessions: [listed], defaultSessionId: listed.id, catalog: previewModelCatalog }); + assert.deepEqual(fetched, []); + assert.equal(result.selection.sessionId, SESSION_ID); + assert.ok(result.sessions.some((session) => session.id === SESSION_ID && session.messages.length === 1)); + }, +)); + +test("a deleted or someone else's conversation still gets the missing notice from the early 404", async () => withEarlyDetail( + { sessionDetail: json({ error: "not found" }, 404), sessionDetailId: SESSION_ID }, `?c=${SESSION_ID}`, async (fetched) => { + const listed = createSession(previewModelCatalog.defaultModelId); + const result = await resolveLookupBootstrap({ sessions: [listed], defaultSessionId: listed.id, catalog: previewModelCatalog }); + assert.deepEqual(fetched, []); + // Landing then says SESSION_MISSING_NOTICE, as without the early read. + assert.equal(result.selection.missing, true); + assert.equal(result.notice, null); + }, +)); + +test("an early 500 still says the lookup failed", async () => withEarlyDetail( + { sessionDetail: json({ error: "busy" }, 500), sessionDetailId: SESSION_ID }, `?c=${SESSION_ID}`, async (fetched) => { + const listed = createSession(previewModelCatalog.defaultModelId); + const result = await resolveLookupBootstrap({ sessions: [listed], defaultSessionId: listed.id, catalog: previewModelCatalog }); + assert.deepEqual(fetched, []); + assert.equal(result.notice, SESSION_LOOKUP_FAILED_NOTICE); + }, +)); + +test("a listed conversation reads its messages from the early read in the landing step", async () => withEarlyDetail( + { sessionDetail: json({ session: row(SESSION_ID) }), sessionDetailId: SESSION_ID }, `?c=${SESSION_ID}`, async (fetched) => { + const session = await fetchSessionDetail(SESSION_ID, previewModelCatalog, undefined, takeBootSessionDetailRead(SESSION_ID)); + assert.deepEqual(fetched, []); + assert.equal(session?.messages.length, 1); + const run = readFileSync(new URL("../src/lib/home-bootstrap-run.ts", import.meta.url), "utf8"); + assert.match(run, /fetchSessionDetail: \(sessionId, catalog, signal\) => fetchSessionDetail\(sessionId, catalog, signal, takeBootSessionDetailRead\(sessionId\)\)/); + }, +)); + +test("a failed early read falls back to the normal request", async () => withEarlyDetail( + { sessionDetail: Promise.reject(new TypeError("network")), sessionDetailId: SESSION_ID }, "", async (fetched) => { + await assert.rejects(fetchSessionDetail(SESSION_ID, previewModelCatalog, undefined, takeBootSessionDetailRead(SESSION_ID))); + assert.deepEqual(fetched, [bootSessionDetailPath(SESSION_ID)]); + }, +));