@avocadostudio-ai/orchestrator-core 0.1.0
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/LICENSE +201 -0
- package/dist/agent/agent-context.d.ts +19 -0
- package/dist/agent/agent-context.js +67 -0
- package/dist/agent/agent-logger.d.ts +5 -0
- package/dist/agent/agent-logger.js +22 -0
- package/dist/agent/agent-loop-openai.d.ts +8 -0
- package/dist/agent/agent-loop-openai.js +172 -0
- package/dist/agent/agent-loop.d.ts +56 -0
- package/dist/agent/agent-loop.js +167 -0
- package/dist/agent/agent-provider.d.ts +28 -0
- package/dist/agent/agent-provider.js +63 -0
- package/dist/agent/agent-tools.d.ts +28 -0
- package/dist/agent/agent-tools.js +899 -0
- package/dist/agent/context/editing-guidelines.md +46 -0
- package/dist/agent/context/role.md +39 -0
- package/dist/agent/integration-prompt.d.ts +9 -0
- package/dist/agent/integration-prompt.js +154 -0
- package/dist/agent/sites-agent-context.d.ts +12 -0
- package/dist/agent/sites-agent-context.js +316 -0
- package/dist/agent/sites-agent-shared.d.ts +161 -0
- package/dist/agent/sites-agent-shared.js +1101 -0
- package/dist/agent/sites-agent-tools.d.ts +18 -0
- package/dist/agent/sites-agent-tools.js +1227 -0
- package/dist/chat/anthropic-cache.d.ts +20 -0
- package/dist/chat/anthropic-cache.js +54 -0
- package/dist/chat/anthropic-planner.d.ts +98 -0
- package/dist/chat/anthropic-planner.js +1012 -0
- package/dist/chat/changelog-coverage-validator.d.ts +37 -0
- package/dist/chat/changelog-coverage-validator.js +215 -0
- package/dist/chat/chat-pipeline-context.d.ts +211 -0
- package/dist/chat/chat-pipeline-context.js +249 -0
- package/dist/chat/chat-pipeline-deterministic.d.ts +61 -0
- package/dist/chat/chat-pipeline-deterministic.js +407 -0
- package/dist/chat/chat-pipeline-image.d.ts +86 -0
- package/dist/chat/chat-pipeline-image.js +897 -0
- package/dist/chat/chat-pipeline-shared.d.ts +69 -0
- package/dist/chat/chat-pipeline-shared.js +212 -0
- package/dist/chat/chat-pipeline-translation.d.ts +27 -0
- package/dist/chat/chat-pipeline-translation.js +417 -0
- package/dist/chat/chat-pipeline-ui.d.ts +14 -0
- package/dist/chat/chat-pipeline-ui.js +244 -0
- package/dist/chat/chat-pipeline.d.ts +99 -0
- package/dist/chat/chat-pipeline.js +3999 -0
- package/dist/chat/decomposer.d.ts +21 -0
- package/dist/chat/decomposer.js +65 -0
- package/dist/chat/gemini-planner.d.ts +70 -0
- package/dist/chat/gemini-planner.js +541 -0
- package/dist/chat/hallucination-validator.d.ts +36 -0
- package/dist/chat/hallucination-validator.js +110 -0
- package/dist/chat/locale-strings.d.ts +47 -0
- package/dist/chat/locale-strings.js +100 -0
- package/dist/chat/plan-json-schema.d.ts +133 -0
- package/dist/chat/plan-json-schema.js +112 -0
- package/dist/chat/planner-types.d.ts +120 -0
- package/dist/chat/planner-types.js +66 -0
- package/dist/chat/planner.d.ts +148 -0
- package/dist/chat/planner.js +1361 -0
- package/dist/chat/prompts.d.ts +67 -0
- package/dist/chat/prompts.js +356 -0
- package/dist/chat/provider-routing.d.ts +14 -0
- package/dist/chat/provider-routing.js +27 -0
- package/dist/chat/variation-pipeline.d.ts +135 -0
- package/dist/chat/variation-pipeline.js +837 -0
- package/dist/chat/vision-alt-generator.d.ts +35 -0
- package/dist/chat/vision-alt-generator.js +152 -0
- package/dist/cms/adapter.d.ts +62 -0
- package/dist/cms/adapter.js +1 -0
- package/dist/cms/bootstrap.d.ts +17 -0
- package/dist/cms/bootstrap.js +85 -0
- package/dist/cms/editor-api-adapter.d.ts +23 -0
- package/dist/cms/editor-api-adapter.js +71 -0
- package/dist/cms/index.d.ts +4 -0
- package/dist/cms/index.js +3 -0
- package/dist/cms/json-file-adapter.d.ts +11 -0
- package/dist/cms/json-file-adapter.js +62 -0
- package/dist/demo-mode.d.ts +59 -0
- package/dist/demo-mode.js +201 -0
- package/dist/errors.d.ts +67 -0
- package/dist/errors.js +129 -0
- package/dist/http/chat-stream-resumable.d.ts +108 -0
- package/dist/http/chat-stream-resumable.js +290 -0
- package/dist/http/chat-stream.d.ts +99 -0
- package/dist/http/chat-stream.js +92 -0
- package/dist/image/gdrive-client.d.ts +22 -0
- package/dist/image/gdrive-client.js +215 -0
- package/dist/image/image-helpers.d.ts +95 -0
- package/dist/image/image-helpers.js +488 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/jira/jira-approval.d.ts +22 -0
- package/dist/jira/jira-approval.js +51 -0
- package/dist/jira/jira-client.d.ts +44 -0
- package/dist/jira/jira-client.js +313 -0
- package/dist/jira/jira-poller.d.ts +46 -0
- package/dist/jira/jira-poller.js +184 -0
- package/dist/jira/jira-processor.d.ts +103 -0
- package/dist/jira/jira-processor.js +1085 -0
- package/dist/jira/jira-types.d.ts +117 -0
- package/dist/jira/jira-types.js +38 -0
- package/dist/logger.d.ts +12 -0
- package/dist/logger.js +28 -0
- package/dist/migration/mcp-server-stdio.d.ts +8 -0
- package/dist/migration/mcp-server-stdio.js +672 -0
- package/dist/migration/migration-prompt.d.ts +7 -0
- package/dist/migration/migration-prompt.js +197 -0
- package/dist/migration/migration-tools.d.ts +17 -0
- package/dist/migration/migration-tools.js +159 -0
- package/dist/migration/scrape-cache.d.ts +9 -0
- package/dist/migration/scrape-cache.js +19 -0
- package/dist/nlp/deterministic-planner-context.d.ts +141 -0
- package/dist/nlp/deterministic-planner-context.js +362 -0
- package/dist/nlp/deterministic-planner-pages.d.ts +26 -0
- package/dist/nlp/deterministic-planner-pages.js +170 -0
- package/dist/nlp/deterministic-planner-patches.d.ts +80 -0
- package/dist/nlp/deterministic-planner-patches.js +508 -0
- package/dist/nlp/deterministic-planner-refs.d.ts +33 -0
- package/dist/nlp/deterministic-planner-refs.js +164 -0
- package/dist/nlp/deterministic-planner-suggestions.d.ts +49 -0
- package/dist/nlp/deterministic-planner-suggestions.js +579 -0
- package/dist/nlp/deterministic-planner.d.ts +85 -0
- package/dist/nlp/deterministic-planner.js +1631 -0
- package/dist/nlp/intent-detection.d.ts +309 -0
- package/dist/nlp/intent-detection.js +730 -0
- package/dist/nlp/intent-helpers.d.ts +15 -0
- package/dist/nlp/intent-helpers.js +243 -0
- package/dist/nlp/intent-patterns.d.ts +40 -0
- package/dist/nlp/intent-patterns.js +223 -0
- package/dist/nlp/plan-normalizer.d.ts +41 -0
- package/dist/nlp/plan-normalizer.js +1537 -0
- package/dist/ops/destructive-action-gate.d.ts +44 -0
- package/dist/ops/destructive-action-gate.js +90 -0
- package/dist/ops/ops-engine.d.ts +151 -0
- package/dist/ops/ops-engine.js +1394 -0
- package/dist/publish/diff-engine.d.ts +18 -0
- package/dist/publish/diff-engine.js +305 -0
- package/dist/publish/publish-helpers.d.ts +87 -0
- package/dist/publish/publish-helpers.js +521 -0
- package/dist/publish/publish-target-registry.d.ts +7 -0
- package/dist/publish/publish-target-registry.js +61 -0
- package/dist/publish/publish-target.d.ts +81 -0
- package/dist/publish/publish-target.js +1 -0
- package/dist/publish/targets/deploy-hook.d.ts +13 -0
- package/dist/publish/targets/deploy-hook.js +123 -0
- package/dist/publish/targets/git.d.ts +13 -0
- package/dist/publish/targets/git.js +55 -0
- package/dist/publish/targets/site-contract.d.ts +19 -0
- package/dist/publish/targets/site-contract.js +124 -0
- package/dist/state/content-source.d.ts +17 -0
- package/dist/state/content-source.js +1 -0
- package/dist/state/in-memory-content-source.d.ts +27 -0
- package/dist/state/in-memory-content-source.js +51 -0
- package/dist/state/session-lock.d.ts +13 -0
- package/dist/state/session-lock.js +29 -0
- package/dist/state/session-state.d.ts +310 -0
- package/dist/state/session-state.js +1083 -0
- package/dist/state/sqlite-store-singleton.d.ts +31 -0
- package/dist/state/sqlite-store-singleton.js +170 -0
- package/dist/state/sqlite-store.d.ts +135 -0
- package/dist/state/sqlite-store.js +421 -0
- package/dist/telemetry/chat-telemetry.d.ts +105 -0
- package/dist/telemetry/chat-telemetry.js +247 -0
- package/dist/telemetry/eval-candidate-store.d.ts +50 -0
- package/dist/telemetry/eval-candidate-store.js +120 -0
- package/dist/telemetry/feedback-store.d.ts +34 -0
- package/dist/telemetry/feedback-store.js +76 -0
- package/dist/telemetry/jira-telemetry.d.ts +57 -0
- package/dist/telemetry/jira-telemetry.js +68 -0
- package/dist/telemetry/migration-telemetry.d.ts +35 -0
- package/dist/telemetry/migration-telemetry.js +40 -0
- package/dist/telemetry/usage.d.ts +24 -0
- package/dist/telemetry/usage.js +80 -0
- package/dist/tools/builtin-registrations.d.ts +12 -0
- package/dist/tools/builtin-registrations.js +33 -0
- package/dist/tools/builtins/gdrive-browse.d.ts +3 -0
- package/dist/tools/builtins/gdrive-browse.js +68 -0
- package/dist/tools/builtins/image-generate.d.ts +3 -0
- package/dist/tools/builtins/image-generate.js +211 -0
- package/dist/tools/builtins/unsplash-get-by-id.d.ts +23 -0
- package/dist/tools/builtins/unsplash-get-by-id.js +119 -0
- package/dist/tools/builtins/unsplash-search.d.ts +3 -0
- package/dist/tools/builtins/unsplash-search.js +74 -0
- package/dist/tools/executor.d.ts +23 -0
- package/dist/tools/executor.js +169 -0
- package/dist/tools/index.d.ts +5 -0
- package/dist/tools/index.js +5 -0
- package/dist/tools/registry.d.ts +21 -0
- package/dist/tools/registry.js +75 -0
- package/dist/tools/runtime.d.ts +27 -0
- package/dist/tools/runtime.js +48 -0
- package/dist/tools/schema-validator.d.ts +24 -0
- package/dist/tools/schema-validator.js +88 -0
- package/dist/tools/types.d.ts +86 -0
- package/dist/tools/types.js +1 -0
- package/dist/variation-images.d.ts +19 -0
- package/dist/variation-images.js +12 -0
- package/package.json +78 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Migration telemetry — persists per-migration run data to JSONL for analysis.
|
|
3
|
+
* Follows the same append-flush pattern as chat-telemetry.ts.
|
|
4
|
+
*/
|
|
5
|
+
export type MigrationTelemetryEntry = {
|
|
6
|
+
timestamp: string;
|
|
7
|
+
streamId: string;
|
|
8
|
+
status: "success" | "error";
|
|
9
|
+
durationMs: number;
|
|
10
|
+
numTurns: number;
|
|
11
|
+
toolCallCount: number;
|
|
12
|
+
toolsUsed: string[];
|
|
13
|
+
/** Tool call sequence with agent context (main vs subagent) */
|
|
14
|
+
toolDetails?: Array<{
|
|
15
|
+
tool: string;
|
|
16
|
+
agent: "main" | "sub";
|
|
17
|
+
durationMs?: number;
|
|
18
|
+
}>;
|
|
19
|
+
inputTokens: number;
|
|
20
|
+
outputTokens: number;
|
|
21
|
+
cacheReadInputTokens: number;
|
|
22
|
+
cacheCreationInputTokens: number;
|
|
23
|
+
totalCostUsd: number;
|
|
24
|
+
sitesCreated: string[];
|
|
25
|
+
userMessage?: string;
|
|
26
|
+
/** Per-model token + cost breakdown from Agent SDK */
|
|
27
|
+
modelBreakdown?: Record<string, {
|
|
28
|
+
inputTokens: number;
|
|
29
|
+
outputTokens: number;
|
|
30
|
+
costUsd: number;
|
|
31
|
+
}>;
|
|
32
|
+
/** Debug artifact directory for this run */
|
|
33
|
+
debugDir?: string;
|
|
34
|
+
};
|
|
35
|
+
export declare function pushMigrationTelemetry(entry: MigrationTelemetryEntry): void;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Migration telemetry — persists per-migration run data to JSONL for analysis.
|
|
3
|
+
* Follows the same append-flush pattern as chat-telemetry.ts.
|
|
4
|
+
*/
|
|
5
|
+
import { appendFile, mkdir } from "node:fs/promises";
|
|
6
|
+
import { resolve, dirname } from "node:path";
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
const TELEMETRY_FILE = resolve(homedir(), ".data/telemetry/migration-telemetry.jsonl");
|
|
9
|
+
const pendingWrites = [];
|
|
10
|
+
let flushTimer = null;
|
|
11
|
+
async function flushNow() {
|
|
12
|
+
if (pendingWrites.length === 0)
|
|
13
|
+
return;
|
|
14
|
+
const pending = pendingWrites.splice(0, pendingWrites.length);
|
|
15
|
+
const lines = pending.map((item) => JSON.stringify(item)).join("\n");
|
|
16
|
+
await mkdir(dirname(TELEMETRY_FILE), { recursive: true });
|
|
17
|
+
await appendFile(TELEMETRY_FILE, `${lines}\n`, "utf8");
|
|
18
|
+
}
|
|
19
|
+
function scheduleFlush() {
|
|
20
|
+
if (flushTimer)
|
|
21
|
+
clearTimeout(flushTimer);
|
|
22
|
+
flushTimer = setTimeout(() => {
|
|
23
|
+
void flushNow().catch((err) => {
|
|
24
|
+
console.error("[migration-telemetry] flush failed:", err instanceof Error ? err.message : String(err));
|
|
25
|
+
});
|
|
26
|
+
}, 150);
|
|
27
|
+
flushTimer.unref(); // don't keep the event loop alive for debug telemetry
|
|
28
|
+
}
|
|
29
|
+
// Flush on shutdown to avoid losing entries from multi-dollar agent runs
|
|
30
|
+
process.on("beforeExit", () => { void flushNow().catch(() => { }); });
|
|
31
|
+
export function pushMigrationTelemetry(entry) {
|
|
32
|
+
pendingWrites.push(entry);
|
|
33
|
+
scheduleFlush();
|
|
34
|
+
// Also log a summary line to console
|
|
35
|
+
const { inputTokens: inp, outputTokens: out, cacheReadInputTokens: cacheR, cacheCreationInputTokens: cacheC, totalCostUsd: cost } = entry;
|
|
36
|
+
console.log(`[migration-telemetry] ${entry.streamId.slice(0, 8)} ${entry.status} | ` +
|
|
37
|
+
`${entry.numTurns} turns, ${entry.toolCallCount} tools | ` +
|
|
38
|
+
`tokens: in=${inp} out=${out} cache_read=${cacheR} cache_create=${cacheC} | ` +
|
|
39
|
+
`cost=$${cost.toFixed(4)} | ${(entry.durationMs / 1000).toFixed(1)}s`);
|
|
40
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export type TokenUsage = {
|
|
2
|
+
inputTokens: number;
|
|
3
|
+
outputTokens: number;
|
|
4
|
+
totalTokens: number;
|
|
5
|
+
cacheCreationInputTokens?: number;
|
|
6
|
+
cacheReadInputTokens?: number;
|
|
7
|
+
};
|
|
8
|
+
export declare const ZERO_USAGE: TokenUsage;
|
|
9
|
+
/**
|
|
10
|
+
* Extract token usage from an OpenAI or Anthropic API response object.
|
|
11
|
+
* Handles both `prompt_tokens`/`completion_tokens` (OpenAI) and
|
|
12
|
+
* `input_tokens`/`output_tokens` (Anthropic) naming conventions.
|
|
13
|
+
*/
|
|
14
|
+
export declare function extractUsage(source: unknown): TokenUsage;
|
|
15
|
+
/** USD per 1 million tokens. Keys use prefix matching. */
|
|
16
|
+
export declare const USD_PER_MTOK: Record<string, {
|
|
17
|
+
input: number;
|
|
18
|
+
output: number;
|
|
19
|
+
}>;
|
|
20
|
+
/**
|
|
21
|
+
* Estimate the USD cost for a given model and token usage.
|
|
22
|
+
* Uses exact key match first, then falls back to prefix matching.
|
|
23
|
+
*/
|
|
24
|
+
export declare function estimateUsd(model: string, usage: TokenUsage): number | null;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Token usage tracking — extracted from benchmark-models.ts for reuse
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
export const ZERO_USAGE = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
|
5
|
+
/**
|
|
6
|
+
* Extract token usage from an OpenAI or Anthropic API response object.
|
|
7
|
+
* Handles both `prompt_tokens`/`completion_tokens` (OpenAI) and
|
|
8
|
+
* `input_tokens`/`output_tokens` (Anthropic) naming conventions.
|
|
9
|
+
*/
|
|
10
|
+
export function extractUsage(source) {
|
|
11
|
+
const usage = source?.usage;
|
|
12
|
+
if (!usage)
|
|
13
|
+
return { ...ZERO_USAGE };
|
|
14
|
+
const promptTokens = typeof usage.prompt_tokens === "number" ? usage.prompt_tokens : 0;
|
|
15
|
+
const completionTokens = typeof usage.completion_tokens === "number" ? usage.completion_tokens : 0;
|
|
16
|
+
const inputTokens = typeof usage.input_tokens === "number" ? usage.input_tokens : promptTokens;
|
|
17
|
+
const outputTokens = typeof usage.output_tokens === "number" ? usage.output_tokens : completionTokens;
|
|
18
|
+
const anthropicCacheCreationTokensRaw = usage.cache_creation_input_tokens;
|
|
19
|
+
const anthropicCacheReadTokensRaw = usage.cache_read_input_tokens;
|
|
20
|
+
const openAIPromptDetails = usage.prompt_tokens_details;
|
|
21
|
+
const openAIInputDetails = usage.input_tokens_details;
|
|
22
|
+
const openAICachedPromptTokensRaw = openAIPromptDetails?.cached_tokens;
|
|
23
|
+
const openAICachedInputTokensRaw = openAIInputDetails?.cached_tokens;
|
|
24
|
+
const cacheCreationInputTokens = typeof anthropicCacheCreationTokensRaw === "number"
|
|
25
|
+
? Math.max(0, anthropicCacheCreationTokensRaw)
|
|
26
|
+
: undefined;
|
|
27
|
+
const cacheReadInputTokens = typeof anthropicCacheReadTokensRaw === "number"
|
|
28
|
+
? Math.max(0, anthropicCacheReadTokensRaw)
|
|
29
|
+
: typeof openAICachedPromptTokensRaw === "number"
|
|
30
|
+
? Math.max(0, openAICachedPromptTokensRaw)
|
|
31
|
+
: typeof openAICachedInputTokensRaw === "number"
|
|
32
|
+
? Math.max(0, openAICachedInputTokensRaw)
|
|
33
|
+
: undefined;
|
|
34
|
+
const totalTokens = typeof usage.total_tokens === "number" ? usage.total_tokens : Math.max(0, inputTokens) + Math.max(0, outputTokens);
|
|
35
|
+
return {
|
|
36
|
+
inputTokens: Math.max(0, inputTokens),
|
|
37
|
+
outputTokens: Math.max(0, outputTokens),
|
|
38
|
+
totalTokens: Math.max(0, totalTokens),
|
|
39
|
+
...(cacheCreationInputTokens !== undefined ? { cacheCreationInputTokens } : {}),
|
|
40
|
+
...(cacheReadInputTokens !== undefined ? { cacheReadInputTokens } : {})
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// Cost estimation
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
/** USD per 1 million tokens. Keys use prefix matching. */
|
|
47
|
+
export const USD_PER_MTOK = {
|
|
48
|
+
// OpenAI — current GA lineup (exact match wins over the "gpt-*" prefix fallbacks below).
|
|
49
|
+
"gpt-5.6-sol": { input: 5, output: 30 },
|
|
50
|
+
"gpt-5.6-terra": { input: 2.5, output: 15 },
|
|
51
|
+
"gpt-5.6-luna": { input: 1, output: 6 },
|
|
52
|
+
"gpt-5.3-codex": { input: 1.75, output: 14 },
|
|
53
|
+
"gpt-5.4-nano": { input: 0.2, output: 1.25 },
|
|
54
|
+
// OpenAI — legacy, still served / referenced by env overrides.
|
|
55
|
+
"gpt-4o-mini": { input: 0.15, output: 0.6 },
|
|
56
|
+
"gpt-4o": { input: 2.5, output: 10 },
|
|
57
|
+
"gpt-5": { input: 1.25, output: 10 },
|
|
58
|
+
// Gemini — current GA lineup. Pro tiers are per-request >200K jumps ($2.50/$15);
|
|
59
|
+
// we track the base ≤200K rate, matching how the other rows report standard usage.
|
|
60
|
+
"gemini-3.6-flash": { input: 1.5, output: 7.5 },
|
|
61
|
+
"gemini-3.5-flash-lite": { input: 0.3, output: 2.5 },
|
|
62
|
+
"gemini-2.5-pro": { input: 1.25, output: 10 },
|
|
63
|
+
"gemini-2.5-flash": { input: 0.3, output: 2.5 },
|
|
64
|
+
// Prefix-matched. Rates current as of the Opus 4.8 / Sonnet 5 / Haiku 4.5 tier.
|
|
65
|
+
// Sonnet 5 has an intro rate of $2/$10 through 2026-08-31; we track the standard
|
|
66
|
+
// $3/$15 so cost estimates don't under-report once the intro window closes.
|
|
67
|
+
"claude-haiku": { input: 1, output: 5 },
|
|
68
|
+
"claude-sonnet": { input: 3, output: 15 },
|
|
69
|
+
"claude-opus": { input: 5, output: 25 }
|
|
70
|
+
};
|
|
71
|
+
/**
|
|
72
|
+
* Estimate the USD cost for a given model and token usage.
|
|
73
|
+
* Uses exact key match first, then falls back to prefix matching.
|
|
74
|
+
*/
|
|
75
|
+
export function estimateUsd(model, usage) {
|
|
76
|
+
const pricing = USD_PER_MTOK[model] ?? USD_PER_MTOK[Object.keys(USD_PER_MTOK).find((key) => model.startsWith(key)) ?? ""];
|
|
77
|
+
if (!pricing)
|
|
78
|
+
return null;
|
|
79
|
+
return (usage.inputTokens / 1_000_000) * pricing.input + (usage.outputTokens / 1_000_000) * pricing.output;
|
|
80
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { ToolRegistry } from "./registry.ts";
|
|
2
|
+
export type DefaultBuiltinName = "unsplash-search" | "image-generate" | "gdrive-browse";
|
|
3
|
+
export interface RegisterDefaultBuiltinsOptions {
|
|
4
|
+
/**
|
|
5
|
+
* Override which builtins are registered. Defaults to env-gated behavior:
|
|
6
|
+
* - unsplash-search: always
|
|
7
|
+
* - image-generate: when OPENAI_API_KEY or GOOGLE_GENAI_API_KEY is set
|
|
8
|
+
* - gdrive-browse: when GOOGLE_DRIVE_FOLDER_ID is set
|
|
9
|
+
*/
|
|
10
|
+
include?: DefaultBuiltinName[];
|
|
11
|
+
}
|
|
12
|
+
export declare function registerDefaultBuiltins(registry: ToolRegistry, options?: RegisterDefaultBuiltinsOptions): DefaultBuiltinName[];
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Default builtin tool registrations.
|
|
2
|
+
//
|
|
3
|
+
// Kept in a separate module so that importing tools/runtime.ts does NOT
|
|
4
|
+
// transitively pull in the image/ subdir (and therefore sharp + googleapis
|
|
5
|
+
// + the image-generation SDKs). Library-mode consumers that only need the
|
|
6
|
+
// chat planner can skip this module entirely; the Fastify orchestrator at
|
|
7
|
+
// apps/orchestrator/src/index.ts calls registerDefaultBuiltins() at startup.
|
|
8
|
+
import { unsplashSearchHandler, unsplashSearchManifest } from "./builtins/unsplash-search.js";
|
|
9
|
+
import { imageGenerateHandler, imageGenerateManifest } from "./builtins/image-generate.js";
|
|
10
|
+
import { gdriveBrowseHandler, gdriveBrowseManifest } from "./builtins/gdrive-browse.js";
|
|
11
|
+
export function registerDefaultBuiltins(registry, options = {}) {
|
|
12
|
+
const env = process.env;
|
|
13
|
+
const defaultInclude = ["unsplash-search"];
|
|
14
|
+
if (env.OPENAI_API_KEY || env.GOOGLE_GENAI_API_KEY)
|
|
15
|
+
defaultInclude.push("image-generate");
|
|
16
|
+
if (env.GOOGLE_DRIVE_FOLDER_ID)
|
|
17
|
+
defaultInclude.push("gdrive-browse");
|
|
18
|
+
const include = options.include ?? defaultInclude;
|
|
19
|
+
const registered = [];
|
|
20
|
+
if (include.includes("unsplash-search")) {
|
|
21
|
+
registry.registerBuiltin(unsplashSearchManifest, unsplashSearchHandler);
|
|
22
|
+
registered.push("unsplash-search");
|
|
23
|
+
}
|
|
24
|
+
if (include.includes("image-generate")) {
|
|
25
|
+
registry.registerBuiltin(imageGenerateManifest, imageGenerateHandler);
|
|
26
|
+
registered.push("image-generate");
|
|
27
|
+
}
|
|
28
|
+
if (include.includes("gdrive-browse")) {
|
|
29
|
+
registry.registerBuiltin(gdriveBrowseManifest, gdriveBrowseHandler);
|
|
30
|
+
registered.push("gdrive-browse");
|
|
31
|
+
}
|
|
32
|
+
return registered;
|
|
33
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { listImages, isGdriveConfigured, fileNameToAlt, resolveGdriveFolderId } from "../../image/gdrive-client.js";
|
|
2
|
+
export const gdriveBrowseManifest = {
|
|
3
|
+
name: "gdrive.browse",
|
|
4
|
+
description: "Browse and search images in the shared Google Drive folder. Use for brand assets, company photos, or user-uploaded images.",
|
|
5
|
+
capability: "read",
|
|
6
|
+
timeoutMs: 8000,
|
|
7
|
+
retryPolicy: { maxAttempts: 2, backoffMs: 200 },
|
|
8
|
+
idempotent: true,
|
|
9
|
+
inputSchema: {
|
|
10
|
+
type: "object",
|
|
11
|
+
additionalProperties: false,
|
|
12
|
+
required: [],
|
|
13
|
+
properties: {
|
|
14
|
+
query: { type: "string", description: "Optional search text to filter images by name" },
|
|
15
|
+
limit: { type: "integer", description: "Max results (1-10, default 5)" }
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
outputSchema: {
|
|
19
|
+
type: "object",
|
|
20
|
+
additionalProperties: false,
|
|
21
|
+
required: ["items"],
|
|
22
|
+
properties: {
|
|
23
|
+
items: {
|
|
24
|
+
type: "array",
|
|
25
|
+
items: {
|
|
26
|
+
type: "object",
|
|
27
|
+
additionalProperties: false,
|
|
28
|
+
required: ["id", "imageUrl", "thumbUrl", "alt", "author", "sourceUrl"],
|
|
29
|
+
properties: {
|
|
30
|
+
id: { type: "string" },
|
|
31
|
+
imageUrl: { type: "string" },
|
|
32
|
+
thumbUrl: { type: "string" },
|
|
33
|
+
alt: { type: "string" },
|
|
34
|
+
author: { type: "string" },
|
|
35
|
+
sourceUrl: { type: "string" }
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
export const gdriveBrowseHandler = async ({ input, context }) => {
|
|
43
|
+
if (!isGdriveConfigured() && !context.gdriveFolderId) {
|
|
44
|
+
return { items: [] };
|
|
45
|
+
}
|
|
46
|
+
const typed = (input ?? {});
|
|
47
|
+
const query = typeof typed.query === "string" ? typed.query.trim() : undefined;
|
|
48
|
+
const limitRaw = typeof typed.limit === "number" ? typed.limit : 5;
|
|
49
|
+
const limit = Math.min(10, Math.max(1, Math.trunc(limitRaw)));
|
|
50
|
+
const folderId = resolveGdriveFolderId(context.gdriveFolderId);
|
|
51
|
+
if (!folderId)
|
|
52
|
+
return { items: [] };
|
|
53
|
+
const orchestratorPublicOrigin = (process.env.ORCHESTRATOR_PUBLIC_ORIGIN ?? "http://localhost:4200").replace(/\/+$/, "");
|
|
54
|
+
const files = await listImages(folderId, query, undefined, limit);
|
|
55
|
+
const items = files.map((file) => {
|
|
56
|
+
const imageUrl = `${orchestratorPublicOrigin}/gdrive/images/${file.id}`;
|
|
57
|
+
const thumbUrl = imageUrl;
|
|
58
|
+
return {
|
|
59
|
+
id: file.id,
|
|
60
|
+
imageUrl,
|
|
61
|
+
thumbUrl,
|
|
62
|
+
alt: fileNameToAlt(file.name),
|
|
63
|
+
author: "Google Drive",
|
|
64
|
+
sourceUrl: `https://drive.google.com/file/d/${file.id}/view`
|
|
65
|
+
};
|
|
66
|
+
});
|
|
67
|
+
return { items };
|
|
68
|
+
};
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { generateVariationImageWithOpenAI, generateVariationImageWithGemini, recordImageGenDuration, estimatedImageGenMs } from "../../image/image-helpers.js";
|
|
2
|
+
import { getPage } from "../../state/session-state.js";
|
|
3
|
+
const ASPECT_RATIO_TO_SIZE = {
|
|
4
|
+
landscape: "1536x1024",
|
|
5
|
+
square: "1024x1024",
|
|
6
|
+
portrait: "1024x1536"
|
|
7
|
+
};
|
|
8
|
+
const SIZE_DIMENSIONS = {
|
|
9
|
+
"1536x1024": { width: 1536, height: 1024 },
|
|
10
|
+
"1024x1024": { width: 1024, height: 1024 },
|
|
11
|
+
"1024x1536": { width: 1024, height: 1536 }
|
|
12
|
+
};
|
|
13
|
+
const COMPOSITION_HINTS = {
|
|
14
|
+
Hero: "Full-width hero section — landscape, cinematic, attention-grabbing",
|
|
15
|
+
Banner: "Banner strip — wide landscape, clean with space for overlay text",
|
|
16
|
+
CTA: "Call-to-action section — atmospheric, supportive background",
|
|
17
|
+
Card: "Card thumbnail — balanced square or portrait composition, focused subject",
|
|
18
|
+
CardGrid: "Card grid thumbnail — balanced, consistent style across cards",
|
|
19
|
+
FeatureGrid: "Feature illustration — clean, focused, minimal background",
|
|
20
|
+
Gallery: "Gallery image — well-composed, standalone photograph",
|
|
21
|
+
Carousel: "Carousel slide — landscape, visually striking",
|
|
22
|
+
TwoColumn: "Two-column layout image — balanced composition, clear subject",
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Enrich a bare prompt with block/page context for better image generation.
|
|
26
|
+
*/
|
|
27
|
+
function enrichPromptWithContext(basePrompt, ctx) {
|
|
28
|
+
const parts = [];
|
|
29
|
+
if (ctx.blockType) {
|
|
30
|
+
parts.push(`Image for: ${ctx.blockType} block`);
|
|
31
|
+
const hint = COMPOSITION_HINTS[ctx.blockType];
|
|
32
|
+
if (hint)
|
|
33
|
+
parts.push(`Composition: ${hint}`);
|
|
34
|
+
}
|
|
35
|
+
// Look up block content for richer context
|
|
36
|
+
if (ctx.session && ctx.blockId && ctx.pageSlug) {
|
|
37
|
+
try {
|
|
38
|
+
const page = getPage(ctx.session, ctx.pageSlug);
|
|
39
|
+
if (page) {
|
|
40
|
+
if (page.title)
|
|
41
|
+
parts.push(`Page: "${page.title}"`);
|
|
42
|
+
const block = page.blocks.find(b => b.id === ctx.blockId);
|
|
43
|
+
if (block) {
|
|
44
|
+
const props = block.props;
|
|
45
|
+
const heading = typeof props.heading === "string" ? props.heading : "";
|
|
46
|
+
const subheading = typeof props.subheading === "string" ? props.subheading : "";
|
|
47
|
+
const title = typeof props.title === "string" ? props.title : "";
|
|
48
|
+
if (heading)
|
|
49
|
+
parts.push(`Block heading: "${heading}"`);
|
|
50
|
+
if (subheading)
|
|
51
|
+
parts.push(`Block subheading: "${subheading}"`);
|
|
52
|
+
if (title && title !== heading)
|
|
53
|
+
parts.push(`Block title: "${title}"`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
// Session lookup may fail — just skip context enrichment
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
parts.push(`\nUser request: ${basePrompt}`);
|
|
62
|
+
if (ctx.style)
|
|
63
|
+
parts.push(`Style: ${ctx.style}`);
|
|
64
|
+
if (ctx.background === "transparent")
|
|
65
|
+
parts.push("Background: transparent (no background, isolated subject)");
|
|
66
|
+
parts.push("Constraints: no text overlays, no logos, no watermarks");
|
|
67
|
+
return parts.join("\n");
|
|
68
|
+
}
|
|
69
|
+
export const imageGenerateManifest = {
|
|
70
|
+
name: "image.generate",
|
|
71
|
+
description: "Generate an AI image from a text prompt. Default to quality 'draft'. Use 'final' only when the user explicitly asks for high quality, polished, or production-ready images.",
|
|
72
|
+
capability: "read",
|
|
73
|
+
timeoutMs: 90000,
|
|
74
|
+
retryPolicy: { maxAttempts: 1 },
|
|
75
|
+
idempotent: false,
|
|
76
|
+
inputSchema: {
|
|
77
|
+
type: "object",
|
|
78
|
+
additionalProperties: false,
|
|
79
|
+
required: ["prompt"],
|
|
80
|
+
properties: {
|
|
81
|
+
prompt: { type: "string", description: "Detailed text prompt describing the image to generate" },
|
|
82
|
+
aspectRatio: {
|
|
83
|
+
type: "string",
|
|
84
|
+
enum: ["landscape", "square", "portrait"],
|
|
85
|
+
description: "Aspect ratio of the generated image (default: landscape)"
|
|
86
|
+
},
|
|
87
|
+
quality: {
|
|
88
|
+
type: "string",
|
|
89
|
+
enum: ["draft", "final"],
|
|
90
|
+
description: "Image quality tier: 'draft' for fast previews, 'final' for production-ready"
|
|
91
|
+
},
|
|
92
|
+
style: { type: "string", description: "Optional style guidance (e.g. 'photorealistic', 'illustration')" },
|
|
93
|
+
background: {
|
|
94
|
+
type: "string",
|
|
95
|
+
enum: ["transparent", "opaque", "auto"],
|
|
96
|
+
description: "Background mode: 'transparent' for cutout/isolated subjects (logos, icons, product shots), 'opaque' for full scenes, 'auto' lets the model decide (default: auto)"
|
|
97
|
+
},
|
|
98
|
+
outputFormat: {
|
|
99
|
+
type: "string",
|
|
100
|
+
enum: ["png", "webp", "jpeg"],
|
|
101
|
+
description: "Output image format (default: png). Use png for transparency, webp for smaller files, jpeg for photos."
|
|
102
|
+
},
|
|
103
|
+
blockType: { type: "string", description: "Block type the image is for (e.g. 'Hero', 'Card') — helps match composition to layout" },
|
|
104
|
+
blockId: { type: "string", description: "Block ID — used to look up block content for context-aware generation" },
|
|
105
|
+
pageSlug: { type: "string", description: "Page slug — used with blockId to look up surrounding content" },
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
outputSchema: {
|
|
109
|
+
type: "object",
|
|
110
|
+
additionalProperties: false,
|
|
111
|
+
required: ["imageUrl", "alt", "width", "height"],
|
|
112
|
+
properties: {
|
|
113
|
+
imageUrl: { type: "string" },
|
|
114
|
+
alt: { type: "string" },
|
|
115
|
+
width: { type: "integer" },
|
|
116
|
+
height: { type: "integer" }
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
export const imageGenerateHandler = async ({ input, context, signal }) => {
|
|
121
|
+
const typed = (input ?? {});
|
|
122
|
+
const prompt = typeof typed.prompt === "string" ? typed.prompt.trim() : "";
|
|
123
|
+
if (!prompt) {
|
|
124
|
+
throw new Error("prompt is required for image.generate");
|
|
125
|
+
}
|
|
126
|
+
const aspectRatio = typed.aspectRatio ?? "landscape";
|
|
127
|
+
const quality = typed.quality ?? "draft";
|
|
128
|
+
const background = typed.background ?? "auto";
|
|
129
|
+
const outputFormat = typed.outputFormat ?? "png";
|
|
130
|
+
const size = ASPECT_RATIO_TO_SIZE[aspectRatio] ?? "1536x1024";
|
|
131
|
+
const dims = SIZE_DIMENSIONS[size] ?? { width: 1536, height: 1024 };
|
|
132
|
+
const model = quality === "final"
|
|
133
|
+
? (process.env.OPENAI_IMAGE_MODEL?.trim() || "gpt-image-2")
|
|
134
|
+
: (process.env.OPENAI_IMAGE_MODEL_DRAFT?.trim() || "gpt-image-1-mini");
|
|
135
|
+
// Enrich prompt with block/page context when available
|
|
136
|
+
const hasContext = typed.blockType || typed.blockId || typed.pageSlug;
|
|
137
|
+
const enrichedPrompt = hasContext
|
|
138
|
+
? enrichPromptWithContext(prompt, {
|
|
139
|
+
session: context.sessionId,
|
|
140
|
+
blockType: typed.blockType,
|
|
141
|
+
blockId: typed.blockId,
|
|
142
|
+
pageSlug: typed.pageSlug,
|
|
143
|
+
style: typed.style,
|
|
144
|
+
background,
|
|
145
|
+
})
|
|
146
|
+
: (typed.style ? `${prompt}. Style: ${typed.style}` : prompt);
|
|
147
|
+
context.onStatusUpdate?.("Generating AI image\u2026");
|
|
148
|
+
// Start progress timer for long-running image generation
|
|
149
|
+
const onProgress = context.onImageProgress;
|
|
150
|
+
const stages = [
|
|
151
|
+
{ at: 0.00, pct: 0, label: "Understanding prompt\u2026" },
|
|
152
|
+
{ at: 0.10, pct: 15, label: "Composing scene\u2026" },
|
|
153
|
+
{ at: 0.30, pct: 40, label: "Rendering image\u2026" },
|
|
154
|
+
{ at: 0.65, pct: 75, label: "Finalizing details\u2026" },
|
|
155
|
+
{ at: 0.90, pct: 95, label: "Almost there\u2026" },
|
|
156
|
+
];
|
|
157
|
+
const estimated = estimatedImageGenMs();
|
|
158
|
+
const startedAt = Date.now();
|
|
159
|
+
let currentStageIdx = 0;
|
|
160
|
+
onProgress?.({ percent: 0, stage: stages[0].label });
|
|
161
|
+
const progressTimer = onProgress ? setInterval(() => {
|
|
162
|
+
const elapsed = Date.now() - startedAt;
|
|
163
|
+
const progress = Math.min(elapsed / estimated, 1);
|
|
164
|
+
while (currentStageIdx < stages.length - 1 && progress >= stages[currentStageIdx + 1].at) {
|
|
165
|
+
currentStageIdx++;
|
|
166
|
+
}
|
|
167
|
+
const cur = stages[currentStageIdx];
|
|
168
|
+
const next = stages[currentStageIdx + 1] ?? { at: 1, pct: 95 };
|
|
169
|
+
const stageProgress = (progress - cur.at) / (next.at - cur.at);
|
|
170
|
+
const pct = Math.min(Math.round(cur.pct + stageProgress * (next.pct - cur.pct)), 95);
|
|
171
|
+
onProgress({ percent: pct, stage: cur.label });
|
|
172
|
+
}, 500) : null;
|
|
173
|
+
const provider = (process.env.IMAGE_GEN_PROVIDER?.trim().toLowerCase()) || "openai";
|
|
174
|
+
const genStart = Date.now();
|
|
175
|
+
let result = null;
|
|
176
|
+
try {
|
|
177
|
+
result = provider === "gemini"
|
|
178
|
+
? await generateVariationImageWithGemini({
|
|
179
|
+
prompt: enrichedPrompt,
|
|
180
|
+
altText: prompt,
|
|
181
|
+
aspectRatio,
|
|
182
|
+
quality,
|
|
183
|
+
background,
|
|
184
|
+
signal,
|
|
185
|
+
})
|
|
186
|
+
: await generateVariationImageWithOpenAI({
|
|
187
|
+
prompt: enrichedPrompt,
|
|
188
|
+
altText: prompt,
|
|
189
|
+
size,
|
|
190
|
+
model,
|
|
191
|
+
background,
|
|
192
|
+
outputFormat,
|
|
193
|
+
signal,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
finally {
|
|
197
|
+
recordImageGenDuration(Date.now() - genStart);
|
|
198
|
+
if (progressTimer)
|
|
199
|
+
clearInterval(progressTimer);
|
|
200
|
+
}
|
|
201
|
+
onProgress?.({ percent: 100, stage: "Done" });
|
|
202
|
+
if (!result) {
|
|
203
|
+
throw new Error("Image generation failed — no image was returned");
|
|
204
|
+
}
|
|
205
|
+
return {
|
|
206
|
+
imageUrl: result.url,
|
|
207
|
+
alt: result.alt,
|
|
208
|
+
width: dims.width,
|
|
209
|
+
height: dims.height
|
|
210
|
+
};
|
|
211
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve an Unsplash photo page URL or bare photo ID to a direct image asset
|
|
3
|
+
* URL via the Unsplash REST API.
|
|
4
|
+
*
|
|
5
|
+
* Reporters frequently drop links like
|
|
6
|
+
* https://unsplash.com/photos/an-aerial-view-of-lava-in-the-ocean-4c0JJqaG93c
|
|
7
|
+
* which are HTML pages, not image assets. Setting `imageUrl` to those breaks
|
|
8
|
+
* the `<img>` tag. This tool extracts the ID (last token after the final
|
|
9
|
+
* hyphen) and fetches `GET /photos/:id` to get `urls.regular`.
|
|
10
|
+
*/
|
|
11
|
+
import type { ToolManifest, ToolHandler } from "../types.ts";
|
|
12
|
+
export declare const unsplashGetByIdManifest: ToolManifest;
|
|
13
|
+
/**
|
|
14
|
+
* Pull the photo ID out of an Unsplash URL. Supported shapes:
|
|
15
|
+
* https://unsplash.com/photos/PHOTOID
|
|
16
|
+
* https://unsplash.com/photos/slug-with-hyphens-PHOTOID
|
|
17
|
+
* https://unsplash.com/photos/PHOTOID?ixid=...
|
|
18
|
+
* http(s)://www.unsplash.com/... (www prefix + http tolerated)
|
|
19
|
+
*
|
|
20
|
+
* Returns null when the URL is not an unsplash.com/photos/* link.
|
|
21
|
+
*/
|
|
22
|
+
export declare function extractUnsplashPhotoId(input: string): string | null;
|
|
23
|
+
export declare const unsplashGetByIdHandler: ToolHandler;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve an Unsplash photo page URL or bare photo ID to a direct image asset
|
|
3
|
+
* URL via the Unsplash REST API.
|
|
4
|
+
*
|
|
5
|
+
* Reporters frequently drop links like
|
|
6
|
+
* https://unsplash.com/photos/an-aerial-view-of-lava-in-the-ocean-4c0JJqaG93c
|
|
7
|
+
* which are HTML pages, not image assets. Setting `imageUrl` to those breaks
|
|
8
|
+
* the `<img>` tag. This tool extracts the ID (last token after the final
|
|
9
|
+
* hyphen) and fetches `GET /photos/:id` to get `urls.regular`.
|
|
10
|
+
*/
|
|
11
|
+
export const unsplashGetByIdManifest = {
|
|
12
|
+
name: "unsplash.get_by_id",
|
|
13
|
+
description: "Resolve an Unsplash photo page URL or ID to a direct image asset URL",
|
|
14
|
+
capability: "read",
|
|
15
|
+
timeoutMs: 6000,
|
|
16
|
+
retryPolicy: { maxAttempts: 2, backoffMs: 200 },
|
|
17
|
+
idempotent: true,
|
|
18
|
+
inputSchema: {
|
|
19
|
+
type: "object",
|
|
20
|
+
additionalProperties: false,
|
|
21
|
+
properties: {
|
|
22
|
+
url: { type: "string", description: "Unsplash photo page URL, e.g. https://unsplash.com/photos/slug-PHOTOID" },
|
|
23
|
+
photoId: { type: "string", description: "Bare Unsplash photo ID (alternative to url)" },
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
outputSchema: {
|
|
27
|
+
type: "object",
|
|
28
|
+
additionalProperties: false,
|
|
29
|
+
required: ["imageUrl", "alt", "author", "sourceUrl", "photoId"],
|
|
30
|
+
properties: {
|
|
31
|
+
imageUrl: { type: "string" },
|
|
32
|
+
alt: { type: "string" },
|
|
33
|
+
author: { type: "string" },
|
|
34
|
+
sourceUrl: { type: "string" },
|
|
35
|
+
photoId: { type: "string" },
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
/**
|
|
40
|
+
* Pull the photo ID out of an Unsplash URL. Supported shapes:
|
|
41
|
+
* https://unsplash.com/photos/PHOTOID
|
|
42
|
+
* https://unsplash.com/photos/slug-with-hyphens-PHOTOID
|
|
43
|
+
* https://unsplash.com/photos/PHOTOID?ixid=...
|
|
44
|
+
* http(s)://www.unsplash.com/... (www prefix + http tolerated)
|
|
45
|
+
*
|
|
46
|
+
* Returns null when the URL is not an unsplash.com/photos/* link.
|
|
47
|
+
*/
|
|
48
|
+
export function extractUnsplashPhotoId(input) {
|
|
49
|
+
const trimmed = input.trim();
|
|
50
|
+
if (!trimmed)
|
|
51
|
+
return null;
|
|
52
|
+
// Bare ID path (no slashes, no dots) — just validate shape
|
|
53
|
+
if (!trimmed.includes("/") && !trimmed.includes(".")) {
|
|
54
|
+
return /^[A-Za-z0-9_-]{6,}$/.test(trimmed) ? trimmed : null;
|
|
55
|
+
}
|
|
56
|
+
let url;
|
|
57
|
+
try {
|
|
58
|
+
url = new URL(trimmed);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
if (!/(^|\.)unsplash\.com$/i.test(url.hostname))
|
|
64
|
+
return null;
|
|
65
|
+
const segments = url.pathname.split("/").filter(Boolean);
|
|
66
|
+
const photosIdx = segments.indexOf("photos");
|
|
67
|
+
if (photosIdx === -1 || photosIdx === segments.length - 1)
|
|
68
|
+
return null;
|
|
69
|
+
const slug = segments[photosIdx + 1];
|
|
70
|
+
// The photo ID is the trailing token after the last hyphen, or the whole slug
|
|
71
|
+
// if there are no hyphens (bare-ID form).
|
|
72
|
+
const lastHyphen = slug.lastIndexOf("-");
|
|
73
|
+
const candidate = lastHyphen === -1 ? slug : slug.slice(lastHyphen + 1);
|
|
74
|
+
return /^[A-Za-z0-9_-]{6,}$/.test(candidate) ? candidate : null;
|
|
75
|
+
}
|
|
76
|
+
export const unsplashGetByIdHandler = async ({ input }) => {
|
|
77
|
+
const typed = (input ?? {});
|
|
78
|
+
const rawId = typeof typed.photoId === "string" ? typed.photoId.trim() : "";
|
|
79
|
+
const rawUrl = typeof typed.url === "string" ? typed.url.trim() : "";
|
|
80
|
+
const photoId = rawId || (rawUrl ? extractUnsplashPhotoId(rawUrl) : null);
|
|
81
|
+
if (!photoId) {
|
|
82
|
+
throw new Error("Could not extract an Unsplash photo ID from the input. Provide an unsplash.com/photos/... URL or a bare photo ID.");
|
|
83
|
+
}
|
|
84
|
+
const accessKey = process.env.UNSPLASH_ACCESS_KEY?.trim();
|
|
85
|
+
if (!accessKey) {
|
|
86
|
+
throw new Error("UNSPLASH_ACCESS_KEY is not configured on the orchestrator.");
|
|
87
|
+
}
|
|
88
|
+
const endpoint = `https://api.unsplash.com/photos/${encodeURIComponent(photoId)}`;
|
|
89
|
+
const res = await fetch(endpoint, {
|
|
90
|
+
headers: {
|
|
91
|
+
Authorization: `Client-ID ${accessKey}`,
|
|
92
|
+
"Accept-Version": "v1",
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
if (!res.ok) {
|
|
96
|
+
const body = await res.text().catch(() => "");
|
|
97
|
+
throw new Error(`Unsplash API ${res.status}: ${res.statusText} — ${body.slice(0, 200)}`);
|
|
98
|
+
}
|
|
99
|
+
const data = (await res.json());
|
|
100
|
+
const base = typeof data.urls?.regular === "string" ? data.urls.regular
|
|
101
|
+
: typeof data.urls?.full === "string" ? data.urls.full
|
|
102
|
+
: typeof data.urls?.raw === "string" ? data.urls.raw
|
|
103
|
+
: "";
|
|
104
|
+
if (!base)
|
|
105
|
+
throw new Error("Unsplash response did not include a usable image URL.");
|
|
106
|
+
const joiner = base.includes("?") ? "&" : "?";
|
|
107
|
+
const imageUrl = `${base}${joiner}auto=format&fit=crop&w=1600&q=80`;
|
|
108
|
+
const altRaw = typeof data.alt_description === "string" ? data.alt_description
|
|
109
|
+
: typeof data.description === "string" ? data.description
|
|
110
|
+
: "";
|
|
111
|
+
const result = {
|
|
112
|
+
imageUrl,
|
|
113
|
+
alt: altRaw.trim() || "Unsplash photo",
|
|
114
|
+
author: typeof data.user?.name === "string" ? data.user.name : "Unsplash",
|
|
115
|
+
sourceUrl: typeof data.links?.html === "string" ? data.links.html : `https://unsplash.com/photos/${photoId}`,
|
|
116
|
+
photoId,
|
|
117
|
+
};
|
|
118
|
+
return result;
|
|
119
|
+
};
|