@johndimm/constellations 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/App.tsx +480 -0
  2. package/FullPageConstellations.tsx +74 -0
  3. package/FullPageConstellationsHostShell.tsx +27 -0
  4. package/README.md +116 -0
  5. package/components/AppConfirmDialog.tsx +46 -0
  6. package/components/AppHeader.tsx +73 -0
  7. package/components/AppNotifications.tsx +21 -0
  8. package/components/BrowsePeople.tsx +832 -0
  9. package/components/ControlPanel.tsx +1023 -0
  10. package/components/Graph.tsx +1525 -0
  11. package/components/HelpOverlay.tsx +168 -0
  12. package/components/NodeContextMenu.tsx +160 -0
  13. package/components/PeopleBrowserSidebar.tsx +690 -0
  14. package/components/Sidebar.tsx +271 -0
  15. package/components/TimelineView.tsx +4 -0
  16. package/hooks/useExpansion.ts +889 -0
  17. package/hooks/useGraphActions.ts +325 -0
  18. package/hooks/useGraphState.ts +414 -0
  19. package/hooks/useKioskMode.ts +47 -0
  20. package/hooks/useNodeClickHandler.ts +172 -0
  21. package/hooks/useSearchHandlers.ts +369 -0
  22. package/host.ts +16 -0
  23. package/index.css +101 -0
  24. package/index.tsx +16 -0
  25. package/kioskDomains.ts +307 -0
  26. package/package.json +78 -0
  27. package/services/aiUtils.ts +364 -0
  28. package/services/cacheService.ts +76 -0
  29. package/services/crossrefService.ts +107 -0
  30. package/services/geminiService.ts +1359 -0
  31. package/services/get-local-graphs.js +5 -0
  32. package/services/graphUtils.ts +347 -0
  33. package/services/imageService.ts +39 -0
  34. package/services/llmClient.ts +194 -0
  35. package/services/openAlexService.ts +173 -0
  36. package/services/wikipediaImage.ts +40 -0
  37. package/services/wikipediaService.ts +1175 -0
  38. package/sessionHandoff.ts +132 -0
  39. package/types.ts +99 -0
  40. package/useFullPageConstellationsHost.ts +116 -0
  41. package/utils/evidenceUtils.ts +107 -0
  42. package/utils/graphLogicUtils.ts +32 -0
  43. package/utils/graphNodeToChannelNotes.ts +71 -0
  44. package/utils/wikiUtils.ts +34 -0
@@ -0,0 +1,5 @@
1
+ const graphs = Object.keys(localStorage)
2
+ .filter(k => k.startsWith('constellations_graph_'))
3
+ .map(k => ({ name: k.replace('constellations_graph_', ''), data:
4
+ JSON.parse(localStorage[k]) }));
5
+ console.log(JSON.stringify(graphs, null, 2));
@@ -0,0 +1,347 @@
1
+ import { GraphNode, GraphLink } from '../types';
2
+
3
+ // Normalize string for deduplication:
4
+ // - Unicode normalize (so visually-identical strings match)
5
+ // - strip zero-width chars + NBSP
6
+ // - lower case
7
+ // - remove leading "the "
8
+ // - remove punctuation (Unicode-aware)
9
+ // - collapse whitespace
10
+ export const normalizeForDedup = (str: unknown) => {
11
+ let s = String(str ?? '');
12
+ try {
13
+ // Normalize to reduce visually-identical variants (e.g., curly quotes, composed accents)
14
+ s = s.normalize('NFKC');
15
+ } catch { }
16
+
17
+ const base = s
18
+ .replace(/[\u200B-\u200D\uFEFF]/g, '') // zero-width chars
19
+ .replace(/\u00A0/g, ' ') // NBSP -> space
20
+ .trim()
21
+ .replace(/\s*\([^)]*\)$/, '') // STRIP DISAMBIGUATIONS (e.g. "(film)")
22
+ .toLowerCase()
23
+ .replace(/[^\p{L}\p{N}\s]/gu, '') // Remove punctuation (keep letters/numbers)
24
+ .replace(/\s+/g, ' ')
25
+ .trim();
26
+
27
+ // Strip common articles from the entire string to handle "a" vs "the" mismatch.
28
+ // e.g. "Interview with a Vampire" vs "Interview with the Vampire"
29
+ const stripped = base.replace(/\b(a|an|the)\b/g, ' ').replace(/\s+/g, ' ').trim();
30
+ return stripped || base;
31
+ };
32
+
33
+ export const canonicalType = (t?: string) => {
34
+ const norm = (t || '').trim().toLowerCase();
35
+ if (!norm) return '';
36
+ // Unify all common creative works and events into a single bucket.
37
+ // This handles cases where Gemini might call a movie an "Event" in one context
38
+ // and a "Movie/Work" in another.
39
+ if ([
40
+ 'work', 'event', 'composite',
41
+ 'book', 'novel', 'short story', 'story', 'essay',
42
+ 'play', 'theatre', 'theater', 'musical',
43
+ 'movie', 'film', 'cinema', 'motion picture', 'film series',
44
+ 'tv', 'tv show', 'tv series', 'television series', 'episode', 'series', 'miniseries',
45
+ 'song', 'track', 'album', 'record', 'single',
46
+ 'painting', 'artwork', 'sculpture', 'photograph',
47
+ 'opera', 'ballet', 'symphony', 'concerto', 'composition', 'piece'
48
+ ].some(v => norm === v || (norm.startsWith(v) && norm.length <= v.length + 3))) {
49
+ return 'work';
50
+ }
51
+ return norm;
52
+ };
53
+
54
+ export const dedupeKey = (title: string, type?: string, wikipediaId?: string | null) => {
55
+ const normType = canonicalType(type);
56
+ // Always use normalized title for case-insensitive deduplication
57
+ // If wikipedia_id exists, include it as additional info, but still dedupe by normalized title
58
+ const normTitle = normalizeForDedup(title);
59
+ if (wikipediaId) return `wiki|${wikipediaId}|${normTitle}|${normType}`;
60
+ return `${normTitle}|${normType}`;
61
+ };
62
+
63
+ // Helper to get base dedupe key.
64
+ // Key insight: duplicates often happen when type metadata is missing/inconsistent on Atomics.
65
+ // To avoid duplicates like "Euclid" appearing twice, we dedupe Atomics by title only (within the Atomic partition),
66
+ // and Composites by title+type (to avoid merging distinct things that share a title).
67
+ export const baseDedupeKey = (node: { title: string; type?: string; is_atomic?: boolean; is_person?: boolean; wikipedia_id?: string | null }) => {
68
+ const normTitle = normalizeForDedup(node.title);
69
+ const isAtomic =
70
+ node.is_atomic ??
71
+ node.is_person ??
72
+ ((node.type || '').trim().toLowerCase() === 'person');
73
+
74
+ if (isAtomic) return `a|${normTitle}`;
75
+
76
+ const normType = canonicalType(node.type);
77
+ // If type is missing, dedupe by title only (we'll merge any typed variant into this bucket).
78
+ // This avoids duplicates like identical works where one node has type metadata and the other doesn't.
79
+ if (!normType) return `c|${normTitle}`;
80
+ return `c|${normTitle}|${normType}`;
81
+ };
82
+
83
+ // Merge duplicate nodes (same normalized title/type) and remap links accordingly.
84
+ export const dedupeGraph = (
85
+ nodes: GraphNode[],
86
+ links: GraphLink[]
87
+ ): { nodes: GraphNode[]; links: GraphLink[] } => {
88
+ const dedupMap = new Map<string, GraphNode>();
89
+ const wikiIdMap = new Map<string, string>(); // wiki_id -> primary_node_id
90
+ const idRemap = new Map<string, string>();
91
+
92
+ const normalizeType = (t?: string) => {
93
+ return (t || '').trim().toLowerCase();
94
+ };
95
+
96
+ const mergeType = (a?: string, b?: string) => {
97
+ const na = normalizeType(a);
98
+ const nb = normalizeType(b);
99
+ if (na === 'person') return a;
100
+ if (nb === 'person') return b;
101
+ return a || b;
102
+ };
103
+
104
+ const mergeNode = (existing: GraphNode, incoming: GraphNode): GraphNode => {
105
+ // Prefer node with wikipedia_id for base properties (title, wikipedia_id)
106
+ const prefer = existing.wikipedia_id ? existing : incoming;
107
+ return {
108
+ ...prefer,
109
+ type: mergeType(existing.type, incoming.type),
110
+ imageUrl: existing.imageUrl || incoming.imageUrl || undefined,
111
+ imageChecked: existing.imageChecked || incoming.imageChecked || !!existing.imageUrl || !!incoming.imageUrl,
112
+ wikiSummary: existing.wikiSummary || incoming.wikiSummary || undefined,
113
+ description: (existing.description && existing.description.length >= (incoming.description || '').length)
114
+ ? existing.description
115
+ : incoming.description,
116
+ year: existing.year ?? incoming.year,
117
+ expanded: existing.expanded || incoming.expanded,
118
+ isLoading: existing.isLoading || incoming.isLoading,
119
+ // Keep wikipedia_id from whichever node has it (already in prefer spread, but explicit for clarity)
120
+ wikipedia_id: existing.wikipedia_id || incoming.wikipedia_id || undefined
121
+ };
122
+ };
123
+
124
+ nodes.forEach(n => {
125
+ const key = baseDedupeKey(n as any);
126
+ const wikiId = n.wikipedia_id ? String(n.wikipedia_id) : null;
127
+
128
+ let existing: GraphNode | undefined;
129
+ let targetKey = key;
130
+
131
+ // 1. Try to find by Wikipedia ID first (strongest match)
132
+ if (wikiId && wikiIdMap.has(wikiId)) {
133
+ const primaryId = wikiIdMap.get(wikiId)!;
134
+ // Find the node in dedupMap that has this ID
135
+ for (const [k, node] of dedupMap.entries()) {
136
+ if (String(node.id) === primaryId) {
137
+ existing = node;
138
+ targetKey = k;
139
+ break;
140
+ }
141
+ }
142
+ }
143
+
144
+ // 2. Fall back to title-based lookup if no wiki_id match
145
+ if (!existing) {
146
+ existing = dedupMap.get(key);
147
+ targetKey = key;
148
+
149
+ // If no exact match, check for title-only collisions in the Composite partition.
150
+ // This handles merging a node with a generic/missing type into a more specific one (or vice versa).
151
+ if (!existing && key.startsWith('c|')) {
152
+ const titleOnlyKey = key.split('|').slice(0, 2).join('|'); // "c|<title>"
153
+ const wildcard = dedupMap.get(titleOnlyKey);
154
+ if (wildcard) {
155
+ existing = wildcard;
156
+ targetKey = titleOnlyKey;
157
+ } else {
158
+ // 2. Try to find ANY typed entry with the same title
159
+ // We search all keys for one that starts with our title-only key
160
+ for (const [k, node] of dedupMap.entries()) {
161
+ if (k.startsWith(titleOnlyKey + '|') || k === titleOnlyKey) {
162
+ existing = node;
163
+ targetKey = k;
164
+ break;
165
+ }
166
+ }
167
+ }
168
+ }
169
+ }
170
+
171
+ if (!existing) {
172
+ dedupMap.set(key, n);
173
+ idRemap.set(String(n.id), String(n.id));
174
+ if (wikiId) wikiIdMap.set(wikiId, String(n.id));
175
+ } else {
176
+ const merged = mergeNode(existing, n);
177
+ dedupMap.set(targetKey, merged);
178
+ idRemap.set(String(n.id), String(merged.id));
179
+ idRemap.set(String(existing.id), String(merged.id));
180
+ if (merged.wikipedia_id) wikiIdMap.set(String(merged.wikipedia_id), String(merged.id));
181
+ }
182
+ });
183
+
184
+ const nodesOut = Array.from(dedupMap.values());
185
+
186
+ const remapId = (value: number | string | GraphNode) => {
187
+ const id = String(typeof value === 'object' ? value.id : value);
188
+ return idRemap.get(id) ?? id;
189
+ };
190
+
191
+ const linkSeen = new Set<string>();
192
+ const linksOut: GraphLink[] = [];
193
+ links.forEach(l => {
194
+ const s = remapId(l.source);
195
+ const t = remapId(l.target);
196
+ if (s === t) return; // drop self-links after remap
197
+ const lid = `${s}-${t}`;
198
+ if (linkSeen.has(lid)) return;
199
+ linkSeen.add(lid);
200
+ linksOut.push({
201
+ ...l,
202
+ source: s,
203
+ target: t,
204
+ id: lid
205
+ });
206
+ });
207
+
208
+ return { nodes: nodesOut, links: linksOut };
209
+ };
210
+
211
+ type ExpansionTarget = GraphNode & {
212
+ edge_label?: string | null;
213
+ edge_meta?: any;
214
+ evidence?: GraphLink['evidence'];
215
+ };
216
+
217
+ export const mergeExpansionGraph = (params: {
218
+ nodes: GraphNode[];
219
+ links: GraphLink[];
220
+ parent: GraphNode;
221
+ targets: ExpansionTarget[];
222
+ seedFromParent?: boolean;
223
+ }): { nodes: GraphNode[]; links: GraphLink[] } => {
224
+ const { nodes, links, parent, targets, seedFromParent = true } = params;
225
+ const existingNodeIds = new Set(nodes.map(n => String(n.id)));
226
+ const nodeMap = new Map<string, GraphNode>(nodes.map(n => [String(n.id), n]));
227
+
228
+ const parentIsAtomic = !!(parent.is_atomic ?? parent.is_person ?? (parent.type || '').toLowerCase() === 'person');
229
+ const expectedChildIsAtomic = !parentIsAtomic;
230
+
231
+ // console.warn(`🔧 [mergeExpansionGraph] Parent "${parent.title}" isAtomic=${parentIsAtomic}, expected child isAtomic=${expectedChildIsAtomic}`);
232
+
233
+ targets.forEach(t => {
234
+ const meta = (t.meta || {}) as Record<string, any>;
235
+ const existing = nodeMap.get(String(t.id));
236
+ const imageUrl = meta.imageUrl ?? existing?.imageUrl ?? t.imageUrl;
237
+ const wikiSummary = meta.wikiSummary ?? (t as any).wikiSummary ?? existing?.wikiSummary;
238
+
239
+ // Trust the LLM's classification first, then existing node, then infer from parent
240
+ const isAtomic =
241
+ (typeof t.is_atomic === 'boolean' ? t.is_atomic : (typeof (t as any).is_person === 'boolean' ? (t as any).is_person : undefined)) ??
242
+ (existing?.is_atomic ?? (existing as any)?.is_person) ??
243
+ expectedChildIsAtomic;
244
+
245
+ // console.warn(`🔧 [mergeExpansionGraph] Target "${t.title}": t.is_atomic=${t.is_atomic}, t.type="${t.type}", computed isAtomic=${isAtomic}`);
246
+
247
+ const initialX = (!existing && seedFromParent && parent.x != null)
248
+ ? parent.x + (Math.random() - 0.5) * 100
249
+ : undefined;
250
+ const initialY = (!existing && seedFromParent && parent.y != null)
251
+ ? parent.y + (Math.random() - 0.5) * 100
252
+ : undefined;
253
+
254
+ const merged: GraphNode = {
255
+ x: existing?.x ?? initialX,
256
+ y: existing?.y ?? initialY,
257
+ ...(existing || {}),
258
+ id: t.id,
259
+ title: t.title || existing?.title || '',
260
+ type: t.type || existing?.type || '',
261
+ is_atomic: isAtomic,
262
+ wikipedia_id: t.wikipedia_id || existing?.wikipedia_id,
263
+ description: wikiSummary || t.description || existing?.description || '',
264
+ year: t.year ?? existing?.year,
265
+ imageUrl,
266
+ imageChecked: !!imageUrl || existing?.imageChecked,
267
+ wikiSummary,
268
+ expanded: existing?.expanded || false,
269
+ isLoading: false
270
+ };
271
+ nodeMap.set(String(t.id), merged);
272
+ });
273
+
274
+ if (nodeMap.has(String(parent.id))) {
275
+ nodeMap.set(String(parent.id), { ...nodeMap.get(String(parent.id))!, expanded: true, isLoading: false });
276
+ }
277
+
278
+ const updatedNodes = Array.from(nodeMap.values());
279
+ const isAtomicForId = new Map<string, boolean>();
280
+ updatedNodes.forEach(n => {
281
+ const v = (n.is_atomic ?? (n as any).is_person);
282
+ if (typeof v === 'boolean') isAtomicForId.set(String(n.id), v);
283
+ else if ((n.type || '').toLowerCase() === 'person') isAtomicForId.set(String(n.id), true);
284
+ });
285
+
286
+ const candidateLinks: GraphLink[] = targets.map(t => ({
287
+ source: parent.id,
288
+ target: t.id,
289
+ id: `${parent.id}-${t.id}`,
290
+ label: t.edge_label || (t as any).role || undefined,
291
+ evidence: t.evidence || t.edge_meta?.evidence || { kind: 'none' }
292
+ }));
293
+
294
+ // console.warn(`🔧 [mergeExpansionGraph] Created ${candidateLinks.length} candidate links`);
295
+
296
+ const bipartiteSafeCandidates = candidateLinks.filter(l => {
297
+ const s = String(typeof l.source === 'object' ? l.source.id : l.source);
298
+ const t = String(typeof l.target === 'object' ? l.target.id : l.target);
299
+ // Match useExpansion: edges incident to the expanded parent trust the AI/cache (avoid empty merges when is_atomic drifted in the DB/UI).
300
+ if (s === String(parent.id) || t === String(parent.id)) return true;
301
+ const sa = isAtomicForId.get(s);
302
+ const ta = isAtomicForId.get(t);
303
+ const pass = (sa === undefined || ta === undefined) || (sa !== ta);
304
+ if (!pass) {
305
+ // console.warn(`🔧 [mergeExpansionGraph] Link ${s}->${t} FILTERED: parent isAtomic=${sa}, child isAtomic=${ta}`);
306
+ }
307
+ return pass;
308
+ });
309
+
310
+ // console.warn(`🔧 [mergeExpansionGraph] After bipartite filter: ${bipartiteSafeCandidates.length} links`);
311
+
312
+ const existingLinkIds = new Set(links.map(l => l.id));
313
+ const updatedExistingLinks = links.map(l => {
314
+ const cand = bipartiteSafeCandidates.find(c => c.id === l.id);
315
+ if (!cand) return l;
316
+ const merged: GraphLink = { ...l };
317
+ if (!merged.label && cand.label) merged.label = cand.label;
318
+ if ((!merged.evidence || merged.evidence.kind === 'none') && cand.evidence) merged.evidence = cand.evidence;
319
+ return merged;
320
+ });
321
+ const newLinksToAdd = bipartiteSafeCandidates.filter(l => !existingLinkIds.has(l.id));
322
+ const combinedLinks = [...updatedExistingLinks, ...newLinksToAdd];
323
+
324
+ // console.warn(`🔧 [mergeExpansionGraph] Combined links: ${combinedLinks.length}`);
325
+
326
+ const degree = new Map<string, number>();
327
+ combinedLinks.forEach(l => {
328
+ const s = String(typeof l.source === 'object' ? l.source.id : l.source);
329
+ const t = String(typeof l.target === 'object' ? l.target.id : l.target);
330
+ degree.set(s, (degree.get(s) || 0) + 1);
331
+ degree.set(t, (degree.get(t) || 0) + 1);
332
+ });
333
+ const prunedNodes = updatedNodes.filter(n => {
334
+ if (String(n.id) === String(parent.id)) return true;
335
+ if (existingNodeIds.has(String(n.id))) return true;
336
+ const deg = degree.get(String(n.id)) || 0;
337
+ const keep = deg > 0;
338
+ if (!keep && !existingNodeIds.has(String(n.id))) {
339
+ // console.warn(`🔧 [mergeExpansionGraph] Node "${n.title}" (${n.id}) PRUNED: degree=${deg}`);
340
+ }
341
+ return keep;
342
+ });
343
+
344
+ // console.warn(`🔧 [mergeExpansionGraph] After pruning: ${prunedNodes.length} nodes from ${updatedNodes.length}`);
345
+
346
+ return dedupeGraph(prunedNodes, combinedLinks);
347
+ };
@@ -0,0 +1,39 @@
1
+ import { getEffectiveCacheBaseUrl } from './cacheService';
2
+
3
+ export type ServerImageResult = {
4
+ url: string | null;
5
+ source?: string;
6
+ pageId?: number;
7
+ pageTitle?: string;
8
+ };
9
+
10
+ export const fetchServerImage = async (
11
+ title: string,
12
+ context?: string,
13
+ baseUrl?: string
14
+ ): Promise<ServerImageResult> => {
15
+ if (!title) return { url: null };
16
+ const resolvedBase =
17
+ baseUrl ||
18
+ getEffectiveCacheBaseUrl() ||
19
+ (typeof window !== 'undefined' ? window.location.origin : '');
20
+ if (!resolvedBase) return { url: null };
21
+ try {
22
+ const params = new URLSearchParams({ title });
23
+ if (context) params.set('context', context);
24
+ const url = new URL(`/api/image?${params.toString()}`, resolvedBase).toString();
25
+ const res = await fetch(url);
26
+ if (!res.ok || !String(res.headers.get('content-type') || '').includes('application/json')) {
27
+ return { url: null };
28
+ }
29
+ const data = await res.json();
30
+ return {
31
+ url: data?.url ?? null,
32
+ source: data?.source,
33
+ pageId: data?.pageId,
34
+ pageTitle: data?.pageTitle
35
+ };
36
+ } catch {
37
+ return { url: null };
38
+ }
39
+ };
@@ -0,0 +1,194 @@
1
+ import {
2
+ clipForLlmLog,
3
+ getEnvVar,
4
+ getLlmProvider,
5
+ getLlmApiKey,
6
+ getResponseText,
7
+ isRateLimitError,
8
+ withRetry,
9
+ withTimeout,
10
+ type LlmProviderId,
11
+ } from "./aiUtils";
12
+
13
+ type RunJsonOptions = {
14
+ system?: string;
15
+ user: string;
16
+ timeoutMs: number;
17
+ /** Required only when getLlmProvider() is gemini */
18
+ gemini?: () => Promise<any>;
19
+ attempts?: number;
20
+ };
21
+
22
+ type OpenAiCompatOpts = { apiKey?: string };
23
+
24
+ async function openAiCompatibleJson(
25
+ provider: "openai" | "deepseek",
26
+ system: string | undefined,
27
+ user: string,
28
+ opts?: OpenAiCompatOpts
29
+ ): Promise<string> {
30
+ const key = opts?.apiKey || (await getLlmApiKey());
31
+ if (!key) throw new Error(`No API key for ${provider}`);
32
+
33
+ const baseUrl =
34
+ provider === "deepseek"
35
+ ? (getEnvVar("VITE_DEEPSEEK_BASE_URL") || "https://api.deepseek.com/v1").replace(/\/$/, "")
36
+ : (getEnvVar("VITE_OPENAI_BASE_URL") || "https://api.openai.com/v1").replace(/\/$/, "");
37
+
38
+ const model =
39
+ provider === "deepseek"
40
+ ? getEnvVar("VITE_DEEPSEEK_MODEL") || "deepseek-chat"
41
+ : getEnvVar("VITE_OPENAI_MODEL") || "gpt-4o-mini";
42
+
43
+ const messages: { role: string; content: string }[] = [];
44
+ if (system?.trim()) {
45
+ messages.push({ role: "system", content: system });
46
+ }
47
+ messages.push({
48
+ role: "user",
49
+ content: `${user}\n\nRespond with a single JSON object only (no markdown fences).`,
50
+ });
51
+
52
+ const res = await fetch(`${baseUrl}/chat/completions`, {
53
+ method: "POST",
54
+ headers: {
55
+ Authorization: `Bearer ${key}`,
56
+ "Content-Type": "application/json",
57
+ },
58
+ body: JSON.stringify({
59
+ model,
60
+ messages,
61
+ response_format: { type: "json_object" },
62
+ }),
63
+ });
64
+
65
+ const data = (await res.json()) as any;
66
+ if (!res.ok) {
67
+ throw new Error(data?.error?.message || data?.message || JSON.stringify(data));
68
+ }
69
+ const text = data?.choices?.[0]?.message?.content;
70
+ if (typeof text !== "string" || !text.trim()) {
71
+ throw new Error("OpenAI-compatible API returned empty content");
72
+ }
73
+ return text;
74
+ }
75
+
76
+ async function anthropicJson(system: string | undefined, user: string): Promise<string> {
77
+ const key = await getLlmApiKey();
78
+ if (!key) throw new Error("No API key for anthropic");
79
+
80
+ const model = getEnvVar("VITE_ANTHROPIC_MODEL") || "claude-3-5-haiku-20241022";
81
+
82
+ const res = await fetch("https://api.anthropic.com/v1/messages", {
83
+ method: "POST",
84
+ headers: {
85
+ "x-api-key": key,
86
+ "anthropic-version": "2023-06-01",
87
+ "content-type": "application/json",
88
+ },
89
+ body: JSON.stringify({
90
+ model,
91
+ max_tokens: 8192,
92
+ ...(system?.trim() ? { system: system.trim() } : {}),
93
+ messages: [
94
+ {
95
+ role: "user",
96
+ content: `${user}\n\nReply with a single valid JSON object only. No markdown, no commentary.`,
97
+ },
98
+ ],
99
+ }),
100
+ });
101
+
102
+ const data = (await res.json()) as any;
103
+ if (!res.ok) {
104
+ throw new Error(data?.error?.message || JSON.stringify(data));
105
+ }
106
+ const block = data?.content?.[0];
107
+ const text = block?.type === "text" ? block.text : "";
108
+ if (typeof text !== "string" || !text.trim()) {
109
+ throw new Error("Anthropic API returned empty content");
110
+ }
111
+ return text;
112
+ }
113
+
114
+ async function runAltProvider(p: Exclude<LlmProviderId, "gemini">, system: string | undefined, user: string) {
115
+ if (p === "anthropic") {
116
+ return anthropicJson(system, user);
117
+ }
118
+ return openAiCompatibleJson(p, system, user, undefined);
119
+ }
120
+
121
+ /**
122
+ * Run a JSON completion: Gemini via @google/genai callback, or OpenAI / DeepSeek / Anthropic via HTTPS.
123
+ */
124
+ export async function runJsonCompletion(options: RunJsonOptions): Promise<string> {
125
+ const p = getLlmProvider();
126
+ const attempts = options.attempts ?? 4;
127
+
128
+ console.info("[LLM] runJsonCompletion REQUEST", {
129
+ provider: p,
130
+ system: clipForLlmLog(options.system ?? ""),
131
+ user: clipForLlmLog(options.user),
132
+ });
133
+
134
+ let text: string;
135
+
136
+ if (p === "gemini") {
137
+ if (!options.gemini) {
138
+ throw new Error("runJsonCompletion: gemini callback is required when LLM_PROVIDER is gemini");
139
+ }
140
+ try {
141
+ const out = await withRetry(
142
+ () => withTimeout(options.gemini!(), options.timeoutMs, "LLM request timed out"),
143
+ attempts,
144
+ 1000
145
+ );
146
+ text = getResponseText(out);
147
+ } catch (e: any) {
148
+ if (isRateLimitError(e)) {
149
+ const openaiKey = getEnvVar("OPENAI_API_KEY") || getEnvVar("VITE_OPENAI_API_KEY");
150
+ if (openaiKey) {
151
+ console.warn(
152
+ "[LLM] Gemini rate/quota exhausted; retrying this request with OpenAI (OPENAI_API_KEY)."
153
+ );
154
+ text = await withTimeout(
155
+ openAiCompatibleJson("openai", options.system, options.user, { apiKey: openaiKey }),
156
+ options.timeoutMs,
157
+ "OpenAI fallback timed out"
158
+ );
159
+ } else if (getEnvVar("DEEPSEEK_API_KEY") || getEnvVar("VITE_DEEPSEEK_API_KEY")) {
160
+ const deepseekKey = getEnvVar("DEEPSEEK_API_KEY") || getEnvVar("VITE_DEEPSEEK_API_KEY")!;
161
+ console.warn(
162
+ "[LLM] Gemini rate/quota exhausted; retrying this request with DeepSeek (DEEPSEEK_API_KEY)."
163
+ );
164
+ text = await withTimeout(
165
+ openAiCompatibleJson("deepseek", options.system, options.user, { apiKey: deepseekKey }),
166
+ options.timeoutMs,
167
+ "DeepSeek fallback timed out"
168
+ );
169
+ } else {
170
+ throw e;
171
+ }
172
+ } else {
173
+ throw e;
174
+ }
175
+ }
176
+ } else {
177
+ text = await withRetry(
178
+ () =>
179
+ withTimeout(
180
+ runAltProvider(p, options.system, options.user),
181
+ options.timeoutMs,
182
+ "LLM request timed out"
183
+ ),
184
+ attempts,
185
+ 1000
186
+ );
187
+ }
188
+
189
+ console.info("[LLM] runJsonCompletion RESPONSE", {
190
+ chars: text.length,
191
+ text: clipForLlmLog(text),
192
+ });
193
+ return text;
194
+ }