返回 DeepSeek-Reasonix
composerHistory.ts
根目录 / desktop / frontend / src / lib / composerHistory.ts
1 // composerHistory provides ↑/↓ prompt-history navigation by reading a lazy
2 // backend "tape" of user prompts. The tape starts with the active session and
3 // then continues through the same session order used by the history sidebar.
4 //
5 // Unlike the old localStorage ring buffer, the session JSONL files are the
6 // canonical source of truth — we read them through the Go bound method
7 // ScanPromptHistory, which exposes cursor pages and invalidates the tape when
8 // sessions are created, deleted, or renamed.
9 //
10 // We don't try to do prefix-search navigation (Ctrl-R in bash) — that needs a
11 // search UI and a keybinding the OS doesn't already take. The arrow
12 // navigation is the common case and is the smallest useful addition.
13
14 import { app } from "./bridge";
15 import type { PromptHistoryEntry, PromptHistoryResult } from "./types";
16
17 // currentNonce identifies the backend tape. olderCursor points to the next
18 // unread segment; cachedEntries are only the entries the user has reached.
19 let currentNonce = "";
20 let olderCursor = "";
21 let hasOlder = true;
22 let cachedEntries: PromptHistoryEntry[] = [];
23 let generation = 0;
24 const PROMPT_HISTORY_PAGE_LIMIT = 50;
25
26 type ScanPromptHistoryResultTuple = [PromptHistoryEntry[] | null, string];
27 type ScanPromptHistoryResultTupleWithErr = [PromptHistoryEntry[] | null, string, unknown];
28 type ScanPromptHistoryResult =
29 | PromptHistoryEntry[]
30 | PromptHistoryResult
31 | ScanPromptHistoryResultTuple
32 | ScanPromptHistoryResultTupleWithErr
33 | Record<string, unknown>;
34
35 interface NormalizedPromptHistoryPage {
36 entries: PromptHistoryEntry[] | null;
37 nonce: string;
38 olderCursor: string;
39 hasOlder: boolean;
40 }
41
42 function asEntries(value: unknown): PromptHistoryEntry[] {
43 return Array.isArray(value) ? (value as PromptHistoryEntry[]) : [];
44 }
45
46 function maybeTupleResult(result: unknown): { entries: PromptHistoryEntry[] | null; nonce: string } | null {
47 if (!Array.isArray(result)) return null;
48 if (result.length >= 2 && typeof result[1] === "string") {
49 return { entries: result[0] === null ? null : asEntries(result[0]), nonce: result[1] };
50 }
51 return null;
52 }
53
54 function maybeTupleMapResult(result: unknown): { entries: PromptHistoryEntry[] | null; nonce: string } | null {
55 if (typeof result !== "object" || result === null) return null;
56 const map = result as Record<string, unknown>;
57 if (!("0" in map) || !("1" in map)) return null;
58 if (typeof map["1"] !== "string") return null;
59 return { entries: map["0"] === null ? null : asEntries(map["0"]), nonce: map["1"] };
60 }
61
62 function normalizePageResult(result: ScanPromptHistoryResult): NormalizedPromptHistoryPage {
63 const tuple = maybeTupleResult(result) ?? maybeTupleMapResult(result);
64 if (tuple !== null) {
65 return { entries: tuple.entries, nonce: tuple.nonce, olderCursor: "", hasOlder: false };
66 }
67
68 if (Array.isArray(result)) {
69 return { entries: asEntries(result), nonce: currentNonce, olderCursor: "", hasOlder: false };
70 }
71
72 if (typeof result === "object" && result !== null) {
73 if ("entries" in result) {
74 const rawEntries = (result as { entries?: unknown }).entries;
75 const nextNonce = typeof (result as { nonce?: unknown }).nonce === "string"
76 ? (result as { nonce: string }).nonce
77 : currentNonce;
78 const nextOlderCursor = typeof (result as { olderCursor?: unknown }).olderCursor === "string"
79 ? (result as { olderCursor: string }).olderCursor
80 : "";
81 const nextHasOlder = typeof (result as { hasOlder?: unknown }).hasOlder === "boolean"
82 ? (result as { hasOlder: boolean }).hasOlder
83 : nextOlderCursor !== "";
84 return {
85 entries: rawEntries === null || rawEntries === undefined ? null : asEntries(rawEntries),
86 nonce: nextNonce,
87 olderCursor: nextOlderCursor,
88 hasOlder: nextHasOlder,
89 };
90 }
91
92 if (typeof (result as { nonce?: unknown }).nonce === "string") {
93 return { entries: null, nonce: (result as { nonce: string }).nonce, olderCursor: "", hasOlder: false };
94 }
95 }
96
97 if (result === null || result === undefined) {
98 return { entries: [], nonce: currentNonce, olderCursor: "", hasOlder: false };
99 }
100 return { entries: asEntries(result), nonce: currentNonce, olderCursor: "", hasOlder: false };
101 }
102
103 // invalidateCache resets the tape so the next loadOlder() call starts from the
104 // current active session. Call this after any session mutation.
105 export function invalidateCache(): void {
106 currentNonce = "";
107 olderCursor = "";
108 hasOlder = true;
109 cachedEntries = [];
110 generation++;
111 }
112
113 export function cacheGeneration(): number {
114 return generation;
115 }
116
117 export async function loadOlder(): Promise<PromptHistoryEntry[]> {
118 if (!hasOlder && olderCursor === "") return [];
119 try {
120 const request = JSON.stringify({
121 nonce: currentNonce,
122 cursor: olderCursor,
123 limit: PROMPT_HISTORY_PAGE_LIMIT,
124 });
125 const result = await app.ScanPromptHistory(request);
126 const page = normalizePageResult(result as ScanPromptHistoryResult);
127 currentNonce = page.nonce;
128 olderCursor = page.olderCursor;
129 hasOlder = page.hasOlder;
130 const entries = page.entries ?? [];
131 if (entries.length > 0) {
132 cachedEntries = cachedEntries.concat(entries);
133 }
134 return entries.slice();
135 } catch {
136 return [];
137 }
138 }
139
140 export function hasMoreOlder(): boolean {
141 return hasOlder || olderCursor !== "";
142 }
143
144 // snapshot returns a defensive copy of the entries loaded so far. If nothing has
145 // been loaded yet, it fetches the first tape page for compatibility with older
146 // callers and tests.
147 export async function snapshot(): Promise<PromptHistoryEntry[]> {
148 if (cachedEntries.length === 0 && hasMoreOlder()) {
149 await loadOlder();
150 }
151 return cachedEntries.slice();
152 }
153
154 // pushHistory is a no-op — prompts are persisted by the Go kernel as session
155 // JSONL files, so there's nothing local to append.
156 export function pushHistory(_text: string): void {
157 // no-op: prompts are recorded by the kernel
158 }
159
160 // clearHistory is a no-op — session logs are the canonical store; there's
161 // nothing local to clear. A "clear prompt history" button would need to delete
162 // session files, which is a bigger operation.
163 export function clearHistory(): void {
164 // no-op: session logs are the canonical store
165 }
166
166 lines TYPESCRIPT