@avocadostudio-ai/orchestrator-core 0.21.0 → 0.22.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/NOTICE +8 -0
- package/dist/agent/site-theme-resolve.d.ts +62 -0
- package/dist/agent/site-theme-resolve.js +260 -0
- package/dist/agent/sites-agent-context.js +19 -6
- package/dist/agent/sites-agent-shared.d.ts +13 -1
- package/dist/agent/sites-agent-shared.js +155 -34
- package/dist/agent/sites-agent-tools.js +54 -8
- package/dist/agent/stock-photos.d.ts +54 -0
- package/dist/agent/stock-photos.js +75 -0
- package/dist/chat/anthropic-planner.js +1 -0
- package/dist/chat/chat-pipeline-context.d.ts +8 -14
- package/dist/chat/chat-pipeline-context.js +10 -2
- package/dist/chat/chat-pipeline-deterministic.js +28 -3
- package/dist/chat/chat-pipeline-translation.d.ts +2 -0
- package/dist/chat/chat-pipeline-translation.js +7 -3
- package/dist/chat/chat-pipeline.d.ts +32 -4
- package/dist/chat/chat-pipeline.js +472 -220
- package/dist/chat/gemini-planner.js +3 -1
- package/dist/chat/locale-strings.d.ts +10 -0
- package/dist/chat/locale-strings.js +21 -0
- package/dist/chat/page-field-scope.d.ts +23 -0
- package/dist/chat/page-field-scope.js +87 -0
- package/dist/chat/plan-json-schema.d.ts +24 -1
- package/dist/chat/plan-json-schema.js +25 -2
- package/dist/chat/planner.d.ts +1 -1
- package/dist/chat/planner.js +4 -2
- package/dist/chat/prompts.d.ts +11 -0
- package/dist/chat/prompts.js +39 -6
- package/dist/chat/site-locales.d.ts +87 -0
- package/dist/chat/site-locales.js +250 -0
- package/dist/cms/bootstrap.d.ts +10 -0
- package/dist/cms/bootstrap.js +9 -0
- package/dist/cms/editor-api-adapter.js +2 -1
- package/dist/cms/json-file-adapter.js +2 -1
- package/dist/demo-mode.d.ts +13 -0
- package/dist/demo-mode.js +69 -1
- package/dist/handler/create-orchestrator.js +122 -37
- package/dist/http/draft-sync-actions.d.ts +85 -0
- package/dist/http/draft-sync-actions.js +118 -0
- package/dist/http/history-actions.d.ts +9 -1
- package/dist/http/history-actions.js +32 -7
- package/dist/http/ops-actions.d.ts +2 -1
- package/dist/http/ops-actions.js +3 -0
- package/dist/http/publish-actions.d.ts +1 -0
- package/dist/http/publish-actions.js +9 -2
- package/dist/http/suggestions-actions.d.ts +18 -0
- package/dist/http/suggestions-actions.js +44 -0
- package/dist/migration/mcp-server-stdio.js +46 -35
- package/dist/nlp/deterministic-planner-context.d.ts +31 -8
- package/dist/nlp/deterministic-planner-context.js +40 -2
- package/dist/nlp/deterministic-planner-patches.js +5 -0
- package/dist/nlp/deterministic-planner.d.ts +1 -1
- package/dist/nlp/deterministic-planner.js +10 -0
- package/dist/nlp/intent-detection.d.ts +7 -0
- package/dist/nlp/intent-detection.js +1 -0
- package/dist/nlp/plan-normalizer.js +44 -0
- package/dist/nlp/suggestion-engine.d.ts +44 -3
- package/dist/nlp/suggestion-engine.js +150 -15
- package/dist/ops/ops-engine.d.ts +25 -0
- package/dist/ops/ops-engine.js +271 -13
- package/dist/publish/diff-engine.d.ts +6 -0
- package/dist/publish/diff-engine.js +99 -2
- package/dist/publish/publish-selection.d.ts +5 -0
- package/dist/publish/publish-selection.js +45 -0
- package/dist/publish/publish-target.d.ts +17 -0
- package/dist/publish/publish-target.js +17 -1
- package/dist/publish/targets/site-contract.js +35 -2
- package/dist/state/draft-sync.d.ts +48 -0
- package/dist/state/draft-sync.js +86 -0
- package/dist/state/in-memory-content-source.d.ts +2 -0
- package/dist/state/incoming-pages.d.ts +36 -0
- package/dist/state/incoming-pages.js +74 -0
- package/dist/state/session-state.d.ts +30 -0
- package/dist/state/session-state.js +74 -0
- package/dist/state/shared-blocks.d.ts +91 -0
- package/dist/state/shared-blocks.js +185 -0
- package/dist/state/sqlite-store.js +13 -1
- package/package.json +6 -4
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { consoleLogger } from "../logger.js";
|
|
8
8
|
import { mkdir, writeFile, readFile, readdir, stat } from "node:fs/promises";
|
|
9
|
-
import { existsSync } from "node:fs";
|
|
9
|
+
import { closeSync, existsSync, openSync, readFileSync } from "node:fs";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
10
11
|
import { resolve, join, dirname } from "node:path";
|
|
11
12
|
import { getAllBlockMeta, defaultPropsForType, validateBlockProps } from "@avocadostudio-ai/shared";
|
|
12
13
|
// ── Utility helpers ──
|
|
@@ -190,7 +191,7 @@ export const THEME_PRESETS = [
|
|
|
190
191
|
* can copy them into themeOverrides.
|
|
191
192
|
*/
|
|
192
193
|
export function buildThemePresetsCatalog() {
|
|
193
|
-
const lines = ["### Theme Presets\nPick the preset that best matches the site's purpose and tone
|
|
194
|
+
const lines = ["### Theme Presets\nPick the preset that best matches the site's purpose and tone and pass its name as `themePreset` in `bootstrap_pages`. The variables below show what each one sets and are the token names to use in `themeOverrides` for a tweak on top (e.g. swap `--brand`).\n"];
|
|
194
195
|
for (const preset of THEME_PRESETS) {
|
|
195
196
|
const overridesJson = JSON.stringify(preset.overrides, null, 2);
|
|
196
197
|
lines.push(`#### ${preset.name}\n${preset.description}\n\`\`\`json\n${overridesJson}\n\`\`\``);
|
|
@@ -198,7 +199,7 @@ export function buildThemePresetsCatalog() {
|
|
|
198
199
|
lines.push(`Google Fonts: \`${preset.googleFontsUrl}\`\n`);
|
|
199
200
|
}
|
|
200
201
|
}
|
|
201
|
-
lines.push(`\n**Custom tweaks are encouraged.** If the user says "nature site but teal",
|
|
202
|
+
lines.push(`\n**Custom tweaks are encouraged.** If the user says "nature site but teal", pass \`themePreset: "forest"\` and \`themeOverrides: { "--brand": "<teal>" }\`; hover, subtle and foreground follow the brand automatically.`);
|
|
202
203
|
return lines.join("\n");
|
|
203
204
|
}
|
|
204
205
|
// ── Sites agent model tiers ──
|
|
@@ -269,6 +270,42 @@ Extract URLs, site names, and scope constraints from the message. If not present
|
|
|
269
270
|
}
|
|
270
271
|
}
|
|
271
272
|
// ── CSS variable persistence ──
|
|
273
|
+
/*
|
|
274
|
+
* The page itself, painted from the theme. Without it `body` is transparent
|
|
275
|
+
* and any section that does not paint its own background shows the browser's
|
|
276
|
+
* white — invisible on a light theme, a white band down a dark one.
|
|
277
|
+
*/
|
|
278
|
+
const PAGE_CANVAS_MARKER = "/* page canvas */";
|
|
279
|
+
const PAGE_CANVAS_RULE = `${PAGE_CANVAS_MARKER}
|
|
280
|
+
html,
|
|
281
|
+
body {
|
|
282
|
+
background: var(--bg-0);
|
|
283
|
+
color: var(--body);
|
|
284
|
+
}`;
|
|
285
|
+
/**
|
|
286
|
+
* A theme that names a web font without importing it renders in the fallback,
|
|
287
|
+
* and nothing says so. Build the import for any family not already loaded.
|
|
288
|
+
* Weighted first, checked against Google, because one family that lacks a
|
|
289
|
+
* requested weight fails the whole stylesheet; unweighted if that is refused
|
|
290
|
+
* or the network is unavailable.
|
|
291
|
+
*/
|
|
292
|
+
async function importForNamedWebFonts(vars, css) {
|
|
293
|
+
const { webFontFamily, googleFontsUrlFor } = await import("./site-theme-resolve.js");
|
|
294
|
+
const families = [vars["--font-heading"], vars["--font-body"]]
|
|
295
|
+
.map(webFontFamily)
|
|
296
|
+
.filter((f) => f !== null)
|
|
297
|
+
.filter((f) => !css.includes(`family=${f.replace(/ /g, "+")}`));
|
|
298
|
+
if (families.length === 0)
|
|
299
|
+
return undefined;
|
|
300
|
+
const weighted = googleFontsUrlFor(families, true);
|
|
301
|
+
try {
|
|
302
|
+
const res = await fetch(weighted, { signal: AbortSignal.timeout(3_000) });
|
|
303
|
+
if (res.ok)
|
|
304
|
+
return weighted;
|
|
305
|
+
}
|
|
306
|
+
catch { /* offline — fall through */ }
|
|
307
|
+
return googleFontsUrlFor(families, false);
|
|
308
|
+
}
|
|
272
309
|
export async function patchGlobalsCssVars(cssPath, vars) {
|
|
273
310
|
if (!existsSync(cssPath))
|
|
274
311
|
return;
|
|
@@ -280,19 +317,17 @@ export async function patchGlobalsCssVars(cssPath, vars) {
|
|
|
280
317
|
css = css.replace(/\.dark\s*\{[^}]*\}/g, "");
|
|
281
318
|
css = css.replace(/\n{3,}/g, "\n\n"); // collapse leftover blank lines
|
|
282
319
|
// Handle Google Fonts import — inject @import url(...) after @import "tailwindcss"
|
|
283
|
-
|
|
320
|
+
let googleFontsUrl = vars["--google-fonts-import"];
|
|
321
|
+
if (!googleFontsUrl)
|
|
322
|
+
googleFontsUrl = await importForNamedWebFonts(vars, css);
|
|
284
323
|
if (googleFontsUrl) {
|
|
285
324
|
// Remove existing Google Fonts imports to avoid duplicates
|
|
286
325
|
css = css.replace(/@import\s+url\(["']https:\/\/fonts\.googleapis\.com[^)]*\);\s*\n?/g, "");
|
|
287
|
-
//
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
}
|
|
293
|
-
else {
|
|
294
|
-
css = `@import url("${googleFontsUrl}");\n` + css;
|
|
295
|
-
}
|
|
326
|
+
// First line of the file, never after `@import "tailwindcss"`. Tailwind
|
|
327
|
+
// inlines its imports, so anything below them compiles to an @import
|
|
328
|
+
// after thousands of lines of rules — which browsers ignore. Every theme
|
|
329
|
+
// font this agent chose rendered in the fallback for exactly that reason.
|
|
330
|
+
css = `@import url("${googleFontsUrl}");\n` + css;
|
|
296
331
|
}
|
|
297
332
|
for (const [prop, value] of Object.entries(vars)) {
|
|
298
333
|
if (prop === "--google-fonts-import")
|
|
@@ -306,7 +341,35 @@ export async function patchGlobalsCssVars(cssPath, vars) {
|
|
|
306
341
|
css = css.replace(/(:root\s*\{[^}]*)(\})/, `$1 ${prop}: ${value};\n$2`);
|
|
307
342
|
}
|
|
308
343
|
}
|
|
344
|
+
if (!css.includes(PAGE_CANVAS_MARKER))
|
|
345
|
+
css = `${css.trimEnd()}\n\n${PAGE_CANVAS_RULE}\n`;
|
|
309
346
|
await writeFile(cssPath, css, "utf-8");
|
|
347
|
+
if (vars["--brand"])
|
|
348
|
+
await recolorGeneratedMonograms(join(dirname(cssPath), "..", "public"), vars["--brand"], vars["--brand-fg"]);
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* The scaffold's logo and favicon are monogram SVGs shown through `<img>`,
|
|
352
|
+
* which cannot see the page's CSS variables — so `fill="var(--brand, …)"`
|
|
353
|
+
* always rendered its fallback blue, whatever brand the theme chose. Bake the
|
|
354
|
+
* brand in. Only a generated monogram (a rect plus initials) is touched; a
|
|
355
|
+
* downloaded or hand-made logo is left alone.
|
|
356
|
+
*/
|
|
357
|
+
async function recolorGeneratedMonograms(publicDir, brand, fg) {
|
|
358
|
+
if (!/^#[0-9a-f]{3,8}$/i.test(brand.trim()))
|
|
359
|
+
return;
|
|
360
|
+
for (const file of ["logo.svg", "favicon.svg"]) {
|
|
361
|
+
const path = join(publicDir, file);
|
|
362
|
+
if (!existsSync(path))
|
|
363
|
+
continue;
|
|
364
|
+
const svg = await readFile(path, "utf-8");
|
|
365
|
+
if (!/^<svg[^>]*>\s*<rect[^>]*\/>\s*<text[^>]*>[A-Za-z0-9]{1,3}<\/text>\s*<\/svg>\s*$/.test(svg))
|
|
366
|
+
continue;
|
|
367
|
+
let next = svg.replace(/(<rect[^>]*fill=")[^"]*(")/, `$1${brand.trim()}$2`);
|
|
368
|
+
if (fg && /^#[0-9a-f]{3,8}$/i.test(fg.trim()))
|
|
369
|
+
next = next.replace(/(<text[^>]*fill=")[^"]*(")/, `$1${fg.trim()}$2`);
|
|
370
|
+
if (next !== svg)
|
|
371
|
+
await writeFile(path, next, "utf-8");
|
|
372
|
+
}
|
|
310
373
|
}
|
|
311
374
|
// ── Schema validation + auto-correction ──
|
|
312
375
|
const PROP_RENAME_RULES = {
|
|
@@ -441,7 +504,30 @@ export function normalizePageBlocks(blocks, log = consoleLogger()) {
|
|
|
441
504
|
return { contentBlocks, footerBlock, strippedCount };
|
|
442
505
|
}
|
|
443
506
|
// ── Site scaffolding templates ──
|
|
444
|
-
|
|
507
|
+
/**
|
|
508
|
+
* The zod a new site must use: exactly the one `@avocadostudio-ai/shared`
|
|
509
|
+
* resolves. Zod 4 brands its minor version into its types, so a site on
|
|
510
|
+
* another minor makes every `registerBlock` schema a cross-version type
|
|
511
|
+
* comparison. With `^4.3.6` a fresh site resolved 4.6.4 against shared's
|
|
512
|
+
* 4.3.6, and `tsc` on it ran out of heap — which failed the repo's
|
|
513
|
+
* typecheck, and the agent's own verification of every custom block.
|
|
514
|
+
*/
|
|
515
|
+
/** What a dev server or a typecheck leaves behind — never a project on its own. */
|
|
516
|
+
const BUILD_LEFTOVERS = new Set([".next", "node_modules", "tsconfig.tsbuildinfo", "next-env.d.ts", ".turbo"]);
|
|
517
|
+
/** An existing site project to reuse, as opposed to a leftover directory. */
|
|
518
|
+
export function isSiteProject(projectDir) {
|
|
519
|
+
return existsSync(join(projectDir, "package.json"));
|
|
520
|
+
}
|
|
521
|
+
export function sharedZodVersion(root = monorepoRoot()) {
|
|
522
|
+
try {
|
|
523
|
+
const pkg = JSON.parse(readFileSync(join(root, "packages/shared/node_modules/zod/package.json"), "utf-8"));
|
|
524
|
+
if (pkg.version)
|
|
525
|
+
return pkg.version;
|
|
526
|
+
}
|
|
527
|
+
catch { /* not installed here — fall back */ }
|
|
528
|
+
return "4.3.6";
|
|
529
|
+
}
|
|
530
|
+
export function packageJson(siteId, name, port, zodVersion = sharedZodVersion()) {
|
|
445
531
|
return JSON.stringify({
|
|
446
532
|
name: `@ai-site-editor/${siteId}`,
|
|
447
533
|
version: "0.0.1",
|
|
@@ -450,7 +536,7 @@ export function packageJson(siteId, name, port) {
|
|
|
450
536
|
scripts: { dev: `next dev -p ${port}`, build: "next build", start: `next start -p ${port}`, typecheck: "tsc --noEmit" },
|
|
451
537
|
dependencies: {
|
|
452
538
|
"@avocadostudio-ai/blocks": "workspace:*", "@avocadostudio-ai/shared": "workspace:*", "@avocadostudio-ai/site-sdk": "workspace:*",
|
|
453
|
-
"@tailwindcss/postcss": "^4.2.1", next: "15.2.8", react: "^19.0.0", "react-dom": "^19.0.0", tailwindcss: "^4.2.1", zod:
|
|
539
|
+
"@tailwindcss/postcss": "^4.2.1", next: "15.2.8", react: "^19.0.0", "react-dom": "^19.0.0", tailwindcss: "^4.2.1", zod: zodVersion,
|
|
454
540
|
},
|
|
455
541
|
devDependencies: { "@types/node": "^22.13.10", "@types/react": "^19.0.10", "@types/react-dom": "^19.0.4", typescript: "^5.7.3" },
|
|
456
542
|
}, null, 2) + "\n";
|
|
@@ -599,6 +685,8 @@ export function globalsCss() {
|
|
|
599
685
|
--surface: #1e293b; --surface-border: #334155; --border: #334155; --card-bg: #1e293b; --card-shadow: 0 1px 3px rgba(0,0,0,0.3); --hero-bg: var(--bg-0); --cta-bg: var(--bg-100); --placeholder-img: #334155;
|
|
600
686
|
--footer-bg: #020617; --footer-text: #94a3b8; --footer-heading: #e2e8f0; --footer-link: #64748b; --footer-link-hover: #cbd5e1; --footer-border: #1e293b;
|
|
601
687
|
}
|
|
688
|
+
|
|
689
|
+
${PAGE_CANVAS_RULE}
|
|
602
690
|
`;
|
|
603
691
|
}
|
|
604
692
|
export function defaultsTs(siteId, siteName) {
|
|
@@ -812,7 +900,18 @@ export async function scaffoldSiteProject(options) {
|
|
|
812
900
|
const root = monorepoRoot();
|
|
813
901
|
const projectDir = join(root, "apps", siteId);
|
|
814
902
|
if (existsSync(projectDir)) {
|
|
815
|
-
|
|
903
|
+
// A directory holding only build output is what a dev server leaves when
|
|
904
|
+
// it outlives its site being deleted: a worker writes .next back. It is
|
|
905
|
+
// not a project, and refusing it made create_site fail until the agent
|
|
906
|
+
// rm -rf'd it by hand.
|
|
907
|
+
const entries = await readdir(projectDir);
|
|
908
|
+
if (entries.every((e) => BUILD_LEFTOVERS.has(e))) {
|
|
909
|
+
const { rm } = await import("node:fs/promises");
|
|
910
|
+
await rm(projectDir, { recursive: true, force: true });
|
|
911
|
+
}
|
|
912
|
+
else {
|
|
913
|
+
throw new Error(`Directory apps/${siteId} already exists`);
|
|
914
|
+
}
|
|
816
915
|
}
|
|
817
916
|
const port = options.port || await findAvailablePort(root);
|
|
818
917
|
await mkdir(projectDir, { recursive: true });
|
|
@@ -1042,6 +1141,14 @@ export async function detectSitePort(projectDir) {
|
|
|
1042
1141
|
/**
|
|
1043
1142
|
* Start a Next.js dev server and wait for it to report "Ready" (or timeout).
|
|
1044
1143
|
* Replaces fire-and-forget spawn patterns that reported success before the server was up.
|
|
1144
|
+
*
|
|
1145
|
+
* The server writes to a log file, never to a pipe back to this process. The
|
|
1146
|
+
* server is detached so it outlives its launcher, but a pipe does not: in CLI
|
|
1147
|
+
* mode the launcher is the short-lived MCP stdio server the Claude CLI spawns,
|
|
1148
|
+
* and once it exited nobody drained the pipe. The server kept listening and
|
|
1149
|
+
* hung on its first request, so the agent reported "Site running at :3500"
|
|
1150
|
+
* over a site that never answered. An orchestrator restart did the same to
|
|
1151
|
+
* every site the SDK path had started.
|
|
1045
1152
|
*/
|
|
1046
1153
|
export async function startAndWaitForDevServer(opts) {
|
|
1047
1154
|
const log = opts.logger ?? consoleLogger();
|
|
@@ -1049,10 +1156,12 @@ export async function startAndWaitForDevServer(opts) {
|
|
|
1049
1156
|
const args = opts.useFilter
|
|
1050
1157
|
? ["--filter", `@ai-site-editor/${opts.siteId}`, "dev"]
|
|
1051
1158
|
: ["dev"];
|
|
1052
|
-
// Kill
|
|
1159
|
+
// Kill whatever is listening on the port first. Listeners only: a bare
|
|
1160
|
+
// `lsof -ti:<port>` also names every client connected to it, which can be
|
|
1161
|
+
// the orchestrator itself.
|
|
1053
1162
|
try {
|
|
1054
1163
|
const { execSync } = await import("node:child_process");
|
|
1055
|
-
const pids = execSync(`lsof -ti:${opts.port} 2>/dev/null`, { encoding: "utf-8" }).trim();
|
|
1164
|
+
const pids = execSync(`lsof -ti:${opts.port} -sTCP:LISTEN 2>/dev/null`, { encoding: "utf-8" }).trim();
|
|
1056
1165
|
if (pids) {
|
|
1057
1166
|
for (const pid of pids.split("\n")) {
|
|
1058
1167
|
try {
|
|
@@ -1064,10 +1173,14 @@ export async function startAndWaitForDevServer(opts) {
|
|
|
1064
1173
|
}
|
|
1065
1174
|
}
|
|
1066
1175
|
catch { /* no process on port — fine */ }
|
|
1067
|
-
|
|
1176
|
+
const logDir = join(tmpdir(), "avocado-dev-servers");
|
|
1177
|
+
await mkdir(logDir, { recursive: true });
|
|
1178
|
+
const logFile = join(logDir, `${opts.siteId}.log`);
|
|
1179
|
+
const out = openSync(logFile, "w");
|
|
1180
|
+
log.info(`[sites-agent] Starting dev server for ${opts.siteId} on port ${opts.port} (log: ${logFile})...`);
|
|
1068
1181
|
const devProcess = spawn("pnpm", args, {
|
|
1069
1182
|
cwd: opts.cwd,
|
|
1070
|
-
stdio: ["ignore",
|
|
1183
|
+
stdio: ["ignore", out, out],
|
|
1071
1184
|
detached: true,
|
|
1072
1185
|
env: {
|
|
1073
1186
|
...process.env,
|
|
@@ -1080,25 +1193,33 @@ export async function startAndWaitForDevServer(opts) {
|
|
|
1080
1193
|
CHOKIDAR_INTERVAL: "1000",
|
|
1081
1194
|
},
|
|
1082
1195
|
});
|
|
1196
|
+
// The child holds its own copy of the descriptor.
|
|
1197
|
+
closeSync(out);
|
|
1083
1198
|
devProcess.unref();
|
|
1084
1199
|
let serverReady = false;
|
|
1085
1200
|
const timeoutMs = opts.timeoutMs ?? 60_000;
|
|
1086
1201
|
await new Promise((resolve) => {
|
|
1087
|
-
|
|
1088
|
-
const
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
}
|
|
1202
|
+
let settled = false;
|
|
1203
|
+
const finish = () => {
|
|
1204
|
+
if (settled)
|
|
1205
|
+
return;
|
|
1206
|
+
settled = true;
|
|
1207
|
+
clearTimeout(timer);
|
|
1208
|
+
clearInterval(poll);
|
|
1209
|
+
devProcess.off("exit", finish);
|
|
1210
|
+
resolve();
|
|
1097
1211
|
};
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1212
|
+
const timer = setTimeout(finish, timeoutMs);
|
|
1213
|
+
const poll = setInterval(() => {
|
|
1214
|
+
readFile(logFile, "utf-8").then((text) => {
|
|
1215
|
+
if (text.includes("Ready") || text.includes(`localhost:${opts.port}`)) {
|
|
1216
|
+
serverReady = true;
|
|
1217
|
+
finish();
|
|
1218
|
+
}
|
|
1219
|
+
}, () => { });
|
|
1220
|
+
}, 250);
|
|
1221
|
+
devProcess.on("exit", finish);
|
|
1101
1222
|
});
|
|
1102
1223
|
log.info(`[sites-agent] Dev server ${serverReady ? "ready" : "timed out"} for ${opts.siteId} at http://localhost:${opts.port}`);
|
|
1103
|
-
return { serverReady };
|
|
1224
|
+
return { serverReady, logFile };
|
|
1104
1225
|
}
|
|
@@ -19,7 +19,9 @@ import { saveScreenshot } from "../migration/migration-tools.js";
|
|
|
19
19
|
import { scopedSessionKey, setPage, bumpVersion, getSiteConfig, setSiteConfig } from "../state/session-state.js";
|
|
20
20
|
import { listImages as listGdriveImages, downloadImage as downloadGdriveImage, isGdriveConfigured, resolveGdriveFolderId, fileNameToAlt, } from "../image/gdrive-client.js";
|
|
21
21
|
import sharp from "sharp";
|
|
22
|
-
import { sanitizeSiteId, monorepoRoot, findAvailablePort, patchGlobalsCssVars, validateAndCorrectProps, fixFooterLinks, analyzeCodebase, cloneRepo, detectSitePort, startAndWaitForDevServer, getDraftModeSecret, packageJson, nextConfigTs, tsconfigJson, postcssConfig, layoutTsx, globalsCss, defaultsTs, editorApiRoute, pageTsx, hybridPageTsx, blocksRegisterTsx, samplePagesJson, defaultLogoSvg, faviconSvg, } from "./sites-agent-shared.js";
|
|
22
|
+
import { sanitizeSiteId, monorepoRoot, findAvailablePort, patchGlobalsCssVars, validateAndCorrectProps, fixFooterLinks, analyzeCodebase, cloneRepo, detectSitePort, startAndWaitForDevServer, getDraftModeSecret, isSiteProject, packageJson, nextConfigTs, tsconfigJson, postcssConfig, layoutTsx, globalsCss, defaultsTs, editorApiRoute, pageTsx, hybridPageTsx, blocksRegisterTsx, samplePagesJson, defaultLogoSvg, faviconSvg, } from "./sites-agent-shared.js";
|
|
23
|
+
import { findStockPhotos } from "./stock-photos.js";
|
|
24
|
+
import { resolveThemeVars, normalizeSiteSlug, normalizeNavLabels, normalizeNavGroups, THEME_PRESET_NAMES } from "./site-theme-resolve.js";
|
|
23
25
|
/** Convert a package name like "coastal-villa-web" → "Coastal Villa Web" */
|
|
24
26
|
function humanizePkgName(name) {
|
|
25
27
|
return name
|
|
@@ -134,7 +136,7 @@ export function createSitesAgentMcpServer(options) {
|
|
|
134
136
|
const root = monorepoRoot();
|
|
135
137
|
const projectDir = join(root, "apps", siteId);
|
|
136
138
|
let existingPort = 0;
|
|
137
|
-
if (
|
|
139
|
+
if (isSiteProject(projectDir)) {
|
|
138
140
|
// Clean content from previous migration but keep project skeleton (package.json, node_modules, etc.)
|
|
139
141
|
const { rm } = await import("node:fs/promises");
|
|
140
142
|
for (const dir of ["content", "blocks", "public/images", ".next"]) {
|
|
@@ -154,6 +156,13 @@ export function createSitesAgentMcpServer(options) {
|
|
|
154
156
|
catch { /* use new port */ }
|
|
155
157
|
}
|
|
156
158
|
}
|
|
159
|
+
else if (existsSync(join(projectDir, ".next"))) {
|
|
160
|
+
// Not a project, just build output a dev server wrote back after its
|
|
161
|
+
// site was deleted. A stale .next under a fresh scaffold serves the
|
|
162
|
+
// old site's chunks.
|
|
163
|
+
const { rm } = await import("node:fs/promises");
|
|
164
|
+
await rm(join(projectDir, ".next"), { recursive: true, force: true });
|
|
165
|
+
}
|
|
157
166
|
// Validate requested/existing port is actually free; fall back to auto-assign
|
|
158
167
|
let port = args.port || existingPort || 0;
|
|
159
168
|
if (port) {
|
|
@@ -387,7 +396,8 @@ export function createSitesAgentMcpServer(options) {
|
|
|
387
396
|
ogImage: z.string().optional().describe("Open Graph image URL"),
|
|
388
397
|
}).optional().describe("SEO metadata for the page"),
|
|
389
398
|
})),
|
|
390
|
-
|
|
399
|
+
themePreset: z.enum(THEME_PRESET_NAMES).optional().describe("Name of a theme preset to apply in full (palette, fonts and their import, radii). Prefer this over copying a preset's variables; use themeOverrides only for tweaks on top, e.g. a different --brand."),
|
|
400
|
+
themeOverrides: z.record(z.string(), z.string()).optional().describe("CSS variable overrides for theming, applied over themePreset. Use the preset token names (--bg-0, --heading, --body, --brand, --font-heading, …); hover/subtle shades and dark-background text are derived."),
|
|
391
401
|
navLabels: z.record(z.string(), z.string()).optional().describe("Custom nav labels per slug, e.g. { '/about': 'Über uns', '/events/teamevent': 'Teamevent' }. Overrides auto-generated labels."),
|
|
392
402
|
navGroups: z.record(z.string(), z.array(z.string())).optional().describe("Group child pages under a parent nav dropdown, e.g. { 'Events': ['/events/teamevent', '/events/polterabend'] }. Parent has no href, children shown in dropdown."),
|
|
393
403
|
siteLogo: z.string().optional().describe("Logo URL (relative, e.g. '/images/logo.png'). Set after downloading with download_remote_image."),
|
|
@@ -397,6 +407,18 @@ export function createSitesAgentMcpServer(options) {
|
|
|
397
407
|
constraints: z.array(z.string()).optional().describe("Content rules the AI must follow, e.g. ['Always use Swiss German spelling', 'Never discount below CHF 49']"),
|
|
398
408
|
}, async (args) => {
|
|
399
409
|
try {
|
|
410
|
+
// Normalize what the agent wrote before anything reads it: `index` is
|
|
411
|
+
// the home page, nav keys are routes, and theme values are resolved to
|
|
412
|
+
// the tokens blocks actually read (see site-theme-resolve.ts).
|
|
413
|
+
for (const page of args.pages)
|
|
414
|
+
page.slug = normalizeSiteSlug(page.slug);
|
|
415
|
+
args.navLabels = normalizeNavLabels(args.navLabels);
|
|
416
|
+
args.navGroups = normalizeNavGroups(args.navGroups);
|
|
417
|
+
const theme = args.themePreset || (args.themeOverrides && Object.keys(args.themeOverrides).length > 0)
|
|
418
|
+
? resolveThemeVars(args.themeOverrides, { preset: args.themePreset })
|
|
419
|
+
: null;
|
|
420
|
+
if (theme)
|
|
421
|
+
args.themeOverrides = theme.vars;
|
|
400
422
|
const totalBlocks = args.pages.reduce((sum, p) => sum + p.blocks.length, 0);
|
|
401
423
|
const slugs = args.pages.map(p => p.slug);
|
|
402
424
|
log.info(`[bootstrap_pages] START siteId=${args.siteId} pages=${args.pages.length} blocks=${totalBlocks} slugs=[${slugs.join(", ")}]`);
|
|
@@ -594,6 +616,7 @@ export function createSitesAgentMcpServer(options) {
|
|
|
594
616
|
pagesCreated: createdPages,
|
|
595
617
|
totalBlocks: totalBlocksFinal,
|
|
596
618
|
persistedToFile: true,
|
|
619
|
+
...(theme?.notes.length ? { themeNotes: theme.notes } : {}),
|
|
597
620
|
}),
|
|
598
621
|
}],
|
|
599
622
|
};
|
|
@@ -624,6 +647,26 @@ export function createSitesAgentMcpServer(options) {
|
|
|
624
647
|
return { content: [{ type: "text", text: JSON.stringify({ localUrl: args.url, error: "Download failed, using original URL" }) }] };
|
|
625
648
|
}
|
|
626
649
|
}, { annotations: { destructiveHint: false, openWorldHint: true } });
|
|
650
|
+
// ── STOCK PHOTOS ──
|
|
651
|
+
const findStockPhotosTool = tool("find_stock_photos", "Find stock photos by search query and download them into the site, in one call. Use this for every photo on a new site unless the user supplied images: never pass download_remote_images a URL you did not get from a tool — an Unsplash URL from memory resolves to an unrelated photo. Queries are keyword searches: two to four concrete words naming the subject (\"pediatric dentist child\", \"dental clinic interior\"); long descriptive phrases find nothing. Each result carries the photo's own description: if it does not match the use, search again with a different query rather than use it.", {
|
|
652
|
+
siteId: z.string().describe("Site ID — photos saved to apps/{siteId}/public/images/"),
|
|
653
|
+
photos: z.array(z.object({
|
|
654
|
+
query: z.string().min(2).describe("Two to four keywords naming the subject, e.g. \"dentist child smiling\""),
|
|
655
|
+
alt: z.string().optional().describe("Alt text for the page"),
|
|
656
|
+
orientation: z.enum(["landscape", "portrait", "squarish"]).optional().describe("landscape (default) for heroes and wide cards, squarish for team portraits"),
|
|
657
|
+
})).min(1).max(20),
|
|
658
|
+
}, async (args) => {
|
|
659
|
+
const outputDir = join(monorepoRoot(), "apps", args.siteId, "public", "images");
|
|
660
|
+
await mkdir(outputDir, { recursive: true });
|
|
661
|
+
const results = await findStockPhotos({
|
|
662
|
+
siteId: args.siteId, photos: args.photos, outputDir, download: downloadImage,
|
|
663
|
+
accessKey: process.env.UNSPLASH_ACCESS_KEY?.trim() || undefined,
|
|
664
|
+
orchestratorUrl: process.env.ORCHESTRATOR_URL,
|
|
665
|
+
});
|
|
666
|
+
const succeeded = results.filter((r) => r.localUrl).length;
|
|
667
|
+
emitPhaseOutcome?.({ tool: "find_stock_photos", data: { succeeded, total: results.length } });
|
|
668
|
+
return { content: [{ type: "text", text: JSON.stringify({ results, succeeded, failed: results.length - succeeded }) }] };
|
|
669
|
+
}, { annotations: { destructiveHint: false, openWorldHint: true } });
|
|
627
670
|
// ── BATCH DOWNLOAD IMAGES ──
|
|
628
671
|
const downloadImagesTool = tool("download_remote_images", "Download multiple remote images in one call. Much more efficient than calling download_remote_image repeatedly — use this when you have 3+ images to download. Returns an array of { url, localUrl } mappings.", {
|
|
629
672
|
siteId: z.string().describe("Site ID — images saved to apps/{siteId}/public/images/"),
|
|
@@ -730,7 +773,10 @@ export function createSitesAgentMcpServer(options) {
|
|
|
730
773
|
const applyThemeTool = tool("apply_theme", "Apply CSS custom property overrides to a site's theme. These are injected into the preview so blocks render with the migrated site's colors and fonts.", {
|
|
731
774
|
siteId: z.string().describe("Site ID to apply theme to"),
|
|
732
775
|
variables: z.record(z.string(), z.string()).describe("CSS variable overrides, e.g. { '--brand': '#2563eb' }"),
|
|
776
|
+
preset: z.enum(THEME_PRESET_NAMES).optional().describe("Theme preset to apply first; variables override it."),
|
|
733
777
|
}, async (args) => {
|
|
778
|
+
const theme = resolveThemeVars(args.variables, { preset: args.preset });
|
|
779
|
+
args.variables = theme.vars;
|
|
734
780
|
const sessionKey = scopedSessionKey(session, args.siteId);
|
|
735
781
|
const existing = getSiteConfig(sessionKey);
|
|
736
782
|
setSiteConfig(sessionKey, {
|
|
@@ -747,7 +793,7 @@ export function createSitesAgentMcpServer(options) {
|
|
|
747
793
|
return {
|
|
748
794
|
content: [{
|
|
749
795
|
type: "text",
|
|
750
|
-
text: JSON.stringify({ status: "applied", variableCount: Object.keys(args.variables).length, persistedToCss: true }),
|
|
796
|
+
text: JSON.stringify({ status: "applied", variableCount: Object.keys(args.variables).length, persistedToCss: true, ...(theme.notes.length ? { themeNotes: theme.notes } : {}) }),
|
|
751
797
|
}],
|
|
752
798
|
};
|
|
753
799
|
}, { annotations: { destructiveHint: false, openWorldHint: false } });
|
|
@@ -979,7 +1025,7 @@ export function createSitesAgentMcpServer(options) {
|
|
|
979
1025
|
// Check if port is in use — find a free one
|
|
980
1026
|
try {
|
|
981
1027
|
const { execSync } = await import("node:child_process");
|
|
982
|
-
const pids = execSync(`lsof -ti:${port} 2>/dev/null`, { encoding: "utf-8" }).trim();
|
|
1028
|
+
const pids = execSync(`lsof -ti:${port} -sTCP:LISTEN 2>/dev/null`, { encoding: "utf-8" }).trim();
|
|
983
1029
|
if (pids) {
|
|
984
1030
|
log.info(`[integrate_site] Port ${port} in use, finding free port...`);
|
|
985
1031
|
port = await findAvailablePort(root, log);
|
|
@@ -1050,7 +1096,7 @@ export function createSitesAgentMcpServer(options) {
|
|
|
1050
1096
|
// Check if port is in use — if so, find a free one and update package.json
|
|
1051
1097
|
try {
|
|
1052
1098
|
const { execSync } = await import("node:child_process");
|
|
1053
|
-
const pids = execSync(`lsof -ti:${port} 2>/dev/null`, { encoding: "utf-8" }).trim();
|
|
1099
|
+
const pids = execSync(`lsof -ti:${port} -sTCP:LISTEN 2>/dev/null`, { encoding: "utf-8" }).trim();
|
|
1054
1100
|
if (pids) {
|
|
1055
1101
|
log.info(`[sites-agent] Port ${port} in use, finding free port...`);
|
|
1056
1102
|
port = await findAvailablePort(root, log);
|
|
@@ -1121,7 +1167,7 @@ export function createSitesAgentMcpServer(options) {
|
|
|
1121
1167
|
// Kill any existing process on the allocated port
|
|
1122
1168
|
try {
|
|
1123
1169
|
const { execSync } = await import("node:child_process");
|
|
1124
|
-
const pids = execSync(`lsof -ti:${port} 2>/dev/null`, { encoding: "utf-8" }).trim();
|
|
1170
|
+
const pids = execSync(`lsof -ti:${port} -sTCP:LISTEN 2>/dev/null`, { encoding: "utf-8" }).trim();
|
|
1125
1171
|
if (pids) {
|
|
1126
1172
|
for (const pid of pids.split("\n")) {
|
|
1127
1173
|
try {
|
|
@@ -1227,7 +1273,7 @@ export function createSitesAgentMcpServer(options) {
|
|
|
1227
1273
|
return createSdkMcpServer({
|
|
1228
1274
|
name: "sites-agent",
|
|
1229
1275
|
version: "1.0.0",
|
|
1230
|
-
tools: [listSitesTool, discoverStructureTool, createSiteTool, scrapeUrlTool, extractTokensTool, bootstrapPagesTool, downloadImageTool, downloadImagesTool, browseGdriveTool, applyThemeTool, analyzeCodebaseTool, cloneRepoTool, integrateSiteTool, launchSiteTool, registerSiteTool, visualQaDiffTool, ...createMigrationTools()],
|
|
1276
|
+
tools: [listSitesTool, discoverStructureTool, createSiteTool, scrapeUrlTool, extractTokensTool, bootstrapPagesTool, downloadImageTool, downloadImagesTool, findStockPhotosTool, browseGdriveTool, applyThemeTool, analyzeCodebaseTool, cloneRepoTool, integrateSiteTool, launchSiteTool, registerSiteTool, visualQaDiffTool, ...createMigrationTools()],
|
|
1231
1277
|
});
|
|
1232
1278
|
}
|
|
1233
1279
|
// Template functions now live in sites-agent-shared.ts — imported at the top of this file.
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stock photography for a site built from a sentence.
|
|
3
|
+
*
|
|
4
|
+
* Create mode had no way to find a photo: the prompt said "use one shared
|
|
5
|
+
* style phrase in every image search", but no tool searched anything, so the
|
|
6
|
+
* agent handed `download_remote_images` Unsplash URLs recalled from memory.
|
|
7
|
+
* Those resolve to whatever photo now owns the id. A children's dental
|
|
8
|
+
* practice got vintage cars, a sparkler and a truck dashboard as its team.
|
|
9
|
+
*
|
|
10
|
+
* This searches first and downloads what the search returned, and hands back
|
|
11
|
+
* each photo's own description so the agent can see a mismatch rather than
|
|
12
|
+
* trust a URL it made up.
|
|
13
|
+
*/
|
|
14
|
+
export type StockPhotoRequest = {
|
|
15
|
+
query: string;
|
|
16
|
+
alt?: string;
|
|
17
|
+
orientation?: "landscape" | "portrait" | "squarish";
|
|
18
|
+
};
|
|
19
|
+
export type StockPhotoResult = {
|
|
20
|
+
query: string;
|
|
21
|
+
localUrl?: string;
|
|
22
|
+
/** What the photo shows, from Unsplash — check it matches the use. */
|
|
23
|
+
description?: string;
|
|
24
|
+
author?: string;
|
|
25
|
+
error?: string;
|
|
26
|
+
};
|
|
27
|
+
type Candidate = {
|
|
28
|
+
id: string;
|
|
29
|
+
url: string;
|
|
30
|
+
description: string;
|
|
31
|
+
author: string;
|
|
32
|
+
};
|
|
33
|
+
type SearchDeps = {
|
|
34
|
+
fetchImpl?: typeof fetch;
|
|
35
|
+
/** Direct Unsplash access; without it the orchestrator's proxy is asked. */
|
|
36
|
+
accessKey?: string;
|
|
37
|
+
orchestratorUrl?: string;
|
|
38
|
+
};
|
|
39
|
+
export declare function searchStockPhotos(req: StockPhotoRequest, deps?: SearchDeps): Promise<Candidate[]>;
|
|
40
|
+
/**
|
|
41
|
+
* Unsplash matches keywords: "pediatric dentist with child patient, bright
|
|
42
|
+
* clinic" finds nothing, "pediatric dentist" finds hundreds. Try the query as
|
|
43
|
+
* given, then its first clause, then its first three and two words.
|
|
44
|
+
*/
|
|
45
|
+
export declare function queryFallbacks(query: string): string[];
|
|
46
|
+
export declare function findStockPhotos(opts: {
|
|
47
|
+
siteId: string;
|
|
48
|
+
photos: StockPhotoRequest[];
|
|
49
|
+
outputDir: string;
|
|
50
|
+
download: (url: string, alt: string | undefined, outputDir: string) => Promise<{
|
|
51
|
+
fileName: string;
|
|
52
|
+
}>;
|
|
53
|
+
} & SearchDeps): Promise<StockPhotoResult[]>;
|
|
54
|
+
export {};
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stock photography for a site built from a sentence.
|
|
3
|
+
*
|
|
4
|
+
* Create mode had no way to find a photo: the prompt said "use one shared
|
|
5
|
+
* style phrase in every image search", but no tool searched anything, so the
|
|
6
|
+
* agent handed `download_remote_images` Unsplash URLs recalled from memory.
|
|
7
|
+
* Those resolve to whatever photo now owns the id. A children's dental
|
|
8
|
+
* practice got vintage cars, a sparkler and a truck dashboard as its team.
|
|
9
|
+
*
|
|
10
|
+
* This searches first and downloads what the search returned, and hands back
|
|
11
|
+
* each photo's own description so the agent can see a mismatch rather than
|
|
12
|
+
* trust a URL it made up.
|
|
13
|
+
*/
|
|
14
|
+
export async function searchStockPhotos(req, deps = {}) {
|
|
15
|
+
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
16
|
+
const q = encodeURIComponent(req.query.trim());
|
|
17
|
+
if (deps.accessKey) {
|
|
18
|
+
const res = await fetchImpl(`https://api.unsplash.com/search/photos?query=${q}&orientation=${req.orientation ?? "landscape"}&per_page=10&content_filter=high`, { headers: { Authorization: `Client-ID ${deps.accessKey}`, "Accept-Version": "v1" }, signal: AbortSignal.timeout(10_000) });
|
|
19
|
+
if (!res.ok)
|
|
20
|
+
throw new Error(`Unsplash search answered ${res.status}`);
|
|
21
|
+
const payload = (await res.json());
|
|
22
|
+
return (payload.results ?? [])
|
|
23
|
+
.filter((r) => r.id && r.urls?.regular)
|
|
24
|
+
.map((r) => ({ id: r.id, url: r.urls.regular, description: r.alt_description ?? r.description ?? "", author: r.user?.name ?? "Unsplash" }));
|
|
25
|
+
}
|
|
26
|
+
const base = deps.orchestratorUrl ?? "http://localhost:4200";
|
|
27
|
+
const res = await fetchImpl(`${base}/unsplash/search?q=${q}&limit=10`, { signal: AbortSignal.timeout(10_000) });
|
|
28
|
+
if (!res.ok)
|
|
29
|
+
throw new Error(res.status === 404 ? "stock photo search is not configured (UNSPLASH_ACCESS_KEY)" : `photo search answered ${res.status}`);
|
|
30
|
+
const payload = (await res.json());
|
|
31
|
+
return (payload.items ?? []).map((i) => ({ id: i.id, url: i.imageUrl, description: i.alt ?? "", author: i.author ?? "Unsplash" }));
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Unsplash matches keywords: "pediatric dentist with child patient, bright
|
|
35
|
+
* clinic" finds nothing, "pediatric dentist" finds hundreds. Try the query as
|
|
36
|
+
* given, then its first clause, then its first three and two words.
|
|
37
|
+
*/
|
|
38
|
+
export function queryFallbacks(query) {
|
|
39
|
+
const full = query.trim().replace(/\s+/g, " ");
|
|
40
|
+
const clause = full.split(/[,;.]/)[0].trim();
|
|
41
|
+
const words = clause.split(" ").filter((w) => !/^(with|and|of|in|at|for|the|a|an|on)$/i.test(w));
|
|
42
|
+
return [...new Set([full, clause, words.slice(0, 3).join(" "), words.slice(0, 2).join(" ")])].filter(Boolean);
|
|
43
|
+
}
|
|
44
|
+
/** Photo ids already placed on each site, so a second call does not repeat one. */
|
|
45
|
+
const usedBySite = new Map();
|
|
46
|
+
export async function findStockPhotos(opts) {
|
|
47
|
+
const used = usedBySite.get(opts.siteId) ?? new Set();
|
|
48
|
+
usedBySite.set(opts.siteId, used);
|
|
49
|
+
const results = [];
|
|
50
|
+
// In order, not in parallel: two requests for similar subjects must not
|
|
51
|
+
// both pick the same top hit.
|
|
52
|
+
for (const req of opts.photos) {
|
|
53
|
+
try {
|
|
54
|
+
let candidates = [];
|
|
55
|
+
let pick;
|
|
56
|
+
for (const query of queryFallbacks(req.query)) {
|
|
57
|
+
candidates = await searchStockPhotos({ ...req, query }, opts);
|
|
58
|
+
pick = candidates.find((c) => !used.has(c.id));
|
|
59
|
+
if (pick)
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
if (!pick) {
|
|
63
|
+
results.push({ query: req.query, error: candidates.length ? "every match is already used on this site" : "no match — try a broader query" });
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
used.add(pick.id);
|
|
67
|
+
const file = await opts.download(pick.url, req.alt ?? pick.description, opts.outputDir);
|
|
68
|
+
results.push({ query: req.query, localUrl: `/images/${file.fileName}`, description: pick.description, author: pick.author });
|
|
69
|
+
}
|
|
70
|
+
catch (e) {
|
|
71
|
+
results.push({ query: req.query, error: e instanceof Error ? e.message : String(e) });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return results;
|
|
75
|
+
}
|
|
@@ -250,6 +250,7 @@ export async function generatePlanWithAnthropic(args) {
|
|
|
250
250
|
locale: args.locale,
|
|
251
251
|
imageSourceChoiceOpen: args.imageSourceChoiceOpen,
|
|
252
252
|
attachmentCount: args.contextPack.attachments?.length,
|
|
253
|
+
attachmentImages: args.contextPack.attachments?.filter((attachment) => attachment.kind === "image").map(({ url, name }) => ({ url, name })),
|
|
253
254
|
});
|
|
254
255
|
// Stable prefix carries cache_control; dynamic tail is sent as a second
|
|
255
256
|
// text block without it, so per-request flags don't bust the cached prefix.
|
|
@@ -45,15 +45,12 @@ export declare function compactPlannerContextPack(args: {
|
|
|
45
45
|
type: import("@avocadostudio-ai/shared").BlockType;
|
|
46
46
|
} | null;
|
|
47
47
|
};
|
|
48
|
-
pageOutline: {
|
|
48
|
+
pageOutline: ({
|
|
49
49
|
id: string;
|
|
50
|
-
type:
|
|
50
|
+
type: string;
|
|
51
51
|
props: Record<string, unknown>;
|
|
52
|
-
arrayProps:
|
|
53
|
-
|
|
54
|
-
labels?: string[];
|
|
55
|
-
}>;
|
|
56
|
-
}[];
|
|
52
|
+
arrayProps: ReturnType<typeof import("../nlp/deterministic-planner-context.ts").arrayPropLengths>;
|
|
53
|
+
} & import("../nlp/deterministic-planner-context.ts").BlockConstraintNote)[];
|
|
57
54
|
pageMeta: import("@avocadostudio-ai/shared").PageMeta | null;
|
|
58
55
|
pageObservations: import("../nlp/intent-detection.ts").PageObservation[];
|
|
59
56
|
pageIntent: string;
|
|
@@ -123,15 +120,12 @@ export declare function minimalPlannerContextPack(args: {
|
|
|
123
120
|
type: import("@avocadostudio-ai/shared").BlockType;
|
|
124
121
|
} | null;
|
|
125
122
|
};
|
|
126
|
-
pageOutline: {
|
|
123
|
+
pageOutline: ({
|
|
127
124
|
id: string;
|
|
128
|
-
type:
|
|
125
|
+
type: string;
|
|
129
126
|
props: Record<string, unknown>;
|
|
130
|
-
arrayProps:
|
|
131
|
-
|
|
132
|
-
labels?: string[];
|
|
133
|
-
}>;
|
|
134
|
-
}[];
|
|
127
|
+
arrayProps: ReturnType<typeof import("../nlp/deterministic-planner-context.ts").arrayPropLengths>;
|
|
128
|
+
} & import("../nlp/deterministic-planner-context.ts").BlockConstraintNote)[];
|
|
135
129
|
pageMeta: import("@avocadostudio-ai/shared").PageMeta | null;
|
|
136
130
|
pageObservations: import("../nlp/intent-detection.ts").PageObservation[];
|
|
137
131
|
pageIntent: string;
|