Purrx

Tour the finished project and set up your machine

Browse every file of the finished AI slide generator before you write any of it, learn how the files import each other, and set up Node.js to run it locally.

10 min+15 XP

It's easier to build something when you've seen the finished thing. Below is the complete project as it will look at the end of the course. You don't need to understand any of it yet: skim a few files to get a feel for the size and shape.

Web app

HTTP, streaming progress, preview

server.jspublic/src/handler.js

Production

reliable, cheap, safe

errorsretryfallbackusagequotacachelimits

AI pipeline

Gemini through LangChain

pipelinellmoutlineresearchprompt

Engine

no AI: same input, same slides

xmlcomponentslibrarythemelayouttextfontscompilepptx

Four layers. Each one only uses the layers below it, which is what makes every part testable on its own.

The finished project

37 files

src/pipeline.js

// The whole generator, rebuilt on LangChain chat models:
//
//   (optional) research → outline → write the deck → compile → repair what's broken
//
// It replaces generate.js. The steps are the same; what changed is that each
// step receives a `model`, so we can swap in a different model, or a fake one in tests.
//
// Every model call goes through `call()`: a timeout per attempt, retries with
// backoff for errors worth retrying, and a fallback model when one is unhealthy.

import { HumanMessage, SystemMessage } from "@langchain/core/messages";
import { contentText, createModel } from "./llm.js";
import { generateOutline } from "./outline.js";
import { researchTopic } from "./research.js";
import { buildSystemPrompt, extractTag } from "./prompt.js";
import { LIBRARY } from "./library.js";
import { compileDeck } from "./compile.js";
import { withRetry, withTimeout } from "./retry.js";
import { createModelPool } from "./fallback.js";
import { replaceSlides, splitSlides } from "./deck-edit.js";
import { createSlideStream } from "./stream.js";
import { createUsage } from "./usage.js";

const MAX_REPAIR_ROUNDS = 2;
const CALL_TIMEOUT_MS = 90_000; // one model call
const DECK_DEADLINE_MS = 240_000; // the whole deck, inside a typical 300s serverless limit

/** The models to use, best first. The second is lighter and usually less busy. */
export function defaultModels() {
  return [createModel(), createModel({ model: "gemini-flash-lite-latest" })];
}

/**
 * Outline → SlideML, following the component library. The reply is streamed, and
 * `onSlide(number, xml)` is called as soon as each slide is complete.
 */
export async function writeDeck(model, outline, { signal, onSlide = () => {}, onReply } = {}) {
  const stream = await model.stream(
    [new SystemMessage(buildSystemPrompt(LIBRARY)), new HumanMessage(`Write the deck for this outline:\n${JSON.stringify(outline, null, 2)}`)],
    { signal },
  );

  const slides = createSlideStream();
  let count = 0;
  let usage;
  for await (const chunk of stream) {
    usage = chunk.usage_metadata ?? usage; // token counts arrive with the last chunk
    for (const slideXml of slides.push(contentText(chunk))) {
      count += 1;
      onSlide(count, slideXml);
    }
  }

  onReply?.({ usage_metadata: usage });
  const xml = extractTag(slides.text, "deck");
  if (!xml) throw new Error("The model didn't reply with a <deck> element.");
  return xml;
}

/**
 * Send only the broken slides back, each with its own problems, and ask for fixed
 * versions of just those. Returns a Map of slide number → fixed XML.
 */
export async function repairSlides(model, xml, problems, { signal, onReply } = {}) {
  const slides = splitSlides(xml);
  const numbers = [...new Set(problems.map((problem) => problem.slide))].filter((number) => slides[number - 1]);

  const request = numbers
    .map((number) => {
      const list = problems.filter((problem) => problem.slide === number).map((problem) => `- ${problem.message}`).join("\n");
      return `<fix slide="${number}">\n${slides[number - 1]}\n</fix>\nProblems:\n${list}`;
    })
    .join("\n\n");

  const reply = await model.invoke(
    [
      new SystemMessage(buildSystemPrompt(LIBRARY)),
      new HumanMessage(
        "These slides from a deck have problems. Fix each one. Reply with one " +
          '<fix slide="N">…the corrected slide…</fix> per slide, and nothing else.\n\n' +
          request,
      ),
    ],
    { signal },
  );

  onReply?.(reply);
  const fixes = new Map();
  for (const match of contentText(reply).matchAll(/<fix slide="(\d+)">([\s\S]*?)<\/fix>/g)) {
    fixes.set(Number(match[1]), match[2].trim());
  }
  return fixes;
}

/** Send the deck back with the compiler's problems and ask for a corrected deck. */
export async function repairDeck(model, xml, problems, { signal, onReply } = {}) {
  const list = problems.map((problem) => `- Slide ${problem.slide}: ${problem.message}`).join("\n");
  const reply = await model.invoke(
    [
      new SystemMessage(buildSystemPrompt(LIBRARY)),
      new HumanMessage(`This deck has problems:\n${list}\n\nFix them and reply with the whole corrected <deck>.\n\n${xml}`),
    ],
    { signal },
  );
  onReply?.(reply);
  return extractTag(contentText(reply), "deck");
}

/**
 * options:
 *   models      chat models, best first (default: defaultModels()); or `model` for just one
 *   research    true to search the web first
 *   slideCount  how many slides to plan (default 6)
 *   theme       "light" or "dark"
 *   compile     the compiler to use (tests pass a fake one)
 *   onEvent     called with { type: "stage" | "slide" | "retry" | "fallback", … } so a UI can show progress
 *   wait        how retries wait (tests pass a fake one)
 */
export async function generateDeck(topic, options = {}) {
  const { research = false, slideCount = 6, theme, compile = compileDeck, onEvent = () => {}, wait } = options;
  const models = options.model ? [options.model] : (options.models ?? defaultModels());

  const usage = createUsage();
  const onReply = (message) => usage.recordReply(message);
  const pool = createModelPool(models, {
    onFallback: (info) => {
      usage.recordFallback();
      onEvent({ type: "fallback", ...info });
    },
  });
  const deadline = Date.now() + DECK_DEADLINE_MS;

  // stage timing ⟶ fallback ⟶ retries ⟶ timeout ⟶ the actual call
  const call = async (stage, fn) => {
    onEvent({ type: "stage", stage, status: "start" });
    const result = await usage.track(stage, () =>
      pool.run((model) =>
        withRetry(() => withTimeout((signal) => fn(model, signal), CALL_TIMEOUT_MS), {
          retries: 2,
          deadline,
          wait,
          onRetry: (info) => {
            usage.recordRetry();
            onEvent({ type: "retry", stage, ...info });
          },
        }),
      ),
    );
    onEvent({ type: "stage", stage, status: "end" });
    return result;
  };

  const findings = research
    ? await call("research", (model, signal) => researchTopic(model, topic, { signal, onReply }))
    : { notes: "", sources: [] };
  const outline = await call("outline", (model, signal) => generateOutline(model, topic, { slideCount, research: findings.notes, signal, onReply }));
  let xml = await call("deck", (model, signal) =>
    writeDeck(model, outline, { signal, onReply, onSlide: (number, slideXml) => onEvent({ type: "slide", number, xml: slideXml }) }),
  );
  let result = compile(xml, theme);
  let repairs = 0;

  while (result.problems.length > 0 && repairs < MAX_REPAIR_ROUNDS) {
    repairs += 1;
    // Deck-level problems (slide 0, like unreadable XML) need the whole deck; otherwise fix only the broken slides.
    const wholeDeck = result.problems.some((problem) => problem.slide === 0);
    const fixed = wholeDeck
      ? await call("repair", (model, signal) => repairDeck(model, xml, result.problems, { signal, onReply }))
      : replaceSlides(xml, await call("repair", (model, signal) => repairSlides(model, xml, result.problems, { signal, onReply })));
    if (!fixed) break;
    const next = compile(fixed, theme);
    if (next.problems.length >= result.problems.length) break;
    xml = fixed;
    result = next;
  }

  return { outline, xml, slides: result.slides, problems: result.problems, repairs, sources: findings.sources, usage: usage.summary() };
}

Four layers

  • Engine (modules 2 to 6): turns SlideML into slides. No AI, so the same input always gives the same output, and it's easy to test.
  • AI pipeline (modules 7 and 8): researches, plans and writes the deck with Gemini.
  • Production (modules 9 to 11): retries, fallback models, streaming, caching, limits, tests and usage tracking.
  • Web app (module 12): an API endpoint and a page with live previews.

How files use each other

Every file is an ES module. It shares functions with export, and uses other files' functions with import. The "type": "module" line in package.json tells Node to treat files this way.

src/compile.js (a few lines)
import { parseXml } from "./xml.js";       // another file in the project
import { layoutSlide } from "./layout.js";
import PptxGenJS from "pptxgenjs";           // a package from npm

export function compileSlide(xml) {          // other files can import this
  return layoutSlide(parseXml(xml));
}
Under the hood — What's the difference between named and default imports?

import { parseXml } from "./xml.js" picks out a function the file exported by name, with export function parseXml. One file can export many names.

import PptxGenJS from "pptxgenjs" takes the one thing a package exports as its default. Our own files only use named exports, which makes it obvious where every function comes from.

Set up your machine (for the end of the course)

You can do every lesson in the browser. To run the finished tool yourself in module 12, you'll need:

  1. Node.js 20 or newer. Install it from nodejs.org, then check with node --version.
  2. A code editor, such as VS Code.
  3. A Gemini , kept in an environment variable, never in your code.
terminal
node --version                 # v20 or newer
mkdir slide-generator && cd slide-generator
npm init -y && npm pkg set type=module
npm install pptxgenjs

Every exercise also has a Download .zip button, so you can take the project so far to your machine at any point.

Key takeaways

  • The project has four layers: engine, AI pipeline, production safeguards and web app.
  • Files share code with import and export; each layer only uses the layers below it.
  • You can do the whole course in the browser, and run the finished project with Node.js 20.

Sign in to run the exercise

Reading is free. Writing code here needs an account so we have somewhere to keep your Gemini key and the +15 XP you are about to earn.