@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,1227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Site-level agent tools using Claude Agent SDK's tool() format.
|
|
3
|
+
*
|
|
4
|
+
* 8 tools for site creation and migration:
|
|
5
|
+
* - list_sites, create_site, scrape_url (with screenshot), extract_design_tokens,
|
|
6
|
+
* bootstrap_pages, download_remote_image, apply_theme, discover_site_structure
|
|
7
|
+
*/
|
|
8
|
+
import { mkdir, writeFile, readFile } from "node:fs/promises";
|
|
9
|
+
import { join, dirname } from "node:path";
|
|
10
|
+
import { existsSync } from "node:fs";
|
|
11
|
+
import { tool, createSdkMcpServer } from "@anthropic-ai/claude-agent-sdk";
|
|
12
|
+
import { createMigrationTools } from "../migration/migration-tools.js";
|
|
13
|
+
import { z } from "zod";
|
|
14
|
+
import { getAllBlockMeta, defaultPropsForType } from "@avocadostudio-ai/shared";
|
|
15
|
+
import { fetchPageContent, downloadImage, extractDesignTokens, mapToThemeVariables, discoverSitePages, scrapeFullPage } from "@avocadostudio-ai/migration-sdk";
|
|
16
|
+
import { getCachedScrape, setCachedScrape } from "../migration/scrape-cache.js";
|
|
17
|
+
import { saveScreenshot } from "../migration/migration-tools.js";
|
|
18
|
+
import { scopedSessionKey, setPage, bumpVersion, getSiteConfig, setSiteConfig } from "../state/session-state.js";
|
|
19
|
+
import { listImages as listGdriveImages, downloadImage as downloadGdriveImage, isGdriveConfigured, resolveGdriveFolderId, fileNameToAlt, } from "../image/gdrive-client.js";
|
|
20
|
+
import sharp from "sharp";
|
|
21
|
+
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
|
+
/** Convert a package name like "villa-puravida-web" → "Villa Puravida Web" */
|
|
23
|
+
function humanizePkgName(name) {
|
|
24
|
+
return name
|
|
25
|
+
.replace(/^@[^/]+\//, "") // strip scope
|
|
26
|
+
.replace(/[-_]/g, " ")
|
|
27
|
+
.replace(/\b\w/g, c => c.toUpperCase())
|
|
28
|
+
.trim() || "My Site";
|
|
29
|
+
}
|
|
30
|
+
// Match remote image URLs — extension-based OR known image CDN hostnames
|
|
31
|
+
const IMAGE_URL_RE = /^https?:\/\/.+\.(jpe?g|png|webp|gif|svg|avif|ico)(\?.*)?$/i;
|
|
32
|
+
const IMAGE_CDN_RE = /^https?:\/\/(images\.unsplash\.com|plus\.unsplash\.com|res\.cloudinary\.com|images\.ctfassets\.net|cdn\.sanity\.io)/i;
|
|
33
|
+
function isRemoteImageUrl(url) {
|
|
34
|
+
return IMAGE_URL_RE.test(url) || IMAGE_CDN_RE.test(url);
|
|
35
|
+
}
|
|
36
|
+
/** Recursively walk props and download any remote image URLs, replacing with local paths. */
|
|
37
|
+
async function localizeRemoteImages(props, imagesDir) {
|
|
38
|
+
let downloaded = 0;
|
|
39
|
+
async function walk(value) {
|
|
40
|
+
if (typeof value === "string" && isRemoteImageUrl(value)) {
|
|
41
|
+
try {
|
|
42
|
+
const result = await downloadImage(value, undefined, imagesDir);
|
|
43
|
+
downloaded++;
|
|
44
|
+
console.log(`[sites-agent] Auto-downloaded remote image: ${value} → ${result.localPath}`);
|
|
45
|
+
return `/images/${result.fileName}`;
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
console.warn(`[sites-agent] Failed to auto-download image: ${value}`);
|
|
49
|
+
return value;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (Array.isArray(value)) {
|
|
53
|
+
return Promise.all(value.map(walk));
|
|
54
|
+
}
|
|
55
|
+
if (value && typeof value === "object") {
|
|
56
|
+
const entries = Object.entries(value);
|
|
57
|
+
const resolved = await Promise.all(entries.map(([, v]) => walk(v)));
|
|
58
|
+
const result = {};
|
|
59
|
+
entries.forEach(([k], i) => { result[k] = resolved[i]; });
|
|
60
|
+
return result;
|
|
61
|
+
}
|
|
62
|
+
return value;
|
|
63
|
+
}
|
|
64
|
+
const result = await walk(props);
|
|
65
|
+
return { props: result, downloaded };
|
|
66
|
+
}
|
|
67
|
+
// Shared utilities imported from sites-agent-shared.ts
|
|
68
|
+
/**
|
|
69
|
+
* Create the MCP server with all sites-agent tools.
|
|
70
|
+
*/
|
|
71
|
+
export function createSitesAgentMcpServer(options) {
|
|
72
|
+
const { session, emitSiteCreated, emitPhaseOutcome } = options;
|
|
73
|
+
// ── LIST SITES ──
|
|
74
|
+
const listSitesTool = tool("list_sites", "List all site project directories in the monorepo apps/ folder.", {}, async () => {
|
|
75
|
+
try {
|
|
76
|
+
const root = monorepoRoot();
|
|
77
|
+
const appsDir = join(root, "apps");
|
|
78
|
+
const { readdir, stat } = await import("node:fs/promises");
|
|
79
|
+
const entries = await readdir(appsDir);
|
|
80
|
+
const sites = [];
|
|
81
|
+
for (const entry of entries) {
|
|
82
|
+
if (entry === "editor" || entry === "orchestrator")
|
|
83
|
+
continue;
|
|
84
|
+
const entryPath = join(appsDir, entry);
|
|
85
|
+
const s = await stat(entryPath).catch(() => null);
|
|
86
|
+
if (!s?.isDirectory())
|
|
87
|
+
continue;
|
|
88
|
+
const hasPkg = existsSync(join(entryPath, "package.json"));
|
|
89
|
+
sites.push({ id: entry, path: entryPath, hasPackageJson: hasPkg });
|
|
90
|
+
}
|
|
91
|
+
return { content: [{ type: "text", text: JSON.stringify({ sites }) }] };
|
|
92
|
+
}
|
|
93
|
+
catch (e) {
|
|
94
|
+
return { content: [{ type: "text", text: `Error: ${e instanceof Error ? e.message : String(e)}` }], isError: true };
|
|
95
|
+
}
|
|
96
|
+
}, { annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false } });
|
|
97
|
+
// ── DISCOVER SITE STRUCTURE ──
|
|
98
|
+
const discoverStructureTool = tool("discover_site_structure", "Discover all pages on a website by checking sitemap.xml, robots.txt, and crawling links from the homepage. Returns a list of pages with URLs and slugs. Use this before migration to understand the site's page structure.", {
|
|
99
|
+
url: z.string().describe("Homepage URL of the site to analyze, e.g. 'https://example.com'"),
|
|
100
|
+
}, async (args) => {
|
|
101
|
+
try {
|
|
102
|
+
console.log(`[discover_structure] Discovering pages on ${args.url}...`);
|
|
103
|
+
const structure = await discoverSitePages(args.url);
|
|
104
|
+
console.log(`[discover_structure] Found ${structure.totalFound} pages via ${structure.source} on ${structure.origin}`);
|
|
105
|
+
emitPhaseOutcome?.({ tool: "discover_site_structure", data: { totalPages: structure.totalFound, origin: structure.origin } });
|
|
106
|
+
return {
|
|
107
|
+
content: [{
|
|
108
|
+
type: "text",
|
|
109
|
+
text: JSON.stringify({
|
|
110
|
+
origin: structure.origin,
|
|
111
|
+
source: structure.source,
|
|
112
|
+
totalPages: structure.totalFound,
|
|
113
|
+
pages: structure.pages,
|
|
114
|
+
}),
|
|
115
|
+
}],
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
catch (e) {
|
|
119
|
+
return { content: [{ type: "text", text: `Error discovering site structure: ${e instanceof Error ? e.message : String(e)}` }], isError: true };
|
|
120
|
+
}
|
|
121
|
+
}, { annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true } });
|
|
122
|
+
// ── CREATE SITE ──
|
|
123
|
+
const createSiteTool = tool("create_site", "Scaffold a complete, runnable Next.js site project in the monorepo. Creates package.json, next.config, layout, page routing, editor API integration, and content files. After creation the site is ready to run with 'pnpm dev'.", {
|
|
124
|
+
name: z.string().describe("Human-readable site name, e.g. 'My Portfolio'"),
|
|
125
|
+
siteId: z.string().optional().describe("Kebab-case ID. If omitted, derived from name."),
|
|
126
|
+
purpose: z.string().optional().describe("What the site is about (used in AI context)"),
|
|
127
|
+
tone: z.string().optional().describe("Voice/tone for AI editing"),
|
|
128
|
+
port: z.number().optional().describe("Dev server port (auto-assigned if omitted)"),
|
|
129
|
+
}, async (args) => {
|
|
130
|
+
try {
|
|
131
|
+
const siteId = args.siteId?.trim() || sanitizeSiteId(args.name);
|
|
132
|
+
const root = monorepoRoot();
|
|
133
|
+
const projectDir = join(root, "apps", siteId);
|
|
134
|
+
let existingPort = 0;
|
|
135
|
+
if (existsSync(projectDir)) {
|
|
136
|
+
// Clean content from previous migration but keep project skeleton (package.json, node_modules, etc.)
|
|
137
|
+
const { rm } = await import("node:fs/promises");
|
|
138
|
+
for (const dir of ["content", "blocks", "public/images", ".next"]) {
|
|
139
|
+
const target = join(projectDir, dir);
|
|
140
|
+
if (existsSync(target))
|
|
141
|
+
await rm(target, { recursive: true, force: true });
|
|
142
|
+
}
|
|
143
|
+
console.log(`[sites-agent] Cleaned previous content from apps/${siteId}`);
|
|
144
|
+
// Reuse the existing port if not explicitly overridden
|
|
145
|
+
if (!args.port) {
|
|
146
|
+
try {
|
|
147
|
+
const existingPkg = JSON.parse(await readFile(join(projectDir, "package.json"), "utf-8"));
|
|
148
|
+
const portMatch = existingPkg.scripts?.dev?.match(/-p\s*(\d+)/);
|
|
149
|
+
if (portMatch)
|
|
150
|
+
existingPort = Number(portMatch[1]);
|
|
151
|
+
}
|
|
152
|
+
catch { /* use new port */ }
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
// Validate requested/existing port is actually free; fall back to auto-assign
|
|
156
|
+
let port = args.port || existingPort || 0;
|
|
157
|
+
if (port) {
|
|
158
|
+
const { createServer } = await import("node:net");
|
|
159
|
+
const free = await new Promise((res) => {
|
|
160
|
+
const s = createServer();
|
|
161
|
+
s.once("error", () => res(false));
|
|
162
|
+
s.once("listening", () => { s.close(() => res(true)); });
|
|
163
|
+
s.listen(port, "127.0.0.1");
|
|
164
|
+
});
|
|
165
|
+
if (!free) {
|
|
166
|
+
console.log(`[create_site] Requested port ${port} in use, auto-assigning...`);
|
|
167
|
+
port = 0;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (!port)
|
|
171
|
+
port = await findAvailablePort(root);
|
|
172
|
+
console.log(`[create_site] START siteId=${siteId} port=${port} name="${args.name}"`);
|
|
173
|
+
// Create project structure
|
|
174
|
+
await mkdir(projectDir, { recursive: true });
|
|
175
|
+
await writeFile(join(projectDir, "package.json"), packageJson(siteId, args.name, port), "utf-8");
|
|
176
|
+
await writeFile(join(projectDir, "next.config.ts"), nextConfigTs(), "utf-8");
|
|
177
|
+
await writeFile(join(projectDir, "tsconfig.json"), tsconfigJson(), "utf-8");
|
|
178
|
+
await writeFile(join(projectDir, "postcss.config.mjs"), postcssConfig(), "utf-8");
|
|
179
|
+
await mkdir(join(projectDir, "app"), { recursive: true });
|
|
180
|
+
await writeFile(join(projectDir, "app/layout.tsx"), layoutTsx(args.name), "utf-8");
|
|
181
|
+
await writeFile(join(projectDir, "app/globals.css"), globalsCss(), "utf-8");
|
|
182
|
+
await mkdir(join(projectDir, "app/api/editor/[...path]"), { recursive: true });
|
|
183
|
+
await writeFile(join(projectDir, "app/api/editor/[...path]/route.ts"), editorApiRoute(), "utf-8");
|
|
184
|
+
await mkdir(join(projectDir, "app/[[...slug]]"), { recursive: true });
|
|
185
|
+
await writeFile(join(projectDir, "app/[[...slug]]/page.tsx"), pageTsx(siteId), "utf-8");
|
|
186
|
+
await mkdir(join(projectDir, "content"), { recursive: true });
|
|
187
|
+
await writeFile(join(projectDir, "content/pages.json"), samplePagesJson(), "utf-8");
|
|
188
|
+
await mkdir(join(projectDir, "lib"), { recursive: true });
|
|
189
|
+
await writeFile(join(projectDir, "lib/defaults.ts"), defaultsTs(siteId, args.name), "utf-8");
|
|
190
|
+
await mkdir(join(projectDir, "public"), { recursive: true });
|
|
191
|
+
await writeFile(join(projectDir, "public/.gitkeep"), "", "utf-8");
|
|
192
|
+
await mkdir(join(projectDir, "blocks"), { recursive: true });
|
|
193
|
+
await writeFile(join(projectDir, "blocks/register.tsx"), blocksRegisterTsx(), "utf-8");
|
|
194
|
+
await writeFile(join(projectDir, "public/logo.svg"), defaultLogoSvg(args.name), "utf-8");
|
|
195
|
+
await writeFile(join(projectDir, "public/favicon.svg"), faviconSvg(args.name), "utf-8");
|
|
196
|
+
const envContent = `ORCHESTRATOR_URL=http://localhost:4200\nDRAFT_MODE_SECRET=${getDraftModeSecret()}\nNEXT_PUBLIC_DEFAULT_SITE_ID=${siteId}\nNEXT_PUBLIC_SITE_NAME=${args.name}\nNEXT_PUBLIC_EDITOR_ORIGIN=http://localhost:4100\n`;
|
|
197
|
+
await writeFile(join(projectDir, ".env.local"), envContent, "utf-8");
|
|
198
|
+
console.log(`[create_site] Wrote 13 project files to apps/${siteId}/`);
|
|
199
|
+
// Run pnpm install if needed (skip if node_modules exists from previous run)
|
|
200
|
+
if (!existsSync(join(projectDir, "node_modules"))) {
|
|
201
|
+
console.log(`[create_site] Running pnpm install...`);
|
|
202
|
+
const { execFile } = await import("node:child_process");
|
|
203
|
+
const { promisify } = await import("node:util");
|
|
204
|
+
await promisify(execFile)("pnpm", ["install", "--no-frozen-lockfile"], { cwd: root, timeout: 60_000 });
|
|
205
|
+
console.log(`[create_site] pnpm install complete`);
|
|
206
|
+
}
|
|
207
|
+
else {
|
|
208
|
+
console.log(`[create_site] Skipping pnpm install — node_modules exists`);
|
|
209
|
+
}
|
|
210
|
+
const siteConfig = {
|
|
211
|
+
id: siteId,
|
|
212
|
+
name: args.name,
|
|
213
|
+
purpose: args.purpose ?? "",
|
|
214
|
+
tone: args.tone ?? "",
|
|
215
|
+
hosting: "local",
|
|
216
|
+
previewUrl: `http://localhost:${port}`,
|
|
217
|
+
constraints: [],
|
|
218
|
+
};
|
|
219
|
+
// Initialize orchestrator session
|
|
220
|
+
const sessionKey = scopedSessionKey(session, siteId);
|
|
221
|
+
setPage(sessionKey, {
|
|
222
|
+
id: "p_home", slug: "/", title: "Home", blocks: [], updatedAt: new Date().toISOString(),
|
|
223
|
+
});
|
|
224
|
+
bumpVersion(sessionKey);
|
|
225
|
+
// Start dev server and wait for it to be ready
|
|
226
|
+
console.log(`[create_site] Starting dev server on port ${port}...`);
|
|
227
|
+
const { serverReady } = await startAndWaitForDevServer({
|
|
228
|
+
siteId, port, cwd: root, useFilter: true,
|
|
229
|
+
});
|
|
230
|
+
console.log(`[create_site] Dev server ${serverReady ? "READY" : "FAILED"} on port ${port}`);
|
|
231
|
+
emitSiteCreated(siteConfig);
|
|
232
|
+
emitPhaseOutcome?.({ tool: "create_site", data: { siteId, port, name: args.name, serverReady } });
|
|
233
|
+
console.log(`[create_site] DONE siteId=${siteId} port=${port} serverReady=${serverReady}`);
|
|
234
|
+
return {
|
|
235
|
+
content: [{
|
|
236
|
+
type: "text",
|
|
237
|
+
text: JSON.stringify({
|
|
238
|
+
status: "site_created",
|
|
239
|
+
config: siteConfig,
|
|
240
|
+
projectPath: projectDir,
|
|
241
|
+
port,
|
|
242
|
+
devServerStarted: serverReady,
|
|
243
|
+
instructions: serverReady
|
|
244
|
+
? `Site scaffolded at apps/${siteId} and dev server running on port ${port}.`
|
|
245
|
+
: `Site scaffolded at apps/${siteId} but dev server failed to start on port ${port}.`,
|
|
246
|
+
}),
|
|
247
|
+
}],
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
catch (e) {
|
|
251
|
+
console.error(`[create_site] ERROR: ${e instanceof Error ? e.message : String(e)}`);
|
|
252
|
+
return { content: [{ type: "text", text: `Error: ${e instanceof Error ? e.message : String(e)}` }], isError: true };
|
|
253
|
+
}
|
|
254
|
+
}, { annotations: { destructiveHint: false, openWorldHint: false } });
|
|
255
|
+
// ── SCRAPE URL (Playwright: rendered DOM + screenshot + sections) ──
|
|
256
|
+
const scrapeUrlTool = tool("scrape_url", "Scrape a web page using a headless browser. Returns: page title/meta, pre-extracted sections with block type suggestions, design tokens, and a full-page screenshot. Handles JS-rendered content, Elementor, and lazy-loaded images. Use this to analyze an existing website for migration.", {
|
|
257
|
+
url: z.string().describe("Full URL to scrape, e.g. 'https://example.com'"),
|
|
258
|
+
}, async (args) => {
|
|
259
|
+
try {
|
|
260
|
+
const scrapeStart = Date.now();
|
|
261
|
+
let result = getCachedScrape(args.url);
|
|
262
|
+
const cached = !!result;
|
|
263
|
+
if (!result) {
|
|
264
|
+
console.log(`[scrape_url] Scraping ${args.url} (browser)...`);
|
|
265
|
+
result = await scrapeFullPage(args.url);
|
|
266
|
+
setCachedScrape(args.url, result);
|
|
267
|
+
}
|
|
268
|
+
const { content, screenshot, sections, outline, nav } = result;
|
|
269
|
+
console.log(`[scrape_url] ${cached ? "CACHED" : "SCRAPED"} ${args.url} in ${Date.now() - scrapeStart}ms — ${sections.length} sections, screenshot=${!!screenshot}, nav=${!!nav}`);
|
|
270
|
+
// Extract design tokens from CSS (with resolved CSS variables from Playwright)
|
|
271
|
+
const tokens = extractDesignTokens(content.css, result.resolvedCssVars);
|
|
272
|
+
const themeVars = mapToThemeVariables(tokens);
|
|
273
|
+
const textData = JSON.stringify({
|
|
274
|
+
title: content.title,
|
|
275
|
+
metaDescription: content.metaDescription,
|
|
276
|
+
baseUrl: content.baseUrl,
|
|
277
|
+
navigation: nav ? { siteName: nav.siteName, logoUrl: nav.logoUrl, items: nav.items } : null,
|
|
278
|
+
// Page outline — compact representation of ALL sections on the page (~2KB)
|
|
279
|
+
// USE THIS as the primary source for identifying sections and blocks.
|
|
280
|
+
pageOutline: outline,
|
|
281
|
+
// Extracted sections with structured content (no rawHtml to save tokens)
|
|
282
|
+
sections: sections.map(s => ({
|
|
283
|
+
index: s.index,
|
|
284
|
+
suggestedBlockType: s.suggestedBlockType,
|
|
285
|
+
classHints: s.classHints,
|
|
286
|
+
id: s.id,
|
|
287
|
+
content: s.content,
|
|
288
|
+
})),
|
|
289
|
+
sectionCount: sections.length,
|
|
290
|
+
designTokens: { colors: tokens.colors.slice(0, 15), fonts: tokens.fonts, radii: tokens.radii.slice(0, 5) },
|
|
291
|
+
themeVariables: themeVars,
|
|
292
|
+
});
|
|
293
|
+
const { mobileScreenshot } = result;
|
|
294
|
+
const contentBlocks = [
|
|
295
|
+
{ type: "text", text: textData },
|
|
296
|
+
];
|
|
297
|
+
if (screenshot)
|
|
298
|
+
contentBlocks.push({ type: "image", data: screenshot.base64, mimeType: "image/jpeg" });
|
|
299
|
+
if (mobileScreenshot)
|
|
300
|
+
contentBlocks.push({ type: "image", data: mobileScreenshot.base64, mimeType: "image/jpeg" });
|
|
301
|
+
// Fire-and-forget screenshot saves — non-critical debug artifacts
|
|
302
|
+
const writes = [];
|
|
303
|
+
if (screenshot)
|
|
304
|
+
writes.push(saveScreenshot("desktop", screenshot.base64, args.url));
|
|
305
|
+
if (mobileScreenshot)
|
|
306
|
+
writes.push(saveScreenshot("mobile", mobileScreenshot.base64, args.url));
|
|
307
|
+
if (writes.length)
|
|
308
|
+
Promise.all(writes).then(() => console.log(`[scrape_url] Screenshots saved`)).catch(() => { });
|
|
309
|
+
return { content: contentBlocks };
|
|
310
|
+
}
|
|
311
|
+
catch (e) {
|
|
312
|
+
// Fall back to simple HTTP fetch (no browser)
|
|
313
|
+
try {
|
|
314
|
+
console.warn(`[sites-agent] Browser scrape failed, falling back to HTTP: ${e instanceof Error ? e.message : String(e)}`);
|
|
315
|
+
const content = await fetchPageContent(args.url);
|
|
316
|
+
const { extractSections: extract, extractPageOutline: outline, extractNavigation: navExtract } = await import("@avocadostudio-ai/migration-sdk");
|
|
317
|
+
const sections = extract(content.html, content.baseUrl);
|
|
318
|
+
const pageOutline = outline(content.html, content.baseUrl);
|
|
319
|
+
const navResult = navExtract(content.html, content.baseUrl);
|
|
320
|
+
const tokens = extractDesignTokens(content.css);
|
|
321
|
+
const themeVars = mapToThemeVariables(tokens);
|
|
322
|
+
const textData = JSON.stringify({
|
|
323
|
+
title: content.title,
|
|
324
|
+
metaDescription: content.metaDescription,
|
|
325
|
+
baseUrl: content.baseUrl,
|
|
326
|
+
navigation: navResult ? { siteName: navResult.siteName, logoUrl: navResult.logoUrl, items: navResult.items } : null,
|
|
327
|
+
pageOutline,
|
|
328
|
+
sections: sections.map(s => ({
|
|
329
|
+
index: s.index,
|
|
330
|
+
suggestedBlockType: s.suggestedBlockType,
|
|
331
|
+
classHints: s.classHints,
|
|
332
|
+
content: s.content,
|
|
333
|
+
})),
|
|
334
|
+
sectionCount: sections.length,
|
|
335
|
+
designTokens: { colors: tokens.colors.slice(0, 15), fonts: tokens.fonts, radii: tokens.radii.slice(0, 5) },
|
|
336
|
+
themeVariables: themeVars,
|
|
337
|
+
});
|
|
338
|
+
return { content: [{ type: "text", text: textData }] };
|
|
339
|
+
}
|
|
340
|
+
catch (fallbackErr) {
|
|
341
|
+
return { content: [{ type: "text", text: `Error scraping URL: ${fallbackErr instanceof Error ? fallbackErr.message : String(fallbackErr)}` }], isError: true };
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}, { annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true } });
|
|
345
|
+
// ── EXTRACT DESIGN TOKENS ──
|
|
346
|
+
const extractTokensTool = tool("extract_design_tokens", "Extract design tokens (colors, fonts, border radii) from CSS text. Maps them to theme variables (--brand, --bg-0, --text-100, etc.).", {
|
|
347
|
+
css: z.string().describe("Raw CSS text to analyze"),
|
|
348
|
+
}, async (args) => {
|
|
349
|
+
try {
|
|
350
|
+
const tokens = extractDesignTokens(args.css);
|
|
351
|
+
const themeVars = mapToThemeVariables(tokens);
|
|
352
|
+
return {
|
|
353
|
+
content: [{
|
|
354
|
+
type: "text",
|
|
355
|
+
text: JSON.stringify({
|
|
356
|
+
tokens: { colors: tokens.colors.slice(0, 20), fonts: tokens.fonts, radii: tokens.radii.slice(0, 5) },
|
|
357
|
+
themeVariables: themeVars,
|
|
358
|
+
}),
|
|
359
|
+
}],
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
catch (e) {
|
|
363
|
+
return { content: [{ type: "text", text: `Error: ${e instanceof Error ? e.message : String(e)}` }], isError: true };
|
|
364
|
+
}
|
|
365
|
+
}, { annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false } });
|
|
366
|
+
// ── BOOTSTRAP PAGES ──
|
|
367
|
+
const bootstrapPagesTool = tool("bootstrap_pages", `Create pages with blocks for a site. The site project must exist under apps/{siteId} (created via create_site or integrate_site). Built-in block types: ${Object.keys(getAllBlockMeta()).join(", ")}. Custom block types registered in blocks/register.ts are also supported — use the exact component name as the block type.`, {
|
|
368
|
+
siteId: z.string().describe("Site ID to create pages for"),
|
|
369
|
+
pages: z.array(z.object({
|
|
370
|
+
slug: z.string().describe("Page slug, e.g. '/' or '/about'"),
|
|
371
|
+
title: z.string().describe("Page title"),
|
|
372
|
+
blocks: z.array(z.object({
|
|
373
|
+
type: z.string().describe("Block type (e.g. Hero, FeatureGrid, CTA)"),
|
|
374
|
+
props: z.record(z.string(), z.unknown()).describe("Block props matching the block schema"),
|
|
375
|
+
})),
|
|
376
|
+
meta: z.object({
|
|
377
|
+
title: z.string().optional().describe("SEO title (from <title> tag)"),
|
|
378
|
+
description: z.string().optional().describe("SEO description (from <meta name='description'>)"),
|
|
379
|
+
ogImage: z.string().optional().describe("Open Graph image URL"),
|
|
380
|
+
}).optional().describe("SEO metadata for the page"),
|
|
381
|
+
})),
|
|
382
|
+
themeOverrides: z.record(z.string(), z.string()).optional().describe("CSS variable overrides for theming"),
|
|
383
|
+
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."),
|
|
384
|
+
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."),
|
|
385
|
+
siteLogo: z.string().optional().describe("Logo URL (relative, e.g. '/images/logo.png'). Set after downloading with download_remote_image."),
|
|
386
|
+
siteName: z.string().optional().describe("Site name displayed in the header nav bar."),
|
|
387
|
+
purpose: z.string().optional().describe("What the site is about — 1-2 sentences describing the business/project. Shown in editor settings and used as AI context for future edits."),
|
|
388
|
+
tone: z.string().optional().describe("Voice/tone guide for AI content generation, e.g. 'Professional but approachable, uses du-form (German informal)'"),
|
|
389
|
+
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']"),
|
|
390
|
+
}, async (args) => {
|
|
391
|
+
try {
|
|
392
|
+
const totalBlocks = args.pages.reduce((sum, p) => sum + p.blocks.length, 0);
|
|
393
|
+
const slugs = args.pages.map(p => p.slug);
|
|
394
|
+
console.log(`[bootstrap_pages] START siteId=${args.siteId} pages=${args.pages.length} blocks=${totalBlocks} slugs=[${slugs.join(", ")}]`);
|
|
395
|
+
// Verify the site project was scaffolded first
|
|
396
|
+
const root = monorepoRoot();
|
|
397
|
+
const projectPkg = join(root, "apps", args.siteId, "package.json");
|
|
398
|
+
if (!existsSync(projectPkg)) {
|
|
399
|
+
console.error(`[bootstrap_pages] GUARD FAILED: apps/${args.siteId}/package.json not found — create_site was not called`);
|
|
400
|
+
return { content: [{ type: "text", text: `Error: Site project apps/${args.siteId} does not exist. Call create_site first.` }], isError: true };
|
|
401
|
+
}
|
|
402
|
+
const sessionKey = scopedSessionKey(session, args.siteId);
|
|
403
|
+
let strippedCount = 0;
|
|
404
|
+
let footerBlock = null;
|
|
405
|
+
// Normalize slugs — ensure leading / and no trailing /
|
|
406
|
+
const normalizedPages = args.pages.map(page => ({
|
|
407
|
+
...page,
|
|
408
|
+
slug: page.slug === "/" ? "/" : `/${page.slug.replace(/^\/+/, "").replace(/\/+$/, "")}`,
|
|
409
|
+
}));
|
|
410
|
+
// Build page docs once — reused for both session state and file persistence
|
|
411
|
+
const imagesDir = join(root, "apps", args.siteId, "public", "images");
|
|
412
|
+
await mkdir(imagesDir, { recursive: true });
|
|
413
|
+
let totalAutoDownloaded = 0;
|
|
414
|
+
// Process pages sequentially to avoid unbounded concurrent image downloads
|
|
415
|
+
const pageDocs = [];
|
|
416
|
+
for (const [pageIdx, page] of normalizedPages.entries()) {
|
|
417
|
+
console.log(`[bootstrap_pages] Processing page ${pageIdx + 1}/${normalizedPages.length}: ${page.slug} (${page.blocks.length} blocks)`);
|
|
418
|
+
const contentBlocks = page.blocks.filter(b => {
|
|
419
|
+
if (b.type === "SiteHeader") {
|
|
420
|
+
strippedCount++;
|
|
421
|
+
return false;
|
|
422
|
+
}
|
|
423
|
+
if (b.type === "Footer") {
|
|
424
|
+
if (!footerBlock) {
|
|
425
|
+
let fp = fixFooterLinks(b.props);
|
|
426
|
+
const v = validateAndCorrectProps("Footer", fp);
|
|
427
|
+
if (v.corrected)
|
|
428
|
+
fp = v.props;
|
|
429
|
+
footerBlock = { type: b.type, props: fp };
|
|
430
|
+
}
|
|
431
|
+
strippedCount++;
|
|
432
|
+
return false;
|
|
433
|
+
}
|
|
434
|
+
return true;
|
|
435
|
+
});
|
|
436
|
+
const blocks = contentBlocks.map((b, i) => {
|
|
437
|
+
const blockMeta = getAllBlockMeta()[b.type];
|
|
438
|
+
const baseProps = blockMeta ? defaultPropsForType(b.type) : {};
|
|
439
|
+
let mergedProps = { ...baseProps };
|
|
440
|
+
for (const [key, value] of Object.entries(b.props)) {
|
|
441
|
+
mergedProps[key] = value;
|
|
442
|
+
}
|
|
443
|
+
if (blockMeta) {
|
|
444
|
+
const validation = validateAndCorrectProps(b.type, mergedProps);
|
|
445
|
+
if (validation.corrected) {
|
|
446
|
+
console.log(`[bootstrap_pages] Block ${b.type}: auto-corrected props`);
|
|
447
|
+
mergedProps = { ...baseProps };
|
|
448
|
+
for (const [key, value] of Object.entries(validation.props)) {
|
|
449
|
+
mergedProps[key] = value;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
else if (validation.error) {
|
|
453
|
+
console.warn(`[bootstrap_pages] Block ${b.type}: validation error — ${validation.error}`);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
else if (!getAllBlockMeta()[b.type]) {
|
|
457
|
+
console.log(`[bootstrap_pages] Block ${b.type}: custom type (not in built-in registry)`);
|
|
458
|
+
}
|
|
459
|
+
return { id: `b_${b.type.toLowerCase()}_${i + 1}`, type: b.type, props: mergedProps };
|
|
460
|
+
});
|
|
461
|
+
// Auto-download any remote image URLs the agent missed
|
|
462
|
+
for (const block of blocks) {
|
|
463
|
+
const { props: localizedProps, downloaded } = await localizeRemoteImages(block.props, imagesDir);
|
|
464
|
+
if (downloaded > 0) {
|
|
465
|
+
block.props = localizedProps;
|
|
466
|
+
totalAutoDownloaded += downloaded;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
pageDocs.push({
|
|
470
|
+
id: `p_${page.slug === "/" ? "home" : page.slug.replace(/^\//, "").replace(/\//g, "_")}`,
|
|
471
|
+
slug: page.slug,
|
|
472
|
+
title: page.title,
|
|
473
|
+
blocks,
|
|
474
|
+
...(page.meta ? { meta: page.meta } : {}),
|
|
475
|
+
updatedAt: new Date().toISOString(),
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
if (strippedCount > 0)
|
|
479
|
+
console.log(`[sites-agent] Stripped ${strippedCount} chrome blocks (SiteHeader/Footer)`);
|
|
480
|
+
if (totalAutoDownloaded > 0)
|
|
481
|
+
console.log(`[sites-agent] Auto-downloaded ${totalAutoDownloaded} remote images as safety net`);
|
|
482
|
+
// Write to session state
|
|
483
|
+
for (const doc of pageDocs)
|
|
484
|
+
setPage(sessionKey, doc);
|
|
485
|
+
const createdPages = pageDocs.map(d => d.slug);
|
|
486
|
+
// Apply site config
|
|
487
|
+
const existing = getSiteConfig(sessionKey);
|
|
488
|
+
const patch = { ...existing };
|
|
489
|
+
if (args.themeOverrides && Object.keys(args.themeOverrides).length > 0) {
|
|
490
|
+
patch.themeOverrides = { ...(existing.themeOverrides ?? {}), ...args.themeOverrides };
|
|
491
|
+
}
|
|
492
|
+
if (args.navLabels)
|
|
493
|
+
patch.navLabels = { ...(existing.navLabels ?? {}), ...args.navLabels };
|
|
494
|
+
if (args.navGroups)
|
|
495
|
+
patch.navGroups = { ...(existing.navGroups ?? {}), ...args.navGroups };
|
|
496
|
+
if (args.siteLogo)
|
|
497
|
+
patch.logo = args.siteLogo;
|
|
498
|
+
if (args.siteName)
|
|
499
|
+
patch.name = args.siteName;
|
|
500
|
+
if (args.purpose)
|
|
501
|
+
patch.purpose = args.purpose;
|
|
502
|
+
if (args.tone)
|
|
503
|
+
patch.tone = args.tone;
|
|
504
|
+
if (args.constraints && args.constraints.length > 0)
|
|
505
|
+
patch.constraints = args.constraints;
|
|
506
|
+
setSiteConfig(sessionKey, patch);
|
|
507
|
+
bumpVersion(sessionKey);
|
|
508
|
+
// Persist to content/pages.json
|
|
509
|
+
const pagesJsonPath = join(root, "apps", args.siteId, "content", "pages.json");
|
|
510
|
+
try {
|
|
511
|
+
let existingPages = [];
|
|
512
|
+
try {
|
|
513
|
+
existingPages = JSON.parse(await readFile(pagesJsonPath, "utf-8"));
|
|
514
|
+
}
|
|
515
|
+
catch { /* fresh */ }
|
|
516
|
+
const allPages = [...existingPages];
|
|
517
|
+
for (const doc of pageDocs) {
|
|
518
|
+
const idx = allPages.findIndex(p => p.slug === doc.slug);
|
|
519
|
+
if (idx >= 0)
|
|
520
|
+
allPages[idx] = doc;
|
|
521
|
+
else
|
|
522
|
+
allPages.push(doc);
|
|
523
|
+
}
|
|
524
|
+
await mkdir(dirname(pagesJsonPath), { recursive: true });
|
|
525
|
+
await writeFile(pagesJsonPath, JSON.stringify(allPages, null, 2) + "\n", "utf-8");
|
|
526
|
+
console.log(`[sites-agent] Wrote ${allPages.length} pages to ${pagesJsonPath}`);
|
|
527
|
+
}
|
|
528
|
+
catch (writeErr) {
|
|
529
|
+
console.warn(`[sites-agent] Failed to write pages.json:`, writeErr);
|
|
530
|
+
}
|
|
531
|
+
// Persist site config (nav labels, nav groups, logo, name, footer) to content/site-config.json
|
|
532
|
+
{
|
|
533
|
+
const configPath = join(root, "apps", args.siteId, "content", "site-config.json");
|
|
534
|
+
try {
|
|
535
|
+
let existing = {};
|
|
536
|
+
if (existsSync(configPath)) {
|
|
537
|
+
try {
|
|
538
|
+
existing = JSON.parse(await readFile(configPath, "utf-8"));
|
|
539
|
+
}
|
|
540
|
+
catch { /* fresh */ }
|
|
541
|
+
}
|
|
542
|
+
const config = { ...existing };
|
|
543
|
+
if (args.siteName)
|
|
544
|
+
config.name = args.siteName;
|
|
545
|
+
if (args.siteLogo)
|
|
546
|
+
config.logo = args.siteLogo;
|
|
547
|
+
if (args.purpose)
|
|
548
|
+
config.purpose = args.purpose;
|
|
549
|
+
if (args.tone)
|
|
550
|
+
config.tone = args.tone;
|
|
551
|
+
if (args.constraints && args.constraints.length > 0)
|
|
552
|
+
config.constraints = args.constraints;
|
|
553
|
+
if (args.navLabels)
|
|
554
|
+
config.navLabels = { ...(existing.navLabels ?? {}), ...args.navLabels };
|
|
555
|
+
if (args.navGroups)
|
|
556
|
+
config.navGroups = { ...(existing.navGroups ?? {}), ...args.navGroups };
|
|
557
|
+
if (footerBlock) {
|
|
558
|
+
const fb = footerBlock;
|
|
559
|
+
config.footer = { id: "chrome_footer", type: fb.type, props: fb.props };
|
|
560
|
+
}
|
|
561
|
+
await writeFile(configPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
|
|
562
|
+
console.log(`[sites-agent] Wrote site config to ${configPath}`);
|
|
563
|
+
}
|
|
564
|
+
catch (err) {
|
|
565
|
+
console.warn(`[sites-agent] Failed to write site-config.json:`, err);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
// Persist theme overrides to globals.css
|
|
569
|
+
if (args.themeOverrides && Object.keys(args.themeOverrides).length > 0) {
|
|
570
|
+
try {
|
|
571
|
+
await patchGlobalsCssVars(join(root, "apps", args.siteId, "app", "globals.css"), args.themeOverrides);
|
|
572
|
+
console.log(`[sites-agent] Updated ${Object.keys(args.themeOverrides).length} theme vars in globals.css`);
|
|
573
|
+
}
|
|
574
|
+
catch (cssErr) {
|
|
575
|
+
console.warn(`[sites-agent] Failed to update globals.css:`, cssErr);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
const totalBlocksFinal = args.pages.reduce((sum, p) => sum + p.blocks.length, 0);
|
|
579
|
+
emitPhaseOutcome?.({ tool: "bootstrap_pages", data: { pagesCreated: createdPages.length, totalBlocks: totalBlocksFinal, pages: createdPages } });
|
|
580
|
+
console.log(`[bootstrap_pages] DONE siteId=${args.siteId} pages=${createdPages.length} blocks=${totalBlocksFinal} autoDownloaded=${totalAutoDownloaded}`);
|
|
581
|
+
return {
|
|
582
|
+
content: [{
|
|
583
|
+
type: "text",
|
|
584
|
+
text: JSON.stringify({
|
|
585
|
+
status: "applied",
|
|
586
|
+
pagesCreated: createdPages,
|
|
587
|
+
totalBlocks: totalBlocksFinal,
|
|
588
|
+
persistedToFile: true,
|
|
589
|
+
}),
|
|
590
|
+
}],
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
catch (e) {
|
|
594
|
+
console.error(`[bootstrap_pages] ERROR: ${e instanceof Error ? e.message : String(e)}`);
|
|
595
|
+
return { content: [{ type: "text", text: `Error: ${e instanceof Error ? e.message : String(e)}` }], isError: true };
|
|
596
|
+
}
|
|
597
|
+
}, { annotations: { destructiveHint: false, openWorldHint: false } });
|
|
598
|
+
// ── DOWNLOAD REMOTE IMAGE ──
|
|
599
|
+
const downloadImageTool = tool("download_remote_image", "Download a remote image and save it to the site's public/images/ directory. Returns a relative URL (/images/filename) for use in block props. Next.js serves files from public/ automatically.", {
|
|
600
|
+
url: z.string().describe("Image URL to download"),
|
|
601
|
+
siteId: z.string().describe("Site ID — image saved to apps/{siteId}/public/images/"),
|
|
602
|
+
alt: z.string().optional().describe("Alt text for the image"),
|
|
603
|
+
}, async (args) => {
|
|
604
|
+
try {
|
|
605
|
+
const root = monorepoRoot();
|
|
606
|
+
const outputDir = join(root, "apps", args.siteId, "public", "images");
|
|
607
|
+
console.log(`[download_image] ${args.url.slice(0, 100)}`);
|
|
608
|
+
const result = await downloadImage(args.url, args.alt, outputDir);
|
|
609
|
+
const localUrl = `/images/${result.fileName}`;
|
|
610
|
+
console.log(`[download_image] OK → ${localUrl}`);
|
|
611
|
+
emitPhaseOutcome?.({ tool: "download_remote_image", data: { fileName: result.fileName } });
|
|
612
|
+
return { content: [{ type: "text", text: JSON.stringify({ localUrl, fileName: result.fileName }) }] };
|
|
613
|
+
}
|
|
614
|
+
catch (e) {
|
|
615
|
+
console.warn(`[download_image] FAILED ${args.url.slice(0, 100)} — ${e instanceof Error ? e.message : String(e)}`);
|
|
616
|
+
return { content: [{ type: "text", text: JSON.stringify({ localUrl: args.url, error: "Download failed, using original URL" }) }] };
|
|
617
|
+
}
|
|
618
|
+
}, { annotations: { destructiveHint: false, openWorldHint: true } });
|
|
619
|
+
// ── BATCH DOWNLOAD IMAGES ──
|
|
620
|
+
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.", {
|
|
621
|
+
siteId: z.string().describe("Site ID — images saved to apps/{siteId}/public/images/"),
|
|
622
|
+
images: z.array(z.object({
|
|
623
|
+
url: z.string().describe("Image URL to download"),
|
|
624
|
+
alt: z.string().optional().describe("Alt text"),
|
|
625
|
+
})).describe("Array of images to download"),
|
|
626
|
+
}, async (args) => {
|
|
627
|
+
const root = monorepoRoot();
|
|
628
|
+
const outputDir = join(root, "apps", args.siteId, "public", "images");
|
|
629
|
+
await mkdir(outputDir, { recursive: true });
|
|
630
|
+
// Download 4 at a time to avoid overwhelming the source server
|
|
631
|
+
const results = [];
|
|
632
|
+
for (let i = 0; i < args.images.length; i += 4) {
|
|
633
|
+
const batch = args.images.slice(i, i + 4);
|
|
634
|
+
const batchResults = await Promise.all(batch.map(async (img) => {
|
|
635
|
+
try {
|
|
636
|
+
const result = await downloadImage(img.url, img.alt, outputDir);
|
|
637
|
+
return { url: img.url, localUrl: `/images/${result.fileName}` };
|
|
638
|
+
}
|
|
639
|
+
catch {
|
|
640
|
+
return { url: img.url, localUrl: img.url, error: "Download failed" };
|
|
641
|
+
}
|
|
642
|
+
}));
|
|
643
|
+
results.push(...batchResults);
|
|
644
|
+
}
|
|
645
|
+
const succeeded = results.filter(r => !r.error).length;
|
|
646
|
+
emitPhaseOutcome?.({ tool: "download_remote_images", data: { succeeded, total: results.length } });
|
|
647
|
+
console.log(`[sites-agent] Batch downloaded ${succeeded}/${results.length} images`);
|
|
648
|
+
return { content: [{ type: "text", text: JSON.stringify({ results, succeeded, failed: results.length - succeeded }) }] };
|
|
649
|
+
}, { annotations: { destructiveHint: false, openWorldHint: true } });
|
|
650
|
+
// ── BROWSE GOOGLE DRIVE IMAGES ──
|
|
651
|
+
const browseGdriveTool = tool("browse_gdrive_images", "Browse images in a Google Drive folder. Downloads them to the site's public/images/ and returns thumbnails so you can see the images and decide where to place them. Use BEFORE bootstrap_pages when the user provides a Google Drive folder.", {
|
|
652
|
+
siteId: z.string().describe("Site ID — images saved to apps/{siteId}/public/images/"),
|
|
653
|
+
folderId: z.string().optional().describe("Google Drive folder ID or URL. Falls back to the configured default folder."),
|
|
654
|
+
query: z.string().optional().describe("Optional search text to filter images by filename"),
|
|
655
|
+
limit: z.number().optional().describe("Max images to return (1-15, default 10)"),
|
|
656
|
+
}, async (args) => {
|
|
657
|
+
if (!isGdriveConfigured() && !args.folderId) {
|
|
658
|
+
return { content: [{ type: "text", text: JSON.stringify({ error: "Google Drive not configured. Set GOOGLE_DRIVE_FOLDER_ID and GOOGLE_API_KEY in .env, or provide a folderId." }) }] };
|
|
659
|
+
}
|
|
660
|
+
const folderId = resolveGdriveFolderId(args.folderId);
|
|
661
|
+
if (!folderId) {
|
|
662
|
+
return { content: [{ type: "text", text: JSON.stringify({ error: "No folder ID provided or configured." }) }] };
|
|
663
|
+
}
|
|
664
|
+
const limit = Math.min(15, Math.max(1, Math.trunc(args.limit ?? 10)));
|
|
665
|
+
const files = await listGdriveImages(folderId, args.query, undefined, limit);
|
|
666
|
+
if (files.length === 0) {
|
|
667
|
+
return { content: [{ type: "text", text: JSON.stringify({ images: [], message: "No images found in the specified folder." }) }] };
|
|
668
|
+
}
|
|
669
|
+
const root = monorepoRoot();
|
|
670
|
+
const outputDir = join(root, "apps", args.siteId, "public", "images");
|
|
671
|
+
await mkdir(outputDir, { recursive: true });
|
|
672
|
+
const THUMB_WIDTH = 256;
|
|
673
|
+
const manifest = [];
|
|
674
|
+
const contentBlocks = [];
|
|
675
|
+
// Download and thumbnail 4 at a time
|
|
676
|
+
for (let i = 0; i < files.length; i += 4) {
|
|
677
|
+
const batch = files.slice(i, i + 4);
|
|
678
|
+
const batchResults = await Promise.all(batch.map(async (file) => {
|
|
679
|
+
try {
|
|
680
|
+
const result = await downloadGdriveImage(file.id);
|
|
681
|
+
if (!result)
|
|
682
|
+
return null;
|
|
683
|
+
// Copy to site's public/images/
|
|
684
|
+
const safeName = file.name.replace(/[^a-zA-Z0-9._-]/g, "_").toLowerCase();
|
|
685
|
+
const localName = `gdrive_${safeName.replace(/\.[^.]+$/, "")}.webp`;
|
|
686
|
+
const localPath = join(outputDir, localName);
|
|
687
|
+
const fullImage = await readFile(result.filePath);
|
|
688
|
+
await writeFile(localPath, fullImage);
|
|
689
|
+
// Generate small thumbnail for vision
|
|
690
|
+
const thumb = await sharp(fullImage)
|
|
691
|
+
.resize({ width: THUMB_WIDTH, withoutEnlargement: true })
|
|
692
|
+
.webp({ quality: 60 })
|
|
693
|
+
.toBuffer();
|
|
694
|
+
return {
|
|
695
|
+
name: file.name,
|
|
696
|
+
localUrl: `/images/${localName}`,
|
|
697
|
+
alt: fileNameToAlt(file.name),
|
|
698
|
+
thumbBase64: thumb.toString("base64"),
|
|
699
|
+
};
|
|
700
|
+
}
|
|
701
|
+
catch {
|
|
702
|
+
return null;
|
|
703
|
+
}
|
|
704
|
+
}));
|
|
705
|
+
for (const r of batchResults) {
|
|
706
|
+
if (!r)
|
|
707
|
+
continue;
|
|
708
|
+
manifest.push({ name: r.name, localUrl: r.localUrl, alt: r.alt });
|
|
709
|
+
contentBlocks.push({ type: "image", data: r.thumbBase64, mimeType: "image/webp" });
|
|
710
|
+
contentBlocks.push({ type: "text", text: `↑ ${r.name} → ${r.localUrl}` });
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
console.log(`[sites-agent] Browsed GDrive: ${manifest.length}/${files.length} images downloaded`);
|
|
714
|
+
return {
|
|
715
|
+
content: [
|
|
716
|
+
{ type: "text", text: `Found ${manifest.length} images. Thumbnails below — use localUrl paths in block props.\n${JSON.stringify(manifest, null, 2)}` },
|
|
717
|
+
...contentBlocks,
|
|
718
|
+
],
|
|
719
|
+
};
|
|
720
|
+
}, { annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true } });
|
|
721
|
+
// ── APPLY THEME ──
|
|
722
|
+
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.", {
|
|
723
|
+
siteId: z.string().describe("Site ID to apply theme to"),
|
|
724
|
+
variables: z.record(z.string(), z.string()).describe("CSS variable overrides, e.g. { '--brand': '#2563eb' }"),
|
|
725
|
+
}, async (args) => {
|
|
726
|
+
const sessionKey = scopedSessionKey(session, args.siteId);
|
|
727
|
+
const existing = getSiteConfig(sessionKey);
|
|
728
|
+
setSiteConfig(sessionKey, {
|
|
729
|
+
...existing,
|
|
730
|
+
themeOverrides: { ...(existing.themeOverrides ?? {}), ...args.variables },
|
|
731
|
+
});
|
|
732
|
+
// Also persist to the site's globals.css
|
|
733
|
+
try {
|
|
734
|
+
await patchGlobalsCssVars(join(monorepoRoot(), "apps", args.siteId, "app", "globals.css"), args.variables);
|
|
735
|
+
}
|
|
736
|
+
catch (err) {
|
|
737
|
+
console.warn(`[sites-agent] Failed to persist theme to globals.css:`, err);
|
|
738
|
+
}
|
|
739
|
+
return {
|
|
740
|
+
content: [{
|
|
741
|
+
type: "text",
|
|
742
|
+
text: JSON.stringify({ status: "applied", variableCount: Object.keys(args.variables).length, persistedToCss: true }),
|
|
743
|
+
}],
|
|
744
|
+
};
|
|
745
|
+
}, { annotations: { destructiveHint: false, openWorldHint: false } });
|
|
746
|
+
// ── CLONE REPO ──
|
|
747
|
+
const cloneRepoTool = tool("clone_repo", "Clone a GitHub repository to a local directory inside the monorepo. Uses `gh repo clone` for auth, falls back to `git clone`. Returns the local path for use with analyze_codebase.", {
|
|
748
|
+
url: z.string().describe("GitHub repo URL (e.g. 'https://github.com/user/repo') or shorthand ('user/repo')"),
|
|
749
|
+
targetDir: z.string().optional().describe("Target directory name inside apps/. Defaults to repo name."),
|
|
750
|
+
}, async (args) => {
|
|
751
|
+
try {
|
|
752
|
+
const result = await cloneRepo(args.url, args.targetDir);
|
|
753
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
754
|
+
}
|
|
755
|
+
catch (e) {
|
|
756
|
+
return { content: [{ type: "text", text: `Error cloning repo: ${e instanceof Error ? e.message : String(e)}` }], isError: true };
|
|
757
|
+
}
|
|
758
|
+
}, { annotations: { destructiveHint: false, openWorldHint: true } });
|
|
759
|
+
// ── INTEGRATE SITE (composite — replaces 8-10 individual tool calls) ──
|
|
760
|
+
const integrateSiteTool = tool("integrate_site", "Add AI Site Editor SDK integration to an existing Next.js project in ONE step. Creates all integration files (catch-all page, editor API route, CMS adapter, content directory, .env.local, blocks register), adds block styles import to layout, installs workspace deps if needed, and starts the dev server. Returns the site config. Use this AFTER analyze_codebase confirms the project is a Next.js app-router site without existing integration.", {
|
|
761
|
+
siteId: z.string().describe("Site ID (kebab-case, matches the directory name in apps/)"),
|
|
762
|
+
name: z.string().describe("Human-readable site name"),
|
|
763
|
+
purpose: z.string().optional().describe("What the site is about"),
|
|
764
|
+
layoutPath: z.string().optional().describe("Relative path to layout file (from analyze_codebase)"),
|
|
765
|
+
useSrcDir: z.boolean().optional().describe("Whether the project uses src/app/ instead of app/"),
|
|
766
|
+
}, async (args) => {
|
|
767
|
+
try {
|
|
768
|
+
const root = monorepoRoot();
|
|
769
|
+
const projectDir = join(root, "apps", args.siteId);
|
|
770
|
+
if (!existsSync(join(projectDir, "package.json"))) {
|
|
771
|
+
return { content: [{ type: "text", text: `Error: apps/${args.siteId}/package.json not found.` }], isError: true };
|
|
772
|
+
}
|
|
773
|
+
const appDir = args.useSrcDir ? "src/app" : "app";
|
|
774
|
+
const filesCreated = [];
|
|
775
|
+
// 1. Add workspace deps to package.json if missing
|
|
776
|
+
const pkgPath = join(projectDir, "package.json");
|
|
777
|
+
const pkg = JSON.parse(await readFile(pkgPath, "utf-8"));
|
|
778
|
+
// Auto-detect site name: prefer <title> from layout metadata, fall back to package.json
|
|
779
|
+
let siteName = args.name;
|
|
780
|
+
const genericNames = new Set(["sample site", "my site", "site", "test site", "new site", "untitled", ""]);
|
|
781
|
+
if (!siteName || genericNames.has(siteName.toLowerCase())) {
|
|
782
|
+
// Try extracting title from layout metadata
|
|
783
|
+
const layoutFile = args.layoutPath
|
|
784
|
+
? join(projectDir, args.layoutPath)
|
|
785
|
+
: join(projectDir, appDir, "layout.tsx");
|
|
786
|
+
if (existsSync(layoutFile)) {
|
|
787
|
+
const layoutSrc = await readFile(layoutFile, "utf-8");
|
|
788
|
+
// Match: title: "..." or title: { default: "..." }
|
|
789
|
+
const titleMatch = layoutSrc.match(/title:\s*(?:\{\s*default:\s*)?["']([^"']+)["']/);
|
|
790
|
+
if (titleMatch?.[1])
|
|
791
|
+
siteName = titleMatch[1];
|
|
792
|
+
}
|
|
793
|
+
// Fall back to humanized package name
|
|
794
|
+
if (!siteName || genericNames.has(siteName.toLowerCase())) {
|
|
795
|
+
siteName = humanizePkgName(String(pkg.name ?? args.siteId));
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
const deps = pkg.dependencies ?? {};
|
|
799
|
+
let depsAdded = false;
|
|
800
|
+
for (const dep of ["@avocadostudio-ai/site-sdk", "@avocadostudio-ai/blocks", "@avocadostudio-ai/shared"]) {
|
|
801
|
+
if (!deps[dep]) {
|
|
802
|
+
deps[dep] = "workspace:*";
|
|
803
|
+
depsAdded = true;
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
if (depsAdded) {
|
|
807
|
+
pkg.dependencies = deps;
|
|
808
|
+
await writeFile(pkgPath, JSON.stringify(pkg, null, 2) + "\n", "utf-8");
|
|
809
|
+
filesCreated.push("package.json (deps added)");
|
|
810
|
+
}
|
|
811
|
+
// 2. Create or wrap catch-all page route for editor-managed pages
|
|
812
|
+
const catchAllDir = join(projectDir, appDir, "[[...slug]]");
|
|
813
|
+
const catchAllPage = join(catchAllDir, "page.tsx");
|
|
814
|
+
const originalPage = join(catchAllDir, "_original-page.tsx");
|
|
815
|
+
const editorPage = join(catchAllDir, "_editor-page.tsx");
|
|
816
|
+
const blocksPrefix = args.useSrcDir ? "../../../" : "../../";
|
|
817
|
+
let isHybrid = false;
|
|
818
|
+
if (!existsSync(catchAllDir)) {
|
|
819
|
+
// Case A: No catch-all — create standard editor catch-all
|
|
820
|
+
await mkdir(catchAllDir, { recursive: true });
|
|
821
|
+
const pageContent = pageTsx(args.siteId)
|
|
822
|
+
.replace('import "../../blocks/register"', `import "${blocksPrefix}blocks/register"`);
|
|
823
|
+
await writeFile(catchAllPage, pageContent, "utf-8");
|
|
824
|
+
filesCreated.push(`${appDir}/[[...slug]]/page.tsx`);
|
|
825
|
+
}
|
|
826
|
+
else if (existsSync(catchAllPage) && !existsSync(originalPage)) {
|
|
827
|
+
// Case B: Existing catch-all, not yet wrapped — create hybrid
|
|
828
|
+
isHybrid = true;
|
|
829
|
+
const { rename } = await import("node:fs/promises");
|
|
830
|
+
await rename(catchAllPage, originalPage);
|
|
831
|
+
const editorContent = pageTsx(args.siteId, { chrome: false })
|
|
832
|
+
.replace('import "../../blocks/register"', `import "${blocksPrefix}blocks/register"`);
|
|
833
|
+
await writeFile(editorPage, editorContent, "utf-8");
|
|
834
|
+
await writeFile(catchAllPage, hybridPageTsx(args.siteId), "utf-8");
|
|
835
|
+
filesCreated.push(`${appDir}/[[...slug]]/_original-page.tsx (preserved)`);
|
|
836
|
+
filesCreated.push(`${appDir}/[[...slug]]/_editor-page.tsx`);
|
|
837
|
+
filesCreated.push(`${appDir}/[[...slug]]/page.tsx (hybrid wrapper)`);
|
|
838
|
+
}
|
|
839
|
+
else if (existsSync(originalPage)) {
|
|
840
|
+
isHybrid = true; // already integrated
|
|
841
|
+
}
|
|
842
|
+
// 3. Create editor API route
|
|
843
|
+
const apiDir = join(projectDir, appDir, "api/editor/[...path]");
|
|
844
|
+
if (!existsSync(apiDir)) {
|
|
845
|
+
await mkdir(apiDir, { recursive: true });
|
|
846
|
+
const apiBlocksPrefix = args.useSrcDir ? "../../../../../" : "../../../../";
|
|
847
|
+
const routeContent = editorApiRoute()
|
|
848
|
+
.replace('import "../../../../blocks/register"', `import "${apiBlocksPrefix}blocks/register"`);
|
|
849
|
+
await writeFile(join(apiDir, "route.ts"), routeContent, "utf-8");
|
|
850
|
+
filesCreated.push(`${appDir}/api/editor/[...path]/route.ts`);
|
|
851
|
+
}
|
|
852
|
+
// 4. Create content directory (empty for hybrid — existing pages fall through to original)
|
|
853
|
+
const contentDir = join(projectDir, "content");
|
|
854
|
+
if (!existsSync(join(contentDir, "pages.json"))) {
|
|
855
|
+
await mkdir(contentDir, { recursive: true });
|
|
856
|
+
await writeFile(join(contentDir, "pages.json"), isHybrid ? "[]\n" : samplePagesJson(), "utf-8");
|
|
857
|
+
filesCreated.push("content/pages.json");
|
|
858
|
+
}
|
|
859
|
+
// 5. Create blocks register file
|
|
860
|
+
const blocksDir = join(projectDir, "blocks");
|
|
861
|
+
if (!existsSync(join(blocksDir, "register.ts")) && !existsSync(join(blocksDir, "register.tsx"))) {
|
|
862
|
+
await mkdir(blocksDir, { recursive: true });
|
|
863
|
+
await writeFile(join(blocksDir, "register.tsx"), blocksRegisterTsx(), "utf-8");
|
|
864
|
+
filesCreated.push("blocks/register.tsx");
|
|
865
|
+
}
|
|
866
|
+
// 6. Create lib/defaults.ts
|
|
867
|
+
const libDir = join(projectDir, "lib");
|
|
868
|
+
if (!existsSync(join(libDir, "defaults.ts"))) {
|
|
869
|
+
await mkdir(libDir, { recursive: true });
|
|
870
|
+
await writeFile(join(libDir, "defaults.ts"), defaultsTs(args.siteId, siteName), "utf-8");
|
|
871
|
+
filesCreated.push("lib/defaults.ts");
|
|
872
|
+
}
|
|
873
|
+
// 7. Create/merge .env.local
|
|
874
|
+
const envPath = join(projectDir, ".env.local");
|
|
875
|
+
const envVars = {
|
|
876
|
+
ORCHESTRATOR_URL: "http://localhost:4200",
|
|
877
|
+
DRAFT_MODE_SECRET: getDraftModeSecret(),
|
|
878
|
+
NEXT_PUBLIC_DEFAULT_SITE_ID: args.siteId,
|
|
879
|
+
NEXT_PUBLIC_SITE_NAME: siteName,
|
|
880
|
+
NEXT_PUBLIC_EDITOR_ORIGIN: "http://localhost:4100",
|
|
881
|
+
};
|
|
882
|
+
if (existsSync(envPath)) {
|
|
883
|
+
const existing = await readFile(envPath, "utf-8");
|
|
884
|
+
const existingKeys = new Set(existing.split("\n").map(l => l.split("=")[0]).filter(Boolean));
|
|
885
|
+
const newLines = [];
|
|
886
|
+
for (const [k, v] of Object.entries(envVars)) {
|
|
887
|
+
if (!existingKeys.has(k))
|
|
888
|
+
newLines.push(`${k}=${v}`);
|
|
889
|
+
}
|
|
890
|
+
if (newLines.length > 0) {
|
|
891
|
+
await writeFile(envPath, existing.trimEnd() + "\n" + newLines.join("\n") + "\n", "utf-8");
|
|
892
|
+
filesCreated.push(".env.local (merged)");
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
else {
|
|
896
|
+
await writeFile(envPath, Object.entries(envVars).map(([k, v]) => `${k}=${v}`).join("\n") + "\n", "utf-8");
|
|
897
|
+
filesCreated.push(".env.local");
|
|
898
|
+
}
|
|
899
|
+
// 8. Add block styles import + EditorOverlay to existing layout
|
|
900
|
+
const layoutFile = args.layoutPath
|
|
901
|
+
? join(projectDir, args.layoutPath)
|
|
902
|
+
: join(projectDir, appDir, "layout.tsx");
|
|
903
|
+
if (existsSync(layoutFile)) {
|
|
904
|
+
let layoutContent = await readFile(layoutFile, "utf-8");
|
|
905
|
+
let layoutModified = false;
|
|
906
|
+
// Add block styles import
|
|
907
|
+
if (!layoutContent.includes("@avocadostudio-ai/blocks/styles.css")) {
|
|
908
|
+
const lines = layoutContent.split("\n");
|
|
909
|
+
let lastImportIdx = -1;
|
|
910
|
+
for (let i = 0; i < lines.length; i++) {
|
|
911
|
+
if (lines[i].startsWith("import "))
|
|
912
|
+
lastImportIdx = i;
|
|
913
|
+
}
|
|
914
|
+
if (lastImportIdx >= 0) {
|
|
915
|
+
lines.splice(lastImportIdx + 1, 0, 'import "@avocadostudio-ai/blocks/styles.css"');
|
|
916
|
+
}
|
|
917
|
+
else {
|
|
918
|
+
lines.unshift('import "@avocadostudio-ai/blocks/styles.css"');
|
|
919
|
+
}
|
|
920
|
+
layoutContent = lines.join("\n");
|
|
921
|
+
layoutModified = true;
|
|
922
|
+
}
|
|
923
|
+
// Add EditorOverlay for preview bridge (enables editor communication)
|
|
924
|
+
if (!layoutContent.includes("EditorOverlay")) {
|
|
925
|
+
// Add import
|
|
926
|
+
const lines = layoutContent.split("\n");
|
|
927
|
+
let lastImportIdx = -1;
|
|
928
|
+
for (let i = 0; i < lines.length; i++) {
|
|
929
|
+
if (lines[i].startsWith("import "))
|
|
930
|
+
lastImportIdx = i;
|
|
931
|
+
}
|
|
932
|
+
const overlayImport = 'import { EditorOverlay } from "@avocadostudio-ai/site-sdk/editor"';
|
|
933
|
+
if (lastImportIdx >= 0) {
|
|
934
|
+
lines.splice(lastImportIdx + 1, 0, overlayImport);
|
|
935
|
+
}
|
|
936
|
+
else {
|
|
937
|
+
lines.unshift(overlayImport);
|
|
938
|
+
}
|
|
939
|
+
layoutContent = lines.join("\n");
|
|
940
|
+
// Insert <EditorOverlay /> before closing </body>
|
|
941
|
+
layoutContent = layoutContent.replace("</body>", ` <EditorOverlay slug="/" editorOrigin={process.env.NEXT_PUBLIC_EDITOR_ORIGIN ?? "http://localhost:4100"} />\n </body>`);
|
|
942
|
+
layoutModified = true;
|
|
943
|
+
}
|
|
944
|
+
if (layoutModified) {
|
|
945
|
+
await writeFile(layoutFile, layoutContent, "utf-8");
|
|
946
|
+
filesCreated.push(args.layoutPath ?? `${appDir}/layout.tsx (styles + editor overlay added)`);
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
// 9. Create public dir and default assets if missing
|
|
950
|
+
const publicDir = join(projectDir, "public");
|
|
951
|
+
if (!existsSync(publicDir))
|
|
952
|
+
await mkdir(publicDir, { recursive: true });
|
|
953
|
+
if (!existsSync(join(publicDir, "logo.svg"))) {
|
|
954
|
+
await writeFile(join(publicDir, "logo.svg"), defaultLogoSvg(siteName), "utf-8");
|
|
955
|
+
filesCreated.push("public/logo.svg");
|
|
956
|
+
}
|
|
957
|
+
if (!existsSync(join(publicDir, "favicon.svg"))) {
|
|
958
|
+
await writeFile(join(publicDir, "favicon.svg"), faviconSvg(siteName), "utf-8");
|
|
959
|
+
filesCreated.push("public/favicon.svg");
|
|
960
|
+
}
|
|
961
|
+
// 10. Install dependencies (skip if monorepo root node_modules exists)
|
|
962
|
+
const rootNodeModules = join(root, "node_modules");
|
|
963
|
+
if (depsAdded && existsSync(rootNodeModules)) {
|
|
964
|
+
const { execFile } = await import("node:child_process");
|
|
965
|
+
const { promisify } = await import("node:util");
|
|
966
|
+
await promisify(execFile)("pnpm", ["install", "--no-frozen-lockfile"], { cwd: root, timeout: 60_000 });
|
|
967
|
+
}
|
|
968
|
+
// 11. Start dev server and register site in editor dashboard
|
|
969
|
+
// (Previously split into separate launch_site step — now self-contained like create_site)
|
|
970
|
+
let port = await detectSitePort(projectDir);
|
|
971
|
+
// Check if port is in use — find a free one
|
|
972
|
+
try {
|
|
973
|
+
const { execSync } = await import("node:child_process");
|
|
974
|
+
const pids = execSync(`lsof -ti:${port} 2>/dev/null`, { encoding: "utf-8" }).trim();
|
|
975
|
+
if (pids) {
|
|
976
|
+
console.log(`[integrate_site] Port ${port} in use, finding free port...`);
|
|
977
|
+
port = await findAvailablePort(root);
|
|
978
|
+
const pkg = JSON.parse(await readFile(join(projectDir, "package.json"), "utf-8"));
|
|
979
|
+
if (pkg.scripts?.dev) {
|
|
980
|
+
pkg.scripts.dev = pkg.scripts.dev.replace(/-p\s*\d+/, `-p ${port}`);
|
|
981
|
+
await writeFile(join(projectDir, "package.json"), JSON.stringify(pkg, null, 2) + "\n", "utf-8");
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
catch { /* no process on port — good */ }
|
|
986
|
+
// Initialize orchestrator session
|
|
987
|
+
const sessionKey = scopedSessionKey(session, args.siteId);
|
|
988
|
+
setPage(sessionKey, {
|
|
989
|
+
id: "p_home", slug: "/", title: "Home", blocks: [], updatedAt: new Date().toISOString(),
|
|
990
|
+
});
|
|
991
|
+
bumpVersion(sessionKey);
|
|
992
|
+
// Start dev server and wait for ready
|
|
993
|
+
const previewUrl = `http://localhost:${port}`;
|
|
994
|
+
const { serverReady } = await startAndWaitForDevServer({
|
|
995
|
+
siteId: args.siteId, port, cwd: projectDir,
|
|
996
|
+
});
|
|
997
|
+
// Register in editor dashboard (emits site_created SSE event)
|
|
998
|
+
const siteConfig = {
|
|
999
|
+
id: args.siteId,
|
|
1000
|
+
name: siteName,
|
|
1001
|
+
purpose: args.purpose ?? "",
|
|
1002
|
+
tone: "",
|
|
1003
|
+
hosting: "local",
|
|
1004
|
+
previewUrl,
|
|
1005
|
+
constraints: [],
|
|
1006
|
+
};
|
|
1007
|
+
emitSiteCreated(siteConfig);
|
|
1008
|
+
emitPhaseOutcome?.({ tool: "integrate_site", data: { siteId: args.siteId, port, name: siteName, filesCreated: filesCreated.length, serverReady } });
|
|
1009
|
+
return {
|
|
1010
|
+
content: [{
|
|
1011
|
+
type: "text",
|
|
1012
|
+
text: JSON.stringify({
|
|
1013
|
+
status: serverReady ? "running" : "integrated",
|
|
1014
|
+
siteId: args.siteId,
|
|
1015
|
+
port,
|
|
1016
|
+
previewUrl,
|
|
1017
|
+
name: siteName,
|
|
1018
|
+
filesCreated,
|
|
1019
|
+
serverReady,
|
|
1020
|
+
}),
|
|
1021
|
+
}],
|
|
1022
|
+
};
|
|
1023
|
+
}
|
|
1024
|
+
catch (e) {
|
|
1025
|
+
return { content: [{ type: "text", text: `Error: ${e instanceof Error ? e.message : String(e)}` }], isError: true };
|
|
1026
|
+
}
|
|
1027
|
+
}, { annotations: { destructiveHint: false, openWorldHint: false } });
|
|
1028
|
+
// ── LAUNCH SITE ──
|
|
1029
|
+
const launchSiteTool = tool("launch_site", "Start the dev server for an integrated site, wait for it to be ready, and register it in the editor. Call this AFTER integrate_site completes. Returns the confirmed preview URL once the server is responding.", {
|
|
1030
|
+
siteId: z.string().describe("Site ID (matches the directory name in apps/)"),
|
|
1031
|
+
name: z.string().optional().describe("Human-readable site name"),
|
|
1032
|
+
purpose: z.string().optional().describe("What the site is about"),
|
|
1033
|
+
}, async (args) => {
|
|
1034
|
+
try {
|
|
1035
|
+
const root = monorepoRoot();
|
|
1036
|
+
const projectDir = join(root, "apps", args.siteId);
|
|
1037
|
+
if (!existsSync(join(projectDir, "package.json"))) {
|
|
1038
|
+
return { content: [{ type: "text", text: `Error: apps/${args.siteId}/package.json not found.` }], isError: true };
|
|
1039
|
+
}
|
|
1040
|
+
let port = await detectSitePort(projectDir);
|
|
1041
|
+
// Check if port is in use — if so, find a free one and update package.json
|
|
1042
|
+
try {
|
|
1043
|
+
const { execSync } = await import("node:child_process");
|
|
1044
|
+
const pids = execSync(`lsof -ti:${port} 2>/dev/null`, { encoding: "utf-8" }).trim();
|
|
1045
|
+
if (pids) {
|
|
1046
|
+
console.log(`[sites-agent] Port ${port} in use, finding free port...`);
|
|
1047
|
+
port = await findAvailablePort(root);
|
|
1048
|
+
// Update dev script with new port
|
|
1049
|
+
const pkg = JSON.parse(await readFile(join(projectDir, "package.json"), "utf-8"));
|
|
1050
|
+
if (pkg.scripts?.dev) {
|
|
1051
|
+
pkg.scripts.dev = pkg.scripts.dev.replace(/-p\s*\d+/, `-p ${port}`);
|
|
1052
|
+
await writeFile(join(projectDir, "package.json"), JSON.stringify(pkg, null, 2) + "\n", "utf-8");
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
catch { /* no process on port — good */ }
|
|
1057
|
+
// Initialize orchestrator session
|
|
1058
|
+
const sessionKey = scopedSessionKey(session, args.siteId);
|
|
1059
|
+
setPage(sessionKey, {
|
|
1060
|
+
id: "p_home", slug: "/", title: "Home", blocks: [], updatedAt: new Date().toISOString(),
|
|
1061
|
+
});
|
|
1062
|
+
bumpVersion(sessionKey);
|
|
1063
|
+
// Start dev server and wait for it to be ready
|
|
1064
|
+
const previewUrl = `http://localhost:${port}`;
|
|
1065
|
+
const { serverReady } = await startAndWaitForDevServer({
|
|
1066
|
+
siteId: args.siteId, port, cwd: projectDir,
|
|
1067
|
+
});
|
|
1068
|
+
const siteName = args.name ?? args.siteId;
|
|
1069
|
+
const siteConfig = {
|
|
1070
|
+
id: args.siteId,
|
|
1071
|
+
name: siteName,
|
|
1072
|
+
purpose: args.purpose ?? "",
|
|
1073
|
+
tone: "",
|
|
1074
|
+
hosting: "local",
|
|
1075
|
+
previewUrl,
|
|
1076
|
+
constraints: [],
|
|
1077
|
+
};
|
|
1078
|
+
emitSiteCreated(siteConfig);
|
|
1079
|
+
emitPhaseOutcome?.({ tool: "launch_site", data: { siteId: args.siteId, port, name: siteName, serverReady } });
|
|
1080
|
+
return {
|
|
1081
|
+
content: [{
|
|
1082
|
+
type: "text",
|
|
1083
|
+
text: JSON.stringify({ status: serverReady ? "running" : "timeout", siteId: args.siteId, port, previewUrl, name: siteName, serverReady }),
|
|
1084
|
+
}],
|
|
1085
|
+
};
|
|
1086
|
+
}
|
|
1087
|
+
catch (e) {
|
|
1088
|
+
return { content: [{ type: "text", text: `Error: ${e instanceof Error ? e.message : String(e)}` }], isError: true };
|
|
1089
|
+
}
|
|
1090
|
+
}, { annotations: { destructiveHint: false, openWorldHint: false } });
|
|
1091
|
+
// ── REGISTER SITE ──
|
|
1092
|
+
const registerSiteTool = tool("register_site", "Register an existing site project with the editor and start its dev server. Call this AFTER integrating the SDK into an existing codebase. The site will appear in the editor's site list and be ready for editing.", {
|
|
1093
|
+
siteId: z.string().describe("Site ID (kebab-case, matches the directory name in apps/)"),
|
|
1094
|
+
name: z.string().describe("Human-readable site name"),
|
|
1095
|
+
purpose: z.string().optional().describe("What the site is about"),
|
|
1096
|
+
port: z.number().optional().describe("Dev server port (auto-detected from package.json if omitted)"),
|
|
1097
|
+
}, async (args) => {
|
|
1098
|
+
try {
|
|
1099
|
+
const root = monorepoRoot();
|
|
1100
|
+
const projectDir = join(root, "apps", args.siteId);
|
|
1101
|
+
if (!existsSync(join(projectDir, "package.json"))) {
|
|
1102
|
+
return { content: [{ type: "text", text: `Error: apps/${args.siteId}/package.json not found. Ensure the project exists.` }], isError: true };
|
|
1103
|
+
}
|
|
1104
|
+
const port = args.port || await detectSitePort(projectDir);
|
|
1105
|
+
// Initialize orchestrator session state
|
|
1106
|
+
const sessionKey = scopedSessionKey(session, args.siteId);
|
|
1107
|
+
setPage(sessionKey, {
|
|
1108
|
+
id: "p_home", slug: "/", title: "Home", blocks: [], updatedAt: new Date().toISOString(),
|
|
1109
|
+
});
|
|
1110
|
+
bumpVersion(sessionKey);
|
|
1111
|
+
// Kill any existing process on the allocated port
|
|
1112
|
+
try {
|
|
1113
|
+
const { execSync } = await import("node:child_process");
|
|
1114
|
+
const pids = execSync(`lsof -ti:${port} 2>/dev/null`, { encoding: "utf-8" }).trim();
|
|
1115
|
+
if (pids) {
|
|
1116
|
+
for (const pid of pids.split("\n")) {
|
|
1117
|
+
try {
|
|
1118
|
+
process.kill(Number(pid), "SIGKILL");
|
|
1119
|
+
}
|
|
1120
|
+
catch { /* already dead */ }
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
catch { /* no process on port */ }
|
|
1125
|
+
// Install dependencies if needed
|
|
1126
|
+
if (!existsSync(join(projectDir, "node_modules"))) {
|
|
1127
|
+
const { execFile } = await import("node:child_process");
|
|
1128
|
+
const { promisify } = await import("node:util");
|
|
1129
|
+
await promisify(execFile)("pnpm", ["install", "--no-frozen-lockfile"], { cwd: root, timeout: 60_000 });
|
|
1130
|
+
}
|
|
1131
|
+
// Start dev server and wait for readiness
|
|
1132
|
+
const { serverReady } = await startAndWaitForDevServer({
|
|
1133
|
+
siteId: args.siteId, port, cwd: root, useFilter: true,
|
|
1134
|
+
});
|
|
1135
|
+
const siteConfig = {
|
|
1136
|
+
id: args.siteId,
|
|
1137
|
+
name: args.name,
|
|
1138
|
+
purpose: args.purpose ?? "",
|
|
1139
|
+
tone: "",
|
|
1140
|
+
hosting: "local",
|
|
1141
|
+
previewUrl: `http://localhost:${port}`,
|
|
1142
|
+
constraints: [],
|
|
1143
|
+
};
|
|
1144
|
+
emitSiteCreated(siteConfig);
|
|
1145
|
+
emitPhaseOutcome?.({ tool: "register_site", data: { siteId: args.siteId, port, name: args.name, serverReady } });
|
|
1146
|
+
return {
|
|
1147
|
+
content: [{
|
|
1148
|
+
type: "text",
|
|
1149
|
+
text: JSON.stringify({
|
|
1150
|
+
status: "registered",
|
|
1151
|
+
siteId: args.siteId,
|
|
1152
|
+
config: siteConfig,
|
|
1153
|
+
port,
|
|
1154
|
+
name: args.name,
|
|
1155
|
+
previewUrl: `http://localhost:${port}`,
|
|
1156
|
+
devServerStarted: serverReady,
|
|
1157
|
+
}),
|
|
1158
|
+
}],
|
|
1159
|
+
};
|
|
1160
|
+
}
|
|
1161
|
+
catch (e) {
|
|
1162
|
+
return { content: [{ type: "text", text: `Error: ${e instanceof Error ? e.message : String(e)}` }], isError: true };
|
|
1163
|
+
}
|
|
1164
|
+
}, { annotations: { destructiveHint: false, openWorldHint: false } });
|
|
1165
|
+
// ── ANALYZE CODEBASE ──
|
|
1166
|
+
const analyzeCodebaseTool = tool("analyze_codebase", "Analyze an existing site project to detect its framework, CMS, routes, styling, and readiness for AI Site Editor integration. Use this before integrating an existing codebase.", {
|
|
1167
|
+
projectPath: z.string().describe("Absolute path to the project root directory"),
|
|
1168
|
+
}, async (args) => {
|
|
1169
|
+
try {
|
|
1170
|
+
const analysis = await analyzeCodebase(args.projectPath);
|
|
1171
|
+
emitPhaseOutcome?.({ tool: "analyze_codebase", data: { framework: analysis.framework, routes: analysis.existingRoutes.length } });
|
|
1172
|
+
return { content: [{ type: "text", text: JSON.stringify(analysis, null, 2) }] };
|
|
1173
|
+
}
|
|
1174
|
+
catch (e) {
|
|
1175
|
+
return { content: [{ type: "text", text: `Error analyzing codebase: ${e instanceof Error ? e.message : String(e)}` }], isError: true };
|
|
1176
|
+
}
|
|
1177
|
+
}, { annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false } });
|
|
1178
|
+
// ── VISUAL QA DIFF ──
|
|
1179
|
+
const visualQaDiffTool = tool("visual_qa_diff", "Take screenshots of the generated site and compare with original source screenshots. Returns a list of visual discrepancies found by comparing the screenshots. Use this AFTER bootstrap_pages to verify migration fidelity.", {
|
|
1180
|
+
generatedSiteUrl: z.string().describe("URL of the generated site to screenshot (e.g. http://localhost:3000)"),
|
|
1181
|
+
originalUrl: z.string().describe("URL of the original source site that was migrated"),
|
|
1182
|
+
}, async (args) => {
|
|
1183
|
+
try {
|
|
1184
|
+
// Take screenshots of the generated site (scrapeFullPage gives us both desktop + mobile)
|
|
1185
|
+
const genScrape = await scrapeFullPage(args.generatedSiteUrl);
|
|
1186
|
+
const genDesktop = genScrape.screenshot;
|
|
1187
|
+
const genMobile = genScrape.mobileScreenshot;
|
|
1188
|
+
// Get original screenshots from cache (should be available from prior scrape_url call)
|
|
1189
|
+
const cachedScrape = getCachedScrape(args.originalUrl);
|
|
1190
|
+
const content = [];
|
|
1191
|
+
content.push({ type: "text", text: "Visual QA comparison. The following images are paired: ORIGINAL then GENERATED for each viewport. Identify all visual discrepancies (colors, spacing, layout, fonts, missing images, wrong block types) and suggest specific fixes." });
|
|
1192
|
+
// Desktop comparison
|
|
1193
|
+
if (cachedScrape?.screenshot) {
|
|
1194
|
+
content.push({ type: "image", data: cachedScrape.screenshot.base64, mimeType: "image/jpeg" });
|
|
1195
|
+
content.push({ type: "text", text: "^ ORIGINAL desktop (1440px)" });
|
|
1196
|
+
}
|
|
1197
|
+
if (genDesktop) {
|
|
1198
|
+
content.push({ type: "image", data: genDesktop.base64, mimeType: "image/jpeg" });
|
|
1199
|
+
content.push({ type: "text", text: "^ GENERATED desktop (1440px)" });
|
|
1200
|
+
}
|
|
1201
|
+
// Mobile comparison
|
|
1202
|
+
if (cachedScrape?.mobileScreenshot && genMobile) {
|
|
1203
|
+
content.push({ type: "image", data: cachedScrape.mobileScreenshot.base64, mimeType: "image/jpeg" });
|
|
1204
|
+
content.push({ type: "text", text: "^ ORIGINAL mobile (390px)" });
|
|
1205
|
+
content.push({ type: "image", data: genMobile.base64, mimeType: "image/jpeg" });
|
|
1206
|
+
content.push({ type: "text", text: "^ GENERATED mobile (390px)" });
|
|
1207
|
+
}
|
|
1208
|
+
content.push({ type: "text", text: "List all discrepancies with severity (critical/major/minor) and suggest specific operations to fix them." });
|
|
1209
|
+
emitPhaseOutcome?.({ tool: "visual_qa_diff", data: { hasOriginal: !!cachedScrape?.screenshot, hasMobile: !!genMobile } });
|
|
1210
|
+
return { content };
|
|
1211
|
+
}
|
|
1212
|
+
catch (e) {
|
|
1213
|
+
return { content: [{ type: "text", text: `Error during visual QA: ${e instanceof Error ? e.message : String(e)}` }], isError: true };
|
|
1214
|
+
}
|
|
1215
|
+
}, { annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true } });
|
|
1216
|
+
return createSdkMcpServer({
|
|
1217
|
+
name: "sites-agent",
|
|
1218
|
+
version: "1.0.0",
|
|
1219
|
+
tools: [listSitesTool, discoverStructureTool, createSiteTool, scrapeUrlTool, extractTokensTool, bootstrapPagesTool, downloadImageTool, downloadImagesTool, browseGdriveTool, applyThemeTool, analyzeCodebaseTool, cloneRepoTool, integrateSiteTool, launchSiteTool, registerSiteTool, visualQaDiffTool, ...createMigrationTools()],
|
|
1220
|
+
});
|
|
1221
|
+
}
|
|
1222
|
+
// Template functions now live in sites-agent-shared.ts — imported at the top of this file.
|
|
1223
|
+
// (sanitizeSiteId, monorepoRoot, findAvailablePort, patchGlobalsCssVars,
|
|
1224
|
+
// validateAndCorrectProps, normalizePageBlocks, scaffoldSiteProject,
|
|
1225
|
+
// packageJson, nextConfigTs, tsconfigJson, postcssConfig, layoutTsx, globalsCss,
|
|
1226
|
+
// defaultsTs, editorApiRoute, pageTsx, blocksRegisterTsx, samplePagesJson,
|
|
1227
|
+
// defaultLogoSvg, faviconSvg)
|