@johndimm/constellations 1.0.6 → 1.0.8
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@johndimm/constellations",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.8",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./index.tsx",
|
|
6
6
|
"exports": {
|
|
@@ -52,7 +52,6 @@
|
|
|
52
52
|
},
|
|
53
53
|
"dependencies": {
|
|
54
54
|
"@google/genai": "^1.33.0",
|
|
55
|
-
"@johndimm/constellations": "^1.0.2",
|
|
56
55
|
"@types/d3": "^7.4.3",
|
|
57
56
|
"d3": "^7.9.0",
|
|
58
57
|
"dotenv": "^16.4.5",
|
package/services/aiService.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { getLlmProvider } from "./aiUtils";
|
|
7
7
|
import * as geminiSvc from "./geminiService";
|
|
8
|
-
import * as altSvc from "./deepseekService"; //
|
|
8
|
+
import * as altSvc from "./deepseekService"; // non-Gemini providers (deepseek / openai / anthropic)
|
|
9
9
|
|
|
10
10
|
export * from "./aiUtils";
|
|
11
11
|
export type { LockedPair } from "./geminiService";
|
package/services/aiUtils.ts
CHANGED
|
@@ -386,7 +386,12 @@ export function setBrowserLlmOverride(provider: LlmProviderId | null): void {
|
|
|
386
386
|
export function getBrowserLlmModel(): string | null {
|
|
387
387
|
if (typeof window === "undefined") return null;
|
|
388
388
|
try {
|
|
389
|
-
|
|
389
|
+
const v = window.localStorage.getItem(BROWSER_LLM_MODEL_KEY)?.trim() || null;
|
|
390
|
+
if (v && getBrowserLlmOverride() === "anthropic" && isRetiredAnthropicModel(v)) {
|
|
391
|
+
window.localStorage.removeItem(BROWSER_LLM_MODEL_KEY);
|
|
392
|
+
return null;
|
|
393
|
+
}
|
|
394
|
+
return v;
|
|
390
395
|
} catch {}
|
|
391
396
|
return null;
|
|
392
397
|
}
|
|
@@ -419,6 +424,31 @@ export function getServerLlmModelOverride(): string | null {
|
|
|
419
424
|
return _serverLlmModelOverride;
|
|
420
425
|
}
|
|
421
426
|
|
|
427
|
+
/** Default when no model is configured (claude-3-5-haiku-20241022 was retired). */
|
|
428
|
+
export const DEFAULT_ANTHROPIC_MODEL = "claude-haiku-4-5-20251001";
|
|
429
|
+
|
|
430
|
+
const RETIRED_ANTHROPIC_MODELS = new Set([
|
|
431
|
+
"claude-3-5-haiku-20241022",
|
|
432
|
+
"claude-3-5-haiku-20240307",
|
|
433
|
+
]);
|
|
434
|
+
|
|
435
|
+
export function isRetiredAnthropicModel(model: string): boolean {
|
|
436
|
+
return RETIRED_ANTHROPIC_MODELS.has(model.trim());
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** Per-request override → env → default; maps retired snapshot ids to current Haiku. */
|
|
440
|
+
export function resolveAnthropicModel(): string {
|
|
441
|
+
const raw =
|
|
442
|
+
getServerLlmModelOverride()?.trim() ||
|
|
443
|
+
readBundledEnv("VITE_ANTHROPIC_MODEL")?.trim() ||
|
|
444
|
+
DEFAULT_ANTHROPIC_MODEL;
|
|
445
|
+
if (isRetiredAnthropicModel(raw)) {
|
|
446
|
+
console.warn(`[anthropic] replacing retired model "${raw}" with "${DEFAULT_ANTHROPIC_MODEL}"`);
|
|
447
|
+
return DEFAULT_ANTHROPIC_MODEL;
|
|
448
|
+
}
|
|
449
|
+
return raw;
|
|
450
|
+
}
|
|
451
|
+
|
|
422
452
|
export function getLlmProvider(): LlmProviderId {
|
|
423
453
|
if (_serverLlmOverride) return _serverLlmOverride;
|
|
424
454
|
const browser = getBrowserLlmOverride();
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { GeminiResponse, PersonWorksResponse, PathResponse } from "../types";
|
|
3
|
-
import { parseJsonFromModelText, withTimeout, withRetry, getEnvCacheUrl, readBundledEnv, getLlmProvider, looksLikePersonName, getServerLlmModelOverride } from "./aiUtils";
|
|
3
|
+
import { parseJsonFromModelText, withTimeout, withRetry, getEnvCacheUrl, readBundledEnv, getLlmProvider, looksLikePersonName, getServerLlmModelOverride, getBrowserLlmModel, resolveAnthropicModel } from "./aiUtils";
|
|
4
4
|
import type { LockedPair } from "./geminiService";
|
|
5
5
|
|
|
6
6
|
export type { LockedPair };
|
|
@@ -8,6 +8,10 @@ export type { LockedPair };
|
|
|
8
8
|
const TIMEOUT_MS = 60000;
|
|
9
9
|
const CLASSIFY_TIMEOUT_MS = 15000;
|
|
10
10
|
|
|
11
|
+
function llmLogTag(): string {
|
|
12
|
+
return `[${getLlmProvider()}]`;
|
|
13
|
+
}
|
|
14
|
+
|
|
11
15
|
function shouldProxy(): boolean {
|
|
12
16
|
if (typeof window === "undefined") return false;
|
|
13
17
|
if ((window as any).__PRERENDER_INJECTED) return false;
|
|
@@ -20,7 +24,7 @@ async function callAiProxy(endpoint: string, body: any) {
|
|
|
20
24
|
const resp = await fetch(url, {
|
|
21
25
|
method: "POST",
|
|
22
26
|
headers: { "Content-Type": "application/json" },
|
|
23
|
-
body: JSON.stringify({ ...body, llmProvider: getLlmProvider() }),
|
|
27
|
+
body: JSON.stringify({ ...body, llmProvider: getLlmProvider(), llmModel: getBrowserLlmModel() ?? undefined }),
|
|
24
28
|
});
|
|
25
29
|
if (!resp.ok) throw new Error(`AI Proxy Error (${resp.status}): ${await resp.text()}`);
|
|
26
30
|
return resp.json();
|
|
@@ -32,7 +36,7 @@ async function callAltLlm(system: string, user: string, timeoutMs = TIMEOUT_MS):
|
|
|
32
36
|
if (provider === "anthropic") {
|
|
33
37
|
const key = readBundledEnv("VITE_ANTHROPIC_API_KEY");
|
|
34
38
|
if (!key) throw new Error("No VITE_ANTHROPIC_API_KEY set");
|
|
35
|
-
const model =
|
|
39
|
+
const model = resolveAnthropicModel();
|
|
36
40
|
const res = await fetch("https://api.anthropic.com/v1/messages", {
|
|
37
41
|
method: "POST",
|
|
38
42
|
headers: {
|
|
@@ -217,7 +221,7 @@ Return JSON with exactly these fields:
|
|
|
217
221
|
reasoning: s(json.reasoning, ""),
|
|
218
222
|
};
|
|
219
223
|
} catch (e) {
|
|
220
|
-
console.warn(
|
|
224
|
+
console.warn(`${llmLogTag()} classifyStartPair failed:`, String(e).slice(0, 200));
|
|
221
225
|
return fallback;
|
|
222
226
|
}
|
|
223
227
|
};
|
|
@@ -270,7 +274,7 @@ Return JSON:
|
|
|
270
274
|
reasoning: json.reasoning as string | undefined,
|
|
271
275
|
};
|
|
272
276
|
} catch (e) {
|
|
273
|
-
console.warn(
|
|
277
|
+
console.warn(`${llmLogTag()} classifyEntity failed:`, String(e).slice(0, 200));
|
|
274
278
|
return fallback;
|
|
275
279
|
}
|
|
276
280
|
};
|
|
@@ -340,7 +344,7 @@ Return JSON:
|
|
|
340
344
|
.map(p => ({ ...p, isAtomic: true }));
|
|
341
345
|
return parsed;
|
|
342
346
|
} catch (e) {
|
|
343
|
-
console.error(
|
|
347
|
+
console.error(`${llmLogTag()} fetchConnections error:`, e);
|
|
344
348
|
return { people: [] };
|
|
345
349
|
}
|
|
346
350
|
};
|
|
@@ -403,7 +407,7 @@ Return JSON:
|
|
|
403
407
|
.map(w => ({ ...w, isAtomic: false }));
|
|
404
408
|
return parsed;
|
|
405
409
|
} catch (e) {
|
|
406
|
-
console.error(
|
|
410
|
+
console.error(`${llmLogTag()} fetchPersonWorks error:`, e);
|
|
407
411
|
return { works: [] };
|
|
408
412
|
}
|
|
409
413
|
};
|
|
@@ -444,7 +448,7 @@ Return JSON:
|
|
|
444
448
|
if (!json || !Array.isArray(json.path)) return { path: [], found: false };
|
|
445
449
|
return { path: json.path, found: json.path.length > 0 };
|
|
446
450
|
} catch (e) {
|
|
447
|
-
console.error(
|
|
451
|
+
console.error(`${llmLogTag()} fetchConnectionPath error:`, e);
|
|
448
452
|
return { path: [], found: false };
|
|
449
453
|
}
|
|
450
454
|
};
|
|
@@ -173,17 +173,28 @@ export function defaultStartPairResult(reason: string, term?: string): {
|
|
|
173
173
|
};
|
|
174
174
|
}
|
|
175
175
|
|
|
176
|
-
/** Messy pasted now-playing / YouTube text;
|
|
177
|
-
function rawTermNeedsMusicEntityExtract(raw: string): boolean {
|
|
176
|
+
/** Messy pasted now-playing / YouTube text; also Soundings-style "Track (Artist)" / "Track by Artist". */
|
|
177
|
+
export function rawTermNeedsMusicEntityExtract(raw: string): boolean {
|
|
178
178
|
const t = raw.trim();
|
|
179
179
|
if (!t) return false;
|
|
180
180
|
if (t.length > 220) return true;
|
|
181
181
|
if (t.includes("\n")) return true;
|
|
182
182
|
if (/https?:\/\/(www\.)?(youtube\.com|youtu\.be)\b/i.test(t)) return true;
|
|
183
183
|
if (/\b(feat\.|ft\.|official\s+video|official\s+audio)\b/i.test(t)) return true;
|
|
184
|
+
// e.g. "Summertime (Miles Davis)" from now-playing bridge — bare title is ambiguous
|
|
185
|
+
if (/^.+\s+\([^)]{2,}\)\s*$/.test(t)) return true;
|
|
186
|
+
if (/^.+\s+by\s+.+$/i.test(t)) return true;
|
|
184
187
|
return false;
|
|
185
188
|
}
|
|
186
189
|
|
|
190
|
+
/** Normalize a graph seed before classify-start (music disambiguation). Used on server + local non-proxy paths. */
|
|
191
|
+
export async function resolveStartSearchTerm(raw: string): Promise<string> {
|
|
192
|
+
const trimmed = raw.trim();
|
|
193
|
+
if (!trimmed) return trimmed;
|
|
194
|
+
if (!rawTermNeedsMusicEntityExtract(trimmed)) return trimmed;
|
|
195
|
+
return extractMusicEntity(trimmed);
|
|
196
|
+
}
|
|
197
|
+
|
|
187
198
|
/**
|
|
188
199
|
* Given raw pasted text (YouTube title, channel name, multi-line description),
|
|
189
200
|
* ask the LLM to pick the best graph starting node — using world knowledge to
|
|
@@ -295,11 +306,13 @@ export const classifyStartPair = async (
|
|
|
295
306
|
return await callAiProxy("/api/ai/classify-start", { term: rawTerm.trim(), wikiContext });
|
|
296
307
|
}
|
|
297
308
|
|
|
298
|
-
const
|
|
309
|
+
const term = await resolveStartSearchTerm(rawTerm);
|
|
299
310
|
if (typeof window !== "undefined" && process.env.NODE_ENV === "development") {
|
|
300
|
-
console.log("[Constellations]", "classifyStartPair local Gemini path", {
|
|
311
|
+
console.log("[Constellations]", "classifyStartPair local Gemini path", {
|
|
312
|
+
term: term.slice(0, 80),
|
|
313
|
+
resolvedFrom: term !== rawTerm.trim() ? rawTerm.trim().slice(0, 80) : undefined,
|
|
314
|
+
});
|
|
301
315
|
}
|
|
302
|
-
const term = needsMusic ? await extractMusicEntity(rawTerm) : rawTerm.trim();
|
|
303
316
|
|
|
304
317
|
const apiKey = await getApiKey();
|
|
305
318
|
// String-level safety heuristic (no Wikipedia required):
|
package/services/llmClient.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import {
|
|
2
2
|
clipForLlmLog,
|
|
3
|
-
getEnvVar,
|
|
4
3
|
getLlmProvider,
|
|
5
4
|
getLlmApiKey,
|
|
6
5
|
getResponseText,
|
|
7
6
|
isRateLimitError,
|
|
7
|
+
resolveAnthropicModel,
|
|
8
8
|
withRetry,
|
|
9
9
|
withTimeout,
|
|
10
10
|
type LlmProviderId,
|
|
@@ -77,7 +77,7 @@ async function anthropicJson(system: string | undefined, user: string): Promise<
|
|
|
77
77
|
const key = await getLlmApiKey();
|
|
78
78
|
if (!key) throw new Error("No API key for anthropic");
|
|
79
79
|
|
|
80
|
-
const model =
|
|
80
|
+
const model = resolveAnthropicModel();
|
|
81
81
|
|
|
82
82
|
const res = await fetch("https://api.anthropic.com/v1/messages", {
|
|
83
83
|
method: "POST",
|
|
@@ -8,6 +8,19 @@ export type NowPlayingSnapshot = {
|
|
|
8
8
|
artist?: string | null;
|
|
9
9
|
};
|
|
10
10
|
|
|
11
|
+
/** Append performer when a bare title is ambiguous (e.g. "Summertime" vs "In the Summertime"). */
|
|
12
|
+
function nowPlayingSearchTerm(track?: string, artist?: string): string {
|
|
13
|
+
const t = (track ?? "").trim();
|
|
14
|
+
const a = (artist ?? "").trim();
|
|
15
|
+
if (!t) return a;
|
|
16
|
+
if (!a) return t;
|
|
17
|
+
if (t.toLowerCase().includes(a.toLowerCase())) return t;
|
|
18
|
+
// Classical / rich titles already name the composer; YouTube channel is often junk.
|
|
19
|
+
if (/[:(~]/.test(t)) return t;
|
|
20
|
+
if (/\([^)]+\)\s*$/.test(t)) return t;
|
|
21
|
+
return `${t} (${a})`;
|
|
22
|
+
}
|
|
23
|
+
|
|
11
24
|
/**
|
|
12
25
|
* State sync for every full-page constellations host (Soundings, Trailer, etc.): URL `q` / `expand`,
|
|
13
26
|
* optional handoff gating, and optional live player bridge (now-playing + external search).
|
|
@@ -72,27 +85,25 @@ export function useFullPageConstellationsHost(input: {
|
|
|
72
85
|
const album = snap?.album?.trim();
|
|
73
86
|
const track = snap?.track?.trim();
|
|
74
87
|
const artist = snap?.artist?.trim();
|
|
88
|
+
const searchTerm = nowPlayingSearchTerm(track, artist);
|
|
75
89
|
const mergedExpand = [
|
|
76
90
|
...extra,
|
|
77
91
|
...(album ? [album] : []),
|
|
78
|
-
...(
|
|
79
|
-
...(
|
|
92
|
+
...(searchTerm ? [searchTerm] : []),
|
|
93
|
+
...(track && searchTerm !== track ? [track] : []),
|
|
94
|
+
...(artist && !searchTerm.toLowerCase().includes(artist.toLowerCase()) ? [artist] : []),
|
|
80
95
|
];
|
|
81
96
|
if (album || track) {
|
|
82
|
-
setNowPlayingKey(`${npRev}::${album || ""}::${track || ""}`);
|
|
97
|
+
setNowPlayingKey(`${npRev}::${album || ""}::${track || ""}::${artist || ""}`);
|
|
83
98
|
} else {
|
|
84
99
|
setNowPlayingKey(null);
|
|
85
100
|
}
|
|
86
101
|
if (qParam) {
|
|
87
102
|
setExternalSearch(null);
|
|
88
103
|
} else {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
// (extractMusicEntity) will parse the title to extract the primary musical entity.
|
|
93
|
-
const searchTerm = track || snap?.artist?.trim() || "";
|
|
94
|
-
if (searchTerm) {
|
|
95
|
-
setExternalSearch({ term: searchTerm, id: `np:${searchTerm.toLowerCase()}` });
|
|
104
|
+
const term = searchTerm || artist || "";
|
|
105
|
+
if (term) {
|
|
106
|
+
setExternalSearch({ term, id: `np:${term.toLowerCase()}` });
|
|
96
107
|
} else {
|
|
97
108
|
setExternalSearch(null);
|
|
98
109
|
}
|