@johndimm/constellations 1.0.0 → 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.
- package/App.tsx +352 -70
- package/FullPageConstellations.tsx +7 -5
- package/components/AppConfirmDialog.tsx +1 -0
- package/components/AppHeader.tsx +69 -29
- package/components/AppNotifications.tsx +1 -0
- package/components/BrowsePeople.tsx +3 -0
- package/components/ControlPanel.tsx +46 -371
- package/components/Graph.tsx +251 -87
- package/components/HelpOverlay.tsx +1 -0
- 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/embedded.css +38 -0
- package/hooks/useExpansion.ts +61 -229
- 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 +57 -19
- package/host.ts +1 -1
- package/index.css +17 -3
- package/package.json +4 -1
- package/services/aiService.ts +23 -0
- package/services/aiUtils.ts +216 -207
- package/services/cacheService.ts +1 -0
- package/services/crossrefService.ts +1 -0
- package/services/deepseekService.ts +467 -0
- package/services/geminiService.ts +532 -733
- 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 +56 -46
- 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
|
-
};
|
|
45
|
+
let __ccLoggedCacheUrlDiag = false;
|
|
53
46
|
|
|
54
|
-
export const
|
|
55
|
-
//
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
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,36 @@ export function withTimeout<T>(promise: Promise<T>, ms: number, errorMsg: string
|
|
|
272
337
|
});
|
|
273
338
|
}
|
|
274
339
|
|
|
275
|
-
|
|
276
|
-
function
|
|
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
|
|
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
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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;
|
package/services/cacheService.ts
CHANGED