From 6dc8e47eb6196cc359cdc68b3d97bf1a4e12ab29 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 9 Jun 2026 11:40:32 -0700 Subject: [PATCH] Add AI template analysis workflow --- apps/api/src/server.ts | 447 +++++++++++++++++++++++++++++++++++++++- apps/web/src/main.tsx | 196 ++++++++++++++---- apps/web/src/styles.css | 59 ++++++ 3 files changed, 661 insertions(+), 41 deletions(-) diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index 9814dcb..f92d680 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -1,5 +1,5 @@ import { existsSync } from "node:fs"; -import { access, mkdir, writeFile } from "node:fs/promises"; +import { access, mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { inflateSync } from "node:zlib"; @@ -11,13 +11,28 @@ import multer from "multer"; import { nanoid } from "nanoid"; import pdfParse from "pdf-parse"; import { z } from "zod"; -import { loadStylePack, planDeckFromSource, SourceDocument } from "@slide-factory/core"; +import { loadStylePack, planDeckFromSource, SourceDocument, StylePack } from "@slide-factory/core"; import { renderPptx } from "@slide-factory/render-pptx"; +const DesignTemplateSchema = z.object({ + id: z.string().optional(), + name: z.string().optional(), + description: z.string().optional(), + summary: z.string().optional(), + colors: z.array(z.string()).optional(), + layoutRules: z.array(z.string()).optional(), + componentRules: z.array(z.string()).optional(), + slideFamilies: z.array(z.string()).optional(), + prompt: z.string().optional(), + analysisSource: z.string().optional(), + sourceName: z.string().optional() +}); + const CreateDeckRequestSchema = z.object({ style: z.string().default("incorta"), instructions: z.string().optional(), audience: z.string().default("executives"), + designTemplate: DesignTemplateSchema.optional(), input: z.object({ type: z.enum(["text", "markdown"]).default("markdown"), title: z.string().optional(), @@ -25,6 +40,42 @@ const CreateDeckRequestSchema = z.object({ }) }); +const TemplateAnalyzeRequestSchema = z.object({ + name: z.string().optional(), + description: z.string().optional() +}); + +type DesignTemplateInput = z.infer; + +type SourceFacts = { + sourceName?: string; + mimeType?: string; + extension?: string; + sizeKb?: number; + imageWidth?: number; + imageHeight?: number; + extractedText?: string; +}; + +type TemplateAnalysis = { + id: string; + name: string; + description: string; + summary: string; + colors: string[]; + layoutRules: string[]; + componentRules: string[]; + slideFamilies: string[]; + prompt: string; + sourceName?: string; + updatedAt: string; + analysisSource: "model-service-gateway" | "heuristic"; + analysisStatus: "ai-analyzed" | "fallback"; + analysisNote: string; + gatewayRequestId?: string; + sourceFacts?: SourceFacts; +}; + const app = express(); const port = Number(process.env.SLIDE_FACTORY_PORT || 3025); const outputDir = path.resolve(process.env.SLIDE_FACTORY_OUTPUT_DIR || "outputs"); @@ -52,7 +103,8 @@ app.post("/api/decks", async (req, res, next) => { source: body.input, instructions: body.instructions, audience: body.audience, - styleId: body.style + styleId: body.style, + designTemplate: body.designTemplate }); res.status(201).json(result); @@ -70,7 +122,8 @@ app.post("/api/decks/from-source", upload.single("source"), async (req, res, nex source, instructions: stringField(req.body.instructions), audience: stringField(req.body.audience) || "executives", - styleId: stringField(req.body.style) || "incorta" + styleId: stringField(req.body.style) || "incorta", + designTemplate: parseDesignTemplateField(req.body.designTemplate) }); res.status(201).json({ @@ -86,6 +139,21 @@ app.post("/api/decks/from-source", upload.single("source"), async (req, res, nex } }); +app.post("/api/templates/analyze", upload.single("source"), async (req, res, next) => { + try { + const body = TemplateAnalyzeRequestSchema.parse(req.body); + const template = await analyzeTemplate({ + name: body.name, + description: body.description, + file: req.file || undefined + }); + + res.status(201).json({ template }); + } catch (error) { + next(error); + } +}); + if (existsSync(webDistDir)) { app.use(express.static(webDistDir)); app.get("*", (req, res, next) => { @@ -128,10 +196,12 @@ async function createDeckArtifacts(options: { instructions?: string; audience: string; styleId: string; + designTemplate?: DesignTemplateInput; }) { const id = nanoid(10); const stylePath = await resolveStylePath(options.styleId); - const style = await loadStylePack(stylePath); + const baseStyle = await loadStylePack(stylePath); + const style = applyDesignTemplateToStyle(baseStyle, options.designTemplate); const deck = planDeckFromSource({ source: options.source, style, @@ -155,6 +225,373 @@ async function createDeckArtifacts(options: { }; } +async function analyzeTemplate(options: { + name?: string; + description?: string; + file?: Express.Multer.File; +}): Promise { + const name = options.name?.trim() || "Untitled design standard"; + const description = options.description?.trim() || ""; + const sourceFacts = await buildSourceFacts(options.file); + const gateway = await analyzeTemplateWithGateway({ name, description, sourceFacts }); + if (gateway) return gateway; + return heuristicTemplateAnalysis({ name, description, sourceFacts }); +} + +async function analyzeTemplateWithGateway(options: { + name: string; + description: string; + sourceFacts: SourceFacts; +}): Promise { + const enabled = process.env.MODEL_SERVICE_GATEWAY_ENABLED === "true"; + const baseUrl = process.env.MODEL_SERVICE_BASE_URL; + const appId = process.env.MODEL_SERVICE_APP_ID || "slide-factory"; + const model = process.env.MODEL_SERVICE_GATEWAY_MODEL || "gpt-4o-mini"; + if (!enabled || !baseUrl) return null; + + const token = await readGatewayToken(); + if (!token) return null; + + try { + const response = await fetch(`${baseUrl.replace(/\/+$/, "")}/api/gateway/chat`, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json" + }, + body: JSON.stringify({ + appId, + model, + temperature: 0.2, + maxOutputTokens: 1800, + attribution: { + feature: "slide-template-analysis", + environment: process.env.NODE_ENV || process.env.ENV || "local" + }, + messages: [ + { + role: "system", + content: [ + "You convert presentation screenshots, slide decks, and design notes into reusable slide design standards.", + "Return strict JSON only. Do not include markdown fences.", + "The current gateway input is text-only, so base visual claims on extracted source facts, filenames, and user-provided notes. Do not invent pixel-specific observations." + ].join(" ") + }, + { + role: "user", + content: JSON.stringify( + { + task: "Create a reusable design template for consistent slide/deck generation.", + templateName: options.name, + userDescription: options.description, + sourceFacts: options.sourceFacts, + requiredJsonShape: { + summary: "one short paragraph", + colors: ["#112233", "#ffffff", "#..."], + layoutRules: ["rule", "rule"], + componentRules: ["rule", "rule"], + slideFamilies: ["title", "two-column", "metric", "roadmap"], + prompt: "detailed reusable design prompt" + } + }, + null, + 2 + ) + } + ] + }), + signal: AbortSignal.timeout(120000) + }); + + if (!response.ok) return null; + const data = (await response.json()) as { id?: string; content?: string }; + const parsed = parseJsonFromText(data.content || ""); + if (!parsed || typeof parsed !== "object") return null; + const record = parsed as Record; + return normalizeTemplateAnalysis({ + id: `template-${nanoid(10)}`, + name: options.name, + description: options.description, + sourceFacts: options.sourceFacts, + source: "model-service-gateway", + status: "ai-analyzed", + note: + "Analyzed through Model Service Gateway from your description and extracted source metadata. Raw image pixels are not yet sent as multimodal vision input.", + gatewayRequestId: data.id, + record + }); + } catch { + return null; + } +} + +function heuristicTemplateAnalysis(options: { + name: string; + description: string; + sourceFacts: SourceFacts; +}): TemplateAnalysis { + const record = { + summary: options.description || "Reusable executive presentation standard with consistent hierarchy, spacing, and visual rhythm.", + colors: inferColors(options.name, options.description, options.sourceFacts), + layoutRules: inferLayoutRules(options.description), + componentRules: [ + "Use repeated title, content, evidence, and takeaway regions across slides.", + "Keep icons minimal, line-based, and functional.", + "Use one accent system for dividers, section labels, status markers, and callouts." + ], + slideFamilies: ["title", "single-column", "two-column", "three-column", "four-column", "metric-callout", "roadmap"], + prompt: [ + `Create slides in the ${options.name} design system.`, + options.description || "Use consistent spacing, hierarchy, and reusable slide sections.", + "Preserve a clear executive narrative, use restrained decoration, and keep visual choices repeatable across a deck." + ].join(" ") + }; + + return normalizeTemplateAnalysis({ + id: `template-${nanoid(10)}`, + name: options.name, + description: options.description, + sourceFacts: options.sourceFacts, + source: "heuristic", + status: "fallback", + note: "Model Service Gateway was not configured or did not return a usable response, so Slide Factory saved a deterministic local template draft.", + record + }); +} + +function normalizeTemplateAnalysis(options: { + id: string; + name: string; + description: string; + sourceFacts: SourceFacts; + source: "model-service-gateway" | "heuristic"; + status: "ai-analyzed" | "fallback"; + note: string; + gatewayRequestId?: string; + record: Record; +}): TemplateAnalysis { + const summary = stringValue(options.record.summary) || options.description || "Reusable slide design standard."; + const colors = stringArray(options.record.colors).map(normalizeHexColor).filter(Boolean).slice(0, 6); + return { + id: options.id, + name: options.name, + description: options.description || summary, + summary, + colors: colors.length ? colors : inferColors(options.name, options.description, options.sourceFacts), + layoutRules: stringArray(options.record.layoutRules).slice(0, 8), + componentRules: stringArray(options.record.componentRules).slice(0, 8), + slideFamilies: stringArray(options.record.slideFamilies).slice(0, 10), + prompt: stringValue(options.record.prompt) || `${options.description}\n\n${summary}`.trim(), + sourceName: options.sourceFacts.sourceName, + updatedAt: new Date().toISOString(), + analysisSource: options.source, + analysisStatus: options.status, + analysisNote: options.note, + gatewayRequestId: options.gatewayRequestId, + sourceFacts: options.sourceFacts + }; +} + +async function buildSourceFacts(file?: Express.Multer.File): Promise { + if (!file) return {}; + const extension = path.extname(file.originalname).toLowerCase(); + const facts: SourceFacts = { + sourceName: file.originalname, + mimeType: file.mimetype, + extension, + sizeKb: Math.round(file.size / 1024) + }; + + const dimensions = imageDimensions(file.buffer, file.mimetype); + if (dimensions) { + facts.imageWidth = dimensions.width; + facts.imageHeight = dimensions.height; + } + + try { + if (extension === ".pptx" || file.mimetype.includes("presentation")) { + facts.extractedText = truncateForPrompt(await extractPptxText(file.buffer)); + } else if (extension === ".pdf" || file.mimetype === "application/pdf") { + facts.extractedText = truncateForPrompt(await extractPdfText(file.buffer)); + } else if (file.mimetype.startsWith("text/")) { + facts.extractedText = truncateForPrompt(file.buffer.toString("utf8")); + } + } catch { + // Source facts are helpful but should not block template creation. + } + + return facts; +} + +async function readGatewayToken(): Promise { + if (process.env.MODEL_SERVICE_GATEWAY_TOKEN) return process.env.MODEL_SERVICE_GATEWAY_TOKEN; + const tokenFile = process.env.MODEL_SERVICE_GATEWAY_TOKEN_FILE; + if (!tokenFile) return null; + + try { + const parsed = JSON.parse(await readFile(tokenFile, "utf8")) as { token?: unknown }; + return typeof parsed.token === "string" && parsed.token.trim() ? parsed.token.trim() : null; + } catch { + return null; + } +} + +function parseDesignTemplateField(value: unknown): DesignTemplateInput | undefined { + if (!value) return undefined; + try { + const parsed = typeof value === "string" ? JSON.parse(value) : value; + return DesignTemplateSchema.parse(parsed); + } catch { + return undefined; + } +} + +function parseJsonFromText(value: string): unknown { + const trimmed = value.trim().replace(/^```json\s*/i, "").replace(/^```\s*/i, "").replace(/```$/i, "").trim(); + try { + return JSON.parse(trimmed); + } catch { + const start = trimmed.indexOf("{"); + const end = trimmed.lastIndexOf("}"); + if (start < 0 || end <= start) return null; + try { + return JSON.parse(trimmed.slice(start, end + 1)); + } catch { + return null; + } + } +} + +function stringValue(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function stringArray(value: unknown): string[] { + if (Array.isArray(value)) { + return value.map((item) => String(item || "").trim()).filter(Boolean); + } + if (typeof value === "string" && value.trim()) { + return value + .split(/\n|;/) + .map((item) => item.replace(/^[-*]\s+/, "").trim()) + .filter(Boolean); + } + return []; +} + +function inferColors(name: string, description: string, sourceFacts: SourceFacts): string[] { + const text = `${name} ${description} ${sourceFacts.sourceName || ""}`.toLowerCase(); + if (text.includes("starbucks")) return ["#006241", "#d4e9e2", "#1e3932", "#ffffff", "#00754a", "#cba258"]; + if (text.includes("incorta")) return ["#ffffff", "#f05a28", "#1e6bff", "#172033", "#596579"]; + if (text.includes("qbr") || text.includes("sprint") || text.includes("green")) { + return ["#07382f", "#eaf4ef", "#0f4a3c", "#ffffff", "#2d6a4f", "#8fb9a8"]; + } + return ["#ffffff", "#f05a28", "#1e6bff", "#172033", "#596579"]; +} + +function inferLayoutRules(description: string): string[] { + const rules = [ + "Keep slide chrome, title placement, section spacing, and footer behavior consistent across every slide.", + "Use a repeatable grid so content can move between one-column, two-column, and multi-column layouts without redesign." + ]; + if (/1,\s*2,\s*3,\s*(or\s*)?4|column/i.test(description)) { + rules.push("Support one-, two-, three-, and four-column content modules."); + } + if (/row/i.test(description)) { + rules.push("Allow one or two stacked rows inside each content column while preserving shared gutters."); + } + return rules; +} + +function applyDesignTemplateToStyle(style: StylePack, designTemplate?: DesignTemplateInput): StylePack { + const colors = (designTemplate?.colors || []).map(normalizeHexColor).filter(Boolean); + if (!colors.length) return style; + + const lightColors = colors.filter((color) => colorLuminance(color) > 0.78); + const darkColors = colors.filter((color) => colorLuminance(color) < 0.42); + const saturated = colors.filter((color) => colorLuminance(color) <= 0.78 && colorLuminance(color) >= 0.12); + const background = lightColors[0] || style.colors.background; + const surface = lightColors[1] || lightColors[0] || style.colors.surface; + const text = darkColors[0] || style.colors.text; + const primary = saturated[0] || darkColors[0] || style.colors.primary; + const secondary = saturated[1] || primary || style.colors.secondary; + const accent = saturated[2] || secondary || style.colors.accent; + + return { + ...style, + id: designTemplate?.id || style.id, + name: designTemplate?.name ? `${style.name} / ${designTemplate.name}` : style.name, + colors: { + ...style.colors, + background: stripHash(background), + surface: stripHash(surface), + text: stripHash(text), + mutedText: stripHash(darkColors[1] || style.colors.mutedText), + primary: stripHash(primary), + secondary: stripHash(secondary), + accent: stripHash(accent), + line: stripHash(lightColors[2] || style.colors.line) + } + }; +} + +function normalizeHexColor(value: string): string { + const trimmed = String(value || "").trim(); + const hex = trimmed.startsWith("#") ? trimmed.slice(1) : trimmed; + if (/^[0-9a-f]{3}$/i.test(hex)) { + return `#${hex + .split("") + .map((char) => `${char}${char}`) + .join("")}`.toLowerCase(); + } + if (/^[0-9a-f]{6}$/i.test(hex)) return `#${hex.toLowerCase()}`; + return ""; +} + +function stripHash(value: string): string { + const normalized = normalizeHexColor(value); + return normalized ? normalized.slice(1).toUpperCase() : value.replace(/^#/, "").toUpperCase(); +} + +function colorLuminance(value: string): number { + const normalized = normalizeHexColor(value); + if (!normalized) return 0.5; + const channels = [1, 3, 5].map((index) => Number.parseInt(normalized.slice(index, index + 2), 16) / 255); + const [r, g, b] = channels.map((channel) => + channel <= 0.03928 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4 + ); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; +} + +function imageDimensions(buffer: Buffer, mimeType: string): { width: number; height: number } | null { + if (mimeType === "image/png" || buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) { + if (buffer.length >= 24) { + return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) }; + } + } + + if (mimeType === "image/jpeg" || (buffer[0] === 0xff && buffer[1] === 0xd8)) { + let offset = 2; + while (offset + 9 < buffer.length) { + if (buffer[offset] !== 0xff) break; + const marker = buffer[offset + 1]; + const size = buffer.readUInt16BE(offset + 2); + if (marker >= 0xc0 && marker <= 0xc3) { + return { height: buffer.readUInt16BE(offset + 5), width: buffer.readUInt16BE(offset + 7) }; + } + offset += 2 + size; + } + } + + return null; +} + +function truncateForPrompt(value: string): string | undefined { + const trimmed = value.replace(/\s+/g, " ").trim(); + if (!trimmed) return undefined; + return trimmed.slice(0, 4000); +} + function sourceFromText(content: string): SourceDocument { const trimmed = content.trim(); if (!trimmed) { diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 8351f72..2fa53b5 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -37,8 +37,17 @@ type DesignTemplate = { id: string; name: string; description: string; + summary?: string; sourceName?: string; colors: string[]; + layoutRules?: string[]; + componentRules?: string[]; + slideFamilies?: string[]; + prompt?: string; + analysisSource?: "built-in" | "model-service-gateway" | "heuristic"; + analysisStatus?: "built-in" | "ai-analyzed" | "fallback"; + analysisNote?: string; + gatewayRequestId?: string; updatedAt: string; locked?: boolean; }; @@ -56,7 +65,15 @@ const starterTemplate: DesignTemplate = { name: "Incorta Starter", description: "White executive deck, orange brand accent, restrained blue secondary accents, Aptos typography, compact business density, fixed title/body/two-column/roadmap layouts.", + summary: "Built-in starter style pack for concise executive decks.", colors: ["#ffffff", "#f05a28", "#1e6bff", "#172033", "#596579"], + layoutRules: ["Use consistent title placement, compact panels, and restrained executive slide density."], + componentRules: ["Use simple callouts, two-column evidence panels, roadmap cards, and a small footer."], + slideFamilies: ["title", "executive-summary", "two-column", "metric-callout", "roadmap", "recommendation"], + prompt: + "Use the Incorta Starter system: white executive slides, orange accent, restrained blue secondary accents, Aptos typography, and compact business density.", + analysisSource: "built-in", + analysisStatus: "built-in", updatedAt: "2026-06-09T00:00:00.000Z", locked: true }; @@ -131,8 +148,14 @@ function App() { id: "draft-template", name: templateName.trim() || "Untitled design standard", description: templateDescription.trim(), + summary: templateDescription.trim(), sourceName: templateFile?.name, colors: starterTemplate.colors, + layoutRules: inferDraftLayoutRules(templateDescription), + componentRules: ["Use repeated slide sections, restrained accents, and consistent icon treatment."], + slideFamilies: ["single-column", "two-column", "three-column", "four-column"], + analysisSource: "heuristic", + analysisStatus: "fallback", updatedAt: new Date().toISOString() }; } @@ -214,22 +237,37 @@ function App() { } } - function saveTemplate() { + async function saveTemplate() { const name = templateName.trim() || "Untitled design standard"; - const template: DesignTemplate = { - id: `template-${Date.now()}`, - name, - description: templateDescription.trim(), - sourceName: templateFile?.name, - colors: starterTemplate.colors, - updatedAt: new Date().toISOString() - }; - setTemplates((current) => [starterTemplate, template, ...current.filter((item) => item.id !== starterTemplate.id)]); - setSelectedTemplateId(template.id); - setEditTemplateId(template.id); - setEditTemplateName(template.name); - setEditTemplateDescription(template.description); - setNotice(`${template.name} saved`); + setBusy(true); + setError(null); + setNotice("Analyzing template..."); + try { + const response = await analyzeTemplate(name); + const json = (await response.json()) as { template?: DesignTemplate; error?: string }; + if (!response.ok || !json.template) throw new Error(json.error || "Template analysis failed"); + const template = json.template; + setTemplates((current) => [ + starterTemplate, + template, + ...current.filter((item) => item.id !== starterTemplate.id && item.id !== template.id) + ]); + setSelectedTemplateId(template.id); + setEditTemplateId(template.id); + setEditTemplateName(template.name); + setEditTemplateDescription(template.description); + setActiveMode("deck-build"); + setNotice( + template.analysisStatus === "ai-analyzed" + ? `${template.name} analyzed by AI and selected for deck building` + : `${template.name} saved with a local fallback and selected for deck building` + ); + } catch (err) { + setError(err instanceof Error ? err.message : "Unknown error"); + setNotice(null); + } finally { + setBusy(false); + } } function loadEditableTemplate(templateId: string) { @@ -303,19 +341,37 @@ function App() { return [ instructions, "", - `Selected design template: ${selectedTemplate.name}.`, - selectedTemplate.description + "Use the selected design template as a binding design standard.", + `Template: ${selectedTemplate.name}.`, + selectedTemplate.description, + selectedTemplate.summary, + selectedTemplate.layoutRules?.length ? `Layout rules:\n- ${selectedTemplate.layoutRules.join("\n- ")}` : "", + selectedTemplate.componentRules?.length ? `Component rules:\n- ${selectedTemplate.componentRules.join("\n- ")}` : "", + selectedTemplate.slideFamilies?.length ? `Supported slide families: ${selectedTemplate.slideFamilies.join(", ")}.` : "", + selectedTemplate.prompt ? `Reusable design prompt: ${selectedTemplate.prompt}` : "" ] .filter(Boolean) .join("\n"); } + function analyzeTemplate(name: string) { + const formData = new FormData(); + formData.set("name", name); + formData.set("description", templateDescription.trim()); + if (templateFile) formData.set("source", templateFile); + return fetch("/api/templates/analyze", { + method: "POST", + body: formData + }); + } + function createDeckFromFile(sourceFile: File) { const formData = new FormData(); formData.set("source", sourceFile); formData.set("style", "incorta"); formData.set("audience", "executives"); formData.set("instructions", designAwareInstructions()); + formData.set("designTemplate", JSON.stringify(templateForRequest(selectedTemplate))); return fetch("/api/decks/from-source", { method: "POST", body: formData @@ -329,6 +385,7 @@ function App() { body: JSON.stringify({ style: "incorta", instructions: designAwareInstructions(), + designTemplate: templateForRequest(selectedTemplate), input: { type: "markdown", content } }) }); @@ -350,9 +407,9 @@ function App() { Google Slides workflow {activeMode === "template-build" && ( - )} {activeMode === "deck-build" && ( @@ -403,6 +460,7 @@ function App() { previewUrl={templatePreviewUrl} sourceLabel={templateSourceLabel} description={templateDescription} + busy={busy} onDrop={onTemplateDrop} onDragging={setTemplateDragging} onFile={setTemplateSourceFile} @@ -484,6 +542,7 @@ function App() { } function TemplateBuildPane(props: { + busy: boolean; dragging: boolean; inputRef: React.RefObject; name: string; @@ -547,9 +606,9 @@ function TemplateBuildPane(props: {