358 lines
16 KiB
Markdown
358 lines
16 KiB
Markdown
# Создаём клон Gamma.app: от промпта до AI-агента на LangGraph
|
||
|
||
*Как фронт-энд разработчик из Сбера создал аналог популярного сервиса для генерации презентаций с помощью LangChain и LangGraph*
|
||
|
||

|
||
*[0:00:02 → 0:00:50]*
|
||
|
||
## Кто создаёт AI-сервисы сегодня?
|
||
|
||
В списке Forbes [топ-50 AI-компаний](https://www.forbes.com/lists/ai50) есть множество знакомых названий: OpenAI, Cursor, Bolt New. Я вот обратил внимание на компанию Speak — они с 2016 года разрабатывают приложение для изучения языков с AI-помощником и попали в топ-50.
|
||
|
||

|
||
*[0:00:50 → 0:02:10]*
|
||
|
||
Но главное открытие: **сейчас создавать AI-приложения можно довольно быстро**. Наши студенты делают продукты не хуже тех, что попадают в топ-рейтинги.
|
||
|
||
## Gamma.app: что внутри магии?
|
||
|
||
[Gamma.app](https://gamma.app/) — это сервис для создания презентаций. Вы вводите тему, он создаёт план презентации с заголовками и буллетами для каждого слайда, а затем генерирует полную презентацию с изображениями и оформлением.
|
||
|
||

|
||
*[0:02:10 → 0:03:12]*
|
||
|
||
Выглядит как волшебство, но давайте разберём, как создаются подобные проекты.
|
||
|
||
## Декомпозиция задачи
|
||
|
||
Чтобы создать аналог Gamma.app, нужно решить две основные задачи:
|
||
|
||

|
||
*[0:03:12 → 0:04:15]*
|
||
|
||
1. **Генерировать план презентации** — структуру с заголовками и основными пунктами
|
||
2. **Создавать презентацию** — контент, изображения и оформление на основе плана
|
||
|
||
## Часть 1: Генерация плана с LangChain
|
||
|
||
### Промпт-инжиниринг в реальности
|
||
|
||
Простой промпт в GigaChat может создать план презентации, но для продакшена нужен более сложный подход:
|
||
|
||

|
||
*[0:05:40 → 0:06:23]*
|
||
|
||
Мой промпт включает:
|
||
- Инструкции по созданию плана
|
||
- Описание доступных типов слайдов (титульный, контентный и т.д.)
|
||
- Требования к формату JSON
|
||
- Текущую дату (важно для актуальных тем)
|
||
|
||
### Шаблоны в LangChain
|
||
|
||
LangChain предоставляет механизм шаблонов для удобной работы с промптами:
|
||
|
||
```typescript
|
||
import { PromptTemplate } from "@langchain/core/prompts";
|
||
|
||
const promptTemplate = PromptTemplate.fromTemplate(
|
||
`Создай план презентации на тему {topic}.
|
||
Учти следующие требования: {requirements}
|
||
Текущая дата: {current_date}
|
||
{format_instructions}`
|
||
);
|
||
|
||
await promptTemplate.invoke({
|
||
topic: "cats"
|
||
requirements: '...',
|
||
current_date: new Date().toISOString(),
|
||
format_instructions: '...'
|
||
});
|
||
|
||
```
|
||
|
||
Переменные в фигурных скобках заменяются на реальные значения при выполнении.
|
||
|
||
### Структурированный вывод с Zod
|
||
|
||
Для надёжной работы с JSON-ответами использую Zod для описания схемы:
|
||
|
||
```typescript
|
||
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("Запрос для поиска в интернете")
|
||
})).describe('Список слайдов презентации')
|
||
});
|
||
```
|
||
|
||
### Создание цепочки LangChain
|
||
|
||
```typescript
|
||
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()
|
||
});
|
||
```
|
||
|
||

|
||
*[0:09:25 → 0:09:55]*
|
||
|
||
## Часть 2: Создание презентации с LangGraph
|
||
|
||
Для генерации самой презентации простого LangChain недостаточно. Нужны:
|
||
- Поиск в интернете для актуальной информации
|
||
- Генерация изображений для каждого слайда
|
||
- Создание контента с учётом контекста
|
||
- Условная логика и роутинг
|
||
|
||

|
||

|
||
|
||
Здесь на помощь приходит **LangGraph**.
|
||
|
||
## Архитектура LangGraph: от нод к агентам
|
||
|
||
### Основные концепции
|
||
|
||
LangGraph работает с **нодами** (nodes) — функциями, которые выполняют конкретные задачи:
|
||
- Каждая нода получает состояние и обновляет его
|
||
- Между нодами есть рёбра (edges) для управления потоком
|
||
- Возможен условный роутинг и циклы
|
||
- Единое состояние передаётся между всеми нодами
|
||
|
||
### Архитектура моего клона
|
||
|
||

|
||
|
||
Я создал компактный и эффективный граф из нескольких нод:
|
||
|
||
1. **Prepare** — подготовка данных и присвоение ID слайдам
|
||
2. **Router** — решение, нужен ли веб-поиск
|
||
3. **WebSearch** — поиск актуальной информации
|
||
4. **Generate Images** — генерация изображений
|
||
5. **Generate Content** — создание контента слайдов
|
||
6. **Final** — финализация результата
|
||
|
||
### Нода подготовки данных
|
||
|
||
```typescript
|
||
async function prepareNode(state: GraphState): Promise<Partial<GraphState>> {
|
||
const presentation = state.presentation;
|
||
|
||
// Присваиваем ID каждому слайду
|
||
presentation.slides.forEach((slide, index) => {
|
||
slide.id = uuidv4();
|
||
});
|
||
|
||
return { presentation };
|
||
}
|
||
```
|
||
|
||
### Условный роутинг
|
||
|
||
Для принятия решений использую **Conditional Edge**:
|
||
|
||
```typescript
|
||
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:
|
||
|
||
```typescript
|
||
async function webSearchNode(data: { slide: Slide }): Promise<Partial<GraphState>> {
|
||
const client = await tavily({ apiKey: process.env['TAVILY_API_KEY'] })
|
||
|
||
const researchResult = await client.search(slide.webResearchQuery, {
|
||
maxResults: 3,
|
||
})
|
||
|
||
return {
|
||
webResearchResult: {
|
||
[slide.id as string]: researchResult.results
|
||
.map(r => r.content?.replace(/\{/g, '{{').replace(/\}/g, '}}'))
|
||
.join('\n\n')
|
||
}
|
||
};
|
||
}
|
||
```
|
||
|
||
**Важный момент**: LangChain воспринимает фигурные скобки как переменные шаблона, поэтому код нужно экранировать двойными скобками.
|
||
|
||
### Генерация изображений с Kandinsky
|
||
|
||
Для генерации изображений использую российскую нейросеть Kandinsky через API:
|
||
|
||
Для работы с api я написал простенькую обёртку для упрощения работы [@brojs/kandinsky](https://gitverse.ru/primakov.a.a/kandinsky-js)
|
||
|
||
```typescript
|
||
async function generateImageNode(data: {
|
||
slide: Slide,
|
||
imageStyle: string
|
||
}): Promise<Partial<GraphState>> {
|
||
const kandinsky = new KandinskyAPI();
|
||
const { imagePrompt, imageNegativePrompt } = slide
|
||
|
||
const imageSize = {
|
||
width: data.slide.type === 'title' ? 512 : 768,
|
||
height: data.slide.type === 'title' ? 512 : 432
|
||
}
|
||
|
||
const images = await generateKandinskyImage({
|
||
imagePrompt,
|
||
imagesStyle,
|
||
...imageSize,
|
||
imageNegativePrompt,
|
||
})
|
||
|
||
return {
|
||
generatedImages: {
|
||
[data.slide.id]: image.url
|
||
}
|
||
};
|
||
}
|
||
```
|
||
|
||
### Генерация контента с сохранением контекста
|
||
|
||
Самая интересная часть — создание контента с учётом предыдущих слайдов:
|
||
|
||
|
||
|
||
```typescript
|
||
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 };
|
||
}
|
||
```
|
||
|
||
Такой подход **сохраняет контекст** между слайдами, позволяя создавать логически связанные презентации.
|
||
|
||
### Сборка графа воедино
|
||
|
||
```typescript
|
||
const builder = new StateGraph(agentState)
|
||
.addNode("prepare", prepareData)
|
||
.addNode('webResearch', webResearch)
|
||
.addNode('toGenerate', () => ({}))
|
||
.addNode('final', state => state)
|
||
.addNode('imageGenerator', imageGenerator)
|
||
.addNode('generateSlideContent', generateSlideContent)
|
||
|
||
builder
|
||
.addEdge(START, "prepare")
|
||
.addConditionalEdges("prepare", afterPrepareRouter)
|
||
.addConditionalEdges("toGenerate", generateImagesAndFirstContent)
|
||
.addConditionalEdges("generateSlideContent", toGenerateNextSlideContent)
|
||
.addEdge('webResearch', 'toGenerate')
|
||
.addEdge('final', END);
|
||
|
||
const app = builder.compile();
|
||
|
||
// Запуск генерации
|
||
const result = await app.invoke({
|
||
presentation: planFromLangChain
|
||
});
|
||
```
|
||
|
||
## Технологический стек
|
||
|
||
- **LangChain** — для простых цепочек обработки
|
||
- **LangGraph** — для сложных сценариев с условными переходами
|
||
- **Zod** — для структурированного вывода
|
||
- **Tavily** — для веб-поиска
|
||
- **Kandinsky API** — для генерации изображений
|
||
- **TypeScript** — для типизации и надёжности
|
||
|
||
## Оптимизация и экономика
|
||
|
||
### Параллельное выполнение
|
||
|
||
LangGraph позволяет запускать ноды параллельно. Например, генерация изображений для разных слайдов происходит одновременно, что значительно ускоряет процесс.
|
||
|
||
### Стоимость генерации
|
||
|
||
Примерная стоимость создания презентации из 8 слайдов около 20-30 Р
|
||
|
||
## Практические советы
|
||
|
||
1. **Экранируйте фигурные скобки** в коде, иначе LangChain будет пытаться их интерпретировать
|
||
2. **Используйте Zod** для гарантированной структуры ответов
|
||
3. **Сохраняйте контекст** между запросами для связности контента
|
||
4. **Тестируйте промпты** на разных темах и языках
|
||
5. **Добавляйте текущую дату** для актуальных тем
|
||
|
||
## Заключение
|
||
|
||
Создание AI-агентов — это **не rocket science**. Фронт-энд разработчик может создать полноценный аналог коммерческого сервиса, используя современные инструменты.
|
||
|
||
Главное — **пробовать и экспериментировать**. Это направление развивается очень быстро, и скоро каждый разработчик будет работать с AI-агентами.
|
||
|
||
[Ссыылка на рабочий прототип](https://platform.bro-js.ru/bro-gamma)
|
||
|
||
---
|
||
|
||
*Статья основана на докладе Александра Примакова, фронт-энд разработчика Сбера с 10-летним опытом в IT. Александр также преподаёт в университетах и успешно запустил курс по созданию AI-агентов для магистрантов КФУ.*
|
||
|
||
### Полезные ссылки
|
||
|
||
- [LangChain Documentation](https://langchain.com/docs)
|
||
- [LangGraph Tutorial](https://langchain.com/langgraph)
|
||
- [Zod Schema Validation](https://zod.dev/)
|
||
- [Tavily Search API](https://tavily.com/)
|
||
- [Kandinsky API](https://fusionbrain.ai/)
|
||
|
||
**Хэштеги:** #AI #LangChain #LangGraph #TypeScript #Презентации #Kandinsky #MachineLearning |