@johndimm/constellations 1.0.1 → 1.0.2

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 (38) hide show
  1. package/App.tsx +352 -70
  2. package/FullPageConstellations.tsx +7 -4
  3. package/components/AppConfirmDialog.tsx +1 -0
  4. package/components/AppHeader.tsx +69 -29
  5. package/components/AppNotifications.tsx +1 -0
  6. package/components/BrowsePeople.tsx +3 -0
  7. package/components/ControlPanel.tsx +46 -371
  8. package/components/Graph.tsx +251 -87
  9. package/components/HelpOverlay.tsx +1 -0
  10. package/components/NodeContextMenu.tsx +123 -3
  11. package/components/PeopleBrowserSidebar.tsx +15 -6
  12. package/components/Sidebar.tsx +46 -19
  13. package/components/TimelineView.tsx +1 -0
  14. package/hooks/useExpansion.ts +61 -229
  15. package/hooks/useGraphActions.ts +1 -0
  16. package/hooks/useGraphState.ts +75 -40
  17. package/hooks/useKioskMode.ts +1 -0
  18. package/hooks/useNodeClickHandler.ts +23 -15
  19. package/hooks/useSearchHandlers.ts +57 -19
  20. package/host.ts +1 -1
  21. package/index.css +17 -3
  22. package/package.json +2 -1
  23. package/services/aiService.ts +23 -0
  24. package/services/aiUtils.ts +216 -207
  25. package/services/cacheService.ts +1 -0
  26. package/services/crossrefService.ts +1 -0
  27. package/services/deepseekService.ts +467 -0
  28. package/services/geminiService.ts +532 -733
  29. package/services/graphUtils.ts +128 -18
  30. package/services/imageService.ts +18 -0
  31. package/services/openAlexService.ts +1 -0
  32. package/services/resolveImageForTitle.ts +458 -0
  33. package/services/wikipediaImage.ts +1 -0
  34. package/services/wikipediaService.ts +56 -46
  35. package/types.ts +3 -0
  36. package/utils/evidenceUtils.ts +1 -0
  37. package/utils/graphLogicUtils.ts +1 -0
  38. package/utils/wikiUtils.ts +14 -2
@@ -1,3 +1,25 @@
1
+ /** Read a Vite-style env var from process (e.g. Next.js) or import.meta (Vite). */
2
+ export function readBundledEnv(key: string): string {
3
+ const fromProcess = getEnvVar(key);
4
+ if (fromProcess) return fromProcess;
5
+ // Dual-bundler: Vite uses `VITE_*`; Next exposes the same values as `NEXT_PUBLIC_VITE_*`
6
+ // (see apps/soundings/next.config `env`), not `NEXT_PUBLIC_*` with the `VITE_` infix stripped.
7
+ const nextKey = key.startsWith("VITE_") ? `NEXT_PUBLIC_${key}` : key;
8
+ const alt = getEnvVar(nextKey);
9
+ if (alt) return alt;
10
+ try {
11
+ // @ts-ignore
12
+ if (typeof import.meta !== "undefined" && import.meta.env) {
13
+ // @ts-ignore
14
+ const v = import.meta.env[key];
15
+ if (v != null && String(v) !== "") return String(v);
16
+ }
17
+ } catch {
18
+ /* ignore */
19
+ }
20
+ return "";
21
+ }
22
+
1
23
  export const getEnvVar = (name: string): string => {
2
24
  // Try process.env first (Node.js / Server)
3
25
  try {
@@ -20,125 +42,72 @@ export const getEnvVar = (name: string): string => {
20
42
  return "";
21
43
  };
22
44
 
23
- export const getEnvCacheUrl = (): string => {
24
- // Use literal access for Vite static replacement
25
- let url = "";
26
- try {
27
- // @ts-ignore
28
- if (typeof import.meta !== 'undefined' && import.meta.env) {
29
- // @ts-ignore
30
- url = import.meta.env.VITE_CACHE_URL || import.meta.env.VITE_CACHE_API_URL || "";
31
- }
32
- } catch (e) { }
33
-
34
- if (url) return url;
35
-
36
- return getEnvVar("VITE_CACHE_URL") || getEnvVar("VITE_CACHE_API_URL");
37
- };
38
-
39
- export const getEnvGeminiModel = (): string => {
40
- // Literal access for Vite
41
- let urlModel = "";
42
- try {
43
- // @ts-ignore
44
- if (typeof import.meta !== 'undefined' && import.meta.env) {
45
- // @ts-ignore
46
- urlModel = import.meta.env.VITE_GEMINI_MODEL || "";
47
- }
48
- } catch (e) { }
49
- if (urlModel) return urlModel;
50
-
51
- return getEnvVar("VITE_GEMINI_MODEL") || "gemini-2.5-flash";
52
- };
45
+ let __ccLoggedCacheUrlDiag = false;
53
46
 
54
- export const getEnvGeminiModelClassify = (): string => {
55
- // Literal access for Vite
56
- let urlModel = "";
57
- try {
58
- // @ts-ignore
59
- if (typeof import.meta !== 'undefined' && import.meta.env) {
60
- // @ts-ignore
61
- urlModel = import.meta.env.VITE_GEMINI_MODEL_CLASSIFY || "";
62
- }
63
- } catch (e) { }
64
- if (urlModel) return urlModel;
65
-
66
- return getEnvVar("VITE_GEMINI_MODEL_CLASSIFY") || getEnvGeminiModel();
67
- };
68
-
69
- export type LlmProviderId = "gemini" | "openai" | "deepseek" | "anthropic";
70
-
71
- /** Node cache server only: per-request override from JSON body `llmProvider`. */
72
- let readServerRequestLlm: () => LlmProviderId | null = () => null;
73
-
74
- /** Register reader from server.ts (uses AsyncLocalStorage). No-op in the browser bundle. */
75
- export function registerServerRequestLlmReader(reader: () => LlmProviderId | null): void {
76
- readServerRequestLlm = reader;
77
- }
78
-
79
- const BROWSER_LLM_KEY = "constellations_llm_provider";
80
-
81
- /** In-browser override (ControlPanel); ignored on the Node cache server. */
82
- export function getBrowserLlmOverride(): LlmProviderId | null {
83
- if (typeof window === "undefined") return null;
47
+ export const getEnvCacheUrl = (): string => {
48
+ // Next.js only inlines `process.env.FOO` when `FOO` is a static property access. Dynamic
49
+ // `process.env[name]` stays empty in the browser bundle, which disables the cache proxy and
50
+ // forces slow client-only paths (e.g. `extractMusicEntity`).
51
+ let source: "static-process" | "readBundled" | "none" = "none";
52
+ let out = "";
84
53
  try {
85
- const v = window.localStorage.getItem(BROWSER_LLM_KEY)?.trim().toLowerCase();
86
- if (v === "openai" || v === "deepseek" || v === "anthropic" || v === "gemini") {
87
- return v;
54
+ if (typeof process !== "undefined" && process.env) {
55
+ const direct = (
56
+ process.env.VITE_CACHE_URL ||
57
+ process.env.VITE_CACHE_API_URL ||
58
+ process.env.NEXT_PUBLIC_VITE_CACHE_URL ||
59
+ process.env.NEXT_PUBLIC_VITE_CACHE_API_URL ||
60
+ ""
61
+ ).trim();
62
+ if (direct) {
63
+ out = direct;
64
+ source = "static-process";
65
+ }
88
66
  }
89
67
  } catch {
90
- /* ignore */
68
+ /* empty */
91
69
  }
92
- return null;
93
- }
94
-
95
- /** Persist or clear browser LLM choice. Pass null to follow .env / VITE_LLM_PROVIDER again. */
96
- export function setBrowserLlmOverride(provider: LlmProviderId | null): void {
97
- if (typeof window === "undefined") return;
98
- try {
99
- if (provider === null) {
100
- window.localStorage.removeItem(BROWSER_LLM_KEY);
101
- } else {
102
- window.localStorage.setItem(BROWSER_LLM_KEY, provider);
70
+ if (!out) {
71
+ const bundled = (
72
+ readBundledEnv("VITE_CACHE_URL") ||
73
+ readBundledEnv("VITE_CACHE_API_URL") ||
74
+ ""
75
+ ).trim();
76
+ if (bundled) {
77
+ out = bundled;
78
+ source = "readBundled";
103
79
  }
104
- } catch {
105
- /* ignore */
106
80
  }
107
- }
108
-
109
- /** Set LLM_PROVIDER (preferred on servers) or VITE_LLM_PROVIDER to openai | deepseek | anthropic | gemini (default). In the browser, a ControlPanel choice overrides via localStorage. On the cache server, an optional JSON field llmProvider overrides for that request only. */
110
- export function getLlmProvider(): LlmProviderId {
111
- const req = readServerRequestLlm();
112
- if (req) return req;
81
+ if (
82
+ typeof window !== "undefined" &&
83
+ typeof process !== "undefined" &&
84
+ process.env.NODE_ENV === "development" &&
85
+ !__ccLoggedCacheUrlDiag
86
+ ) {
87
+ __ccLoggedCacheUrlDiag = true;
88
+ let host = "";
89
+ try {
90
+ if (out) host = new URL(out).host;
91
+ } catch {
92
+ host = "(invalid URL)";
93
+ }
94
+ console.log("[Constellations]", "getEnvCacheUrl (first read)", { resolved: !!out, source, host });
95
+ }
96
+ return out;
97
+ };
113
98
 
114
- const browser = getBrowserLlmOverride();
115
- if (browser) return browser;
99
+ /** Default when unset; override with VITE_GEMINI_MODEL or NEXT_PUBLIC_GEMINI_MODEL (Next maps via next.config env). */
100
+ const DEFAULT_GEMINI_MODEL = "gemini-2.5-flash";
116
101
 
117
- // LLM_PROVIDER first: Render/Heroku/etc. set this; VITE_* must not override it if both exist.
118
- const raw = (getEnvVar("LLM_PROVIDER") || getEnvVar("VITE_LLM_PROVIDER") || "gemini")
119
- .trim()
120
- .toLowerCase();
121
- if (raw === "openai" || raw === "deepseek" || raw === "anthropic" || raw === "gemini") {
122
- return raw;
123
- }
124
- return "gemini";
125
- }
102
+ export const getEnvGeminiModel = (): string => {
103
+ const m = readBundledEnv("VITE_GEMINI_MODEL").trim();
104
+ return m || DEFAULT_GEMINI_MODEL;
105
+ };
126
106
 
127
- /** API key for the active provider (Gemini uses existing getApiKey / AI Studio). */
128
- export async function getLlmApiKey(): Promise<string> {
129
- const p = getLlmProvider();
130
- if (p === "gemini") {
131
- return getApiKey();
132
- }
133
- // Prefer plain env names on servers (OPENAI_API_KEY); VITE_* is for local Vite client.
134
- const keys: Record<Exclude<LlmProviderId, "gemini">, [string, string]> = {
135
- openai: ["OPENAI_API_KEY", "VITE_OPENAI_API_KEY"],
136
- deepseek: ["DEEPSEEK_API_KEY", "VITE_DEEPSEEK_API_KEY"],
137
- anthropic: ["ANTHROPIC_API_KEY", "VITE_ANTHROPIC_API_KEY"],
138
- };
139
- const [primary, secondary] = keys[p];
140
- return getEnvVar(primary) || getEnvVar(secondary);
141
- }
107
+ export const getEnvGeminiModelClassify = (): string => {
108
+ const m = readBundledEnv("VITE_GEMINI_MODEL_CLASSIFY").trim();
109
+ return m || getEnvGeminiModel();
110
+ };
142
111
 
143
112
  // Robust text extraction from Gemini API response
144
113
  export function getResponseText(response: any): string {
@@ -178,6 +147,80 @@ export function cleanJson(text: unknown): string {
178
147
  return text.replace(/```(?:json)?\s*([\s\S]*?)\s*```/g, '$1').trim();
179
148
  }
180
149
 
150
+ /** Extract first top-level `{...}` or `[...]` from a string (string-aware). */
151
+ function extractFirstJsonSlice(s: string): string | null {
152
+ const trimmed = s.trim();
153
+ const startObj = trimmed.indexOf("{");
154
+ const startArr = trimmed.indexOf("[");
155
+ let start = -1;
156
+ if (startObj >= 0 && (startArr < 0 || startObj < startArr)) start = startObj;
157
+ else if (startArr >= 0) start = startArr;
158
+ else return null;
159
+ let depth = 0;
160
+ let inString = false;
161
+ let escape = false;
162
+ for (let i = start; i < trimmed.length; i++) {
163
+ const c = trimmed[i];
164
+ if (inString) {
165
+ if (escape) {
166
+ escape = false;
167
+ continue;
168
+ }
169
+ if (c === "\\") {
170
+ escape = true;
171
+ continue;
172
+ }
173
+ if (c === '"') inString = false;
174
+ continue;
175
+ }
176
+ if (c === '"') {
177
+ inString = true;
178
+ continue;
179
+ }
180
+ if (c === "{" || c === "[") depth++;
181
+ else if (c === "}" || c === "]") {
182
+ depth--;
183
+ if (depth === 0) return trimmed.slice(start, i + 1);
184
+ }
185
+ }
186
+ return null;
187
+ }
188
+
189
+ /**
190
+ * Parse JSON from Gemini / proxy output: handles markdown fences, then plain text that may
191
+ * include a JSON object embedded in prose (or model noise like "You are a…" before the object).
192
+ */
193
+ export function parseJsonFromModelText(text: unknown): unknown | null {
194
+ if (typeof text !== "string" || !text.trim()) return null;
195
+ const cleaned = cleanJson(text);
196
+ if (!cleaned) return null;
197
+ try {
198
+ return JSON.parse(cleaned);
199
+ } catch {
200
+ const slice = extractFirstJsonSlice(cleaned);
201
+ if (!slice) return null;
202
+ try {
203
+ return JSON.parse(slice);
204
+ } catch {
205
+ return null;
206
+ }
207
+ }
208
+ }
209
+
210
+ /**
211
+ * Read a fetch Response and parse JSON without throwing. Non-OK responses and HTML or plain
212
+ * error pages (e.g. Wikipedia rate limit text starting with "You are...") return null.
213
+ */
214
+ export async function jsonFromResponse<T = unknown>(res: Response): Promise<T | null> {
215
+ const text = await res.text();
216
+ if (!res.ok) return null;
217
+ try {
218
+ return JSON.parse(text) as T;
219
+ } catch {
220
+ return null;
221
+ }
222
+ }
223
+
181
224
  // Safely retrieve API key
182
225
  export async function getApiKey() {
183
226
  let key = "";
@@ -229,28 +272,50 @@ export async function getApiKey() {
229
272
  }
230
273
 
231
274
  /**
232
- * `fetch` with a hard timeout so a hung cache/LLM endpoint cannot leave expansions spinning forever.
233
- * Do not pass `signal` in init unless you compose with this controller (not supported here).
275
+ * Strip YouTube channel names, bare years, and other web junk from a pasted search term.
276
+ * Handles multi-line pastes like:
277
+ * "Alban Berg- Lyric Suite Part 3 Allegro misterioso\nplayingmusiconmars\n1926"
278
+ * Returns the first substantive line with trailing noise removed.
234
279
  */
235
- export async function fetchWithTimeout(
236
- input: RequestInfo | URL,
237
- init: RequestInit = {},
238
- timeoutMs = 45000,
239
- ): Promise<Response> {
240
- const controller = new AbortController();
241
- const id = setTimeout(() => controller.abort(), timeoutMs);
242
- try {
243
- return await fetch(input, { ...init, signal: controller.signal });
244
- } finally {
245
- clearTimeout(id);
280
+ export function sanitizeSearchTerm(raw: string): string {
281
+ if (!raw || typeof raw !== "string") return raw;
282
+
283
+ const RECORD_LABELS = /^(warner classics|deutsche grammophon|ecm records|decca|hyperion|harmonia mundi|naïve|sony classical|emi classics|philips classics|virgin classics|erato|chandos|bis records|naxos|ondine|telarc)$/i;
284
+
285
+ const isJunkLine = (line: string): boolean => {
286
+ const t = line.trim();
287
+ if (!t) return true;
288
+ // Pure year
289
+ if (/^\d{4}$/.test(t)) return true;
290
+ // YouTube channel pattern: no spaces, lowercase + digits, length > 4
291
+ if (!/\s/.test(t) && /[a-z]/.test(t) && /\d/.test(t) && t.length > 4) return true;
292
+ // Single word, all lowercase, no digits — likely a username without numbers
293
+ if (!/\s/.test(t) && t === t.toLowerCase() && t.length > 10) return true;
294
+ // Known record labels when appearing alone on a line
295
+ if (RECORD_LABELS.test(t)) return true;
296
+ return false;
297
+ };
298
+
299
+ // Split on newlines, filter junk lines
300
+ const lines = raw.split(/\n/).map(l => l.trim()).filter(l => !isJunkLine(l));
301
+ if (lines.length === 0) return raw.split(/\n/)[0]?.trim() || raw.trim();
302
+
303
+ // From the first good line, strip trailing tokens that look like channel names or years
304
+ let first = lines[0].replace(/\s+[a-z][a-z0-9]{4,}\d+\s*$/i, "").trim(); // e.g. "concerts1899"
305
+
306
+ // "Performer plays/performs Composer: Work" → "Composer: Work"
307
+ // e.g. "Gautier Capuçon plays Fauré: Sicilienne" → "Fauré: Sicilienne"
308
+ const playsMatch = first.match(/^.+?\s+(?:plays?|performs?|interprets?|conducted?\s+by)\s+(.+)$/i);
309
+ if (playsMatch) {
310
+ const extracted = playsMatch[1].trim();
311
+ if (extracted.length > 3) first = extracted;
246
312
  }
247
- }
248
313
 
249
- /** Truncate for console; LLM prompts/contexts can be huge. */
250
- export function clipForLlmLog(text: string, maxChars = 16000): string {
251
- const s = String(text ?? "");
252
- if (s.length <= maxChars) return s;
253
- return `${s.slice(0, maxChars)}\n… [truncated ${s.length - maxChars} more chars]`;
314
+ // Strip trailing parenthetical performer info: "Work (Orchestra / Conductor)" "Work"
315
+ // e.g. "Pavane pour une infante défunte (Orchestre national de France / Dalia Stasevska)"
316
+ first = first.replace(/\s*\([^)]*(?:\/|orchestra|ensemble|philharmonic|conducted)[^)]*\)\s*$/i, "").trim();
317
+
318
+ return first || lines[0];
254
319
  }
255
320
 
256
321
  // Wrap promise with timeout
@@ -272,92 +337,36 @@ export function withTimeout<T>(promise: Promise<T>, ms: number, errorMsg: string
272
337
  });
273
338
  }
274
339
 
275
- /** Collects message / nested API fields / JSON so 429s are not missed as "[object Object]". */
276
- function errorTextForMatch(e: any): string {
277
- const parts: string[] = [];
278
- if (e?.message) parts.push(String(e.message));
279
- if (e?.error) {
280
- parts.push(
281
- typeof e.error === "string" ? e.error : JSON.stringify(e.error)
282
- );
283
- }
284
- if (e?.status) parts.push(String(e.status));
285
- if (e?.code !== undefined && e?.code !== null) parts.push(String(e.code));
286
- if (typeof e === "string") parts.push(e);
287
- if (typeof e === "object" && parts.length === 0) {
288
- try {
289
- parts.push(JSON.stringify(e));
290
- } catch {
291
- parts.push(String(e));
292
- }
293
- }
294
- return parts.join(" ").toLowerCase();
295
- }
296
-
297
- /** True for HTTP 429 / RESOURCE_EXHAUSTED (e.g. Vertex quota). */
298
- export function isRateLimitError(e: any): boolean {
299
- if (e?.error?.code === 429) return true;
300
- if (e?.code === 429) return true;
301
- const s = String(e?.error?.status || "").toLowerCase();
302
- if (s === "resource_exhausted") return true;
303
- const t = errorTextForMatch(e);
304
- return t.includes("429") || t.includes("resource_exhausted");
305
- }
306
-
307
- function isTransientError(errText: string): boolean {
308
- return (
309
- errText.includes("429") ||
310
- errText.includes("resource_exhausted") ||
311
- errText.includes("rate limit") ||
312
- errText.includes("timeout") ||
313
- errText.includes("fetch") ||
314
- errText.includes("network") ||
315
- errText.includes("econnreset") ||
316
- errText.includes("etimedout") ||
317
- errText.includes("503") ||
318
- errText.includes("unavailable")
319
- );
320
- }
321
-
322
- /**
323
- * Retries on transient API failures. If a 429 (rate / quota) is seen, the run can extend
324
- * to `rateLimitAttempts` tries with longer waits (Vertex often needs many seconds between retries).
325
- */
326
- export async function withRetry<T>(
327
- fn: () => Promise<T>,
328
- attempts = 3,
329
- backoffMs = 1000,
330
- rateLimitAttempts = 8
331
- ): Promise<T> {
340
+ // Improved retry logic with exponential backoff and jitter
341
+ export async function withRetry<T>(fn: () => Promise<T>, attempts = 3, backoffMs = 1000): Promise<T> {
332
342
  let lastError: any;
333
- let maxTries = Math.max(1, attempts);
334
- for (let i = 0; i < maxTries; i++) {
343
+ for (let i = 0; i < attempts; i++) {
335
344
  try {
336
345
  return await fn();
337
346
  } catch (error: any) {
338
347
  lastError = error;
339
- if (isRateLimitError(error)) {
340
- maxTries = Math.max(maxTries, rateLimitAttempts);
341
- }
342
- const errText = errorTextForMatch(error);
343
- const retryable = isTransientError(errText);
344
- const isLast = i + 1 >= maxTries;
345
- if (isLast || !retryable) {
348
+ const errorStr = String(error?.message || error || '').toLowerCase();
349
+ // Only retry if it looks like a transient error (rate limit, timeout, or network)
350
+ const isRetryable =
351
+ errorStr.includes('429') ||
352
+ errorStr.includes('resource_exhausted') ||
353
+ errorStr.includes('rate limit') ||
354
+ errorStr.includes('timeout') ||
355
+ errorStr.includes('fetch') ||
356
+ errorStr.includes('network');
357
+
358
+ if (i < attempts - 1 && isRetryable) {
359
+ // Exponential backoff: 1s, 2s, 4s...
360
+ const baseDelay = backoffMs * Math.pow(2, i);
361
+ // Add jitter: +/- 20% to avoid "thundering herd"
362
+ const jitter = baseDelay * 0.2 * (Math.random() * 2 - 1);
363
+ const delay = Math.max(0, baseDelay + jitter);
364
+
365
+ console.warn(`[Retry] Attempt ${i + 1} failed. Retrying in ${Math.round(delay)}ms...`, errorStr);
366
+ await new Promise(res => setTimeout(res, delay));
367
+ } else {
346
368
  throw error;
347
369
  }
348
- const isRate = isRateLimitError(error);
349
- // Longer waits for 429/RESOURCE_EXHAUSTED (capped) vs generic transient errors
350
- const baseDelay = isRate
351
- ? Math.min(90_000, 5_000 * Math.pow(1.45, i))
352
- : backoffMs * Math.pow(2, i);
353
- const jitter = baseDelay * 0.2 * (Math.random() * 2 - 1);
354
- const delay = Math.max(0, baseDelay + jitter);
355
-
356
- console.warn(
357
- `[Retry] Attempt ${i + 1} failed. Retrying in ${Math.round(delay)}ms...`,
358
- errText.slice(0, 500)
359
- );
360
- await new Promise((res) => setTimeout(res, delay));
361
370
  }
362
371
  }
363
372
  throw lastError;
@@ -1,3 +1,4 @@
1
+ "use client";
1
2
  import { getEnvCacheUrl } from "./aiUtils";
2
3
 
3
4
  // Logic to determine effective cache base URL
@@ -1,3 +1,4 @@
1
+ "use client";
1
2
  type CrossrefWork = {
2
3
  DOI?: string;
3
4
  title?: string[];