import { jsxLocPlugin } from "@builder.io/vite-plugin-jsx-loc"; import tailwindcss from "@tailwindcss/vite"; import react from "@vitejs/plugin-react"; import { spawn } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; import { defineConfig, type Plugin, type ViteDevServer } from "vite"; import { vitePluginManusRuntime } from "vite-plugin-manus-runtime"; // ============================================================================= // Manus Debug Collector - Vite Plugin // Writes browser logs directly to files, trimmed when exceeding size limit // ============================================================================= const PROJECT_ROOT = import.meta.dirname; const LOG_DIR = path.join(PROJECT_ROOT, ".manus-logs"); const MAX_LOG_SIZE_BYTES = 1 * 1024 * 1024; // 1MB per log file const TRIM_TARGET_BYTES = Math.floor(MAX_LOG_SIZE_BYTES * 0.6); // Trim to 60% to avoid constant re-trimming type LogSource = "browserConsole" | "networkRequests" | "sessionReplay"; function ensureLogDir() { if (!fs.existsSync(LOG_DIR)) { fs.mkdirSync(LOG_DIR, { recursive: true }); } } function trimLogFile(logPath: string, maxSize: number) { try { if (!fs.existsSync(logPath) || fs.statSync(logPath).size <= maxSize) { return; } const lines = fs.readFileSync(logPath, "utf-8").split("\n"); const keptLines: string[] = []; let keptBytes = 0; // Keep newest lines (from end) that fit within 60% of maxSize const targetSize = TRIM_TARGET_BYTES; for (let i = lines.length - 1; i >= 0; i--) { const lineBytes = Buffer.byteLength(`${lines[i]}\n`, "utf-8"); if (keptBytes + lineBytes > targetSize) break; keptLines.unshift(lines[i]); keptBytes += lineBytes; } fs.writeFileSync(logPath, keptLines.join("\n"), "utf-8"); } catch { /* ignore trim errors */ } } function writeToLogFile(source: LogSource, entries: unknown[]) { if (entries.length === 0) return; ensureLogDir(); const logPath = path.join(LOG_DIR, `${source}.log`); // Format entries with timestamps const lines = entries.map((entry) => { const ts = new Date().toISOString(); return `[${ts}] ${JSON.stringify(entry)}`; }); // Append to log file fs.appendFileSync(logPath, `${lines.join("\n")}\n`, "utf-8"); // Trim if exceeds max size trimLogFile(logPath, MAX_LOG_SIZE_BYTES); } /** * Vite plugin to collect browser debug logs * - POST /__manus__/logs: Browser sends logs, written directly to files * - Files: browserConsole.log, networkRequests.log, sessionReplay.log * - Auto-trimmed when exceeding 1MB (keeps newest entries) */ function vitePluginManusDebugCollector(): Plugin { return { name: "manus-debug-collector", transformIndexHtml(html) { if (process.env.NODE_ENV === "production") { return html; } return { html, tags: [ { tag: "script", attrs: { src: "/__manus__/debug-collector.js", defer: true, }, injectTo: "head", }, ], }; }, configureServer(server: ViteDevServer) { // POST /__manus__/logs: Browser sends logs (written directly to files) server.middlewares.use("/__manus__/logs", (req, res, next) => { if (req.method !== "POST") { return next(); } const handlePayload = (payload: any) => { // Write logs directly to files if (payload.consoleLogs?.length > 0) { writeToLogFile("browserConsole", payload.consoleLogs); } if (payload.networkRequests?.length > 0) { writeToLogFile("networkRequests", payload.networkRequests); } if (payload.sessionEvents?.length > 0) { writeToLogFile("sessionReplay", payload.sessionEvents); } res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ success: true })); }; const reqBody = (req as { body?: unknown }).body; if (reqBody && typeof reqBody === "object") { try { handlePayload(reqBody); } catch (e) { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ success: false, error: String(e) })); } return; } let body = ""; req.on("data", (chunk) => { body += chunk.toString(); }); req.on("end", () => { try { const payload = JSON.parse(body); handlePayload(payload); } catch (e) { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ success: false, error: String(e) })); } }); }); }, }; } function vitePluginStorageProxy(): Plugin { return { name: "manus-storage-proxy", configureServer(server: ViteDevServer) { server.middlewares.use("/manus-storage", async (req, res) => { const key = req.url?.replace(/^\//, ""); if (!key) { res.writeHead(400, { "Content-Type": "text/plain" }); res.end("Missing storage key"); return; } const forgeBaseUrl = (process.env.BUILT_IN_FORGE_API_URL || "").replace(/\/+$/, ""); const forgeKey = process.env.BUILT_IN_FORGE_API_KEY; if (!forgeBaseUrl || !forgeKey) { res.writeHead(500, { "Content-Type": "text/plain" }); res.end("Storage proxy not configured"); return; } try { const forgeUrl = new URL("v1/storage/presign/get", forgeBaseUrl + "/"); forgeUrl.searchParams.set("path", key); const forgeResp = await fetch(forgeUrl, { headers: { Authorization: `Bearer ${forgeKey}` }, }); if (!forgeResp.ok) { res.writeHead(502, { "Content-Type": "text/plain" }); res.end("Storage backend error"); return; } const { url } = (await forgeResp.json()) as { url: string }; if (!url) { res.writeHead(502, { "Content-Type": "text/plain" }); res.end("Empty signed URL"); return; } res.writeHead(307, { Location: url, "Cache-Control": "no-store" }); res.end(); } catch { res.writeHead(502, { "Content-Type": "text/plain" }); res.end("Storage proxy error"); } }); }, }; } // ============================================================================= // IMP-52 u3/u4 — user_overrides.json persistence (MDX-stem keyed store). // // On-disk layout: /data/user_overrides/.json. Mirrors // the Python contract in src/user_overrides_io.py — same validate_key regex, // same graceful-degrade (corrupt → {}) so backend pipeline entry fallback // (u2) and the vite endpoints (u3 GET, u4 PUT) agree on every file. // // Helpers are named exports so vitest can drive handleGetUserOverrides / // handlePutUserOverrides with mock req/res without booting a real dev // server. vite still consumes the default `defineConfig` export below. // ============================================================================= export const USER_OVERRIDES_KEY_RE = /^[A-Za-z0-9_][A-Za-z0-9_.\-]*$/; // The six in-scope axes — mirror of KNOWN_AXES in // src/user_overrides_io.py minus `slide_css` (known gap, IMP-45 #74 — // the Python side persists it for backend consumption; the Vite PUT does // not write it because the frontend never mutates the slide-level CSS // override). Any payload key outside this allowlist is silently dropped // by the PUT handler (u4) so the on-disk schema cannot drift from the // backend pipeline (u2) contract. Foreign top-level keys already on disk // are preserved verbatim (see mergeUserOverrides). // IMP-51 (#79) u2: added `image_overrides` (image_id → {x,y,w,h} // percent-of-slide coordinates). // IMP-55 (#93) u1: added `manual_section_assignment` (bool intent marker // — drag-drop sets true, layout apply/cancel sets false). export const KNOWN_USER_OVERRIDES_AXES = [ "layout", "zone_geometries", "zone_sections", "frames", "image_overrides", "manual_section_assignment", ] as const; export type KnownUserOverridesAxis = (typeof KNOWN_USER_OVERRIDES_AXES)[number]; // 1MB cap on PUT bodies. Override files in practice are < 10KB (5 axes, // each a small dict). The cap is a safety net against runaway client // loops, not a real schema constraint. const USER_OVERRIDES_PUT_MAX_BYTES = 1_000_000; export function isValidUserOverridesKey(key: string): boolean { if (!key) return false; if (key.includes("..")) return false; if (key.includes("/") || key.includes("\\")) return false; return USER_OVERRIDES_KEY_RE.test(key); } export function userOverridesPath(root: string, key: string): string { return path.join(root, "data", "user_overrides", `${key}.json`); } // Minimal req/res shapes — node IncomingMessage / ServerResponse have many // fields the handler does not touch, so we accept a structural subset for // testability. type GetReqLike = { method?: string; url?: string }; type PutReqLike = { method?: string; url?: string; on(event: "data" | "end" | "error", cb: (...args: any[]) => void): unknown; }; type ResLike = { writeHead: (status: number, headers?: Record) => void; end: (body?: string) => void; }; // IMP-52 u3 — GET /api/user-overrides/:key handler. Returns true when the // handler took over the response, false when the caller should `next()`. // Invariants: // • method != GET → false (chain continues; u4 PUT may handle) // • invalid key → 400 {"error":"invalid key"} // • file missing → 200 {} // • file unreadable/corrupt → 200 {} (graceful degrade, mirrors u1 load) // • non-object JSON root → 200 {} (mirrors u1 load) // • valid object JSON → 200 with parsed JSON body export function handleGetUserOverrides( req: GetReqLike, res: ResLike, root: string, ): boolean { if (req.method !== "GET") return false; const url = req.url || ""; const key = url.split("?")[0].replace(/^\//, ""); if (!isValidUserOverridesKey(key)) { res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" }); res.end(JSON.stringify({ error: "invalid key" })); return true; } const filePath = userOverridesPath(root, key); if (!fs.existsSync(filePath)) { res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" }); res.end("{}"); return true; } let parsed: unknown; try { const raw = fs.readFileSync(filePath, "utf-8"); parsed = JSON.parse(raw); } catch { res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" }); res.end("{}"); return true; } if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" }); res.end("{}"); return true; } res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" }); res.end(JSON.stringify(parsed)); return true; } // IMP-52 u4 — pure merge function. Mirrors src/user_overrides_io.save(): // • Only KNOWN_USER_OVERRIDES_AXES present in `partial` are mutated. // • Axes absent from `partial` are preserved verbatim from `existing`. // • Foreign top-level keys in `existing` (future axes like zone_sizes) // are preserved verbatim — allowlist guards what the PUT writes, NOT // what the file already holds. // • `partial[axis] = null` is the explicit clear sentinel (remove key). // • Any non-axis keys in `partial` are silently dropped (allowlist). export function mergeUserOverrides( existing: Record, partial: Record, ): Record { const merged: Record = { ...existing }; for (const axis of KNOWN_USER_OVERRIDES_AXES) { if (!(axis in partial)) continue; const value = partial[axis]; if (value === null) { delete merged[axis]; } else { merged[axis] = value; } } return merged; } // IMP-52 u4 — atomic file write via tmp + rename. Mirrors the // `_atomic_write_json` semantics in src/user_overrides_io.py so a // crashed/interrupted PUT cannot leave a half-written .json on disk // (the next GET / pipeline-entry read would otherwise return {} via // graceful degrade, silently losing the user's prior overrides). export function atomicWriteUserOverrides( filePath: string, data: Record, ): void { const dir = path.dirname(filePath); if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); const tmpName = path.join( dir, `.${path.basename(filePath)}.${process.pid}.${Date.now()}.tmp`, ); try { fs.writeFileSync( tmpName, JSON.stringify(data, null, 2) + "\n", "utf-8", ); fs.renameSync(tmpName, filePath); } catch (err) { try { fs.unlinkSync(tmpName); } catch { // best-effort cleanup; the rename source may not exist on early failure } throw err; } } // IMP-52 u4 — PUT /api/user-overrides/:key handler. Returns true when the // handler took over the response, false when the caller should `next()`. // Invariants: // • method != PUT → false (chain continues; GET runs first) // • invalid key → 400 {"error":"invalid key"} // • body > 1MB → 413 {"error":"payload too large"} // • invalid JSON → 400 {"error":"invalid JSON"} // • non-object JSON root → 400 {"error":"body must be a JSON object"} // • write failure → 500 {"error":"write failed: ..."} // • success → 200 with merged JSON body // // Existing-file read uses the same graceful-degrade rules as GET (corrupt // JSON / non-object root → treat as empty {}) so a PUT cannot fail solely // because a prior file is unparseable — the new payload replaces it. export function handlePutUserOverrides( req: PutReqLike, res: ResLike, root: string, ): boolean { if (req.method !== "PUT") return false; const url = req.url || ""; const key = url.split("?")[0].replace(/^\//, ""); if (!isValidUserOverridesKey(key)) { res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" }); res.end(JSON.stringify({ error: "invalid key" })); return true; } let body = ""; let aborted = false; req.on("data", (chunk: Buffer | string) => { if (aborted) return; body += typeof chunk === "string" ? chunk : chunk.toString(); if (body.length > USER_OVERRIDES_PUT_MAX_BYTES) { aborted = true; res.writeHead(413, { "Content-Type": "application/json; charset=utf-8", }); res.end(JSON.stringify({ error: "payload too large" })); } }); req.on("end", () => { if (aborted) return; let parsed: unknown; try { parsed = body.length > 0 ? JSON.parse(body) : {}; } catch { res.writeHead(400, { "Content-Type": "application/json; charset=utf-8", }); res.end(JSON.stringify({ error: "invalid JSON" })); return; } if ( typeof parsed !== "object" || parsed === null || Array.isArray(parsed) ) { res.writeHead(400, { "Content-Type": "application/json; charset=utf-8", }); res.end(JSON.stringify({ error: "body must be a JSON object" })); return; } const partial = parsed as Record; const filePath = userOverridesPath(root, key); // Load existing — corrupt / non-object → {} so the PUT still succeeds // and recovers the file to a clean state. Mirrors u1 load() graceful // degrade. let existing: Record = {}; if (fs.existsSync(filePath)) { try { const raw = fs.readFileSync(filePath, "utf-8"); const ex = JSON.parse(raw); if ( typeof ex === "object" && ex !== null && !Array.isArray(ex) ) { existing = ex as Record; } } catch { // corrupt → treat as empty } } const merged = mergeUserOverrides(existing, partial); try { atomicWriteUserOverrides(filePath, merged); } catch (err) { res.writeHead(500, { "Content-Type": "application/json; charset=utf-8", }); res.end(JSON.stringify({ error: `write failed: ${String(err)}` })); return; } res.writeHead(200, { "Content-Type": "application/json; charset=utf-8", }); res.end(JSON.stringify(merged)); }); req.on("error", () => { if (aborted) return; aborted = true; res.writeHead(500, { "Content-Type": "application/json; charset=utf-8", }); res.end(JSON.stringify({ error: "request error" })); }); return true; } // ============================================================================= // Phase Z API Plugin — MDX 업로드 → 파이프라인 실행 → 결과 노출 // // Endpoints (vite dev middleware) : // POST /api/run multipart/JSON body {filename, content} → run_id // GET /data/runs/{run_id}/{path} → {DESIGN_AGENT_ROOT}/data/runs/{run_id}/phase_z2/{path} // GET /api/user-overrides/{key} → data/user_overrides/{key}.json (IMP-52 u3) // PUT /api/user-overrides/{key} → partial-merge save (IMP-52 u4) // // 환경 변수 (선택) : // DESIGN_AGENT_ROOT python pipeline 실행 cwd. default = D:/ad-hoc/kei/design_agent // ============================================================================= function vitePluginPhaseZApi(): Plugin { const DESIGN_AGENT_ROOT = process.env.DESIGN_AGENT_ROOT || "D:\\ad-hoc\\kei\\design_agent"; const UPLOADS_DIR = path.join(DESIGN_AGENT_ROOT, "samples", "uploads"); const RUNS_DIR = path.join(DESIGN_AGENT_ROOT, "data", "runs"); return { name: "phase-z-api", configureServer(server: ViteDevServer) { // ── POST /api/run — MDX 업로드 → 파이프라인 실행 (sync) ── server.middlewares.use("/api/run", (req, res, next) => { if (req.method !== "POST") return next(); let body = ""; req.on("data", (chunk) => { body += chunk.toString(); }); req.on("end", () => { let payload: { filename?: string; content?: string; overrides?: { layout?: string; frames?: Record; // unit_id → template_id zoneGeometries?: Record; // zone_id → bbox (slide-body 내부 0~1) // IMP-08 B-3 : zone_id -> list of canonical section_id assignments // (e.g., "top": ["03-1-sub-1"]). Forwarded as --override-section-assignment. zoneSections?: Record; }; // IMP-43 (#72) u6 — optional PREV_RUN_ID to reuse Step 0/1/2/5/6 // artifacts from a prior run and resume execution at Step 7. // Lives at the payload root (NOT under `overrides`) because the // backend u1 post-merge guard rejects most override axes when // --reuse-from is supplied. Absent / empty = full pipeline // (byte-identical to pre-u6 spawn). reuseFromRunId?: string; }; try { payload = JSON.parse(body); } catch (e) { res.writeHead(400, { "Content-Type": "application/json" }); res.end( JSON.stringify({ success: false, error: `bad JSON: ${String(e)}` }) ); return; } const { filename, content, overrides, reuseFromRunId } = payload; if (!filename || typeof content !== "string") { res.writeHead(400, { "Content-Type": "application/json" }); res.end( JSON.stringify({ success: false, error: "missing filename or content", }) ); return; } // Security: filename 정규화 (path 분리 / .. 차단) const safeName = filename.replace(/[\\/]/g, "_").replace(/\.\./g, "_"); if (!safeName.match(/\.mdx?$/i)) { res.writeHead(400, { "Content-Type": "application/json" }); res.end( JSON.stringify({ success: false, error: "filename must end with .mdx or .md", }) ); return; } // 1. MDX 저장 if (!fs.existsSync(UPLOADS_DIR)) { fs.mkdirSync(UPLOADS_DIR, { recursive: true }); } const mdxPath = path.join(UPLOADS_DIR, safeName); fs.writeFileSync(mdxPath, content, "utf-8"); // 2. run_id 생성 (basename + timestamp) const baseName = safeName .replace(/\.mdx?$/i, "") .replace(/[^a-zA-Z0-9_]/g, "_"); const ts = new Date() .toISOString() .replace(/[-:T]/g, "") .replace(/\..+$/, "") .slice(0, 14); // YYYYMMDDHHMMSS const runId = `${baseName}_${ts}`; // 3. python -m src.phase_z2_pipeline {mdx_path} {run_id} (cwd=DESIGN_AGENT_ROOT) // shell: false → argv 그대로 전달 (파일명 공백/한글 안전). // Windows 에서 .exe 확장자 자동 탐색을 위해 python.exe 명시. // Step D : overrides 가 들어오면 --override-layout / --override-frame 인자로 forward. const cliArgs = ["-m", "src.phase_z2_pipeline", mdxPath, runId]; if (overrides?.layout && typeof overrides.layout === "string") { cliArgs.push("--override-layout", overrides.layout); } if (overrides?.frames && typeof overrides.frames === "object") { for (const [unitId, templateId] of Object.entries(overrides.frames)) { if (typeof templateId === "string" && templateId) { cliArgs.push("--override-frame", `${unitId}=${templateId}`); } } } if (overrides?.zoneGeometries && typeof overrides.zoneGeometries === "object") { for (const [zoneId, geom] of Object.entries(overrides.zoneGeometries)) { if (geom && typeof geom === "object") { const { x, y, w, h } = geom; cliArgs.push( "--override-zone-geometry", `${zoneId}=${x},${y},${w},${h}` ); } } } // IMP-08 B-3 — zoneSections override forward to CLI. // Each entry becomes `--override-section-assignment ZONE=sid[,sid]`. // Empty arrays and non-string sids are filtered out so the backend // never receives bogus assignments from a partially-built UI state. if (overrides?.zoneSections && typeof overrides.zoneSections === "object") { for (const [zoneId, sids] of Object.entries(overrides.zoneSections)) { if (!Array.isArray(sids)) continue; const cleaned = sids.filter((s) => typeof s === "string" && s.trim()); if (cleaned.length === 0) continue; cliArgs.push( "--override-section-assignment", `${zoneId}=${cleaned.join(",")}` ); } } // IMP-43 (#72) u6 — --reuse-from forward. Backend // (u1) parses this flag, validates the snapshot, copies Step // 0/1/2/5/6 artifacts from data/runs//phase_z2 into // the new run_dir, and resumes execution at Step 7. The post-merge // guard at the same site rejects --override-layout / // --override-zone-geometry / --override-section-assignment / // --override-image with axis-named fail-closed exit; only // --override-frame (above) is preserved. Truthy check excludes // empty string + undefined so an invalid argument never reaches // argparse. if (reuseFromRunId && typeof reuseFromRunId === "string") { cliArgs.push("--reuse-from", reuseFromRunId); } console.log( `[phase-z-api] spawn pipeline: run_id=${runId}, mdx=${mdxPath}, args=${JSON.stringify(cliArgs.slice(2))}` ); const pythonExe = process.platform === "win32" ? "python.exe" : "python"; // 2026-05-14 — env toggle forward (보고용 일회성). // PHASE_Z_ALLOW_RESTRUCTURE / PHASE_Z_ALLOW_REJECT : status 통과 // 2026-05-21 — IMP-38 retire PHASE_Z_MAX_RANK env (never read by backend). // v4 fallback chain max_rank 는 templates/phase_z2/catalog/v4_fallback_policy.yaml 의 // 정식 정책 (dynamic_usable_count_based) 으로 결정 — backend src/phase_z2_pipeline.py // 의 lookup_v4_match_with_fallback() 가 load_v4_fallback_policy() 로 적용. const proc = spawn(pythonExe, cliArgs, { cwd: DESIGN_AGENT_ROOT, shell: false, env: { ...process.env, PHASE_Z_ALLOW_RESTRUCTURE: "1", PHASE_Z_ALLOW_REJECT: "1", }, }); let stdout = ""; let stderr = ""; proc.stdout.on("data", (chunk) => { stdout += chunk.toString(); }); proc.stderr.on("data", (chunk) => { stderr += chunk.toString(); }); proc.on("error", (err) => { res.writeHead(500, { "Content-Type": "application/json" }); res.end( JSON.stringify({ success: false, error: `spawn failed: ${err.message}`, }) ); }); proc.on("exit", (code) => { const outDir = path.join(RUNS_DIR, runId, "phase_z2"); const finalHtmlExists = fs.existsSync(path.join(outDir, "final.html")); const previewExists = fs.existsSync(path.join(outDir, "preview.png")); // 2026-05-14 — success 판정 보완. // exit_code 0 → full PASS // exit_code != 0 → status enum 차이 (visual_regression / partial_coverage 등) // 다만 final.html 존재 시 frontend 는 표시 가능 (clipping warning 만). // = (exit_code 0) OR (final.html 존재) 모두 success=true. const success = code === 0 || finalHtmlExists; res.writeHead(success ? 200 : 500, { "Content-Type": "application/json", }); res.end( JSON.stringify({ success, run_id: runId, exit_code: code, final_html_exists: finalHtmlExists, preview_exists: previewExists, stdout: stdout.slice(-3000), stderr: stderr.slice(-3000), }) ); }); }); }); // ── GET /api/sample-mdx → samples/mdx/{03|04|05} (데모) ── // 페이지 로드 시 frontend 가 자동 fetch — 상대방에게 mdx 파일 공유 안 해도 되게. // 2026-05-14 — query param `?mdx=04` / `?mdx=05` 추가 (default = 03). // 04 / 05 도 frontend simulation 가능하도록 frame/layout override 적용 path 유지. const SAMPLE_MDX_MAP: Record = { "03": "03. DX 시행을 위한 필수 요건 및 혁신 방안.mdx", "04": "04. DX 지연 요인.mdx", "05": "05. 설계 방식의 왜곡.mdx", }; server.middlewares.use("/api/sample-mdx", (req, res, next) => { if (req.method !== "GET") return next(); const url = new URL(req.url || "/", "http://x"); const which = url.searchParams.get("mdx") || "03"; const filename = SAMPLE_MDX_MAP[which] || SAMPLE_MDX_MAP["03"]; const samplePath = path.join( DESIGN_AGENT_ROOT, "samples", "mdx", filename ); if (!fs.existsSync(samplePath)) { res.writeHead(404); res.end("sample mdx not found"); return; } res.setHeader("Content-Type", "text/markdown; charset=utf-8"); res.setHeader("X-Mdx-Filename", encodeURIComponent(filename)); fs.createReadStream(samplePath).pipe(res); }); // ── GET /frame-preview/{frame_number} → data/figma_previews/{NN}.png ── // V4 후보 카드 thumbnail. data/figma_previews/ 는 frame_number 1~32 모두 존재 (Figma original export). // 우선 figma_to_html_agent/blocks/{frame_number}/preview.png 시도해서 더 detail 한 렌더 결과 있으면 사용 — 단 frame_id 와 frame_number 가 다른 식별자라 path 매핑이 없으므로 일단 figma_previews 만 사용. server.middlewares.use("/frame-preview", (req, res, next) => { if (req.method !== "GET") return next(); const url = (req.url || "").split("?")[0]; const arg = url.replace(/^\//, "").replace(/[^0-9]/g, ""); if (!arg) return next(); // 1-digit / 2-digit 모두 zero-pad 시도 (e.g., "13" → "13.png", "5" → "05.png") const padded = arg.padStart(2, "0"); const previewPath = path.join( DESIGN_AGENT_ROOT, "data", "figma_previews", `${padded}.png` ); if (!fs.existsSync(previewPath)) { res.writeHead(404); res.end("frame preview not found"); return; } res.setHeader("Content-Type", "image/png"); fs.createReadStream(previewPath).pipe(res); }); // ── GET / PUT /api/user-overrides/{key} → data/user_overrides/{key}.json ── // IMP-52 u3 (GET) + u4 (PUT) — MDX-stem keyed user overrides. Logic // lives in the pure helpers (handleGetUserOverrides / handlePutUserOverrides) // so vitest can exercise them without booting vite. Both handlers // return false when the HTTP method does not match, so they chain // cleanly: GET first, then PUT, then next() for everything else // (e.g., OPTIONS / preflight handled by upstream middleware). server.middlewares.use("/api/user-overrides", (req, res, next) => { if (handleGetUserOverrides(req, res, DESIGN_AGENT_ROOT)) return; if (handlePutUserOverrides(req, res, DESIGN_AGENT_ROOT)) return; next(); }); // ── GET /data/runs/{run_id}/{path} → {RUNS_DIR}/{run_id}/phase_z2/{path} ── server.middlewares.use("/data/runs", (req, res, next) => { if (req.method !== "GET") return next(); const url = req.url || ""; if (url.includes("..") || url.includes("\\")) { res.writeHead(400); res.end("Bad Request"); return; } const segments = url.split("?")[0].split("/").filter((s) => s.length > 0); if (segments.length < 2) return next(); const [runId, ...rest] = segments; const filePath = path.join(RUNS_DIR, runId, "phase_z2", ...rest); if (!fs.existsSync(filePath) || fs.statSync(filePath).isDirectory()) { // Fallback to next middleware (vite default static — client/public) return next(); } const ext = path.extname(filePath).toLowerCase(); const mimeMap: Record = { ".html": "text/html; charset=utf-8", ".json": "application/json; charset=utf-8", ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".svg": "image/svg+xml", ".md": "text/markdown; charset=utf-8", ".txt": "text/plain; charset=utf-8", }; res.setHeader("Content-Type", mimeMap[ext] || "application/octet-stream"); fs.createReadStream(filePath).pipe(res); }); }, }; } const plugins = [react(), tailwindcss(), jsxLocPlugin(), vitePluginManusRuntime(), vitePluginManusDebugCollector(), vitePluginStorageProxy(), vitePluginPhaseZApi()]; export default defineConfig({ plugins, resolve: { alias: { "@": path.resolve(import.meta.dirname, "client", "src"), "@shared": path.resolve(import.meta.dirname, "shared"), "@assets": path.resolve(import.meta.dirname, "attached_assets"), }, }, envDir: path.resolve(import.meta.dirname), root: path.resolve(import.meta.dirname, "client"), build: { outDir: path.resolve(import.meta.dirname, "dist/public"), emptyOutDir: true, }, server: { port: 3000, strictPort: false, // Will find next available port if 3000 is busy host: true, allowedHosts: [ ".manuspre.computer", ".manus.computer", ".manus-asia.computer", ".manuscomputer.ai", ".manusvm.computer", "localhost", "127.0.0.1", ], fs: { strict: true, deny: ["**/.*"], }, }, });