17 KiB
Создаём клон Gamma.app: от промпта до AI-агента на LangGraph
Как фронт-энд разработчик из Сбера создал аналог популярного сервиса для генерации презентаций с помощью LangChain и LangGraph
Кто создаёт AI-сервисы сегодня?
В списке Forbes топ-50 AI-компаний есть множество знакомых названий: OpenAI, Cursor, Bolt New. Особенно интересна компания Speak — они с 2016 года разрабатывают приложение для изучения языков с AI-помощником и попали в топ-50.
Но главное открытие: сейчас создавать AI-приложения можно довольно быстро. Наши студенты делают продукты не хуже тех, что попадают в топ-рейтинги.
Gamma.app: что внутри магии?
Gamma.app — это сервис для создания презентаций. Вы вводите тему, он создаёт план презентации с заголовками и буллетами для каждого слайда, а затем генерирует полную презентацию с изображениями и оформлением.
Выглядит как волшебство, но давайте разберём, как это работает изнутри.
Декомпозиция задачи
Чтобы создать аналог Gamma.app, нужно решить две основные задачи:
- Генерировать план презентации — структуру с заголовками и основными пунктами
- Создавать презентацию — контент, изображения и оформление на основе плана
Часть 1: Генерация плана с LangChain
Промпт-инжиниринг в реальности
Простой промпт в ChatGPT может создать план презентации, но для продакшена нужен более сложный подход:
Мой промпт включает:
- Инструкции по созданию плана
- Описание доступных типов слайдов (титульный, контентный и т.д.)
- Требования к формату JSON
- Текущую дату (важно для актуальных тем)
Шаблоны в LangChain
LangChain предоставляет механизм шаблонов для удобной работы с промптами:
const template = `
Создай план презентации на тему {topic}.
Учти следующие требования: {requirements}
Текущая дата: {current_date}
{format_instructions}
`;
Переменные в фигурных скобках заменяются на реальные значения при выполнении.
Структурированный вывод с Zod
Для надёжной работы с JSON-ответами использую Zod для описания схемы:
const presentationSchema = z.object({
title: z.string().describe("Заголовок презентации"),
description: z.string().describe("Описание презентации"),
imageStyle: z.string().describe("Общий стиль изображений"),
slides: z.array(z.object({
title: z.string().describe("Заголовок слайда"),
bullets: z.array(z.string()).describe("Основные пункты"),
imagePrompt: z.string().describe("Промпт для генерации изображения"),
webSearchQuery: z.string().optional().describe("Запрос для поиска в интернете")
}))
});
Создание цепочки LangChain
const parser = StructuredOutputParser.fromZodSchema(presentationSchema);
const chain = template.pipe(model).pipe(parser);
const result = await chain.invoke({
topic: "Фотографирование котят",
format_instructions: parser.getFormatInstructions(),
current_date: new Date().toISOString()
});
Часть 2: Создание презентации с LangGraph
Для генерации самой презентации простого LangChain недостаточно. Нужны:
- Поиск в интернете для актуальной информации
- Генерация изображений для каждого слайда
- Создание контента с учётом контекста
- Условная логика и роутинг
Здесь на помощь приходит LangGraph.
Архитектура LangGraph: от нод к агентам
Основные концепции
LangGraph работает с нодами (nodes) — функциями, которые выполняют конкретные задачи:
- Каждая нода получает состояние и обновляет его
- Между нодами есть рёбра (edges) для управления потоком
- Возможен условный роутинг и циклы
- Единое состояние передаётся между всеми нодами
Архитектура моего клона
Я создал компактный и эффективный граф из нескольких нод:
- Prepare — подготовка данных и присвоение ID слайдам
- Router — решение, нужен ли веб-поиск
- WebSearch — поиск актуальной информации
- Generate Images — генерация изображений
- Generate Content — создание контента слайдов
- Final — финализация результата
Нода подготовки данных
async function prepareNode(state: GraphState): Promise<Partial<GraphState>> {
const presentation = state.presentation;
// Присваиваем ID каждому слайду
presentation.slides.forEach((slide, index) => {
slide.id = `slide-${index}`;
});
return { presentation };
}
Условный роутинг
Для принятия решений использую Conditional Edge:
function routeToWebSearch(state: GraphState): string[] {
const sends = [];
state.presentation.slides.forEach(slide => {
if (slide.webSearchQuery) {
sends.push(
new Send("webresearch", { slide })
);
}
});
return sends.length > 0 ? sends : ["generate"];
}
Веб-поиск с Tavily
Для поиска актуальной информации использую Tavily API:
async function webSearchNode(data: { slide: Slide }): Promise<Partial<GraphState>> {
const tavily = new TavilySearchResults({
maxResults: 5,
searchDepth: "advanced"
});
const results = await tavily.search(data.slide.webSearchQuery);
// Экранируем фигурные скобки в коде
const cleanResults = results.replace(/\{/g, '{{').replace(/\}/g, '}}');
return {
webSearchResults: {
[data.slide.id]: cleanResults
}
};
}
Важный момент: LangChain воспринимает фигурные скобки как переменные шаблона, поэтому код нужно экранировать двойными скобками.
Генерация изображений с Kandinsky
Для генерации изображений использую российскую нейросеть Kandinsky через API:
async function generateImageNode(data: {
slide: Slide,
imageStyle: string
}): Promise<Partial<GraphState>> {
const kandinsky = new KandinskyAPI();
const imagePrompt = `${data.slide.imagePrompt}, ${data.imageStyle}`;
const image = await kandinsky.generateImage({
prompt: imagePrompt,
negativePrompt: data.slide.negativeImagePrompt,
width: data.slide.type === 'title' ? 512 : 768,
height: data.slide.type === 'title' ? 512 : 432
});
return {
generatedImages: {
[data.slide.id]: image.url
}
};
}
Генерация контента с сохранением контекста
Самая интересная часть — создание контента с учётом предыдущих слайдов:
async function generateContentNode(state: GraphState): Promise<Partial<GraphState>> {
const messages = [
{ role: 'system', content: 'Ты создаёшь контент для слайдов презентации...' }
];
for (const slide of state.presentation.slides) {
// Добавляем запрос на генерацию контента
messages.push({
role: 'user',
content: `Создай контент для слайда "${slide.title}".
Используй найденную информацию: ${state.webSearchResults[slide.id] || ''}
Основные пункты: ${slide.bullets.join(', ')}`
});
// Получаем ответ от LLM
const response = await model.invoke(messages);
messages.push({ role: 'assistant', content: response });
// Запрашиваем комментарий для спикера
messages.push({
role: 'user',
content: 'Добавь комментарий для спикера: что рассказывать по этому слайду?'
});
const speakerNotes = await model.invoke(messages);
messages.push({ role: 'assistant', content: speakerNotes });
// Сохраняем результат
slide.content = response;
slide.speakerNotes = speakerNotes;
}
return { presentation: state.presentation };
}
Такой подход сохраняет контекст между слайдами, позволяя создавать логически связанные презентации.
Сборка графа воедино
const graph = new StateGraph(GraphState)
.addNode("prepare", prepareNode)
.addNode("webresearch", webSearchNode)
.addNode("generateImages", generateImageNode)
.addNode("generateContent", generateContentNode)
.addNode("final", finalNode)
.setEntryPoint("prepare")
.addConditionalEdges("prepare", routeToWebSearch)
.addEdge("webresearch", "generateImages")
.addEdge("generateImages", "generateContent")
.addEdge("generateContent", "final")
.setFinishPoint("final");
const app = graph.compile();
// Запуск генерации
const result = await app.invoke({
presentation: planFromLangChain
});
Технологический стек
- LangChain — для простых цепочек обработки
- LangGraph — для сложных сценариев с условными переходами
- Zod — для структурированного вывода
- Tavily — для веб-поиска
- Kandinsky API — для генерации изображений
- TypeScript — для типизации и надёжности
Оптимизация и экономика
Параллельное выполнение
LangGraph позволяет запускать ноды параллельно. Например, генерация изображений для разных слайдов происходит одновременно, что значительно ускоряет процесс.
Стоимость генерации
Примерная стоимость создания презентации из 8 слайдов:
- Планирование: ~$0.02
- Веб-поиск: ~$0.01
- Генерация изображений: ~$0.06
- Создание контента: ~$0.02
Итого: ~$0.11 себестоимость при продажной цене $2-5.
Практические советы
- Экранируйте фигурные скобки в коде, иначе LangChain будет пытаться их интерпретировать
- Используйте Zod для гарантированной структуры ответов
- Сохраняйте контекст между запросами для связности контента
- Тестируйте промпты на разных темах и языках
- Добавляйте текущую дату для актуальных тем
Заключение
Создание AI-агентов — это не rocket science. Фронт-энд разработчик может создать полноценный аналог коммерческого сервиса, используя современные инструменты.
Главное — пробовать и экспериментировать. Это направление развивается очень быстро, и скоро каждый разработчик будет работать с AI-агентами.
QR-код для доступа к демо-версии клона Gamma.app
Статья основана на докладе Александра Примакова, фронт-энд разработчика Сбера с 10-летним опытом в IT. Александр также преподаёт в университетах и успешно запустил курс по созданию AI-агентов для магистрантов КФУ.
Полезные ссылки
Хэштеги: #AI #LangChain #LangGraph #TypeScript #Презентации #Kandinsky #MachineLearning