返回 DeepSeek-Reasonix
chatTurnRail.ts
根目录 / desktop / frontend / src / lib / chatTurnRail.ts
1 import type { TranscriptOutlineEntry } from "./transcriptProtocol";
2
3 /** The identities a loaded user turn exposes to the rail. */
4 export interface LoadedTurnNode {
5 /** The item's own id, which is also its DOM anchor key. */
6 readonly id: string;
7 readonly messageId?: string;
8 }
9
10 /** Item keys the transcript gives a history record that has no message id. */
11 const RECORD_ITEM_PREFIX = "record:";
12
13 /**
14 * The transcript record identity behind a loaded user item, or undefined when
15 * it does not have one yet.
16 *
17 * `historyItems` and `transcriptStore` key a history record without a message
18 * id as `record:<recordId>` while the outline carries the bare `<recordId>`
19 * (`m:<messageId>` for everything the snapshot protocol emitted). Comparing the
20 * two item keys directly therefore never matches for older history, which is
21 * why this conversion lives here rather than being re-derived at each call
22 * site.
23 */
24 export function recordIdOf(item: LoadedTurnNode): string | undefined {
25 if (item.id.startsWith(RECORD_ITEM_PREFIX)) return item.id.slice(RECORD_ITEM_PREFIX.length);
26 // A committed row already carries the outline's own form.
27 if (item.id.startsWith("m:")) return item.id;
28 if (item.messageId) return `m:${item.messageId}`;
29 // `he:<entryId>` belongs to the windowed legacy store, whose ids live in a
30 // different key space and which never has an outline: a tab on that path
31 // never installs a snapshot, so the rail stays in its loaded-turn mode.
32 // Reporting no identity is correct — guessing one could match the wrong turn.
33 return undefined;
34 }
35
36 /** Mounted user turns indexed by both stable identities they can be found by. */
37 export interface LoadedTurnIndex {
38 readonly byMessageId: ReadonlyMap<string, string>;
39 readonly byRecordId: ReadonlyMap<string, string>;
40 }
41
42 /**
43 * One pass over the mounted order. The merge must not scan the mounted set per
44 * outline entry: at ten thousand turns against tens of thousands of nodes that
45 * becomes the dominant cost of a render.
46 */
47 export function indexLoadedTurns(order: readonly string[], read: (key: string) => LoadedTurnNode | undefined): LoadedTurnIndex {
48 const byMessageId = new Map<string, string>();
49 const byRecordId = new Map<string, string>();
50 for (const key of order) {
51 const node = read(key);
52 if (node === undefined) continue;
53 if (node.messageId !== undefined && !byMessageId.has(node.messageId)) byMessageId.set(node.messageId, key);
54 const recordId = recordIdOf(node);
55 if (recordId !== undefined && !byRecordId.has(recordId)) byRecordId.set(recordId, key);
56 }
57 return { byMessageId, byRecordId };
58 }
59
60 /**
61 * The mounted node that answers for one outline entry, or undefined while that
62 * turn is still unloaded.
63 *
64 * Matches on identity, never on the shape of the anchor key: a question
65 * submitted in this app session keeps its optimistic `u<seq>` id after the
66 * authoritative message settles and only gains a `messageId`. A message ID is
67 * the identity that survives settlement, so it wins; the record ID is the
68 * stable fallback that also covers a question that has not been committed yet.
69 *
70 * Shared by the rail and by the jump transaction so both agree on when a target
71 * has really mounted.
72 */
73 export function findLoadedTurn(index: LoadedTurnIndex, entry: TranscriptOutlineEntry): string | undefined {
74 if (entry.messageId !== undefined) {
75 const byMessage = index.byMessageId.get(entry.messageId);
76 if (byMessage !== undefined) return byMessage;
77 }
78 return index.byRecordId.get(entry.id);
79 }
80
81 /** Ordered, de-duplicated outline entries. */
82 export function alignOutlineEntries(entries: readonly TranscriptOutlineEntry[]): TranscriptOutlineEntry[] {
83 const seen = new Set<string>();
84 const aligned: TranscriptOutlineEntry[] = [];
85 for (const entry of entries) {
86 if (seen.has(entry.id)) continue;
87 seen.add(entry.id);
88 aligned.push(entry);
89 }
90 aligned.sort((left, right) => left.order - right.order);
91 return aligned;
92 }
93
93 lines TYPESCRIPT