Фикс Stitch-пайплайна в генераторе слайдов

Когда генератор слайдов перестаёт работать так, как задумано — это не всегда очевидно. Иногда система делает вид, что всё в порядке: запросы проходят, ответы возвращаются, слайды появляются. Но под капотом творится хаос — вместо нового Stitch-пайплайна работает старый legacy-код, дизайн каждого слайда генерируется случайно, а два параллельных запроса создают два разных проекта в Stitch. Именно с этим мы столкнулись в slides-generator, и вот как это починили.
Что такое slides-generator и причём тут Stitch
Slides-generator — это Next.js приложение для автоматической генерации HTML-презентаций. Пользователь вводит тему или загружает документы, AI анализирует контент и генерирует набор слайдов в виде HTML. Затем слайды можно просмотреть прямо в браузере, экспортировать в PDF через Puppeteer или отправить в Google Slides.
Ключевая часть пайплайна — Google Stitch API, который использует Gemini для генерации красивого HTML-кода слайдов. Stitch умеет создавать визуально богатые компоненты с правильной типографикой, цветами и лейаутом. В теории — отличный инструмент. На практике — интеграция была сломана с самого начала.
Проблема первая: Stitch не использовался вообще
Самый неожиданный баг: весь новый Stitch-пайплайн фактически не работал. Вот что происходило на самом деле:
Шаг 1: /api/plan вызывал старую функцию generateFromRawText(), которая отправляла запрос к Claude и получала в ответ полный HTML всех слайдов сразу. Этот HTML сохранялся в поле ai_spec.
Шаг 2: /api/generate видел, что ai_spec уже существует, и полностью пропускал Stitch. Зачем генерировать слайды через Stitch, если HTML уже есть?
В результате Stitch API не вызывался никогда. Слайды генерировались старым способом — одним большим запросом к Claude, который возвращал монолитный HTML. Это было медленно (60+ секунд ожидания), непредсказуемо по качеству и не использовало те возможности, ради которых Stitch вообще подключался.
Фикс потребовал рефакторинга обоих роутов:
// БЫЛО: /api/plan/route.ts
const spec = await generateFromRawText(text); // legacy, возвращает полный HTML
await saveSpec(presentationId, { ai_spec: spec });
// СТАЛО: используем planSlidesForStitch
const plan = await planSlidesForStitch(text);
await saveSpec(presentationId, {
title: plan.title,
slides: plan.slides.map(s => ({
title: s.title,
stitchPrompt: s.stitchPrompt,
html: undefined // HTML ещё нет, будет генерироваться через Stitch
})),
designSystem: plan.designSystem
});А в /api/generate изменили условие пропуска Stitch:
// БЫЛО: пропускаем если spec существует
if (!spec) {
// запускаем Stitch
}
// СТАЛО: пропускаем только если В spec уже есть HTML
if (!spec || !spec.slides?.some(s => s.html)) {
// запускаем Stitch
}Теперь планирование через Claude занимает ~10 секунд вместо 60+, а генерация каждого слайда идёт через Stitch с промтом для конкретного слайда.
Проблема вторая: каждый слайд выглядел по-разному
Когда Stitch наконец заработал, обнаружилась следующая проблема: у каждого слайда был свой уникальный дизайн. Один слайд — оранжевый (#f49434), следующий — тёмно-красный (#d44211), потом бордовый (#bd0f0f), потом жёлтый (#f9e406). Презентация выглядела как лоскутное одеяло.
Причина проста: каждый stitchPrompt описывал только контент слайда, но не говорил ничего о цветах и шрифтах. Gemini каждый раз сам выбирал палитру — и выбирал по-разному.
Решение — ввести концепцию designSystem, который Claude выбирает один раз на всю презентацию и который обязательно включается в каждый промт для Stitch:
interface StitchDesignSystem {
primaryColor: string; // например "#2563eb"
backgroundColor: string; // например "#ffffff"
textColor: string; // например "#1e293b"
accentColor: string; // например "#f59e0b"
fontFamily: string; // например "Inter"
style: string; // например "modern-minimal"
}
interface StitchPlan {
title: string;
designSystem: StitchDesignSystem;
slides: Array<{
title: string;
stitchPrompt: string; // начинается с MANDATORY DESIGN SYSTEM: ...
}>;
}Системный промт для Claude теперь требует: каждый stitchPrompt должен начинаться со строки "MANDATORY DESIGN SYSTEM: primary color #..., background #..., text #..., font ...". Gemini получает конкретные hex-значения и следует им, не изобретая собственную палитру.
Проблема третья: race condition и два Stitch-проекта
Ещё один неприятный баг — TOCTOU race condition. Если два запроса к /api/generate приходили почти одновременно (например, пользователь быстро кликнул дважды или браузер отправил повторный запрос), оба проходили проверку if (!stitchProjectId) и оба создавали новый Stitch-проект. В итоге у одной презентации появлялось два проекта, слайды перемешивались между ними, генерация ломалась.
Фикс — атомарный guard через базу данных. Вместо read-check-write используем атомарный UPDATE с условием:
// Атомарно проверяем и создаём projectId
const { data, error } = await supabase
.from('presentations')
.update({ stitch_project_id: newProjectId })
.eq('id', presentationId)
.is('stitch_project_id', null) // условие атомарности
.select('stitch_project_id')
.single();
// Если update не прошёл (другой запрос уже создал) — читаем существующий
if (!data) {
const existing = await getExistingProjectId(presentationId);
// используем существующий
}Дополнительно установили MAX_CONCURRENT = 1 для генерации слайдов — Stitch API не любит параллельные запросы в рамках одного проекта, и последовательная генерация дала более стабильные результаты.
Проблема четвёртая: слайды иногда не генерировались
В реальных тестах обнаружился паттерн: первый слайд генерируется успешно, второй — нет. В базе данных второй слайд оставался пустым (без HTML, без screenId). При этом Stitch возвращал 200 OK на вызов generate_screen_from_text — никакой ошибки!
Проблема в polling timeout. После вызова generate_screen_from_text мы поллили list_screens в ожидании появления нового экрана. Таймаут стоял 30 секунд — и этого не хватало. Stitch иногда обрабатывает запрос дольше.
Изменения:
- Polling timeout: 30с → 90 секунд (18 итераций по 5 секунд)
- Добавили per-slide retry: если слайд не сгенерировался за 90 секунд, пробуем ещё раз через 10 секунд
async function generateScreen(
projectId: string,
prompt: string,
retries = 2
): Promise<string | undefined> {
for (let attempt = 0; attempt < retries; attempt++) {
if (attempt > 0) {
await sleep(10_000); // пауза перед retry
}
await stitch.generate_screen_from_text({ projectId, prompt });
// Поллинг до 90 секунд
for (let i = 0; i < 18; i++) {
await sleep(5_000);
const screens = await stitch.list_screens({ projectId });
const newScreen = findNewScreen(screens);
if (newScreen) return newScreen.html;
}
}
return undefined; // все попытки провалились
}Прогресс-трекинг: пользователь не должен смотреть в пустой экран
Отдельная история — UX во время генерации. Пользователь нажимал кнопку, попадал на страницу с сообщением «AI планирует слайды...» и ждал. Без таймера, без подэтапов, без понимания, что вообще происходит. Это ощущение черного ящика — худшее, что можно сделать с пользователем в момент ожидания.
Добавили несколько вещей:
- Таймер прошедшего времени — «AI планирует слайды... (23с)» — пользователь видит, что процесс идёт
- Анимированные подэтапы — «Анализирую контент → Формирую структуру → Генерирую дизайн-систему»
- Прогресс по слайдам на странице презентации — каждый готовый слайд появляется сразу, не нужно ждать всех
const [elapsed, setElapsed] = useState(0);
const [subStep, setSubStep] = useState(0);
useEffect(() => {
if (!isPlanning) return;
const timer = setInterval(() => setElapsed(e => e + 1), 1000);
return () => clearInterval(timer);
}, [isPlanning]);
// В JSX:
<p className="text-muted">
{PLANNING_SUBSTEPS[subStep]} ({elapsed}с)
</p>Экспорт: человекочитаемые ошибки
Экспорт в Google Slides требует OAuth-авторизации. Если пользователь не авторизован, старый код просто падал с необработанной ошибкой — никакого сообщения, просто ничего не происходило.
Добавили обработку в handleExport:
async function handleExport() {
try {
const result = await exportToGoogleSlides(presentationId);
window.open(result.url, '_blank');
} catch (error) {
if (error.code === 'GOOGLE_AUTH_REQUIRED') {
setExportError({
message: 'Необходима авторизация в Google',
authUrl: error.authUrl
});
} else {
setExportError({ message: 'Ошибка экспорта. Попробуйте ещё раз.' });
}
}
}Теперь пользователь видит сообщение с кнопкой «Авторизоваться в Google» вместо молчаливого провала.
Layout: превью на 2/3 экрана
Ещё одна вещь из плана — пропорции лейаута. На странице презентации превью слайда занимало примерно половину экрана, остальное — панели управления. Это неудобно: главное, что должен видеть пользователь — сам слайд.
Изменили CSS-сетку: превью теперь занимает 2/3 ширины, боковая панель — 1/3. Убрали фиксированную ширину у iframe, заменили на width: 100% чтобы он адаптировался к контейнеру.
/* БЫЛО */
.presentation-layout {
display: grid;
grid-template-columns: 1fr 1fr;
}
/* СТАЛО */
.presentation-layout {
display: grid;
grid-template-columns: 2fr 1fr;
}
.slide-preview iframe {
width: 100%; /* вместо фиксированных 640px */
aspect-ratio: 16/9;
}Результат и текущее состояние
После всех изменений пайплайн работает так, как задумывался изначально:
/api/planвызываетplanSlidesForStitch()— Claude за ~10 секунд возвращает структуру с единой дизайн-системой/api/generateберётstitchPromptsиз сохранённого плана и последовательно генерирует каждый слайд через Stitch- Каждый слайд появляется на странице сразу после генерации
- Все слайды используют одну палитру и шрифт
- Параллельные запросы не создают дубликаты благодаря атомарному guard'у
Общее время генерации 8-10 слайдов: 3-5 минут. Это медленнее, чем один запрос к Claude, но качество HTML-слайдов несравнимо выше — Stitch генерирует полноценные визуальные компоненты, а не просто текст в div'ах.
Гит-история показывает путь: от первых PDF-экспортов и базовой интеграции Stitch — до исправления race conditions, polling timeout'ов и design consistency. Каждый коммит решал конкретную проблему, которую нашли в реальном использовании.
Выводы
Главный урок этого проекта — интеграция AI API сложнее, чем кажется. Stitch API возвращает 200 OK даже когда экран не создался. Gemini выбирает цвета произвольно, если не дать точные hex-значения. Параллельные запросы создают race conditions там, где ты не ожидаешь. Всё это нужно проверять в реальных условиях, а не полагаться на то, что «API сказал успех».
Второй урок: legacy-код выживает дольше, чем ты думаешь. Функция generateFromRawText() была помечена как deprecated, но продолжала вызываться из /api/plan, потому что никто не проверил весь путь целиком. Хорошая практика — после добавления нового пайплайна сразу удалять старый, чтобы не было соблазна использовать его как fallback навсегда.
Третий урок: UX ожидания — это отдельная фича. Таймер и подэтапы кажутся мелочью, но именно они определяют, будет ли пользователь ждать или закроет вкладку. Генерация презентации занимает минуты — это долго. Без обратной связи пользователь решит, что что-то сломалось.
Четвёртый урок: polling с фиксированным timeout'ом — это технический долг с процентами. 30 секунд казалось достаточно, но Stitch иногда работает медленнее. Timeout нужно устанавливать с запасом и добавлять retry — особенно когда работаешь с внешним API, поведение которого не контролируешь. В нашем случае 90 секунд + 2 попытки решили проблему пропущенных слайдов.
Следующий шаг — переход на кастомные HTML-шаблоны с рендерингом через Playwright вместо Stitch. Это даст полный контроль над дизайном и уберёт зависимость от стороннего API, которое может быть нестабильным. Но это уже тема для отдельной статьи.
Полезные ссылки:

AI-инженер, предприниматель, маркетолог. Основатель feberra.com и x10seo.ru. 13 лет в перфоманс-маркетинге, 3 года в системной интеграции AI в бизнес.
Связанный проект
Slides Generator →