П/ВИН

Как мы починили Stitch-пайплайн в генераторе слайдов

·8 мин чтения

Есть такой тип задач, который выглядит как «надо немного починить», а в итоге оказывается полноценным рефактором архитектуры. Именно так получилось с нашим генератором презентаций. Я сел разбираться с парой жалоб на UX — и вытащил целый клубок взаимосвязанных проблем. Расскажу, что нашёл и как это исправил.

Контекст проекта

Slides Generator — это сервис, который по текстовому описанию генерирует готовую презентацию. Пользователь вводит тему, загружает структуру или изображения, нажимает «создать» — и получает набор слайдов, которые можно экспортировать в Google Slides или скачать PDF.

Под капотом работают два AI-инструмента:

  • Claude от Anthropic — планирует структуру презентации, создаёт промпты для каждого слайда
  • Stitch от Google — генерирует HTML/CSS для каждого слайда по промпту

Стек: Next.js 16, React 19, TypeScript, Tailwind CSS 4, Supabase, Google APIs.

А вот пайплайн, который должен был работать:

/api/plan → Claude планирует слайды → сохраняет stitchPrompts
/api/generate → Stitch генерирует HTML по промптам → сохраняет в БД

И пайплайн, который реально работал до наших правок:

/api/plan → Claude генерирует ВЕСЬ HTML сразу → сохраняет как ai_spec
/api/generate → видит ai_spec → пропускает Stitch → ничего не делает

Stitch просто не использовался. Вообще.

Пять проблем, которые мы нашли

1. Legacy-функция вместо Stitch

В /api/plan/route.ts был вызов generateFromRawText() — старая функция, которая просила Claude сгенерировать полный HTML всей презентации за один запрос. Результат сохранялся в поле ai_spec.

Когда потом приходил /api/generate, он видел заполненный ai_spec и думал: «всё уже готово, пропускаю». Stitch при этом не вызывался никогда.

До:

// /api/plan/route.ts — старый код
const spec = await generateFromRawText(userInput);
await db.update({ ai_spec: JSON.stringify(spec) });

После:

// /api/plan/route.ts — новый код
const plan = await planSlidesForStitch(userInput);
const minimalSpec = {
  title: plan.title,
  slides: plan.slides.map(s => ({
    title: s.title,
    stitchPrompt: s.stitchPrompt,
    html: undefined // HTML ещё нет, Stitch сгенерирует позже
  }))
};
await db.update({ ai_spec: JSON.stringify(minimalSpec) });

Теперь план сохраняет только структуру и промпты, без HTML. Шаг планирования занимает ~10 секунд вместо 60+.

2. /api/generate не замечал отсутствие HTML

Даже после фикса первой проблемы оставалась вторая: generate-роут проверял только if (!spec), то есть «есть ли вообще запись в БД». Если запись была — он считал, что всё готово, и не запускал Stitch.

До:

if (!spec) {
  // запускаем генерацию
}
// иначе — считаем что уже готово

После:

if (!spec || !spec.slides?.some(s => s.html)) {
  // запускаем Stitch для слайдов без HTML
}

Теперь роут смотрит на реальное наличие HTML в каждом слайде, а не просто на существование записи.

3. Race condition и дублирование Stitch-проектов

Это была неприятная находка. На одну презентацию из 4 слайдов Stitch создал два отдельных проекта: 13878568539903239050 и 14328319685877422969. Оба содержали слайды, оба были неполными.

Причина — классический TOCTOU (Time-Of-Check-Time-Of-Use) race condition. Guard выглядел примерно так:

// Псевдокод старого guard'а
const existing = await getStitchProject(presentationId);
if (!existing) {
  const project = await stitch.createProject(); // ← между check и create
  // второй запрос может пройти то же самое одновременно
}

Два одновременных запроса /api/generate оба проходили проверку (оба видели existing = null) и оба создавали новый Stitch-проект.

Решение — атомарный upsert в БД:

// Атомарная операция — создаём запись только если её нет
const result = await db
  .insert(generations)
  .values({ presentationId, stitchProjectId: null, status: 'pending' })
  .onConflictDoNothing()
  .returning();
 
if (result.length === 0) {
  // Запись уже существует — другой запрос уже обрабатывает
  return Response.json({ status: 'already_running' });
}

Теперь только один из конкурирующих запросов пройдёт дальше. Остальные получат already_running и корректно завершатся.

4. Разный дизайн на каждом слайде

Это была самая визуально заметная проблема. Пользователь видел презентацию, где каждый слайд был в своей цветовой гамме: #f49434, #d44211, #bd0f0f, #f9e406 — Stitch каждый раз выбирал произвольную палитру.

Причина: промпты для слайдов не содержали никаких конкретных цветов или шрифтов. Stitch получал просто «сделай слайд про рынок» и каждый раз импровизировал.

Решение — Design System в промптах:

Обновили системный промпт для Claude, чтобы он обязательно выбирал единую дизайн-систему и прописывал её в каждый stitchPrompt:

// Новый STITCH_PLANNER_SYSTEM промпт (фрагмент)
const STITCH_PLANNER_SYSTEM = `
You MUST choose a single design system for the entire presentation:
- Pick ONE primary color (hex), ONE background color, ONE text color, ONE font family
- EVERY stitchPrompt MUST start with:
  "MANDATORY DESIGN SYSTEM: primary color #XXXXXX, background #XXXXXX, 
   text #XXXXXX, font [FontName]"
- Never deviate from this design system across slides
`;

Добавили интерфейс StitchDesignSystem и поле designSystem в StitchPlan:

interface StitchDesignSystem {
  primaryColor: string;   // e.g. "#2563eb"
  backgroundColor: string; // e.g. "#ffffff"
  textColor: string;      // e.g. "#1e293b"
  fontFamily: string;     // e.g. "Inter"
}
 
interface StitchPlan {
  title: string;
  designSystem: StitchDesignSystem;
  slides: Array<{
    title: string;
    stitchPrompt: string;
  }>;
}

5. Polling timeout был слишком коротким

Stitch — асинхронный. Ты вызываешь generate_screen_from_text, он возвращает «принято», а потом ты должен поллить list_screens пока экран не появится.

Изначально таймаут был 30 секунд — слайд не успевал генерироваться, код записывал html = undefined, и пользователь видел чёрный экран с заголовком. Из реальных логов:

// Слайд 1 — готов (9713 chars HTML, есть screenId)
// Слайд 2 — пустой (нет html, нет screenId)
// → generate_screen_from_text вернул ок, но экран не появился за 30с

Решение: увеличили timeout с 30 до 90 секунд (18 итераций по 5 секунд), добавили retry для каждого слайда:

async function generateScreen(
  projectId: string,
  prompt: string
): Promise<string> {
  const MAX_POLLS = 18; // 90 секунд
  const POLL_INTERVAL = 5000;
  
  await stitch.generateScreenFromText({ projectId, prompt });
  
  for (let i = 0; i < MAX_POLLS; i++) {
    await sleep(POLL_INTERVAL);
    const screens = await stitch.listScreens(projectId);
    const newScreen = screens.find(/* логика определения нового экрана */);
    if (newScreen) return newScreen.html;
  }
  
  throw new Error(`Screen not ready after ${MAX_POLLS * POLL_INTERVAL / 1000}s`);
}
 
// В generate route — retry для каждого слайда
const MAX_RETRIES = 2;
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
  try {
    slide.html = await generateScreen(projectId, slide.stitchPrompt);
    break;
  } catch (err) {
    if (attempt < MAX_RETRIES - 1) {
      await sleep(10_000); // 10с между попытками
    }
  }
}

Прогресс-трекинг на фронтенде

Помимо бэкенд-фиксов, улучшили UX на странице создания. Раньше пользователь видел статичный текст «AI планирует слайды...» и просто ждал — без какого-либо понимания, что происходит.

Добавили:

  1. Таймер — показывает сколько секунд идёт текущий шаг
  2. Анимированные подэтапы — «Анализирую тему», «Выбираю структуру», «Создаю промпты»
  3. Прогресс по слайдам — «Генерирую слайд 2 из 5»
// Хук для таймера
const [elapsed, setElapsed] = useState(0);
const timerRef = useRef<NodeJS.Timeout>();
 
const setStepWithTimer = (step: string) => {
  clearInterval(timerRef.current);
  setElapsed(0);
  setStep(step);
  timerRef.current = setInterval(() => {
    setElapsed(e => e + 1);
  }, 1000);
};
 
// В JSX
<p className="text-sm text-gray-500">
  {step} ({elapsed}с)
</p>

Обработка ошибок экспорта

Отдельно улучшили handleExport — раньше ошибки Google OAuth просто игнорировались. Теперь при ошибке экспорта показываем понятное сообщение и ссылку на переавторизацию:

try {
  await exportToGoogleSlides(presentationId);
} catch (err) {
  if (err.message.includes('auth')) {
    setExportError({
      message: 'Google аккаунт не подключён',
      authUrl: '/api/auth/google'
    });
  } else {
    setExportError({ message: 'Ошибка экспорта, попробуйте ещё раз' });
  }
}

Результаты

  • npm run build — exit 0, 0 ошибок
  • Шаг планирования: ~10с вместо 60с+
  • Дублирующиеся Stitch-проекты: устранены (атомарный guard)
  • Разнобой в дизайне слайдов: устранён (единая design system в промптах)
  • Чёрные слайды из-за timeout: устранены (90с + retry)
  • Коммиты: 0e2ce17, c2a1b11, запушено и задеплоено на presentation.pashavin.ru

Выводы

Главный урок этой сессии — «работает» и «работает правильно» это разные вещи. Система принимала запросы, отдавала ответы, в логах не было ошибок. Но Stitch — ключевой компонент всей архитектуры — просто не вызывался. Это был тихий баг, который сложнее всего обнаружить: не exception, не 500-ошибка, просто обход ценного куска системы через legacy-путь.

TOCTOU race condition — классика распределённых систем. Когда у тебя два запроса могут обрабатываться одновременно, любая проверка «существует ли X» без атомарной записи — это потенциальный баг. Решение всегда одно: делать check и create одной атомарной операцией на уровне БД (INSERT ... ON CONFLICT DO NOTHING). Блокировки на уровне приложения не спасают при горизонтальном масштабировании или просто при двух быстрых кликах пользователя.

Про design consistency в AI-генерации: если ты даёшь модели свободу выбора — она выберет. Каждый раз по-своему. Stitch видел промпт без цветов и честно придумывал что-то своё. Решение — не ограничивать модель жёсткими правилами постфактум, а встраивать constraints прямо в системный промпт на этапе планирования. Claude выбирает дизайн-систему один раз, прописывает её в каждый stitchPrompt — и Stitch не имеет пространства для импровизации.

Асинхронные API с поллингом требуют реалистичных таймаутов. 30 секунд казались разумными — на практике оказалось мало. Правило простое: если у тебя нет SLA от провайдера, закладывай минимум 3x от ожидаемого времени, плюс retry. Лучше подождать лишние 60 секунд, чем показать пользователю чёрный экран. Документация Stitch API и Anthropic API полезна, но реальные тайминги всегда нужно проверять эмпирически.

И последнее — систематический дебаггинг реально работает. Когда пришла жалоба «что-то не так со слайдами», первый импульс был сразу что-то поменять. Но я сначала прочитал весь пайплайн от начала до конца, посмотрел данные в БД, проверил реальный Stitch-проект. Это заняло 20 минут — и дало полную картину всех 5 проблем сразу, включая их взаимосвязи. Точечные фиксы без понимания root cause создают новые баги. Потраченное время на исследование всегда окупается.

Паша Вин
Паша Вин

Фулстек-разработчик, строю SaaS-продукты и автоматизации на Next.js, Python и AI. Пишу о реальных кейсах из продакшена.

Связанный проект

Смотреть в портфолио →