A ?c= link reads its conversation in the HTML's first round (issue #126, BUG-1477)
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:
co-authored by
Claude Opus 5.5
parent
18c6e13028
commit
16fcb8c289
@@ -1,5 +1,10 @@
|
||||
# 印度占星 Skill 更新日志
|
||||
|
||||
## 2026-10-28 — 用链接打开对话更快
|
||||
|
||||
- 从分享链接、侧栏历史或刷新打开某条对话时,对话内容和账户同时开始读取,少等一轮(手机上约 0.15~0.4 秒)。打开别人的或已删除的对话,提示和以前一样。
|
||||
- Skill 版本不变(BUG-1477,issue #126)。
|
||||
|
||||
## 2026-10-28 — 咨询准备数据更快,含出生资料的缓存文件只有本机服务能读
|
||||
|
||||
- 同一张盘连着问时,每轮准备数据卡从约 0.1 秒降到几毫秒;每天第一问不再在回答路径上现算整包,问题一发出去就在后台提前算好。回答内容不变。
|
||||
|
||||
@@ -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`。
|
||||
|
||||
@@ -35,3 +35,8 @@
|
||||
17. 侧栏某条对话「…」→ 转发(分享):提示「已复制当前聊天」,粘贴出来有对话、Markdown 报告和 JSON 分享包三段。手机 Safari 也试一次。
|
||||
18. 建档或改资料时选出生日期:选得上、存得住;手动输入 2 月 30 日这类不存在的日期不被接受。
|
||||
|
||||
## #126 用 `?c=` 链接打开对话更快(BUG-1477)
|
||||
|
||||
19. 复制一条自己对话的链接(地址栏里带 `?c=`),新开一个无痕窗口登录后直接打开:对话一次出现、内容齐全。
|
||||
20. 打开一条已删除的对话链接:仍提示「该对话不存在或已被删除」,停在空白首页。
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)]);
|
||||
},
|
||||
));
|
||||
Reference in New Issue
Block a user