@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.
- package/App.tsx +480 -0
- package/FullPageConstellations.tsx +74 -0
- package/FullPageConstellationsHostShell.tsx +27 -0
- package/README.md +116 -0
- package/components/AppConfirmDialog.tsx +46 -0
- package/components/AppHeader.tsx +73 -0
- package/components/AppNotifications.tsx +21 -0
- package/components/BrowsePeople.tsx +832 -0
- package/components/ControlPanel.tsx +1023 -0
- package/components/Graph.tsx +1525 -0
- package/components/HelpOverlay.tsx +168 -0
- package/components/NodeContextMenu.tsx +160 -0
- package/components/PeopleBrowserSidebar.tsx +690 -0
- package/components/Sidebar.tsx +271 -0
- package/components/TimelineView.tsx +4 -0
- package/hooks/useExpansion.ts +889 -0
- package/hooks/useGraphActions.ts +325 -0
- package/hooks/useGraphState.ts +414 -0
- package/hooks/useKioskMode.ts +47 -0
- package/hooks/useNodeClickHandler.ts +172 -0
- package/hooks/useSearchHandlers.ts +369 -0
- package/host.ts +16 -0
- package/index.css +101 -0
- package/index.tsx +16 -0
- package/kioskDomains.ts +307 -0
- package/package.json +78 -0
- package/services/aiUtils.ts +364 -0
- package/services/cacheService.ts +76 -0
- package/services/crossrefService.ts +107 -0
- package/services/geminiService.ts +1359 -0
- package/services/get-local-graphs.js +5 -0
- package/services/graphUtils.ts +347 -0
- package/services/imageService.ts +39 -0
- package/services/llmClient.ts +194 -0
- package/services/openAlexService.ts +173 -0
- package/services/wikipediaImage.ts +40 -0
- package/services/wikipediaService.ts +1175 -0
- package/sessionHandoff.ts +132 -0
- package/types.ts +99 -0
- package/useFullPageConstellationsHost.ts +116 -0
- package/utils/evidenceUtils.ts +107 -0
- package/utils/graphLogicUtils.ts +32 -0
- package/utils/graphNodeToChannelNotes.ts +71 -0
- package/utils/wikiUtils.ts +34 -0
|
@@ -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
|
+
}
|