| 1 | import "server-only"; |
| 2 | |
| 3 | import { toUIMessageStream } from "@ai-sdk/langchain"; |
| 4 | import { ChatPromptTemplate } from "@langchain/core/prompts"; |
| 5 | import { RunnableSequence } from "@langchain/core/runnables"; |
| 6 | import { consumeStream, createUIMessageStreamResponse } from "ai"; |
| 7 | import { NextResponse } from "next/server"; |
| 8 | |
| 9 | import { templates } from "@/constants/antv-templates"; |
| 10 | import { modelPicker } from "@/lib/modelPicker"; |
| 11 | import { logger } from "@/lib/observability/server/logger"; |
| 12 | import { |
| 13 | buildInfographicLayoutInstruction, |
| 14 | filterInfographicTemplatesForOrientation, |
| 15 | getInfographicOrientationForSlideLayout, |
| 16 | type InfographicOrientation, |
| 17 | type InfographicSlideLayout, |
| 18 | } from "@/lib/presentation/infographic-layout"; |
| 19 | import { auth } from "@/server/auth"; |
| 20 | |
| 21 | const INFOGRAPHIC_MODEL = "google/gemini-3-flash-preview"; |
| 22 | |
| 23 | type PromptToDiagramRequest = { |
| 24 | prompt: string; |
| 25 | slideLayoutType?: InfographicSlideLayout; |
| 26 | requestedOrientation?: InfographicOrientation; |
| 27 | layoutInstruction?: string; |
| 28 | }; |
| 29 | |
| 30 | function isPromptToDiagramRequest( |
| 31 | value: unknown, |
| 32 | ): value is PromptToDiagramRequest { |
| 33 | if (!value || typeof value !== "object") return false; |
| 34 | |
| 35 | const candidate = value as Partial<PromptToDiagramRequest>; |
| 36 | return ( |
| 37 | typeof candidate.prompt === "string" && |
| 38 | (candidate.slideLayoutType === undefined || |
| 39 | typeof candidate.slideLayoutType === "string") && |
| 40 | (candidate.requestedOrientation === undefined || |
| 41 | typeof candidate.requestedOrientation === "string") && |
| 42 | (candidate.layoutInstruction === undefined || |
| 43 | typeof candidate.layoutInstruction === "string") |
| 44 | ); |
| 45 | } |
| 46 | |
| 47 | function organizeTemplates(templateList: string[]): string { |
| 48 | const categories: Record<string, string[]> = { |
| 49 | wordCloud: [], |
| 50 | compare: [], |
| 51 | hierarchy: [], |
| 52 | list: [], |
| 53 | quadrant: [], |
| 54 | relation: [], |
| 55 | sequence: [], |
| 56 | }; |
| 57 | |
| 58 | for (const templateName of templateList) { |
| 59 | if (templateName.startsWith("chart-wordcloud")) { |
| 60 | categories.wordCloud!.push(templateName); |
| 61 | } else if (templateName.startsWith("compare-")) { |
| 62 | categories.compare!.push(templateName); |
| 63 | } else if (templateName.startsWith("hierarchy-")) { |
| 64 | categories.hierarchy!.push(templateName); |
| 65 | } else if (templateName.startsWith("list-")) { |
| 66 | categories.list!.push(templateName); |
| 67 | } else if (templateName.startsWith("quadrant-")) { |
| 68 | categories.quadrant!.push(templateName); |
| 69 | } else if (templateName.startsWith("relation-")) { |
| 70 | categories.relation!.push(templateName); |
| 71 | } else if (templateName.startsWith("sequence-")) { |
| 72 | categories.sequence!.push(templateName); |
| 73 | } |
| 74 | } |
| 75 | |
| 76 | return Object.entries(categories) |
| 77 | .filter(([, items]) => items.length > 0) |
| 78 | .map(([category, items]) => { |
| 79 | const title = |
| 80 | category === "wordCloud" |
| 81 | ? "Word Cloud" |
| 82 | : `${category.charAt(0).toUpperCase()}${category.slice(1)}`; |
| 83 | return `\n### ${title} Templates\n${items.map((item) => `- ${item}`).join("\n")}`; |
| 84 | }) |
| 85 | .join("\n"); |
| 86 | } |
| 87 | |
| 88 | const SYSTEM_PROMPT = `You are an expert Information Designer and AntV Infographic Syntax Specialist. Your sole purpose is to transform a user prompt into valid AntV Infographic DSL code. |
| 89 | |
| 90 | ## Response Format |
| 91 | |
| 92 | You must output ONLY the infographic syntax code. Do not provide conversational filler, explanations, or preambles. Do NOT wrap your output in a markdown code block. |
| 93 | |
| 94 | ## The AntV Infographic Syntax |
| 95 | |
| 96 | The syntax is strict, case-sensitive, and indentation-based (2 spaces). It follows this structure: |
| 97 | |
| 98 | \`\`\` |
| 99 | infographic <template-id> |
| 100 | theme |
| 101 | colorBg transparent |
| 102 | data |
| 103 | title <Main Title> |
| 104 | desc <Subtitle or Description> |
| 105 | items |
| 106 | - label <Item Title> |
| 107 | desc <Item Description> |
| 108 | value <Optional Numeric Value> |
| 109 | icon <Icon ID> |
| 110 | \`\`\` |
| 111 | |
| 112 | **IMPORTANT**: The theme block MUST come immediately after the infographic line, BEFORE the data block. Make sure \`colorBg\` is always set to \`transparent\`. |
| 113 | |
| 114 | ## Template Library |
| 115 | |
| 116 | Select the most appropriate template-id based on the structure inferred from the prompt. |
| 117 | {templateList} |
| 118 | |
| 119 | Do not use chart-* templates except chart-wordcloud and chart-wordcloud-rotate. Standard charts are not part of the infographic generation flow. |
| 120 | |
| 121 | ## Layout Fit Contract |
| 122 | |
| 123 | {layoutInstruction} |
| 124 | |
| 125 | - The selected template MUST match the required infographic orientation. Treat orientation as a hard requirement. |
| 126 | - Horizontal means landscape/wide: left-to-right flow, rows, grids, quadrants, or compact wide diagrams. |
| 127 | - Vertical means portrait/stacked: top-to-bottom flow, columns, vertical roadmaps, or compact stacked hierarchy. |
| 128 | - Do not choose a template just because it matches the topic; choose one that also fits the required orientation. |
| 129 | - Do not mention the slide layout, orientation rule, or fitting instructions in the generated infographic text. |
| 130 | |
| 131 | ## Icon Selection Rules |
| 132 | |
| 133 | You must assign an icon to every item in the items list. Use one of these methods: |
| 134 | |
| 135 | **Option A: Material Design Icons (Recommended)** |
| 136 | Use mdi/ prefix. Examples: mdi/rocket-launch, mdi/account-group, mdi/lightbulb, mdi/source-branch |
| 137 | |
| 138 | **Option B: Font Awesome** |
| 139 | Use fa/ prefix. Examples: fa/check-circle, fa/users, fa/cog |
| 140 | |
| 141 | **Option C: Semantic Search (Auto-Select)** |
| 142 | If unsure of the exact icon ID, use: ref:search:svg:<keyword> |
| 143 | Example: ref:search:svg:artificial intelligence |
| 144 | |
| 145 | ## Styling & Theme Options |
| 146 | |
| 147 | Add a stylize property inside the theme block for special effects: |
| 148 | |
| 149 | - **Hand-Drawn/Sketchy**: stylize rough (adds pencil sketch effect) |
| 150 | - **Gradient**: stylize linear-gradient or stylize radial-gradient |
| 151 | - **Pattern**: stylize pattern (fills with geometric textures) |
| 152 | |
| 153 | ## Syntax Rules |
| 154 | |
| 155 | 1. Entry starts with: infographic <template-name> |
| 156 | 2. Key-value pairs use spaces for separation |
| 157 | 3. Indentation uses 2 spaces |
| 158 | 4. Object arrays use - on new lines (e.g., items) |
| 159 | 5. Simple arrays stay inline (e.g., palette #ff5a5f #1fb6ff #13ce66) |
| 160 | |
| 161 | ## Data Field Mapping (choose ONE main field) |
| 162 | |
| 163 | - compare-* => compares |
| 164 | - chart-wordcloud* => items |
| 165 | - hierarchy-* => root |
| 166 | - list-* => items |
| 167 | - quadrant-* => items |
| 168 | - relation-* => items |
| 169 | - sequence-* => items |
| 170 | |
| 171 | ## Binary / Hierarchy Constraints |
| 172 | |
| 173 | - compare-binary-* and compare-hierarchy-left-right-* require exactly two root nodes; all compare items must live under those two roots. |
| 174 | - hierarchy-* uses a single root; do not repeat root. |
| 175 | |
| 176 | ## Item Count Limits |
| 177 | |
| 178 | - Never generate more than 5 visible content items. This is a hard cap across top-level items, direct root children, comparison points, relation nodes, and word-cloud terms. |
| 179 | - For list-*, quadrant-*, sequence-*, relation-*, and comparable layout templates, use 3 to 5 top-level items. |
| 180 | - For hierarchy-* templates, use 3 to 5 direct child nodes under the single root unless the selected template strictly requires fewer. |
| 181 | - For compare-* templates, use exactly two sides and keep the combined comparison points to 4 or 5 visible points total. |
| 182 | - If the prompt contains more details than the chosen layout can fit, synthesize and merge related ideas into the strongest 5 or fewer items instead of listing everything. |
| 183 | - Avoid nested child nodes unless the selected template requires them; when nesting is required, keep the total visible content items at 5 or fewer. |
| 184 | |
| 185 | ## Relation Guidance |
| 186 | |
| 187 | - For relation-* templates, model relationships explicitly. |
| 188 | - Prefer relations with arrows (A -> B) when the template supports it. |
| 189 | - If only items are allowed, express connections via concise item labels and descriptions. |
| 190 | |
| 191 | ## Prompt to Visual Mapping |
| 192 | |
| 193 | - Generate all text in the same language as the user's prompt |
| 194 | - Use the prompt only as guidance; do not copy it verbatim in long phrases |
| 195 | - Avoid using more than 3 consecutive words from the user's prompt |
| 196 | - Rephrase and synthesize ideas into concise infographic-friendly content |
| 197 | - Keep each item label at 20 characters or fewer |
| 198 | - Keep each item description at 60 characters or fewer |
| 199 | - Infer and add supporting nodes where helpful |
| 200 | `; |
| 201 | |
| 202 | const USER_PROMPT = `## User Prompt |
| 203 | |
| 204 | {prompt} |
| 205 | |
| 206 | Convert the prompt into one complete AntV infographic syntax output.`; |
| 207 | |
| 208 | export async function POST(req: Request) { |
| 209 | let endSpanOnReturn = true; |
| 210 | const actionName = "presentation.prompt_to_diagram.post"; |
| 211 | const span = logger.startSpan(`allweone.api.${actionName}`, { |
| 212 | attributes: { |
| 213 | "allweone.scope": "api", |
| 214 | "allweone.action.type": "api_route", |
| 215 | "allweone.action.name": actionName, |
| 216 | "http.method": "POST", |
| 217 | "http.route": "/api/presentation/prompt-to-diagram", |
| 218 | }, |
| 219 | }); |
| 220 | |
| 221 | try { |
| 222 | const session = await auth(); |
| 223 | if (!session) { |
| 224 | span.event("allweone.api.request_rejected", { |
| 225 | "allweone.validation.error": "unauthorized", |
| 226 | }); |
| 227 | return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); |
| 228 | } |
| 229 | |
| 230 | const body: unknown = await req.json(); |
| 231 | |
| 232 | if (!isPromptToDiagramRequest(body) || body.prompt.trim().length === 0) { |
| 233 | span.event("allweone.api.request_rejected", { |
| 234 | "allweone.validation.error": "missing_prompt", |
| 235 | }); |
| 236 | return NextResponse.json( |
| 237 | { error: "No prompt provided for diagram generation" }, |
| 238 | { status: 400 }, |
| 239 | ); |
| 240 | } |
| 241 | |
| 242 | const prompt = body.prompt; |
| 243 | const requestedOrientation = |
| 244 | body.requestedOrientation ?? |
| 245 | getInfographicOrientationForSlideLayout(body.slideLayoutType); |
| 246 | const layoutInstruction = |
| 247 | body.layoutInstruction ?? |
| 248 | buildInfographicLayoutInstruction(body.slideLayoutType); |
| 249 | const templateList = organizeTemplates( |
| 250 | filterInfographicTemplatesForOrientation(templates, requestedOrientation), |
| 251 | ); |
| 252 | const promptToDiagramChain = RunnableSequence.from([ |
| 253 | ChatPromptTemplate.fromMessages([ |
| 254 | ["system", SYSTEM_PROMPT], |
| 255 | ["user", USER_PROMPT], |
| 256 | ]), |
| 257 | modelPicker(INFOGRAPHIC_MODEL), |
| 258 | ]); |
| 259 | |
| 260 | const stream = await promptToDiagramChain.stream({ |
| 261 | prompt, |
| 262 | templateList, |
| 263 | layoutInstruction, |
| 264 | }); |
| 265 | span.event("allweone.api.response_stream_created"); |
| 266 | endSpanOnReturn = false; |
| 267 | |
| 268 | return createUIMessageStreamResponse({ |
| 269 | stream: toUIMessageStream(stream), |
| 270 | consumeSseStream: ({ stream: sseStream }) => { |
| 271 | void consumeStream({ |
| 272 | stream: sseStream, |
| 273 | onError: (error) => { |
| 274 | span.error(error); |
| 275 | }, |
| 276 | }).finally(() => { |
| 277 | span.end(); |
| 278 | }); |
| 279 | }, |
| 280 | }); |
| 281 | } catch (error) { |
| 282 | span.error(error); |
| 283 | return NextResponse.json( |
| 284 | { error: "Failed to generate diagram" }, |
| 285 | { status: 500 }, |
| 286 | ); |
| 287 | } finally { |
| 288 | if (endSpanOnReturn) { |
| 289 | span.end(); |
| 290 | } |
| 291 | } |
| 292 | } |
| 293 |