@johndimm/constellations 1.0.1 → 1.0.3
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 +360 -66
- package/FullPageConstellations.tsx +7 -4
- package/components/AppConfirmDialog.tsx +1 -0
- package/components/AppHeader.tsx +67 -30
- package/components/AppNotifications.tsx +1 -0
- package/components/BrowsePeople.tsx +3 -0
- package/components/ControlPanel.tsx +229 -250
- package/components/Graph.tsx +251 -87
- package/components/HelpOverlay.tsx +2 -1
- package/components/NodeContextMenu.tsx +123 -3
- package/components/PeopleBrowserSidebar.tsx +15 -6
- package/components/Sidebar.tsx +46 -19
- package/components/TimelineView.tsx +1 -0
- package/hooks/useExpansion.ts +85 -230
- package/hooks/useGraphActions.ts +1 -0
- package/hooks/useGraphState.ts +75 -40
- package/hooks/useKioskMode.ts +1 -0
- package/hooks/useNodeClickHandler.ts +23 -15
- package/hooks/useSearchHandlers.ts +60 -21
- package/host.ts +1 -1
- package/index.css +17 -3
- package/index.tsx +5 -3
- package/package.json +4 -2
- package/services/aiService.ts +27 -0
- package/services/aiUtils.ts +285 -195
- package/services/cacheService.ts +1 -0
- package/services/crossrefService.ts +1 -0
- package/services/deepseekService.ts +479 -0
- package/services/geminiService.ts +543 -736
- package/services/graphUtils.ts +128 -18
- package/services/imageService.ts +18 -0
- package/services/openAlexService.ts +1 -0
- package/services/resolveImageForTitle.ts +458 -0
- package/services/wikipediaImage.ts +1 -0
- package/services/wikipediaService.ts +79 -49
- package/sessionHandoff.ts +26 -0
- package/types.ts +3 -0
- package/utils/evidenceUtils.ts +1 -0
- package/utils/graphLogicUtils.ts +1 -0
- package/utils/wikiUtils.ts +14 -2
package/services/aiUtils.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
};
|
|
53
|
-
|
|
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";
|
|
45
|
+
let __ccLoggedCacheUrlDiag = false;
|
|
80
46
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
/*
|
|
68
|
+
/* empty */
|
|
91
69
|
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
115
|
-
|
|
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
|
-
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
*
|
|
233
|
-
*
|
|
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
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
const
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
return
|
|
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,117 @@ export function withTimeout<T>(promise: Promise<T>, ms: number, errorMsg: string
|
|
|
272
337
|
});
|
|
273
338
|
}
|
|
274
339
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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();
|
|
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]`;
|
|
295
344
|
}
|
|
296
345
|
|
|
297
|
-
/** True for HTTP 429 / RESOURCE_EXHAUSTED (e.g. Vertex quota). */
|
|
298
346
|
export function isRateLimitError(e: any): boolean {
|
|
299
|
-
if (e?.error?.code === 429) return true;
|
|
300
|
-
if (e?.code === 429) return true;
|
|
347
|
+
if (e?.error?.code === 429 || e?.code === 429) return true;
|
|
301
348
|
const s = String(e?.error?.status || "").toLowerCase();
|
|
302
349
|
if (s === "resource_exhausted") return true;
|
|
303
|
-
const t =
|
|
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();
|
|
304
354
|
return t.includes("429") || t.includes("resource_exhausted");
|
|
305
355
|
}
|
|
306
356
|
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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";
|
|
320
399
|
}
|
|
321
400
|
|
|
322
401
|
/**
|
|
323
|
-
*
|
|
324
|
-
*
|
|
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.
|
|
325
407
|
*/
|
|
326
|
-
export
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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
|
+
|
|
421
|
+
// Improved retry logic with exponential backoff and jitter
|
|
422
|
+
export async function withRetry<T>(fn: () => Promise<T>, attempts = 3, backoffMs = 1000): Promise<T> {
|
|
332
423
|
let lastError: any;
|
|
333
|
-
let
|
|
334
|
-
for (let i = 0; i < maxTries; i++) {
|
|
424
|
+
for (let i = 0; i < attempts; i++) {
|
|
335
425
|
try {
|
|
336
426
|
return await fn();
|
|
337
427
|
} catch (error: any) {
|
|
338
428
|
lastError = error;
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
429
|
+
const errorStr = String(error?.message || error || '').toLowerCase();
|
|
430
|
+
// Only retry if it looks like a transient error (rate limit, timeout, or network)
|
|
431
|
+
const isRetryable =
|
|
432
|
+
errorStr.includes('429') ||
|
|
433
|
+
errorStr.includes('resource_exhausted') ||
|
|
434
|
+
errorStr.includes('rate limit') ||
|
|
435
|
+
errorStr.includes('timeout') ||
|
|
436
|
+
errorStr.includes('fetch') ||
|
|
437
|
+
errorStr.includes('network');
|
|
438
|
+
|
|
439
|
+
if (i < attempts - 1 && isRetryable) {
|
|
440
|
+
// Exponential backoff: 1s, 2s, 4s...
|
|
441
|
+
const baseDelay = backoffMs * Math.pow(2, i);
|
|
442
|
+
// Add jitter: +/- 20% to avoid "thundering herd"
|
|
443
|
+
const jitter = baseDelay * 0.2 * (Math.random() * 2 - 1);
|
|
444
|
+
const delay = Math.max(0, baseDelay + jitter);
|
|
445
|
+
|
|
446
|
+
console.warn(`[Retry] Attempt ${i + 1} failed. Retrying in ${Math.round(delay)}ms...`, errorStr);
|
|
447
|
+
await new Promise(res => setTimeout(res, delay));
|
|
448
|
+
} else {
|
|
346
449
|
throw error;
|
|
347
450
|
}
|
|
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
451
|
}
|
|
362
452
|
}
|
|
363
453
|
throw lastError;
|
package/services/cacheService.ts
CHANGED