Add AI template analysis workflow

This commit is contained in:
Codex 2026-06-09 11:40:32 -07:00
parent 9855b92605
commit 6dc8e47eb6
3 changed files with 661 additions and 41 deletions

View file

@ -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<typeof DesignTemplateSchema>;
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<TemplateAnalysis> {
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<TemplateAnalysis | null> {
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<string, unknown>;
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<string, unknown>;
}): 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<SourceFacts> {
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<string | null> {
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) {

View file

@ -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)]);
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);
setNotice(`${template.name} saved`);
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
</a>
{activeMode === "template-build" && (
<button className="primary-action" onClick={saveTemplate}>
<Save size={18} />
Save template
<button className="primary-action" onClick={saveTemplate} disabled={busy}>
{busy ? <Loader2 className="spin" size={18} /> : <WandSparkles size={18} />}
{busy ? "Analyzing" : "Analyze template"}
</button>
)}
{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<HTMLInputElement>;
name: string;
@ -547,9 +606,9 @@ function TemplateBuildPane(props: {
<textarea value={props.description} onChange={(event) => props.onDescription(event.target.value)} />
</label>
<button type="button" className="secondary-action wide-action" onClick={props.onSave}>
<Save size={16} />
Save template
<button type="button" className="secondary-action wide-action" onClick={props.onSave} disabled={props.busy}>
{props.busy ? <Loader2 className="spin" size={16} /> : <WandSparkles size={16} />}
{props.busy ? "Analyzing template" : "Analyze and save template"}
</button>
</>
);
@ -595,9 +654,10 @@ type DeckPaneProps = {
function TemplateSelect(props: { templates: DesignTemplate[]; value: string; onChange: (id: string) => void }) {
const active = props.templates.find((template) => template.id === props.value) || starterTemplate;
return (
<div className="template-select">
<div className="template-card">
<div className="template-card-header">
<label className="field">
<span>Design template</span>
<span>Template to apply</span>
<select value={props.value} onChange={(event) => props.onChange(event.target.value)}>
{props.templates.map((template) => (
<option key={template.id} value={template.id}>
@ -608,6 +668,12 @@ function TemplateSelect(props: { templates: DesignTemplate[]; value: string; onC
</label>
<Swatches colors={active.colors} />
</div>
<p className="template-summary">{active.summary || active.description}</p>
<div className="template-meta-row">
<AnalysisBadge template={active} />
{active.sourceName && <span className="source-chip">{active.sourceName}</span>}
</div>
</div>
);
}
@ -742,13 +808,32 @@ function OutputPanel(props: {
<div className="result">
<strong>{props.template.name}</strong>
<Swatches colors={props.template.colors} />
<AnalysisBadge template={props.template} />
{props.template.description && <p className="template-summary">{props.template.description}</p>}
{props.template.summary && props.template.summary !== props.template.description && (
<p className="template-summary">{props.template.summary}</p>
)}
{props.template.layoutRules?.length ? (
<ul className="template-rule-list">
{props.template.layoutRules.slice(0, 4).map((rule) => (
<li key={rule}>{rule}</li>
))}
</ul>
) : null}
<span>{props.templates.length} templates</span>
{props.template.sourceName && <span className="source-chip">{props.template.sourceName}</span>}
{props.template.gatewayRequestId && <span className="source-chip">Gateway {props.template.gatewayRequestId}</span>}
</div>
) : (
<>
{!props.result && !props.error && <p className="muted">Ready for a source.</p>}
{!props.result && !props.error && (
<div className="result">
<p className="muted">Ready for a source.</p>
<strong>Using {props.template.name}</strong>
<AnalysisBadge template={props.template} />
<p className="template-summary">{props.template.summary || props.template.description}</p>
</div>
)}
{props.result && (
<div className="result">
<strong>{props.result.title}</strong>
@ -777,6 +862,45 @@ function Swatches(props: { colors: string[] }) {
);
}
function AnalysisBadge(props: { template: DesignTemplate }) {
const status = props.template.analysisStatus || props.template.analysisSource || "saved";
const label =
status === "ai-analyzed"
? "AI analyzed"
: status === "fallback"
? "Local fallback"
: status === "built-in"
? "Built in"
: "Saved";
return <span className={`analysis-badge ${status === "ai-analyzed" ? "is-ai" : ""}`}>{label}</span>;
}
function templateForRequest(template: DesignTemplate) {
return {
id: template.id,
name: template.name,
description: template.description,
summary: template.summary,
colors: template.colors,
layoutRules: template.layoutRules,
componentRules: template.componentRules,
slideFamilies: template.slideFamilies,
prompt: template.prompt,
analysisSource: template.analysisSource,
sourceName: template.sourceName
};
}
function inferDraftLayoutRules(description: string): string[] {
const rules = [
"Keep title placement, spacing, and section hierarchy consistent across slides.",
"Use a repeatable grid for each slide family."
];
if (/column/i.test(description)) rules.push("Support one-, two-, three-, and four-column compositions.");
if (/row/i.test(description)) rules.push("Support one- and two-row modules inside content columns.");
return rules;
}
function formatBytes(value: number) {
if (value < 1024) return `${value} B`;
if (value < 1024 * 1024) return `${Math.round(value / 1024)} KB`;

View file

@ -23,6 +23,11 @@ button {
cursor: pointer;
}
button:disabled {
cursor: not-allowed;
opacity: 0.65;
}
.app-shell {
min-height: 100vh;
padding: 24px;
@ -229,6 +234,29 @@ textarea {
gap: 12px;
}
.template-card {
display: grid;
gap: 10px;
padding: 14px;
border: 1px solid #d8dee9;
border-radius: 8px;
background: #fff;
}
.template-card-header {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: end;
gap: 12px;
}
.template-meta-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.swatches {
display: inline-flex;
align-items: center;
@ -309,6 +337,23 @@ textarea {
overflow-wrap: anywhere;
}
.analysis-badge {
display: inline-flex;
width: fit-content;
align-items: center;
padding: 4px 8px;
border-radius: 999px;
color: #596579;
background: #f4f6fa;
font-size: 12px;
font-weight: 750;
}
.analysis-badge.is-ai {
color: #0b6b38;
background: #e9f7ef;
}
.template-summary {
margin: 0;
color: #596579;
@ -316,6 +361,16 @@ textarea {
line-height: 1.45;
}
.template-rule-list {
display: grid;
gap: 6px;
margin: 0;
padding-left: 18px;
color: #596579;
font-size: 13px;
line-height: 1.35;
}
.result a {
display: inline-flex;
align-items: center;
@ -362,6 +417,10 @@ textarea {
grid-template-columns: 1fr;
}
.template-card-header {
grid-template-columns: 1fr;
}
.drop-zone {
grid-template-columns: auto 1fr;
}