@johndimm/constellations 1.0.2 → 1.0.4
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 +12 -0
- package/components/AppHeader.tsx +14 -17
- package/components/ControlPanel.tsx +306 -2
- package/components/HelpOverlay.tsx +1 -1
- package/hooks/useExpansion.ts +25 -2
- package/hooks/useSearchHandlers.ts +3 -2
- package/index.tsx +5 -3
- package/package.json +4 -3
- package/services/aiService.ts +19 -15
- package/services/aiUtils.ts +81 -0
- package/services/deepseekService.ts +75 -63
- package/services/geminiService.ts +16 -8
- package/services/wikipediaService.ts +23 -3
- package/sessionHandoff.ts +26 -0
package/App.tsx
CHANGED
|
@@ -669,6 +669,18 @@ const App: React.FC<AppProps> = ({
|
|
|
669
669
|
selectedKioskDomainId={selectedKioskDomainId}
|
|
670
670
|
onSelectKioskDomain={(id) => { setSelectedKioskDomainId(id); setPathStart(''); setPathEnd(''); }}
|
|
671
671
|
onUpdateKioskDomains={setKioskDomains}
|
|
672
|
+
onClear={handleClear}
|
|
673
|
+
onClearCache={cacheEnabled ? handleClearCache : undefined}
|
|
674
|
+
onExpandAllLeafNodes={handleExpandAllLeafNodes}
|
|
675
|
+
onSave={handleSaveGraph}
|
|
676
|
+
onLoad={(name) => handleLoadGraph(name, applyGraphData)}
|
|
677
|
+
onDeleteGraph={handleDeleteGraph}
|
|
678
|
+
onImport={(data) => handleImport(data, applyGraphData)}
|
|
679
|
+
savedGraphs={savedGraphs}
|
|
680
|
+
helpHover={helpHover}
|
|
681
|
+
onHelpHoverChange={setHelpHover}
|
|
682
|
+
onToggleHelp={() => setShowHelp(!showHelp)}
|
|
683
|
+
showHelp={showHelp}
|
|
672
684
|
isProcessing={isProcessing}
|
|
673
685
|
isCompact={isCompact}
|
|
674
686
|
onToggleCompact={() => setIsCompact(!isCompact)}
|
package/components/AppHeader.tsx
CHANGED
|
@@ -37,8 +37,6 @@ const AppHeader: React.FC<AppHeaderProps> = ({
|
|
|
37
37
|
}) => {
|
|
38
38
|
if (!showHeader) return null;
|
|
39
39
|
|
|
40
|
-
const showLeave = closeHref || onClose;
|
|
41
|
-
|
|
42
40
|
return (
|
|
43
41
|
<header
|
|
44
42
|
className={`absolute left-0 right-0 z-[200] h-14 max-h-14 min-h-14 shrink-0 pointer-events-auto bg-slate-900/95 backdrop-blur flex items-center justify-between px-2 sm:px-3 py-2 gap-2 overflow-x-hidden max-w-full ${offsetTopClass}`}
|
|
@@ -57,9 +55,19 @@ const AppHeader: React.FC<AppHeaderProps> = ({
|
|
|
57
55
|
>
|
|
58
56
|
{panelCollapsed ? <ChevronRight size={18} /> : <ChevronLeft size={18} />}
|
|
59
57
|
</button>
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
58
|
+
{closeHref ? (
|
|
59
|
+
<a
|
|
60
|
+
href={closeHref}
|
|
61
|
+
title="Film & Music — return to hub"
|
|
62
|
+
className="text-base sm:text-lg font-bold text-red-500 whitespace-nowrap hover:text-red-400 transition-colors"
|
|
63
|
+
>
|
|
64
|
+
Constellations
|
|
65
|
+
</a>
|
|
66
|
+
) : (
|
|
67
|
+
<span className="text-base sm:text-lg font-bold text-red-500 whitespace-nowrap">
|
|
68
|
+
Constellations
|
|
69
|
+
</span>
|
|
70
|
+
)}
|
|
63
71
|
</div>
|
|
64
72
|
<div className="flex items-center gap-2 sm:gap-3 flex-shrink-0 mr-1">
|
|
65
73
|
{selectedNode && (
|
|
@@ -79,18 +87,7 @@ const AppHeader: React.FC<AppHeaderProps> = ({
|
|
|
79
87
|
{sidebarCollapsed ? <ChevronLeft size={18} /> : <ChevronRight size={18} />}
|
|
80
88
|
</button>
|
|
81
89
|
)}
|
|
82
|
-
{
|
|
83
|
-
<a
|
|
84
|
-
href={closeHref}
|
|
85
|
-
onClick={onClose}
|
|
86
|
-
className="w-9 h-9 sm:w-10 sm:h-10 bg-slate-800/80 border border-slate-700 rounded-lg flex items-center justify-center text-slate-300 hover:text-white hover:border-slate-600 transition flex-shrink-0"
|
|
87
|
-
title="Return to the main app"
|
|
88
|
-
aria-label="Return to the main app"
|
|
89
|
-
>
|
|
90
|
-
<X size={20} strokeWidth={2} />
|
|
91
|
-
</a>
|
|
92
|
-
)}
|
|
93
|
-
{showLeave && !closeHref && onClose && (
|
|
90
|
+
{!closeHref && onClose && (
|
|
94
91
|
<button
|
|
95
92
|
type="button"
|
|
96
93
|
onClick={(e) => {
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import React, { useState, useEffect } from 'react';
|
|
3
|
-
import { Search, Minimize2, Maximize2, Calendar, Network, X, Link as LinkIcon, ArrowRight, Type, ChevronLeft, ChevronRight, ChevronDown, Settings } from 'lucide-react';
|
|
2
|
+
import React, { useState, useEffect, useRef } from 'react';
|
|
3
|
+
import { Search, Minimize2, Maximize2, Maximize, Calendar, Network, X, Link as LinkIcon, ArrowRight, Type, ChevronLeft, ChevronRight, ChevronDown, Settings, Download, Trash2, Share2, Copy, Upload, Plus, HelpCircle, Cpu } from 'lucide-react';
|
|
4
|
+
import type { LlmProviderId } from '../services/aiUtils';
|
|
5
|
+
import { getBrowserLlmOverride, setBrowserLlmOverride, getEnvCacheUrl } from '../services/aiUtils';
|
|
4
6
|
import { DEFAULT_KIOSK_DOMAINS, saveKioskDomains, saveSelectedKioskDomainId } from '../kioskDomains';
|
|
5
7
|
import type { KioskDomain } from '../kioskDomains';
|
|
6
8
|
|
|
@@ -22,6 +24,18 @@ interface ControlPanelProps {
|
|
|
22
24
|
selectedKioskDomainId?: string;
|
|
23
25
|
onSelectKioskDomain?: (domainId: string) => void;
|
|
24
26
|
onUpdateKioskDomains?: (domains: KioskDomain[]) => void;
|
|
27
|
+
onClear?: () => void;
|
|
28
|
+
onClearCache?: () => void;
|
|
29
|
+
onExpandAllLeafNodes?: () => void;
|
|
30
|
+
onSave?: (name: string) => void;
|
|
31
|
+
onLoad?: (name: string) => void;
|
|
32
|
+
onDeleteGraph?: (name: string) => void;
|
|
33
|
+
onImport?: (data: any) => void;
|
|
34
|
+
savedGraphs?: string[];
|
|
35
|
+
helpHover?: string | null;
|
|
36
|
+
onHelpHoverChange?: (value: string | null) => void;
|
|
37
|
+
onToggleHelp?: () => void;
|
|
38
|
+
showHelp?: boolean;
|
|
25
39
|
isProcessing: boolean;
|
|
26
40
|
isCompact: boolean;
|
|
27
41
|
onToggleCompact: () => void;
|
|
@@ -66,6 +80,18 @@ const ControlPanel: React.FC<ControlPanelProps> = ({
|
|
|
66
80
|
selectedKioskDomainId,
|
|
67
81
|
onSelectKioskDomain,
|
|
68
82
|
onUpdateKioskDomains,
|
|
83
|
+
onClear,
|
|
84
|
+
onClearCache,
|
|
85
|
+
onExpandAllLeafNodes,
|
|
86
|
+
onSave,
|
|
87
|
+
onLoad,
|
|
88
|
+
onDeleteGraph,
|
|
89
|
+
onImport,
|
|
90
|
+
savedGraphs = [],
|
|
91
|
+
helpHover,
|
|
92
|
+
onHelpHoverChange,
|
|
93
|
+
onToggleHelp,
|
|
94
|
+
showHelp,
|
|
69
95
|
isProcessing,
|
|
70
96
|
isCompact,
|
|
71
97
|
onToggleCompact,
|
|
@@ -88,12 +114,42 @@ const ControlPanel: React.FC<ControlPanelProps> = ({
|
|
|
88
114
|
const [newDomainLabel, setNewDomainLabel] = useState('');
|
|
89
115
|
const [newTerm, setNewTerm] = useState('');
|
|
90
116
|
const [bulkTerms, setBulkTerms] = useState('');
|
|
117
|
+
const [showSave, setShowSave] = useState(false);
|
|
118
|
+
const [showLoad, setShowLoad] = useState(false);
|
|
119
|
+
const [showShare, setShowShare] = useState(false);
|
|
120
|
+
const [saveName, setSaveName] = useState('');
|
|
121
|
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
122
|
+
const [llmSelectValue, setLlmSelectValue] = useState<'env' | LlmProviderId>(() => getBrowserLlmOverride() ?? 'env');
|
|
91
123
|
|
|
92
124
|
// Collapsible sections state - combined toggle for examples section
|
|
93
125
|
const [showExamples, setShowExamples] = useState(false);
|
|
94
126
|
|
|
95
127
|
const domainsImportRef = React.useRef<HTMLInputElement>(null);
|
|
96
128
|
|
|
129
|
+
const handleSaveSubmit = (e: React.FormEvent) => {
|
|
130
|
+
e.preventDefault();
|
|
131
|
+
if (saveName.trim() && onSave) {
|
|
132
|
+
onSave(saveName.trim());
|
|
133
|
+
setShowSave(false);
|
|
134
|
+
setSaveName('');
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const handleImportFile = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
139
|
+
const file = e.target.files?.[0];
|
|
140
|
+
if (!file || !onImport) return;
|
|
141
|
+
const reader = new FileReader();
|
|
142
|
+
reader.onload = (ev) => {
|
|
143
|
+
try {
|
|
144
|
+
const data = JSON.parse(ev.target?.result as string);
|
|
145
|
+
onImport(data);
|
|
146
|
+
setShowLoad(false);
|
|
147
|
+
} catch { alert('Invalid JSON file'); }
|
|
148
|
+
finally { e.target.value = ''; }
|
|
149
|
+
};
|
|
150
|
+
reader.readAsText(file);
|
|
151
|
+
};
|
|
152
|
+
|
|
97
153
|
const handleSubmit = (e: React.FormEvent) => {
|
|
98
154
|
e.preventDefault();
|
|
99
155
|
if (searchMode === 'explore') {
|
|
@@ -183,8 +239,65 @@ const ControlPanel: React.FC<ControlPanelProps> = ({
|
|
|
183
239
|
</div>
|
|
184
240
|
)}
|
|
185
241
|
|
|
242
|
+
{/* Dev docs links */}
|
|
243
|
+
<div className="mb-3 flex gap-3">
|
|
244
|
+
<a href="/doc/help.html" target="_blank" rel="noreferrer"
|
|
245
|
+
className="text-[10px] text-slate-500 hover:text-slate-300 transition-colors">
|
|
246
|
+
Help
|
|
247
|
+
</a>
|
|
248
|
+
<a href="/doc/prompt.html" target="_blank" rel="noreferrer"
|
|
249
|
+
className="text-[10px] text-slate-500 hover:text-slate-300 transition-colors">
|
|
250
|
+
Prompt
|
|
251
|
+
</a>
|
|
252
|
+
<a href="/doc/journal.html" target="_blank" rel="noreferrer"
|
|
253
|
+
className="text-[10px] text-slate-500 hover:text-slate-300 transition-colors">
|
|
254
|
+
Dev Journal
|
|
255
|
+
</a>
|
|
256
|
+
</div>
|
|
257
|
+
|
|
186
258
|
{/* Button Groups */}
|
|
187
259
|
<div className="space-y-4 mb-4">
|
|
260
|
+
{/* Group: File */}
|
|
261
|
+
{onSave && (
|
|
262
|
+
<div>
|
|
263
|
+
<div className="text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-1.5 flex items-center gap-1.5">
|
|
264
|
+
<Download size={10} /> File
|
|
265
|
+
</div>
|
|
266
|
+
<div className="flex flex-wrap gap-2 text-xs">
|
|
267
|
+
<button
|
|
268
|
+
onClick={() => {
|
|
269
|
+
let defaultName = searchMode === 'explore' && exploreTerm ? exploreTerm
|
|
270
|
+
: searchMode === 'connect' && pathStart && pathEnd ? `${pathStart} to ${pathEnd}`
|
|
271
|
+
: `Graph ${new Date().toLocaleTimeString()}`;
|
|
272
|
+
setSaveName(defaultName);
|
|
273
|
+
setShowSave(!showSave);
|
|
274
|
+
setShowLoad(false);
|
|
275
|
+
setShowShare(false);
|
|
276
|
+
onHelpHoverChange?.(null);
|
|
277
|
+
}}
|
|
278
|
+
className={`px-3 py-1.5 rounded-md border border-slate-700 bg-slate-800/80 text-slate-200 hover:text-amber-300 transition-colors ${helpHover === 'save' ? 'ring-2 ring-amber-400 ring-offset-2 ring-offset-slate-900' : ''}`}
|
|
279
|
+
title="Save Graph"
|
|
280
|
+
>
|
|
281
|
+
SAVE
|
|
282
|
+
</button>
|
|
283
|
+
<button
|
|
284
|
+
onClick={() => { setShowLoad(!showLoad); setShowSave(false); setShowShare(false); }}
|
|
285
|
+
className={`px-3 py-1.5 rounded-md border border-slate-700 bg-slate-800/80 text-slate-200 hover:text-amber-300 transition-colors ${helpHover === 'load' ? 'ring-2 ring-amber-400 ring-offset-2 ring-offset-slate-900' : ''}`}
|
|
286
|
+
title="Load Graph"
|
|
287
|
+
>
|
|
288
|
+
LOAD
|
|
289
|
+
</button>
|
|
290
|
+
<button
|
|
291
|
+
onClick={() => { setShowShare(!showShare); setShowSave(false); setShowLoad(false); onHelpHoverChange?.(null); }}
|
|
292
|
+
className={`px-3 py-1.5 rounded-md border border-slate-700 bg-slate-800/80 text-slate-200 hover:text-amber-300 transition-colors ${helpHover === 'share' ? 'ring-2 ring-amber-400 ring-offset-2 ring-offset-slate-900' : ''}`}
|
|
293
|
+
title="Share Graph"
|
|
294
|
+
>
|
|
295
|
+
SHARE
|
|
296
|
+
</button>
|
|
297
|
+
</div>
|
|
298
|
+
</div>
|
|
299
|
+
)}
|
|
300
|
+
|
|
188
301
|
{/* Group: View */}
|
|
189
302
|
<div>
|
|
190
303
|
<div className="text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-1.5 flex items-center gap-1.5">
|
|
@@ -220,8 +333,199 @@ const ControlPanel: React.FC<ControlPanelProps> = ({
|
|
|
220
333
|
</button>
|
|
221
334
|
</div>
|
|
222
335
|
</div>
|
|
336
|
+
|
|
337
|
+
{/* Group: LLM */}
|
|
338
|
+
<div>
|
|
339
|
+
<div className="text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-1.5 flex items-center gap-1.5">
|
|
340
|
+
<Cpu size={10} /> LLM
|
|
341
|
+
</div>
|
|
342
|
+
<select
|
|
343
|
+
value={llmSelectValue}
|
|
344
|
+
onChange={(e) => {
|
|
345
|
+
const v = e.target.value as 'env' | LlmProviderId;
|
|
346
|
+
setBrowserLlmOverride(v === 'env' ? null : v);
|
|
347
|
+
setLlmSelectValue(v);
|
|
348
|
+
}}
|
|
349
|
+
className="w-full bg-slate-900 border border-slate-600 text-slate-100 text-xs rounded-md px-2 py-2 focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
|
350
|
+
title="Model provider for AI calls from this tab"
|
|
351
|
+
>
|
|
352
|
+
<option value="env">Default (from .env)</option>
|
|
353
|
+
<option value="gemini">Google Gemini</option>
|
|
354
|
+
<option value="openai">OpenAI</option>
|
|
355
|
+
<option value="deepseek">DeepSeek</option>
|
|
356
|
+
<option value="anthropic">Anthropic</option>
|
|
357
|
+
</select>
|
|
358
|
+
{getEnvCacheUrl() && (
|
|
359
|
+
<p className="text-[9px] text-slate-500 mt-1 leading-snug">
|
|
360
|
+
Proxied — keys live on the server. Default uses{' '}
|
|
361
|
+
<span className="text-slate-400">LLM_PROVIDER</span> env var.
|
|
362
|
+
</p>
|
|
363
|
+
)}
|
|
364
|
+
</div>
|
|
365
|
+
|
|
366
|
+
{/* Group: Actions */}
|
|
367
|
+
{(onClear || onClearCache || onExpandAllLeafNodes || onToggleHelp) && (
|
|
368
|
+
<div>
|
|
369
|
+
<div className="text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-1.5 flex items-center gap-1.5">
|
|
370
|
+
<Plus size={10} /> Actions
|
|
371
|
+
</div>
|
|
372
|
+
<div className="flex flex-wrap gap-2 text-xs">
|
|
373
|
+
{onClear && (
|
|
374
|
+
<button
|
|
375
|
+
onClick={onClear}
|
|
376
|
+
className="text-slate-300 hover:text-red-300 p-1.5 rounded-md border border-slate-700 bg-slate-800/80 transition-colors"
|
|
377
|
+
title="Clear graph"
|
|
378
|
+
>
|
|
379
|
+
<Trash2 size={16} />
|
|
380
|
+
</button>
|
|
381
|
+
)}
|
|
382
|
+
{onClearCache && (
|
|
383
|
+
<button
|
|
384
|
+
onClick={onClearCache}
|
|
385
|
+
className="px-3 py-1.5 rounded-md border border-slate-700 bg-slate-800/80 text-slate-200 hover:text-orange-300 transition-colors"
|
|
386
|
+
title="Clear API cache (forces fresh data from LLM)"
|
|
387
|
+
>
|
|
388
|
+
CLEAR CACHE
|
|
389
|
+
</button>
|
|
390
|
+
)}
|
|
391
|
+
{onExpandAllLeafNodes && (
|
|
392
|
+
<button
|
|
393
|
+
onClick={onExpandAllLeafNodes}
|
|
394
|
+
disabled={isProcessing}
|
|
395
|
+
className="px-3 py-1.5 rounded-md border border-slate-700 bg-slate-800/80 text-slate-200 hover:text-emerald-300 inline-flex items-center gap-1 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
|
396
|
+
title="Expand all leaf nodes"
|
|
397
|
+
>
|
|
398
|
+
<Maximize size={14} className="text-emerald-400" />
|
|
399
|
+
EXPAND ALL
|
|
400
|
+
</button>
|
|
401
|
+
)}
|
|
402
|
+
{onToggleHelp && (
|
|
403
|
+
<button
|
|
404
|
+
onClick={onToggleHelp}
|
|
405
|
+
className={`px-3 py-1.5 rounded-md border border-slate-700 bg-slate-800/80 text-slate-200 hover:text-white flex items-center gap-1 transition-colors ${helpHover === 'help' ? 'ring-2 ring-amber-400 ring-offset-2 ring-offset-slate-900' : ''}`}
|
|
406
|
+
title="Help & Info"
|
|
407
|
+
>
|
|
408
|
+
<HelpCircle size={14} /> HELP
|
|
409
|
+
</button>
|
|
410
|
+
)}
|
|
411
|
+
</div>
|
|
412
|
+
</div>
|
|
413
|
+
)}
|
|
223
414
|
</div>
|
|
224
415
|
|
|
416
|
+
{/* Share Dialog */}
|
|
417
|
+
{showShare && onSave && (
|
|
418
|
+
<div className="mb-4 bg-slate-800 p-3 rounded-lg border border-slate-600 animate-in fade-in slide-in-from-top-2 duration-200">
|
|
419
|
+
<div className="flex justify-between items-center mb-3">
|
|
420
|
+
<h3 className="text-sm font-bold text-white flex items-center gap-2">
|
|
421
|
+
<Share2 size={14} /> Share Graph
|
|
422
|
+
</h3>
|
|
423
|
+
<button onClick={() => setShowShare(false)}><X size={14} className="text-slate-400" /></button>
|
|
424
|
+
</div>
|
|
425
|
+
<div className="grid grid-cols-3 gap-2">
|
|
426
|
+
<button
|
|
427
|
+
onClick={() => onSave('__COPY_LINK__')}
|
|
428
|
+
className="flex flex-col items-center justify-center gap-2 bg-slate-700 hover:bg-slate-600 text-white p-3 rounded-lg transition-colors border border-slate-600"
|
|
429
|
+
>
|
|
430
|
+
<LinkIcon size={20} className="text-orange-400" />
|
|
431
|
+
<span className="text-[10px] font-bold uppercase tracking-wider text-center">Copy Link</span>
|
|
432
|
+
</button>
|
|
433
|
+
<button
|
|
434
|
+
onClick={() => onSave('__COPY__')}
|
|
435
|
+
className="flex flex-col items-center justify-center gap-2 bg-slate-700 hover:bg-slate-600 text-white p-3 rounded-lg transition-colors border border-slate-600"
|
|
436
|
+
>
|
|
437
|
+
<Copy size={20} className="text-purple-400" />
|
|
438
|
+
<span className="text-[10px] font-bold uppercase tracking-wider text-center">Copy JSON</span>
|
|
439
|
+
</button>
|
|
440
|
+
<button
|
|
441
|
+
onClick={() => onSave('__EXPORT__')}
|
|
442
|
+
className="flex flex-col items-center justify-center gap-2 bg-slate-700 hover:bg-slate-600 text-white p-3 rounded-lg transition-colors border border-slate-600"
|
|
443
|
+
>
|
|
444
|
+
<Download size={20} className="text-indigo-400" />
|
|
445
|
+
<span className="text-[10px] font-bold uppercase tracking-wider text-center">Download File</span>
|
|
446
|
+
</button>
|
|
447
|
+
</div>
|
|
448
|
+
</div>
|
|
449
|
+
)}
|
|
450
|
+
|
|
451
|
+
{/* Save Dialog */}
|
|
452
|
+
{showSave && onSave && (
|
|
453
|
+
<div className="mb-4 bg-slate-800 p-3 rounded-lg border border-slate-600">
|
|
454
|
+
<div className="flex justify-between items-center mb-2">
|
|
455
|
+
<h3 className="text-sm font-bold text-white">Save Graph</h3>
|
|
456
|
+
<button onClick={() => setShowSave(false)}><X size={14} className="text-slate-400" /></button>
|
|
457
|
+
</div>
|
|
458
|
+
<form onSubmit={handleSaveSubmit} className="flex gap-2">
|
|
459
|
+
<input
|
|
460
|
+
type="text"
|
|
461
|
+
value={saveName}
|
|
462
|
+
onChange={(e) => setSaveName(e.target.value)}
|
|
463
|
+
placeholder="Graph Name..."
|
|
464
|
+
className="flex-1 bg-slate-900 border border-slate-700 text-white px-2 py-1 rounded text-sm focus:outline-none focus:border-indigo-500"
|
|
465
|
+
autoFocus
|
|
466
|
+
/>
|
|
467
|
+
<button type="submit" className="bg-green-600 hover:bg-green-500 text-white px-3 py-1 rounded text-sm font-medium">
|
|
468
|
+
Save
|
|
469
|
+
</button>
|
|
470
|
+
<button
|
|
471
|
+
type="button"
|
|
472
|
+
onClick={() => onSave('__EXPORT__')}
|
|
473
|
+
className="bg-indigo-600 hover:bg-indigo-500 text-white px-2 py-1 rounded text-sm font-medium flex items-center"
|
|
474
|
+
title="Export as JSON"
|
|
475
|
+
>
|
|
476
|
+
<Download size={14} />
|
|
477
|
+
</button>
|
|
478
|
+
</form>
|
|
479
|
+
</div>
|
|
480
|
+
)}
|
|
481
|
+
|
|
482
|
+
{/* Load Dialog */}
|
|
483
|
+
{showLoad && onLoad && (
|
|
484
|
+
<div className="mb-4 bg-slate-800 p-3 rounded-lg border border-slate-600 max-h-60 overflow-y-auto">
|
|
485
|
+
<div className="flex justify-between items-center mb-2">
|
|
486
|
+
<h3 className="text-sm font-bold text-white">Load Graph</h3>
|
|
487
|
+
<div className="flex items-center gap-2">
|
|
488
|
+
{onImport && (
|
|
489
|
+
<button
|
|
490
|
+
onClick={() => fileInputRef.current?.click()}
|
|
491
|
+
className="text-slate-400 hover:text-blue-400 flex items-center gap-1 text-xs"
|
|
492
|
+
title="Import JSON"
|
|
493
|
+
>
|
|
494
|
+
<Upload size={14} /> Import
|
|
495
|
+
</button>
|
|
496
|
+
)}
|
|
497
|
+
<button onClick={() => setShowLoad(false)}><X size={14} className="text-slate-400" /></button>
|
|
498
|
+
</div>
|
|
499
|
+
</div>
|
|
500
|
+
<input type="file" ref={fileInputRef} onChange={handleImportFile} accept=".json" className="hidden" />
|
|
501
|
+
{savedGraphs.length === 0 ? (
|
|
502
|
+
<p className="text-slate-400 text-xs italic">No saved graphs.</p>
|
|
503
|
+
) : (
|
|
504
|
+
<div className="space-y-1">
|
|
505
|
+
{savedGraphs.map(name => (
|
|
506
|
+
<div key={name} className="flex justify-between items-center bg-slate-900 p-2 rounded hover:bg-slate-700 group transition-colors">
|
|
507
|
+
<button
|
|
508
|
+
onClick={() => { onLoad(name); setShowLoad(false); }}
|
|
509
|
+
className="text-white text-sm text-left flex-1"
|
|
510
|
+
>
|
|
511
|
+
{name}
|
|
512
|
+
</button>
|
|
513
|
+
{onDeleteGraph && (
|
|
514
|
+
<button
|
|
515
|
+
onClick={(e) => { e.stopPropagation(); onDeleteGraph(name); setShowLoad(false); }}
|
|
516
|
+
className="text-slate-400 hover:text-red-400 transition-colors p-1.5 rounded-md hover:bg-slate-800"
|
|
517
|
+
title="Delete Graph"
|
|
518
|
+
>
|
|
519
|
+
<Trash2 size={14} />
|
|
520
|
+
</button>
|
|
521
|
+
)}
|
|
522
|
+
</div>
|
|
523
|
+
))}
|
|
524
|
+
</div>
|
|
525
|
+
)}
|
|
526
|
+
</div>
|
|
527
|
+
)}
|
|
528
|
+
|
|
225
529
|
</div>
|
|
226
530
|
|
|
227
531
|
<div onMouseEnter={() => setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} className="flex-1 min-h-0 flex flex-col overflow-hidden">
|
|
@@ -109,7 +109,7 @@ const HelpOverlay: React.FC<HelpOverlayProps> = ({
|
|
|
109
109
|
className="flex items-center gap-2 text-slate-300 hover:text-white transition-colors"
|
|
110
110
|
>
|
|
111
111
|
<LinkIcon size={14} className="text-slate-500" />
|
|
112
|
-
<span>
|
|
112
|
+
<span>Prompt</span>
|
|
113
113
|
</a>
|
|
114
114
|
<a
|
|
115
115
|
href="/doc/api_queries.html"
|
package/hooks/useExpansion.ts
CHANGED
|
@@ -182,9 +182,21 @@ export function useExpansion(options: UseExpansionOptions) {
|
|
|
182
182
|
// and vice versa.
|
|
183
183
|
const parentIsAtomic = !!(node.is_atomic ?? (node as any).is_person ?? (node.type || '').toLowerCase() === 'person');
|
|
184
184
|
const expectedChildIsAtomic = !parentIsAtomic;
|
|
185
|
+
|
|
186
|
+
// Validate cache semantic consistency: if most cached children look like the
|
|
187
|
+
// wrong bipartite type (e.g. persons cached from when this node was an Event,
|
|
188
|
+
// but now it's a Person), skip the cache so the LLM fetches fresh data.
|
|
189
|
+
const ATOMIC_TYPE_WORDS = new Set(['person', 'actor', 'author', 'director', 'artist', 'musician', 'character', 'scientist', 'philosopher', 'researcher', 'composer', 'photographer']);
|
|
190
|
+
const atomicLookingCount = upgraded.filter((cn: any) => ATOMIC_TYPE_WORDS.has((cn.type || '').toLowerCase())).length;
|
|
191
|
+
const mostlyCachedAreAtomic = upgraded.length > 0 && atomicLookingCount > upgraded.length / 2;
|
|
192
|
+
const cacheSemanticValid = mostlyCachedAreAtomic === expectedChildIsAtomic;
|
|
193
|
+
if (!cacheSemanticValid) {
|
|
194
|
+
console.warn(`[useExpansion] Cache bypassed: cached nodes are mostly ${mostlyCachedAreAtomic ? 'atomic' : 'composite'} but expected ${expectedChildIsAtomic ? 'atomic' : 'composite'} for "${node.title}"`);
|
|
195
|
+
}
|
|
196
|
+
|
|
185
197
|
validCached = upgraded.map((cn: any) => ({ ...cn, is_atomic: expectedChildIsAtomic }));
|
|
186
198
|
|
|
187
|
-
if (validCached.length >= 5) {
|
|
199
|
+
if (cacheSemanticValid && validCached.length >= 5) {
|
|
188
200
|
const existingNodeIdsBefore = new Set(graphDataRef.current.nodes.map(n => String(n.id)));
|
|
189
201
|
const newChildIds: (string | number)[] = validCached.filter(cn => !existingNodeIdsBefore.has(String(cn.id))).map(cn => cn.id);
|
|
190
202
|
// Include ALL connected nodes for highlighting, not just new ones
|
|
@@ -538,6 +550,13 @@ export function useExpansion(options: UseExpansionOptions) {
|
|
|
538
550
|
let nodesToUse = resultsWithWiki;
|
|
539
551
|
if (!exploreTerm.toLowerCase().startsWith('list of ')) nodesToUse = nodesToUse.filter((n: any) => !isBadListPage(n.title));
|
|
540
552
|
|
|
553
|
+
// Preserve LLM-assigned is_atomic before cache fetch may overwrite with stale DB values.
|
|
554
|
+
const freshAtomicByTitle = new Map<string, boolean>(
|
|
555
|
+
resultsWithWiki
|
|
556
|
+
.filter((cn: any) => typeof cn.is_atomic === 'boolean')
|
|
557
|
+
.map((cn: any) => [String(cn.title || '').toLowerCase(), Boolean(cn.is_atomic)])
|
|
558
|
+
);
|
|
559
|
+
|
|
541
560
|
let finalIDMap: Record<string, number> | undefined;
|
|
542
561
|
if (cacheEnabled) {
|
|
543
562
|
let combinedNodes = [...resultsWithWiki];
|
|
@@ -609,7 +628,11 @@ export function useExpansion(options: UseExpansionOptions) {
|
|
|
609
628
|
const existing = nodeMap.get(String(cn.id));
|
|
610
629
|
nodeMap.set(String(cn.id), {
|
|
611
630
|
id: cn.id, title: cn.title, type: cn.type,
|
|
612
|
-
|
|
631
|
+
// Prefer LLM-assigned is_atomic (most accurate); fall back to bipartite inference.
|
|
632
|
+
// DB is_atomic is unreliable (can be stale from a prior wrong classification).
|
|
633
|
+
is_atomic: freshAtomicByTitle.has(String(cn.title || '').toLowerCase())
|
|
634
|
+
? freshAtomicByTitle.get(String(cn.title || '').toLowerCase())!
|
|
635
|
+
: expectedChildIsAtomic,
|
|
613
636
|
wikipedia_id: cn.wikipedia_id, description: cn.description || existing?.description || "",
|
|
614
637
|
year: cn.year ?? existing?.year, imageUrl: meta.imageUrl ?? existing?.imageUrl,
|
|
615
638
|
imageChecked: !!(meta.imageUrl ?? existing?.imageUrl) || existing?.imageChecked,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import React, { useState, useCallback, useEffect, useRef } from 'react';
|
|
3
3
|
import { GraphNode, GraphLink } from '../types';
|
|
4
|
-
import { classifyStartPair, fetchConnectionPath, LockedPair, classifyEntity, fetchConnections } from '../services/
|
|
4
|
+
import { classifyStartPair, fetchConnectionPath, LockedPair, classifyEntity, fetchConnections } from '../services/aiService';
|
|
5
5
|
import { fetchWikipediaSummary } from '../services/wikipediaService';
|
|
6
6
|
import { dedupeGraph, normalizeForDedup } from '../services/graphUtils';
|
|
7
7
|
import { clampToViewport } from '../utils/graphLogicUtils';
|
|
@@ -147,8 +147,10 @@ export function useSearchHandlers(options: UseSearchHandlersOptions) {
|
|
|
147
147
|
|
|
148
148
|
const dim = dimensionsRef.current;
|
|
149
149
|
const startNode: GraphNode = {
|
|
150
|
+
...nodeData,
|
|
150
151
|
id: nodeData.id,
|
|
151
152
|
title: canonicalTitle,
|
|
153
|
+
// Fresh classification always wins over stale DB values.
|
|
152
154
|
type,
|
|
153
155
|
is_atomic: isAtomic,
|
|
154
156
|
wikipedia_id: wiki.pageid?.toString(),
|
|
@@ -161,7 +163,6 @@ export function useSearchHandlers(options: UseSearchHandlersOptions) {
|
|
|
161
163
|
atomic_type: chosenPair.atomicType,
|
|
162
164
|
composite_type: chosenPair.compositeType,
|
|
163
165
|
imageUrl: nodeData.imageUrl || nodeData.image_url,
|
|
164
|
-
...nodeData
|
|
165
166
|
};
|
|
166
167
|
|
|
167
168
|
setGraphData({ nodes: [startNode], links: [] });
|
package/index.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import ReactDOM from 'react-dom/client';
|
|
3
|
-
import App from '
|
|
3
|
+
import App from '@johndimm/constellations/App';
|
|
4
4
|
import './index.css';
|
|
5
5
|
|
|
6
6
|
const rootElement = document.getElementById('root');
|
|
@@ -9,8 +9,10 @@ if (!rootElement) {
|
|
|
9
9
|
}
|
|
10
10
|
|
|
11
11
|
const root = ReactDOM.createRoot(rootElement);
|
|
12
|
+
const hubUrl: string = import.meta.env.VITE_HUB_URL || "http://127.0.0.1:8000";
|
|
13
|
+
|
|
12
14
|
root.render(
|
|
13
15
|
<React.StrictMode>
|
|
14
|
-
<App />
|
|
16
|
+
<App closeHref={hubUrl} />
|
|
15
17
|
</React.StrictMode>
|
|
16
|
-
);
|
|
18
|
+
);
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@johndimm/constellations",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.4",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./index.tsx",
|
|
6
6
|
"exports": {
|
|
7
|
-
".": "./
|
|
7
|
+
".": "./host.ts",
|
|
8
8
|
"./App": "./App.tsx",
|
|
9
9
|
"./FullPageConstellations": "./FullPageConstellations.tsx",
|
|
10
10
|
"./host": "./host.ts",
|
|
@@ -52,6 +52,8 @@
|
|
|
52
52
|
},
|
|
53
53
|
"dependencies": {
|
|
54
54
|
"@google/genai": "^1.33.0",
|
|
55
|
+
"@johndimm/constellations": "^1.0.2",
|
|
56
|
+
"@types/d3": "^7.4.3",
|
|
55
57
|
"d3": "^7.9.0",
|
|
56
58
|
"dotenv": "^16.4.5",
|
|
57
59
|
"lucide-react": "^0.560.0"
|
|
@@ -60,7 +62,6 @@
|
|
|
60
62
|
"@tailwindcss/postcss": "^4.1.18",
|
|
61
63
|
"@tailwindcss/vite": "^4.1.18",
|
|
62
64
|
"@types/chrome": "^0.1.36",
|
|
63
|
-
"@types/d3": "^7.4.3",
|
|
64
65
|
"@types/node": "^22.14.0",
|
|
65
66
|
"@vitejs/plugin-react": "^5.0.0",
|
|
66
67
|
"autoprefixer": "^10.4.23",
|
package/services/aiService.ts
CHANGED
|
@@ -1,23 +1,27 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Provider dispatcher.
|
|
3
|
-
*
|
|
2
|
+
* Provider dispatcher. gemini → geminiService; everything else →
|
|
3
|
+
* deepseekService (which re-reads getLlmProvider() inside callAltLlm
|
|
4
|
+
* to pick the right API: deepseek / openai / anthropic).
|
|
4
5
|
*/
|
|
5
|
-
import {
|
|
6
|
-
import * as
|
|
7
|
-
import * as
|
|
6
|
+
import { getLlmProvider } from "./aiUtils";
|
|
7
|
+
import * as geminiSvc from "./geminiService";
|
|
8
|
+
import * as altSvc from "./deepseekService"; // handles deepseek, openai, anthropic
|
|
8
9
|
|
|
9
10
|
export * from "./aiUtils";
|
|
10
11
|
export type { LockedPair } from "./geminiService";
|
|
11
12
|
|
|
12
|
-
|
|
13
|
-
const
|
|
13
|
+
function getSvc(fn: string) {
|
|
14
|
+
const p = getLlmProvider();
|
|
15
|
+
console.info(`[LLM] ${p} · ${fn}`);
|
|
16
|
+
return p === "gemini" ? geminiSvc : altSvc;
|
|
17
|
+
}
|
|
14
18
|
|
|
15
|
-
export const classifyStartPair = (...args: Parameters<typeof
|
|
16
|
-
export const classifyEntity = (...args: Parameters<typeof
|
|
17
|
-
export const fetchConnections = (...args: Parameters<typeof
|
|
18
|
-
export const fetchPersonWorks = (...args: Parameters<typeof
|
|
19
|
-
export const fetchConnectionPath = (...args: Parameters<typeof
|
|
20
|
-
export const findWikipediaTitle = (...args: Parameters<typeof
|
|
19
|
+
export const classifyStartPair = (...args: Parameters<typeof geminiSvc.classifyStartPair>) => getSvc("classifyStartPair").classifyStartPair(...args);
|
|
20
|
+
export const classifyEntity = (...args: Parameters<typeof geminiSvc.classifyEntity>) => getSvc("classifyEntity").classifyEntity(...args);
|
|
21
|
+
export const fetchConnections = (...args: Parameters<typeof geminiSvc.fetchConnections>) => getSvc("fetchConnections").fetchConnections(...args);
|
|
22
|
+
export const fetchPersonWorks = (...args: Parameters<typeof geminiSvc.fetchPersonWorks>) => getSvc("fetchPersonWorks").fetchPersonWorks(...args);
|
|
23
|
+
export const fetchConnectionPath = (...args: Parameters<typeof geminiSvc.fetchConnectionPath>) => getSvc("fetchConnectionPath").fetchConnectionPath(...args);
|
|
24
|
+
export const findWikipediaTitle = (...args: Parameters<typeof geminiSvc.findWikipediaTitle>) => getSvc("findWikipediaTitle").findWikipediaTitle(...args);
|
|
21
25
|
// Always uses Gemini — relies on Google Search grounding which is Gemini-specific.
|
|
22
|
-
export const fetchOrgKeyPeopleBlockViaSearch =
|
|
23
|
-
export const defaultStartPairResult = (...args: Parameters<typeof
|
|
26
|
+
export const fetchOrgKeyPeopleBlockViaSearch = geminiSvc.fetchOrgKeyPeopleBlockViaSearch;
|
|
27
|
+
export const defaultStartPairResult = (...args: Parameters<typeof geminiSvc.defaultStartPairResult>) => getSvc("defaultStartPairResult").defaultStartPairResult(...args);
|
package/services/aiUtils.ts
CHANGED
|
@@ -337,6 +337,87 @@ export function withTimeout<T>(promise: Promise<T>, ms: number, errorMsg: string
|
|
|
337
337
|
});
|
|
338
338
|
}
|
|
339
339
|
|
|
340
|
+
export function clipForLlmLog(text: string, maxChars = 16000): string {
|
|
341
|
+
const s = String(text ?? "");
|
|
342
|
+
if (s.length <= maxChars) return s;
|
|
343
|
+
return `${s.slice(0, maxChars)}\n… [truncated ${s.length - maxChars} more chars]`;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export function isRateLimitError(e: any): boolean {
|
|
347
|
+
if (e?.error?.code === 429 || e?.code === 429) return true;
|
|
348
|
+
const s = String(e?.error?.status || "").toLowerCase();
|
|
349
|
+
if (s === "resource_exhausted") return true;
|
|
350
|
+
const t = [e?.message, e?.error, e?.status, e?.code, typeof e === "string" ? e : ""]
|
|
351
|
+
.map(x => (typeof x === "object" ? JSON.stringify(x) : String(x ?? "")))
|
|
352
|
+
.join(" ")
|
|
353
|
+
.toLowerCase();
|
|
354
|
+
return t.includes("429") || t.includes("resource_exhausted");
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
export type LlmProviderId = "gemini" | "deepseek" | "openai" | "anthropic";
|
|
358
|
+
|
|
359
|
+
const BROWSER_LLM_KEY = "constellations_llm_provider";
|
|
360
|
+
|
|
361
|
+
function isValidProvider(v: string): v is LlmProviderId {
|
|
362
|
+
return v === "gemini" || v === "deepseek" || v === "openai" || v === "anthropic";
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
export function getBrowserLlmOverride(): LlmProviderId | null {
|
|
366
|
+
if (typeof window === "undefined") return null;
|
|
367
|
+
try {
|
|
368
|
+
const v = window.localStorage.getItem(BROWSER_LLM_KEY)?.trim().toLowerCase() ?? "";
|
|
369
|
+
if (isValidProvider(v)) return v;
|
|
370
|
+
} catch {}
|
|
371
|
+
return null;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
export function setBrowserLlmOverride(provider: LlmProviderId | null): void {
|
|
375
|
+
if (typeof window === "undefined") return;
|
|
376
|
+
try {
|
|
377
|
+
if (provider === null) {
|
|
378
|
+
window.localStorage.removeItem(BROWSER_LLM_KEY);
|
|
379
|
+
} else {
|
|
380
|
+
window.localStorage.setItem(BROWSER_LLM_KEY, provider);
|
|
381
|
+
}
|
|
382
|
+
} catch {}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Server-side per-request override (Node.js module memory, set before each proxy call).
|
|
386
|
+
// This is intentionally simple — dev server is single-user so concurrent-request races are fine.
|
|
387
|
+
let _serverLlmOverride: LlmProviderId | null = null;
|
|
388
|
+
|
|
389
|
+
export function setServerLlmOverride(provider: LlmProviderId | null): void {
|
|
390
|
+
_serverLlmOverride = provider;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
export function getLlmProvider(): LlmProviderId {
|
|
394
|
+
if (_serverLlmOverride) return _serverLlmOverride;
|
|
395
|
+
const browser = getBrowserLlmOverride();
|
|
396
|
+
if (browser) return browser;
|
|
397
|
+
const raw = (readBundledEnv("VITE_AI_PROVIDER") || "deepseek").trim().toLowerCase();
|
|
398
|
+
return isValidProvider(raw) ? raw : "deepseek";
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Returns true if `term` is likely a person name (2–4 Title-Case words, no parens or digits,
|
|
403
|
+
* no leading article). Used as a sanity-check on LLM classification results and fallbacks.
|
|
404
|
+
* False-positives (e.g. "Star Wars") can happen, but this is only consulted when the model
|
|
405
|
+
* returns or falls back to isAtomic=false, so the worst case is a wrong default that the user
|
|
406
|
+
* can easily correct by re-searching with a disambiguated term.
|
|
407
|
+
*/
|
|
408
|
+
export function looksLikePersonName(term: string): boolean {
|
|
409
|
+
const t = term.trim();
|
|
410
|
+
if (/[()[\]{}]/.test(t)) return false; // parenthetical tags → work title
|
|
411
|
+
if (/\d/.test(t)) return false; // digits → year / track number
|
|
412
|
+
const words = t.split(/\s+/);
|
|
413
|
+
if (words.length < 2 || words.length > 4) return false;
|
|
414
|
+
// Leading stopwords rule out "The Godfather", "A Star Is Born", etc.
|
|
415
|
+
if (/^(the|a|an|of|in|on|at|to|for|with|by|la|le|les|el|los|das|der|die)$/i.test(words[0])) return false;
|
|
416
|
+
// Each word: Title-Case word (≥2 chars), single initial with period, or name suffix
|
|
417
|
+
const nameWordRe = /^[A-Z][a-z'-]{1,}\.?$|^[A-Z]\.$|^(Jr|Sr|II|III|IV|VI|VII|VIII|IX)\.?$/;
|
|
418
|
+
return words.every(w => nameWordRe.test(w));
|
|
419
|
+
}
|
|
420
|
+
|
|
340
421
|
// Improved retry logic with exponential backoff and jitter
|
|
341
422
|
export async function withRetry<T>(fn: () => Promise<T>, attempts = 3, backoffMs = 1000): Promise<T> {
|
|
342
423
|
let lastError: any;
|
|
@@ -1,20 +1,13 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { GeminiResponse, PersonWorksResponse, PathResponse } from "../types";
|
|
3
|
-
import { parseJsonFromModelText, withTimeout, withRetry, getEnvCacheUrl, readBundledEnv } from "./aiUtils";
|
|
3
|
+
import { parseJsonFromModelText, withTimeout, withRetry, getEnvCacheUrl, readBundledEnv, getLlmProvider, looksLikePersonName } from "./aiUtils";
|
|
4
4
|
import type { LockedPair } from "./geminiService";
|
|
5
5
|
|
|
6
6
|
export type { LockedPair };
|
|
7
7
|
|
|
8
|
-
const DEEPSEEK_API_URL = "https://api.deepseek.com/chat/completions";
|
|
9
|
-
const DEFAULT_MODEL = "deepseek-chat";
|
|
10
|
-
|
|
11
8
|
const TIMEOUT_MS = 60000;
|
|
12
9
|
const CLASSIFY_TIMEOUT_MS = 15000;
|
|
13
10
|
|
|
14
|
-
function getDeepSeekApiKey(): string {
|
|
15
|
-
return readBundledEnv("VITE_DEEPSEEK_API_KEY");
|
|
16
|
-
}
|
|
17
|
-
|
|
18
11
|
function shouldProxy(): boolean {
|
|
19
12
|
if (typeof window === "undefined") return false;
|
|
20
13
|
if ((window as any).__PRERENDER_INJECTED) return false;
|
|
@@ -27,24 +20,60 @@ async function callAiProxy(endpoint: string, body: any) {
|
|
|
27
20
|
const resp = await fetch(url, {
|
|
28
21
|
method: "POST",
|
|
29
22
|
headers: { "Content-Type": "application/json" },
|
|
30
|
-
body: JSON.stringify(body),
|
|
23
|
+
body: JSON.stringify({ ...body, llmProvider: getLlmProvider() }),
|
|
31
24
|
});
|
|
32
25
|
if (!resp.ok) throw new Error(`AI Proxy Error (${resp.status}): ${await resp.text()}`);
|
|
33
26
|
return resp.json();
|
|
34
27
|
}
|
|
35
28
|
|
|
36
|
-
async function
|
|
37
|
-
const
|
|
38
|
-
|
|
29
|
+
async function callAltLlm(system: string, user: string, timeoutMs = TIMEOUT_MS): Promise<string> {
|
|
30
|
+
const provider = getLlmProvider();
|
|
31
|
+
|
|
32
|
+
if (provider === "anthropic") {
|
|
33
|
+
const key = readBundledEnv("VITE_ANTHROPIC_API_KEY");
|
|
34
|
+
if (!key) throw new Error("No VITE_ANTHROPIC_API_KEY set");
|
|
35
|
+
const model = readBundledEnv("VITE_ANTHROPIC_MODEL") || "claude-3-5-haiku-20241022";
|
|
36
|
+
const res = await fetch("https://api.anthropic.com/v1/messages", {
|
|
37
|
+
method: "POST",
|
|
38
|
+
headers: {
|
|
39
|
+
"x-api-key": key,
|
|
40
|
+
"anthropic-version": "2023-06-01",
|
|
41
|
+
"content-type": "application/json",
|
|
42
|
+
},
|
|
43
|
+
body: JSON.stringify({
|
|
44
|
+
model,
|
|
45
|
+
max_tokens: 8192,
|
|
46
|
+
...(system.trim() ? { system: system.trim() } : {}),
|
|
47
|
+
messages: [{ role: "user", content: `${user}\n\nReply with a single valid JSON object only. No markdown, no commentary.` }],
|
|
48
|
+
}),
|
|
49
|
+
});
|
|
50
|
+
if (!res.ok) {
|
|
51
|
+
const err = await res.text();
|
|
52
|
+
throw new Error(`Anthropic API error (${res.status}): ${err}`);
|
|
53
|
+
}
|
|
54
|
+
const data = await res.json();
|
|
55
|
+
const block = data?.content?.[0];
|
|
56
|
+
return block?.type === "text" ? block.text : "";
|
|
57
|
+
}
|
|
39
58
|
|
|
40
|
-
|
|
59
|
+
// OpenAI-compatible: openai or deepseek
|
|
60
|
+
const isOpenAI = provider === "openai";
|
|
61
|
+
const baseUrl = isOpenAI
|
|
62
|
+
? (readBundledEnv("VITE_OPENAI_BASE_URL") || "https://api.openai.com/v1")
|
|
63
|
+
: (readBundledEnv("VITE_DEEPSEEK_BASE_URL") || "https://api.deepseek.com/v1");
|
|
64
|
+
const model = isOpenAI
|
|
65
|
+
? (readBundledEnv("VITE_OPENAI_MODEL") || "gpt-4o-mini")
|
|
66
|
+
: (readBundledEnv("VITE_DEEPSEEK_MODEL") || "deepseek-chat");
|
|
67
|
+
const key = isOpenAI
|
|
68
|
+
? readBundledEnv("VITE_OPENAI_API_KEY")
|
|
69
|
+
: readBundledEnv("VITE_DEEPSEEK_API_KEY");
|
|
70
|
+
if (!key) throw new Error(`No API key set for ${provider}`);
|
|
71
|
+
|
|
72
|
+
const res = await fetch(`${baseUrl.replace(/\/$/, "")}/chat/completions`, {
|
|
41
73
|
method: "POST",
|
|
42
|
-
headers: {
|
|
43
|
-
"Content-Type": "application/json",
|
|
44
|
-
Authorization: `Bearer ${apiKey}`,
|
|
45
|
-
},
|
|
74
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${key}` },
|
|
46
75
|
body: JSON.stringify({
|
|
47
|
-
model
|
|
76
|
+
model,
|
|
48
77
|
messages: [
|
|
49
78
|
{ role: "system", content: system },
|
|
50
79
|
{ role: "user", content: user },
|
|
@@ -52,16 +81,26 @@ async function callDeepSeek(system: string, user: string, timeoutMs = TIMEOUT_MS
|
|
|
52
81
|
response_format: { type: "json_object" },
|
|
53
82
|
}),
|
|
54
83
|
});
|
|
55
|
-
|
|
56
84
|
if (!res.ok) {
|
|
57
85
|
const err = await res.text();
|
|
58
|
-
throw new Error(
|
|
86
|
+
throw new Error(`${provider} API error (${res.status}): ${err}`);
|
|
59
87
|
}
|
|
60
|
-
|
|
61
88
|
const data = await res.json();
|
|
62
89
|
return data.choices?.[0]?.message?.content ?? "";
|
|
63
90
|
}
|
|
64
91
|
|
|
92
|
+
export const defaultStartPairResult = (reason: string, term?: string) => {
|
|
93
|
+
const isPerson = term ? looksLikePersonName(term) : false;
|
|
94
|
+
return {
|
|
95
|
+
type: isPerson ? "Person" : "Event",
|
|
96
|
+
description: "",
|
|
97
|
+
isAtomic: isPerson,
|
|
98
|
+
atomicType: "Person",
|
|
99
|
+
compositeType: "Event",
|
|
100
|
+
reasoning: reason,
|
|
101
|
+
};
|
|
102
|
+
};
|
|
103
|
+
|
|
65
104
|
const SYSTEM_INSTRUCTION = `
|
|
66
105
|
You are a Bipartite Graph Generator.
|
|
67
106
|
Your goal is to build a graph that alternates between an "Atomic" type and a "Composite" type.
|
|
@@ -132,19 +171,16 @@ export const classifyStartPair = async (
|
|
|
132
171
|
compositeType: string;
|
|
133
172
|
reasoning: string;
|
|
134
173
|
}> => {
|
|
135
|
-
const fallback =
|
|
136
|
-
type: "Event",
|
|
137
|
-
description: "",
|
|
138
|
-
isAtomic: false,
|
|
139
|
-
atomicType: "Person",
|
|
140
|
-
compositeType: "Event",
|
|
141
|
-
reasoning: "Default fallback.",
|
|
142
|
-
};
|
|
143
|
-
|
|
144
|
-
if (shouldProxy()) return callAiProxy("/api/ai/classify-start", { term, wikiContext });
|
|
174
|
+
const fallback = defaultStartPairResult("Default fallback.", term);
|
|
145
175
|
|
|
146
|
-
|
|
147
|
-
|
|
176
|
+
if (shouldProxy()) {
|
|
177
|
+
const proxyResult = await callAiProxy("/api/ai/classify-start", { term, wikiContext });
|
|
178
|
+
if (!proxyResult.isAtomic && looksLikePersonName(term)) {
|
|
179
|
+
console.warn("[classifyStartPair] proxy returned isAtomic=false for apparent person name; overriding", term);
|
|
180
|
+
return { ...proxyResult, isAtomic: true, type: "Person" };
|
|
181
|
+
}
|
|
182
|
+
return proxyResult;
|
|
183
|
+
}
|
|
148
184
|
|
|
149
185
|
const prompt = `Choose the most appropriate bipartite pair for: "${term}".
|
|
150
186
|
|
|
@@ -165,7 +201,7 @@ Return JSON with exactly these fields:
|
|
|
165
201
|
|
|
166
202
|
try {
|
|
167
203
|
const raw = await withTimeout(
|
|
168
|
-
withRetry(() =>
|
|
204
|
+
withRetry(() => callAltLlm(SYSTEM_INSTRUCTION, prompt), 3, 1000),
|
|
169
205
|
CLASSIFY_TIMEOUT_MS,
|
|
170
206
|
"classifyStartPair timed out"
|
|
171
207
|
);
|
|
@@ -201,9 +237,6 @@ export const classifyEntity = async (
|
|
|
201
237
|
|
|
202
238
|
if (shouldProxy()) return callAiProxy("/api/ai/classify", { term, wikiContext });
|
|
203
239
|
|
|
204
|
-
const apiKey = getDeepSeekApiKey();
|
|
205
|
-
if (!apiKey) return fallback;
|
|
206
|
-
|
|
207
240
|
const wikiPrompt = wikiContext ? `\n\nUSE THIS VERIFIED INFORMATION:\n${wikiContext}\n` : "";
|
|
208
241
|
|
|
209
242
|
const prompt = `Classify "${term}".${wikiPrompt}
|
|
@@ -222,7 +255,7 @@ Return JSON:
|
|
|
222
255
|
|
|
223
256
|
try {
|
|
224
257
|
const raw = await withRetry(
|
|
225
|
-
() => withTimeout(
|
|
258
|
+
() => withTimeout(callAltLlm(SYSTEM_INSTRUCTION, prompt), CLASSIFY_TIMEOUT_MS, "classifyEntity timed out"),
|
|
226
259
|
3,
|
|
227
260
|
1000
|
|
228
261
|
);
|
|
@@ -256,9 +289,6 @@ export const fetchConnections = async (
|
|
|
256
289
|
return callAiProxy("/api/ai/connections", { nodeName, context, excludeNodes, wikiContext, wikipediaId, atomicType, compositeType, mentioningPageTitles });
|
|
257
290
|
}
|
|
258
291
|
|
|
259
|
-
const apiKey = getDeepSeekApiKey();
|
|
260
|
-
if (!apiKey) return { people: [] };
|
|
261
|
-
|
|
262
292
|
const atomicLabel = atomicType || "ATOMIC entity";
|
|
263
293
|
const compositeLabel = compositeType || "COMPOSITE entity";
|
|
264
294
|
const wikiIdStr = wikipediaId ? ` (Wikipedia ID: ${wikipediaId})` : "";
|
|
@@ -299,7 +329,7 @@ Return JSON:
|
|
|
299
329
|
|
|
300
330
|
try {
|
|
301
331
|
const raw = await withRetry(
|
|
302
|
-
() => withTimeout(
|
|
332
|
+
() => withTimeout(callAltLlm(SYSTEM_INSTRUCTION, prompt), TIMEOUT_MS, "fetchConnections timed out"),
|
|
303
333
|
4,
|
|
304
334
|
1000
|
|
305
335
|
);
|
|
@@ -328,9 +358,6 @@ export const fetchPersonWorks = async (
|
|
|
328
358
|
return callAiProxy("/api/ai/works", { nodeName, excludeNodes, wikiContext, wikipediaId, atomicType, compositeType, mentioningPageTitles });
|
|
329
359
|
}
|
|
330
360
|
|
|
331
|
-
const apiKey = getDeepSeekApiKey();
|
|
332
|
-
if (!apiKey) return { works: [] };
|
|
333
|
-
|
|
334
361
|
const atomicLabel = atomicType || "ATOMIC entity";
|
|
335
362
|
const compositeLabel = compositeType || "COMPOSITE entity";
|
|
336
363
|
const wikiIdStr = wikipediaId ? ` (Wikipedia ID: ${wikipediaId})` : "";
|
|
@@ -365,7 +392,7 @@ Return JSON:
|
|
|
365
392
|
|
|
366
393
|
try {
|
|
367
394
|
const raw = await withRetry(
|
|
368
|
-
() => withTimeout(
|
|
395
|
+
() => withTimeout(callAltLlm(SYSTEM_INSTRUCTION, prompt), TIMEOUT_MS, "fetchPersonWorks timed out"),
|
|
369
396
|
4,
|
|
370
397
|
1000
|
|
371
398
|
);
|
|
@@ -388,9 +415,6 @@ export const fetchConnectionPath = async (
|
|
|
388
415
|
): Promise<PathResponse> => {
|
|
389
416
|
if (shouldProxy()) return callAiProxy("/api/ai/path", { start, end, context });
|
|
390
417
|
|
|
391
|
-
const apiKey = getDeepSeekApiKey();
|
|
392
|
-
if (!apiKey) return { path: [], found: false };
|
|
393
|
-
|
|
394
418
|
const wikiPrompt = (context?.startWiki || context?.endWiki)
|
|
395
419
|
? `\n\nVERIFIED INFO:\n${context?.startWiki ? `[${start}]: ${context.startWiki}\n` : ""}${context?.endWiki ? `[${end}]: ${context.endWiki}\n` : ""}`
|
|
396
420
|
: "";
|
|
@@ -412,7 +436,7 @@ Return JSON:
|
|
|
412
436
|
|
|
413
437
|
try {
|
|
414
438
|
const raw = await withTimeout(
|
|
415
|
-
|
|
439
|
+
callAltLlm(SYSTEM_INSTRUCTION, prompt),
|
|
416
440
|
45000,
|
|
417
441
|
"fetchConnectionPath timed out"
|
|
418
442
|
);
|
|
@@ -431,9 +455,6 @@ export const findWikipediaTitle = async (
|
|
|
431
455
|
): Promise<{ title: string; imageHint?: string } | null> => {
|
|
432
456
|
if (shouldProxy()) return callAiProxy("/api/ai/title", { name, description });
|
|
433
457
|
|
|
434
|
-
const apiKey = getDeepSeekApiKey();
|
|
435
|
-
if (!apiKey) return null;
|
|
436
|
-
|
|
437
458
|
const prompt = `Find the exact English Wikipedia article title for "${name}"${description ? ` described as "${description}"` : ""}.
|
|
438
459
|
|
|
439
460
|
Return JSON:
|
|
@@ -444,7 +465,7 @@ Return JSON:
|
|
|
444
465
|
|
|
445
466
|
try {
|
|
446
467
|
const raw = await withTimeout(
|
|
447
|
-
|
|
468
|
+
callAltLlm("You are a Wikipedia lookup assistant. Return strict JSON only.", prompt),
|
|
448
469
|
10000,
|
|
449
470
|
"findWikipediaTitle timed out"
|
|
450
471
|
);
|
|
@@ -456,12 +477,3 @@ Return JSON:
|
|
|
456
477
|
}
|
|
457
478
|
};
|
|
458
479
|
|
|
459
|
-
|
|
460
|
-
export const defaultStartPairResult = (reason: string) => ({
|
|
461
|
-
type: "Event",
|
|
462
|
-
description: "",
|
|
463
|
-
isAtomic: false,
|
|
464
|
-
atomicType: "Person",
|
|
465
|
-
compositeType: "Event",
|
|
466
|
-
reasoning: reason,
|
|
467
|
-
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { GoogleGenAI, Type } from "@google/genai";
|
|
3
3
|
import { GeminiResponse, PersonWorksResponse, PathResponse } from "../types";
|
|
4
|
-
import { getApiKey, getResponseText, cleanJson, parseJsonFromModelText, withTimeout, withRetry, getEnvCacheUrl, getEnvGeminiModel, getEnvGeminiModelClassify, sanitizeSearchTerm } from "./aiUtils";
|
|
4
|
+
import { getApiKey, getResponseText, cleanJson, parseJsonFromModelText, withTimeout, withRetry, getEnvCacheUrl, getEnvGeminiModel, getEnvGeminiModelClassify, sanitizeSearchTerm, looksLikePersonName, getLlmProvider } from "./aiUtils";
|
|
5
5
|
|
|
6
6
|
export { getApiKey, getResponseText, cleanJson, parseJsonFromModelText, withTimeout, withRetry, getEnvCacheUrl, getEnvGeminiModel, getEnvGeminiModelClassify } from "./aiUtils";
|
|
7
7
|
|
|
@@ -120,7 +120,7 @@ async function callAiProxy(endpoint: string, body: any) {
|
|
|
120
120
|
const resp = await fetch(url, {
|
|
121
121
|
method: "POST",
|
|
122
122
|
headers: { "Content-Type": "application/json" },
|
|
123
|
-
body: JSON.stringify(body)
|
|
123
|
+
body: JSON.stringify({ ...body, llmProvider: getLlmProvider() })
|
|
124
124
|
});
|
|
125
125
|
|
|
126
126
|
if (resp.status === 404 && endpoint === "/api/ai/classify-start") {
|
|
@@ -154,7 +154,7 @@ function shouldProxy(): boolean {
|
|
|
154
154
|
return !!baseUrl;
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
-
export function defaultStartPairResult(reason: string): {
|
|
157
|
+
export function defaultStartPairResult(reason: string, term?: string): {
|
|
158
158
|
type: string;
|
|
159
159
|
description: string;
|
|
160
160
|
isAtomic: boolean;
|
|
@@ -162,10 +162,11 @@ export function defaultStartPairResult(reason: string): {
|
|
|
162
162
|
compositeType: string;
|
|
163
163
|
reasoning: string;
|
|
164
164
|
} {
|
|
165
|
+
const isPerson = term ? looksLikePersonName(term) : false;
|
|
165
166
|
return {
|
|
166
|
-
type: "Event",
|
|
167
|
+
type: isPerson ? "Person" : "Event",
|
|
167
168
|
description: "",
|
|
168
|
-
isAtomic:
|
|
169
|
+
isAtomic: isPerson,
|
|
169
170
|
atomicType: "Person",
|
|
170
171
|
compositeType: "Event",
|
|
171
172
|
reasoning: reason,
|
|
@@ -291,7 +292,13 @@ export const classifyStartPair = async (
|
|
|
291
292
|
});
|
|
292
293
|
}
|
|
293
294
|
if (proxy) {
|
|
294
|
-
|
|
295
|
+
const proxyResult = await callAiProxy("/api/ai/classify-start", { term: rawTerm.trim(), wikiContext });
|
|
296
|
+
// Sanity check: if proxy says non-atomic but the term strongly looks like a person name, correct it.
|
|
297
|
+
if (!proxyResult.isAtomic && looksLikePersonName(rawTerm)) {
|
|
298
|
+
console.warn("[classifyStartPair] proxy returned isAtomic=false for apparent person name; overriding", rawTerm);
|
|
299
|
+
return { ...proxyResult, isAtomic: true, type: "Person" };
|
|
300
|
+
}
|
|
301
|
+
return proxyResult;
|
|
295
302
|
}
|
|
296
303
|
|
|
297
304
|
const needsMusic = rawTermNeedsMusicEntityExtract(rawTerm);
|
|
@@ -330,7 +337,7 @@ export const classifyStartPair = async (
|
|
|
330
337
|
|
|
331
338
|
|
|
332
339
|
if (!apiKey) {
|
|
333
|
-
return defaultStartPairResult("No API key available; defaulting to Person↔Event.");
|
|
340
|
+
return defaultStartPairResult("No API key available; defaulting to Person↔Event.", term);
|
|
334
341
|
}
|
|
335
342
|
|
|
336
343
|
const prompt = `Choose the most appropriate bipartite pair for this session based on the input: "${term}".
|
|
@@ -391,7 +398,8 @@ Rules:
|
|
|
391
398
|
} catch (e: any) {
|
|
392
399
|
console.warn("[classifyStartPair]", term, String(e?.message || e).slice(0, 200));
|
|
393
400
|
return defaultStartPairResult(
|
|
394
|
-
"Classification API unavailable (quota/rate limit or error); defaulting to Person↔Event."
|
|
401
|
+
"Classification API unavailable (quota/rate limit or error); defaulting to Person↔Event.",
|
|
402
|
+
term
|
|
395
403
|
);
|
|
396
404
|
}
|
|
397
405
|
};
|
|
@@ -4,6 +4,20 @@ import { jsonFromResponse } from "./aiUtils";
|
|
|
4
4
|
|
|
5
5
|
type WikiImageCacheEntry = { url: string | null; pageId?: number; pageTitle?: string; misses?: number };
|
|
6
6
|
|
|
7
|
+
// Session-level rate-limit gate: after any 429, block all Wikipedia/Wikidata calls for 90s.
|
|
8
|
+
let _wikiRateLimitedUntil = 0;
|
|
9
|
+
function wikiIsRateLimited() { return Date.now() < _wikiRateLimitedUntil; }
|
|
10
|
+
function wikiSetRateLimited() {
|
|
11
|
+
_wikiRateLimitedUntil = Date.now() + 90_000;
|
|
12
|
+
console.warn('[Wiki] 429 received — pausing all Wikipedia/Wikidata calls for 90s');
|
|
13
|
+
}
|
|
14
|
+
async function wikiFetch(url: string, init?: RequestInit): Promise<Response | null> {
|
|
15
|
+
if (wikiIsRateLimited()) return null;
|
|
16
|
+
const res = await fetch(url, init);
|
|
17
|
+
if (res.status === 429) { wikiSetRateLimited(); return null; }
|
|
18
|
+
return res;
|
|
19
|
+
}
|
|
20
|
+
|
|
7
21
|
// DuckDuckGo image search fallback (posters/cover art when Wikimedia lacks a usable image).
|
|
8
22
|
export const fetchDuckDuckGoPoster = async (q: string): Promise<string | null> => {
|
|
9
23
|
// Respect network sandbox: if running in a browser without CORS, skip.
|
|
@@ -493,6 +507,7 @@ export const fetchWikipediaSummary = async (
|
|
|
493
507
|
depth: number = 0,
|
|
494
508
|
triedNoContext = false
|
|
495
509
|
): Promise<{ extract: string | null; pageid: number | null; title: string | null; year?: number | null; mentioningPageTitles?: string[] | null; searchContext?: string | null }> => {
|
|
510
|
+
if (wikiIsRateLimited()) return { extract: null, pageid: null, title: null };
|
|
496
511
|
const normKey = `${query.trim().toLowerCase()}|${context || ''}`;
|
|
497
512
|
if (visited.has(normKey) || depth > 2) {
|
|
498
513
|
return { extract: null, pageid: null, title: null };
|
|
@@ -504,7 +519,8 @@ export const fetchWikipediaSummary = async (
|
|
|
504
519
|
const tryDirectLookup = async (titleToFetch: string) => {
|
|
505
520
|
try {
|
|
506
521
|
const directUrl = `https://en.wikipedia.org/w/api.php?action=query&format=json&prop=extracts|pageprops&exintro&explaintext&titles=${encodeURIComponent(titleToFetch)}&redirects=1&origin=*`;
|
|
507
|
-
const directRes = await
|
|
522
|
+
const directRes = await wikiFetch(directUrl);
|
|
523
|
+
if (!directRes) return null;
|
|
508
524
|
const directData = (await jsonFromResponse(directRes)) as { query?: { pages?: unknown; redirects?: unknown } } | null;
|
|
509
525
|
if (!directData) return null;
|
|
510
526
|
const directPages = directData.query?.pages;
|
|
@@ -646,7 +662,8 @@ export const fetchWikipediaSummary = async (
|
|
|
646
662
|
|
|
647
663
|
const avoidMedia = /\b(project|program|programme|operation|war|battle|campaign|treaty|scandal|scientist)\b/i.test(baseQuery);
|
|
648
664
|
const searchUrl = `https://en.wikipedia.org/w/api.php?action=query&format=json&list=search&srsearch=${encodeURIComponent(searchQuery)}&srlimit=5&origin=*`;
|
|
649
|
-
const searchRes = await
|
|
665
|
+
const searchRes = await wikiFetch(searchUrl);
|
|
666
|
+
if (!searchRes) return { extract: null, pageid: null, title: null };
|
|
650
667
|
const searchData = (await jsonFromResponse(searchRes)) as { query?: { search?: any[] } } | null;
|
|
651
668
|
|
|
652
669
|
let bestTitle = query;
|
|
@@ -796,7 +813,8 @@ export const fetchWikipediaSummary = async (
|
|
|
796
813
|
}
|
|
797
814
|
}
|
|
798
815
|
const summaryUrl = `https://en.wikipedia.org/w/api.php?action=query&format=json&prop=extracts|pageprops&exintro&explaintext&titles=${encodeURIComponent(titleToTry)}&redirects=1&origin=*`;
|
|
799
|
-
const summaryRes = await
|
|
816
|
+
const summaryRes = await wikiFetch(summaryUrl);
|
|
817
|
+
if (!summaryRes) break;
|
|
800
818
|
const summaryData = (await jsonFromResponse(summaryRes)) as { query?: { pages?: unknown } } | null;
|
|
801
819
|
if (!summaryData) continue;
|
|
802
820
|
const pages = summaryData.query?.pages;
|
|
@@ -1061,8 +1079,10 @@ const fetchWikidataLabels = async (ids: string[], signal: AbortSignal): Promise<
|
|
|
1061
1079
|
|
|
1062
1080
|
const resolveWikidataIdBySearch = async (label: string, signal: AbortSignal): Promise<string | null> => {
|
|
1063
1081
|
try {
|
|
1082
|
+
if (wikiIsRateLimited()) return null;
|
|
1064
1083
|
const url = `https://www.wikidata.org/w/api.php?action=wbsearchentities&format=json&language=en&limit=8&search=${encodeURIComponent(label)}&origin=*`;
|
|
1065
1084
|
const res = await fetch(url, { signal });
|
|
1085
|
+
if (res.status === 429) { wikiSetRateLimited(); return null; }
|
|
1066
1086
|
const data = (await jsonFromResponse(res)) as { search?: any[] } | null;
|
|
1067
1087
|
const results: any[] = data?.search || [];
|
|
1068
1088
|
if (!results.length) return null;
|
package/sessionHandoff.ts
CHANGED
|
@@ -130,3 +130,29 @@ export function takeEmbedHandoffForInitialState(): ConstellationsSessionHandoffV
|
|
|
130
130
|
embedHandoffMem = null;
|
|
131
131
|
return null;
|
|
132
132
|
}
|
|
133
|
+
|
|
134
|
+
declare global {
|
|
135
|
+
interface Window {
|
|
136
|
+
__soundingsConstellationsGetHandoff?: () => unknown;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Serialize current embedded graph (`__soundingsConstellationsGetHandoff`) before navigating away. */
|
|
141
|
+
export function persistWindowConstellationsHandoffToSession(): void {
|
|
142
|
+
if (typeof window === 'undefined') return;
|
|
143
|
+
try {
|
|
144
|
+
const fn = window.__soundingsConstellationsGetHandoff;
|
|
145
|
+
if (typeof fn !== 'function') return;
|
|
146
|
+
const payload = fn();
|
|
147
|
+
if (!payload || typeof payload !== 'object') return;
|
|
148
|
+
const p = payload as { v?: number; graph?: { nodes?: unknown[] } };
|
|
149
|
+
if (p.v !== 1 || !p.graph?.nodes?.length) return;
|
|
150
|
+
try {
|
|
151
|
+
sessionStorage.setItem(SOUNDINGS_CONSTELLATIONS_HANDOFF_KEY, JSON.stringify(payload));
|
|
152
|
+
} catch (e) {
|
|
153
|
+
console.warn('[constellations] handoff too large for sessionStorage', e);
|
|
154
|
+
}
|
|
155
|
+
} catch (e) {
|
|
156
|
+
console.warn('[constellations] handoff persist', e);
|
|
157
|
+
}
|
|
158
|
+
}
|