A ?c= link reads its conversation in the HTML's first round (issue #126, BUG-1477)
Independent Staging Quality Gate / validate (push) Successful in 15m37s
Independent Staging Quality Gate / publish (push) Successful in 8m8s

The early-read script now also sends /api/sessions/<id> when ?c= carries a
valid session id (same rule as parseSessionUrlQuery), next to the account.
Only the home bootstrap takes it, bound to that id and once: the lookup for a
conversation off the first page, or the landing fetchSessionDetail. 401/404/
error handling and notices are unchanged; a failed early read refetches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jesse_Chen
2026-10-28 11:59:01 +08:00
co-authored by Claude Opus 5.5
parent 18c6e13028
commit 16fcb8c289
7 changed files with 203 additions and 8 deletions
+5
View File
@@ -1,5 +1,10 @@
# 印度占星 Skill 更新日志
## 2026-10-28 — 用链接打开对话更快
- 从分享链接、侧栏历史或刷新打开某条对话时,对话内容和账户同时开始读取,少等一轮(手机上约 0.15~0.4 秒)。打开别人的或已删除的对话,提示和以前一样。
- Skill 版本不变(BUG-1477,issue #126)。
## 2026-10-28 — 咨询准备数据更快,含出生资料的缓存文件只有本机服务能读
- 同一张盘连着问时,每轮准备数据卡从约 0.1 秒降到几毫秒;每天第一问不再在回答路径上现算整包,问题一发出去就在后台提前算好。回答内容不变。
+15
View File
@@ -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/<id>` 要等账户、会话列表、模型目录、回答状态都回来以后才发,揭幕多等一轮往返(估,手机网络约 150~400 ms)。
- 触发条件:任何带 `?c=<会话 id>` 的冷开首页。
- 根因:BUG-1127 的早读脚本只发账户、人物、模型、会话列表四条;会话详情由启动代码在第二轮才发。
- 修复:早读脚本在 `?c=` 带着合法会话 id 时(与页面 `parseSessionUrlQuery` 同一个 id 规则)同一轮多发一条 `/api/sessions/<id>`,并记下 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`。
+5
View File
@@ -35,3 +35,8 @@
17. 侧栏某条对话「…」→ 转发(分享):提示「已复制当前聊天」,粘贴出来有对话、Markdown 报告和 JSON 分享包三段。手机 Safari 也试一次。
18. 建档或改资料时选出生日期:选得上、存得住;手动输入 2 月 30 日这类不存在的日期不被接受。
## #126 用 `?c=` 链接打开对话更快(BUG-1477)
19. 复制一条自己对话的链接(地址栏里带 `?c=`),新开一个无痕窗口登录后直接打开:对话一次出现、内容齐全。
20. 打开一条已删除的对话链接:仍提示「该对话不存在或已被删除」,停在空白首页。
+31
View File
@@ -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<Response>;
sessions?: Promise<Response>;
sessionsSubject?: string;
sessionDetail?: Promise<Response>;
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<Response> | 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<Response> | 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<Response> | null, fresh: () => Promise<Response>): Promise<Response> {
return early ? early.catch(() => fresh()) : fresh();
+3 -2
View File
@@ -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<HomeBootstrapIo, "restoreConsultationRecovery"> = {
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,
+9 -6
View File
@@ -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<Response> | null = null,
): Promise<ChatSession | null> {
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<Response> | null = null,
): Promise<SessionLookupResult> {
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 {
@@ -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<string, unknown> = {
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<string, unknown>, search: string, run: (fetched: string[]) => Promise<void>) {
const host = globalThis as unknown as Record<string, unknown>;
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)]);
},
));