# Создаём клон Gamma.app: от промпта до AI-агента на LangGraph

*Как фронт-энд разработчик из Сбера создал аналог популярного сервиса для генерации презентаций с помощью LangChain и LangGraph*

![Слайд с информацией о спикере](https://via.placeholder.com/800x450/2D3748/FFFFFF?text=Александр+Примаков%0A10+лет+в+IT%0A6+лет+в+Сбере) 
*[0:00:02 → 0:00:50]*

## Кто создаёт AI-сервисы сегодня?

В списке Forbes топ-50 AI-компаний есть множество знакомых названий: OpenAI, Cursor, Bolt New. Особенно интересна компания Speak — они с 2016 года разрабатывают приложение для изучения языков с AI-помощником и попали в топ-50.

![Слайд с компаниями, использующими AI](https://via.placeholder.com/800x450/2D3748/FFFFFF?text=Forbes+Top+50+AI+Companies%0AOpenAI%2C+Cursor%2C+Bolt+New%2C+Speak)
*[0:00:50 → 0:02:10]*

Но главное открытие: **сейчас создавать AI-приложения можно довольно быстро**. Наши студенты делают продукты не хуже тех, что попадают в топ-рейтинги.

## Gamma.app: что внутри магии?

Gamma.app — это сервис для создания презентаций. Вы вводите тему, он создаёт план презентации с заголовками и буллетами для каждого слайда, а затем генерирует полную презентацию с изображениями и оформлением.

![Демонстрация работы сервиса gamma.app](https://via.placeholder.com/800x450/4A5568/FFFFFF?text=Gamma.app+Demo%0AВводим+тему+→+План+→+Презентация)
*[0:02:10 → 0:03:12]*

Выглядит как волшебство, но давайте разберём, как это работает изнутри.

## Декомпозиция задачи

Чтобы создать аналог Gamma.app, нужно решить две основные задачи:

![Слайд с задачами для решения](https://via.placeholder.com/800x450/2B6CB0/FFFFFF?text=Задачи%0A1.+Генерация+плана+презентации%0A2.+Создание+презентации+по+плану)
*[0:03:12 → 0:04:15]*

1. **Генерировать план презентации** — структуру с заголовками и основными пунктами
2. **Создавать презентацию** — контент, изображения и оформление на основе плана

## Часть 1: Генерация плана с LangChain

### Промпт-инжиниринг в реальности

Простой промпт в ChatGPT может создать план презентации, но для продакшена нужен более сложный подход:

![Слайд с промптом для генерации презентации](https://via.placeholder.com/800x450/2B6CB0/FFFFFF?text=Промпт+для+генерации%0A•+Уговариваем+создать+план%0A•+Описываем+типы+слайдов%0A•+Форматирование+в+JSON%0A•+Текущая+дата)
*[0:05:40 → 0:06:23]*

Мой промпт включает:
- Инструкции по созданию плана
- Описание доступных типов слайдов (титульный, контентный и т.д.)
- Требования к формату JSON
- Текущую дату (важно для актуальных тем)

### Шаблоны в LangChain

LangChain предоставляет механизм шаблонов для удобной работы с промптами:

```typescript
const template = `
Создай план презентации на тему {topic}.
Учти следующие требования: {requirements}
Текущая дата: {current_date}
{format_instructions}
`;
```

Переменные в фигурных скобках заменяются на реальные значения при выполнении.

### Структурированный вывод с Zod

Для надёжной работы с JSON-ответами использую Zod для описания схемы:

![Слайд с описанием JSON схемы](https://via.placeholder.com/800x450/2B6CB0/FFFFFF?text=JSON+Schema+с+Zod%0Apresentation%3A+%7B%0A++title%3A+string%0A++slides%3A+array%0A++imageStyle%3A+string%0A%7D)
*[0:06:23 → 0:08:00]*

```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("Запрос для поиска в интернете")
  }))
});
```

### Создание цепочки LangChain

![Слайд с описанием работы парсера ответа от LLM](https://via.placeholder.com/800x450/2B6CB0/FFFFFF?text=LangChain+Pipeline%0ATemplate+→+Model+→+Parser+→+JSON)
*[0:08:00 → 0:09:25]*

```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()
});
```

![Демонстрация распарсенного JSON](https://via.placeholder.com/800x450/2B6CB0/FFFFFF?text=Результат+парсинга%0A%7B%0A++title%3A+%22Фотографирование+котят%22%0A++slides%3A+%5B...%5D%0A%7D)
*[0:09:25 → 0:09:55]*

## Часть 2: Создание презентации с LangGraph

Для генерации самой презентации простого LangChain недостаточно. Нужны:
- Поиск в интернете для актуальной информации
- Генерация изображений для каждого слайда
- Создание контента с учётом контекста
- Условная логика и роутинг

![Демонстрация аналога сервиса gamma.app](https://via.placeholder.com/800x450/4A5568/FFFFFF?text=Клон+Gamma.app%0AГенерация+презентации%0Aпо+готовому+плану)
*[0:09:55 → 0:10:57]*

Здесь на помощь приходит **LangGraph**.

## Архитектура LangGraph: от нод к агентам

![Слайд с объяснением работы LangGraph](https://via.placeholder.com/800x450/2B6CB0/FFFFFF?text=LangGraph+Architecture%0ANodes+→+Edges+→+State+→+Graph)
*[0:10:57 → 0:26:20]*

### Основные концепции

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 = `slide-${index}`;
  });
  
  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 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:

```typescript
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
    }
  };
}
```

### Генерация контента с сохранением контекста

Самая интересная часть — создание контента с учётом предыдущих слайдов:

```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 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.

## Практические советы

1. **Экранируйте фигурные скобки** в коде, иначе LangChain будет пытаться их интерпретировать
2. **Используйте Zod** для гарантированной структуры ответов
3. **Сохраняйте контекст** между запросами для связности контента
4. **Тестируйте промпты** на разных темах и языках
5. **Добавляйте текущую дату** для актуальных тем

## Заключение

Создание AI-агентов — это **не rocket science**. Фронт-энд разработчик может создать полноценный аналог коммерческого сервиса, используя современные инструменты.

Главное — **пробовать и экспериментировать**. Это направление развивается очень быстро, и скоро каждый разработчик будет работать с AI-агентами.

![QR-код для доступа к приложению](https://via.placeholder.com/200x200/2D3748/FFFFFF?text=QR+Code%0AГенератор+презентаций)

*QR-код для доступа к демо-версии клона Gamma.app*

---

*Статья основана на докладе Александра Примакова, фронт-энд разработчика Сбера с 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 