commit d27156d15218c8ac2a667c677aa9ee28a4588dba Author: dimitrievgs Date: Fri Sep 12 00:26:19 2025 +0300 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e2275c6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +venv/ +env/ + +node_modules/ + +__pycache__ \ No newline at end of file diff --git a/Target.md b/Target.md new file mode 100644 index 0000000..33026cd --- /dev/null +++ b/Target.md @@ -0,0 +1,134 @@ +https://www.perplexity.ai/search/sozdai-requiremmnts-txt-rkA2KeVJR1y0gtlrZpwecA + +--- + +доработай чтобы удовлетворить всем требованиям + +`какие есть открытые агенты, доступные на декстопе вин 10, к которым можно подсоединить как внешние (типа gemini по api) так и внутренние (развернутые в ollama) llm, и чтобы можно было задавать кастомные промпты, типо сделать запрос через curl и получить ссылки на изображения внутри json взять их их и вывести все 4 в чате чтобы визульано было видно что получено, сделать запрос по полученной ссылке на изображения, проанализировать изображение и вывести промпт чтобы сгенерировтаь подобное - а промпт например подать запрос по удаленному эндпоинту. или запросить в некой папке got --diff staged и создать сообщение на основе этого для коммита. или например аналзировать формируемый где-то субтитры видеозвонка и на сонове того брать последние 50000 символов и пропускать через промпт выдавая на выходе предложения по дальнейшему развитию игры в днд`. если нужно задай вначале все необходимые вопросы и не приступай к генерации пока не получишь всю нужную информацию. Если нужно а я не даю, продолжай требовать её + +--- + +1. Ollama не будет использоваться, только внешние модели пока что `MODELS = { + "gemini-2.0-flash": { + "name": "gemini-2.0-flash", + "provider": "openai", + "baseUrl": "https://render-service-gsu7.onrender.com/g/v1beta", + "apiKey": "AIzaSyDpueKFWVqknVKlQn6TdasLmJ2lvAUiBik", + "stream": True, + "capabilities": ["vision"], + }, + "gemini-2.5-flash": { + "name": "gemini-2.5-flash", + "provider": "openai", + "baseUrl": "https://render-service-gsu7.onrender.com/g/v1beta", + "apiKey": "AIzaSyDpueKFWVqknVKlQn6TdasLmJ2lvAUiBik", + "stream": True, + "capabilities": ["vision"], + }, + "gemini-2.5-flash-preview-05-20": { + "name": "gemini-2.5-flash-preview-05-20", + "provider": "openai", + "baseUrl": "https://render-service-gsu7.onrender.com/g/v1beta", + "apiKey": "AIzaSyDpueKFWVqknVKlQn6TdasLmJ2lvAUiBik", + "stream": True, + "capabilities": ["vision"], + }, + "mistral-small-latest": { + "name": "mistral-small-latest", + "provider": "mistralai", + "baseUrl": "https://render-service-gsu7.onrender.com/m", + "apiKey": "Q0m29fvxBY0Cfdj4sjHaKqccy1NjonLW", + "stream": True, + "capabilities": ["vision"], + }, +}`, заложи заглушку, которую потом можно развить под ollama (типо пустой словарь соединений с моделями). + +1. Веб интерфейс не слабее chatgpt или perlexity, с историями диалогов, с о средней панелью (левая история, правая чат, а средняя - граф) с графом как на flowith, сделай его через react flow если нет лучше альтернативы. суть в том, что активный чат - это выбранная ветка на графе, и новое сообщение в чате порождает новый лист в графе, а граф перестраивается с использованием библиотеки dagre. активный нод в графе - ыбранный последнее сообщение в правой панели, например, если выбрать промужточное на графе (щелкнуть лкм по ноду), то сообщения в правой панели выведутся от корня графа вплоть до щелкнутого сообщения. и если спроить, то будет добален ноду новый ребенок и дальше новое ветвления с постоянным обновлением посдвечиваемого синими цветом активного листа согласно последнему сообщению(сообщения пользователя и llm разными цветми контура нода). я пробовал streamlit и он показался оцень примитивным, возможно, я не прав. +Соответсвтенно хранятся одержимое нодов графа в чем-то и при отправке сообщения нужно использовать память (формировать её?) но не в лоб как в дипсик, чтобы она глупо конкатенировалась, а суммаризацией, возможно либо с отсечкой по кол-ву токенов последних - предложи сначала варианты, какие есть. т.е. для новой ветки формируемый промпт будет разным (отправляемыая история). +1. Нужно бесплатное решение - предложи варианты. и то, что будет работать на gtx 1060 ti . +2. подробное описание и стиль как минимум. предложи, что ещё? +3. сначала предложи вариант платформы для звонка, (варианты), которые позволяю непрерывно извлекать уже сформированные на данный момент звонка субтитры и передавать в систему (промежуточные субтитры). +4. необходимость запустить код в obsidiab ````dataviewjs +const defaultlocations = ["Фаэрун"]; +const defaultCurrentDate = "1491/09/29"; +const defaultStartDate = "1490/01/01"; +const defaultEndDate = "1493/3/30"; +const defaultPcsParticipationOnly = false; +const defaultIncludeRegionsParticipants = false; +const fullWidth = true; + +const scriptPath = "scripts/timeline-widget"; +await dv.view(scriptPath, { arg1: dv, arg2: defaultlocations, arg3: defaultCurrentDate, arg4: defaultStartDate, arg5: defaultEndDate, arg6: defaultPcsParticipationOnly, arg7: defaultIncludeRegionsParticipants, arg8: fullWidth }); + +```` и считать результат. ВОзможность собирать данные из obsidian. + +Также этот эндпоинт на удаленном ресурсе (остаь пока там произвольную константу для url), который имеет этот ресурс для получения изображения по промпту : `def _process_image_generation(prompt: str): + """ + Вспомогательная функция для обработки логики генерации изображений. + Возвращает кортеж (image_urls, error_message). + """ + image_urls = [] + error_message = None + + # При каждой генерации изображений создаем новый объект BingArt + # с помощью функции load_cookies, чтобы убедиться, что куки актуальны. + # Это позволяет избежать проблем с устаревшими сессиями. + # Важно: close_session() вызывается в finally, чтобы гарантировать закрытие сессии. + local_bing_art = BingArt( + auth_cookie_U=cookies.get('_U'), + auth_cookie_KievRPSSecAuth=cookies.get('KievRPSSecAuth')) + + try: + if prompt: + results = local_bing_art.generate_images(prompt) + if results and 'images' in results: + # Фильтруем и обрезаем до 4 изображений + image_urls = [ + img['url'] for i, img in enumerate(results['images']) + if i % 2 == 0 + ][:4] + else: + error_message = "Не удалось получить изображения." + else: + error_message = "Коммандер, введи промпт!" + except Exception as e: + error_message = f"Произошла ошибка: {str(e)}" + app.logger.error(f"Ошибка при генерации изображения: {e}", + exc_info=True) + finally: + # Важно: закрываем сессию BingArt после использования + local_bing_art.close_session() + + return image_urls, error_message + + +@app.route('/generate-images', methods=['GET', 'POST']) +def generate_images(): + """ + Генерация изображений с использованием bingart. + Поддерживает как POST (form data), так и GET (query parameter) запросы. + """ + if request.method == 'POST': + prompt = request.form.get('prompt') + else: # GET request + prompt = request.args.get('prompt') + + session['prompt'] = prompt # Сохраняем промпт в сессии + + image_urls, error_message = _process_image_generation(prompt) + + if error_message: + session['error_message'] = error_message + return jsonify({'error': error_message}), 500 + + session['image_urls'] = image_urls + return jsonify({'image_urls': image_urls})` + +7. Думаю, они должны предлагаться как команды по слешу, но эти команды должны предлагаться исходя из контекста. но также должны быть автодополнение - когда слеш начинаешь писать, и предлагается автодополнение. Предлоижи сначала варианты. + +храниться они будут в отдельной папке в подпапках, а при начале письма подставляются в виде `<имя папки>/<ввод пользователя><вариант продолжения N>` + и все варианты должны предлагаться в некотором popup-окне или лучше. +8. Русский. + +--- + diff --git a/Target2.md b/Target2.md new file mode 100644 index 0000000..319be52 --- /dev/null +++ b/Target2.md @@ -0,0 +1,217 @@ +Для реализации вашего комплексного агента с учётом всех требований предлагаю следующий план и архитектуру. Ниже — ключевые моменты, архитектурные решения и рекомендации, а в конце — пример кода для базового LLM-клиента с заглушкой под Ollama и интеграцией Gemini через LangChain. + +## 1. Поддержка только внешних моделей (Gemini и Mistral), заглушка под Ollama + +- В `MODELS` оставляем только внешние модели, как вы указали. +- Создаём пустой словарь `LOCAL_MODELS = {}` для будущей интеграции Ollama. +- Для Gemini используем **официальный LangChain-пакет `langchain-google-genai`** (рекомендуется, т.к. поддерживает чат, vision, структурированные ответы). +- Для Mistral — пока простой HTTP-клиент (зависит от API). + +## 2. Веб-интерфейс (React + React Flow + Dagre) + +- Три панели: + - Левая — история диалогов (ветки графа) + - Средняя — визуализация графа сообщений (React Flow + Dagre для layout) + - Правая — чат с выбранной веткой (последовательность сообщений от корня до выбранного узла) +- Активный чат — выбранный лист графа. Новое сообщение добавляет новый лист (ребёнок) в граф. +- При клике на узел графа показываем в чате цепочку сообщений от корня до этого узла. +- Ноды разного цвета: пользователь — один цвет, LLM — другой. +- Для хранения истории и графа — локальное хранилище (IndexedDB) или backend (например, FastAPI + БД). +- Для суммаризации истории и формирования промптов: + - Использовать **суммаризацию через LLM** (например, Gemini) — для сжатия истории до лимита токенов. + - Альтернативно — скользящее окно последних сообщений с ограничением по токенам. + - Можно комбинировать: последние N сообщений + суммаризация более старых. + +## 3. Бесплатное решение и работа на GTX 1060 Ti + +- Для LLM — использовать только внешние API (Gemini, Mistral), чтобы не нагружать локальную видеокарту. +- Для визуализации — React + React Flow — лёгкие и бесплатные. +- Для анализа изображений — использовать возможности Gemini Vision или внешние бесплатные API (например, CLIP + локальный сервер, если потребуется). +- Для git — локальные команды через subprocess (git diff) — бесплатно. +- Для субтитров — интеграция с выбранной платформой звонков (см. ниже). + +## 4. Описание и стиль + +- Подробные комментарии в коде. +- Документация API. +- UI с подсказками и автодополнением. +- Локализация на русский. + +## 5. Платформа для видеозвонков с субтитрами + +Варианты: + +| Платформа | Особенности субтитров | API для извлечения субтитров | +|-------------------|-----------------------------------------------|-----------------------------------------------| +| Zoom | Автоматические субтитры, можно получать через API | Есть Webhooks и API для получения транскриптов | +| Microsoft Teams | Автоматические субтитры, API ограничены | Можно использовать Graph API для записи | +| Google Meet | Субтитры в реальном времени, API нет | Можно использовать сторонние решения для захвата | +| Jitsi | Открытый исходный код, можно интегрировать субтитры | Можно реализовать собственный плагин | + +Рекомендация: **Zoom** — наиболее зрелая платформа с API для субтитров. + +## 6. Интеграция с Obsidian + +- Можно запускать `dataviewjs` скрипты через Obsidian API. +- Для интеграции в агент — сделать отдельный узел, который запускает скрипт и получает результат. +- Для удалённого эндпоинта генерации изображений — сделать HTTP-клиент, который отправляет промпт и получает JSON с 4 ссылками. + +## 7. Команды с автодополнением и контекстом + +- Ввод команд через слеш `/` в поле ввода. +- Автодополнение реализуется через локальный индекс команд (хранить в папках). +- При вводе `/` показывать popup с командами, фильтровать по введённому тексту. +- Команды могут подставляться в виде `/`. +- Можно использовать библиотеку для автокомплита (например, React Autosuggest). + +## 8. Язык — русский + +# Резюме и дальнейшие шаги + +- Для веб-интерфейса рекомендую React + React Flow + Dagre. Можно использовать готовые шаблоны и расширять их. +- Для суммаризации истории — использовать Gemini для генерации кратких резюме, комбинировать с оконным ограничением по токенам. +- Для субтитров — рекомендую рассмотреть Zoom API или Jitsi с кастомной интеграцией. +- Для git diff — использовать Python `subprocess` с `git diff --staged`. +- Для автодополнения команд — React Autosuggest или аналогичные библиотеки. +- Для генерации и анализа изображений — использовать Gemini Vision API и ваш удалённый эндпоинт с bingart. +- Вся логика с графом сообщений и ветвлениями — на фронтенде и backend (FastAPI или Node.js). + +Если хотите, могу помочь с примером React-приложения для визуализации графа и чата или с backend для интеграции git и субтитров. + +Жду ваших комментариев или уточнений для следующего шага! + +--- + +1. ОК +2. хранение через indexed db. +3.давай комбинацию +gemini vision - ок пока, добавь заглушку для расширения. +Пока для Google Meet и Microsoft TEams. +7. React Autosuggest - годится + +--- + +Ваши уточнения приняты, предлагаю архитектуру и ключевые технические решения для вашего агента, а также примерный план реализации фронтенда с React Flow + Dagre и IndexedDB, и backend-структуру. + +## Архитектура и ключевые решения + +| Требование | Решение / Технология | +|-----------------------------------|-----------------------------------------------------------------------------------------------------------| +| **LLM** | Внешние модели Gemini и Mistral через API, заглушка под Ollama (пока пустая). | +| **Веб-интерфейс** | React + React Flow для графа + Dagre для автоматического layout, React Autosuggest для автодополнения. | +| **Хранение истории и графа** | IndexedDB (через библиотеку `idb` или `dexie.js`) для локального хранения диалогов и графа. | +| **Визуализация графа** | React Flow с динамическим пересчётом layout через Dagre (пример из [1][2][4]). | +| **Чат с историей и ветвлениями** | Правая панель — чат, показывающий цепочку сообщений от корня до выбранного узла графа. | +| **Суммаризация истории** | Комбинация: последние N сообщений + вызов Gemini для суммаризации старой истории (с учётом лимита токенов).| +| **Анализ изображений** | Использовать Gemini Vision API, заглушка для расширения. | +| **Платформы для субтитров** | Google Meet и Microsoft Teams (API для получения транскриптов, интеграция через polling или webhook). | +| **Git интеграция** | Локальный запуск `git diff --staged` через Python subprocess, формирование сообщения коммита. | +| **Генерация изображений** | Вызов удалённого эндпоинта с bingart (пример функции из вашего запроса). | +| **Команды с автодополнением** | React Autosuggest, команды хранятся в IndexedDB, показываются в popup при вводе `/`. | +| **Язык интерфейса** | Русский. | + +## Краткое описание React Flow + Dagre для графа + +- Используйте пример из [1], где через dagre вычисляется layout для узлов и рёбер. +- При добавлении нового сообщения создаётся новый узел и ребро к предыдущему. +- При клике на узел — обновляется чат справа, показывая цепочку сообщений от корня до выбранного узла. +- Используйте разные цвета и стили для нодов пользователя и LLM. +- Для автодополнения команд — React Autosuggest, данные команд загружать из IndexedDB. + +## IndexedDB для хранения + +- Используйте `dexie.js` или `idb` для удобной работы с IndexedDB. +- Структура БД: + - Таблица `dialogs` — id, название, дата создания. + - Таблица `nodes` — id, dialog_id, parent_id, content, role (user/llm), timestamp. + - Таблица `commands` — id, folder, command_text, description. + +## Рекомендации по дальнейшей реализации + +- **Frontend**: + - Создайте React-приложение с тремя панелями. + - Используйте React Flow с Dagre для графа (примеры из [1][2][4]). + - Реализуйте IndexedDB для хранения истории и команд. + - Подключите React Autosuggest для автодополнения команд. + +- **Backend**: + - API для вызова LLM (Gemini). + - API для git diff, генерации сообщений коммита. + - API для получения субтитров (интеграция с Google Meet / MS Teams). + - API для генерации и анализа изображений (bingart). + +- **Суммаризация истории**: + - При отправке нового сообщения формируйте промпт из последних N сообщений + краткое резюме старой истории через LLM. + +Если хотите, могу помочь с примером React-компонента для графа на React Flow + Dagre или примером IndexedDB-схемы. Также могу подготовить пример автодополнения команд через React Autosuggest. + +Жду ваших пожеланий по следующему шагу! + +--- + +а langgrpah - здесь нужен? потому что я хочу, чтобы по слешу не просто промпты поставлялись, а целые действия выполнялись. Где-то промпт присовокупляется к запросу (но промпт не подставляется, т.е. пользователь не видит), а где неявносначала выполняется скрипт генерации с рандомизацией, а его вывод + запрос подается на вход llm через api + +--- + +LangGraph — это именно тот инструмент, который вам нужен для реализации описанной логики с «целыми действиями» по слеш-командам, где: + +- не просто подставляются промпты, а +- запускаются сложные workflow с несколькими узлами (например, генерация с рандомизацией, вызов внешних API, анализ изображений, git diff и т.д.), +- с хранением состояния и ветвлением диалога в графе, +- с возможностью динамически добавлять новые узлы и ветви, +- и с контролем, какие промпты и когда подмешиваются к запросам (причём прозрачно для пользователя). + +### Почему LangGraph подходит именно для вашей задачи + +- **Графовая модель**: вы описываете логику как набор узлов (actions, LLM вызовы, скрипты), связанных рёбрами — именно это и есть LangGraph[1][3][5]. +- **Вызов функций и внешних API**: LangGraph позволяет в узлах выполнять произвольный код (скрипты, запросы), а результат подмешивать к промптам или использовать отдельно[3][4]. +- **Состояние и память**: хранит состояние диалога, можно реализовать суммаризацию, ветвления, переключение контекста[1][5]. +- **Мультиагентность и сложные сценарии**: можно объединять разные LLM, инструменты, API, что идеально для ваших слеш-команд с разными действиями[2][6]. +- **Поддержка потоковой генерации и трассировки**: удобно для интерактивных чатов с визуализацией и отладкой[5]. +- **Расширяемость под Ollama и другие LLM**: можно подключать любые модели и переключаться между ними[6]. + +### Как это будет работать в вашем случае + +1. Пользователь вводит в чат команду с `/` — фронтенд (React) через автодополнение предлагает варианты. +2. По выбору команды LangGraph запускает соответствующий узел или цепочку узлов: + - например, сначала запускает скрипт генерации с рандомизацией (не показывая пользователю), + - потом подмешивает результат в промпт, + - вызывает LLM через API, + - получает ответ, + - анализирует изображения, + - при необходимости вызывает git diff, субтитры и т.д. +3. Каждый шаг — отдельный узел графа с состоянием, что позволяет легко отлаживать и расширять логику. +4. Визуализация графа в React Flow отражает структуру диалога и действия агента. +5. Активный узел и ветвь подсвечиваются, чат показывает цепочку сообщений от корня до текущего узла. + +### Итог + +- **LangGraph — это не просто вызов LLM, а полноценный оркестратор сложных агентных workflow с хранением состояния и ветвлением.** +- Он идеально подходит, если нужно, чтобы по слеш-командам выполнялись не только промпты, а целые сценарии с вызовами функций, API, анализом и генерацией. +- Простые случаи (только промпты) можно реализовать и без него, но для вашей задачи с динамическими действиями и состоянием — LangGraph значительно упростит поддержку и масштабирование. + +### Рекомендация + +Используйте LangGraph как основу backend-логики для вашего агента. Реализуйте узлы для: + +- генерации промптов с рандомизацией, +- вызова LLM (Gemini и Mistral), +- анализа изображений (Gemini Vision или кастомные API), +- git diff и обработки субтитров, +- управления состоянием диалога и ветвлениями. + +Фронтенд будет взаимодействовать с LangGraph через API, получать состояние графа и визуализировать его. + +Если хотите, могу помочь с примером простого LangGraph workflow, который реализует слеш-команду с генерацией и вызовом LLM, или с архитектурой API для интеграции с React. + +**Кратко:** Да, LangGraph нужен и рекомендован для вашей задачи, чтобы управлять сложными действиями, а не просто подставлять промпты. + +--- + + `ConversationSummaryMemory, ConversationSummaryBufferMemory - позволяет задать окно (в токенах), в рамках которого мы сохраняем диалоги в неизменной форме, а при превышении - суммаризируем. + +ConversationBufferWindowMemory - сохраняет только последние k диалогов. + +ConversationKGMemory - использует граф знаний для сохранения памяти. + +ConversationEntityMemory - сохраняет в памяти знания об определенном объекте.` здесь не использовать что-то из этого? как вообще с графом быть? \ No newline at end of file diff --git a/TechSpecification.md b/TechSpecification.md new file mode 100644 index 0000000..8c85e7f --- /dev/null +++ b/TechSpecification.md @@ -0,0 +1,118 @@ +## ТЗ: Интерактивный Агент с LLM и Визуализацией Графа Диалогов + +**Цель:** Создать десктопное приложение-агента, позволяющее взаимодействовать с внешними LLM (Gemini, Mistral) через API, выполнять сложные действия по слеш-командам и визуализировать историю диалогов в виде графа. + +**1. LLM:** + +* Использовать только внешние модели: Gemini (через LangChain), Mistral (через HTTP API). +* Заглушка для Ollama (пустой словарь `LOCAL_MODELS`). + +**2. Веб-интерфейс (React):** + +* Три панели: + * История диалогов (различные графы запросов), по умолчанию слева. + * Визуализация активного графа сообщений (React Flow + Dagre), по умолчанию по центру. + * Чат с выбранной веткой активного графа, по умолчанию справа. +* Активный чат = выбранный лист графа. +* Клик по ноду = показ сообщений от корня до нода. +* Разные цвета для нодов пользователя и LLM. + +**3. Хранение:** + +* Бэкенд хранит историю графов запросов. +* IndexedDB (dexie.js или idb) для временного хранения активного графа на фронтенде (опционально). + +**4. Суммаризация истории:** + +* Комбинация: последние N сообщений + суммаризация старой истории через LLM (Gemini). + +**5. Анализ изображений:** + +* Gemini Vision API, заглушка для расширения. + +**6. Платформы субтитров:** + +* Google Meet и Microsoft Teams: возможность получения текущих, не финальных субтитров (API или polling/webhook). + +**7. Генерация изображений:** + +* Вызов удаленного эндпоинта bingart. + +Эндпоинт генерации изображений + +* **URL:** `/generate-images` +* **Метод:** GET или POST. +* **Параметры:** + * `prompt` (строка): Текст запроса для генерации изображения. Передается либо как query parameter в GET-запросе, либо как form data в POST-запросе. +* **Результат (JSON):** + * `image_urls` (массив строк): Список URL сгенерированных изображений (до 4 штук). + * `error` (строка, опционально): Сообщение об ошибке, если генерация не удалась. + +Пример успешного ответа: + +```json +{ + "image_urls": [ + "url1", + "url2", + "url3", + "url4" + ] +} +``` + +**Пример ответа с ошибкой:** + +```json +{ + "error": "Не удалось получить изображения." +} +``` + +**8. Команды:** + +* Слеш-команды (`/`) с автодополнением (React Autosuggest). +* Команды хранятся в базе данных на бэкенде. +* Запуск workflow через LangGraph. + +**9. LangGraph:** + +* Оркестрация сложных workflow по слеш-командам. +* Узлы: генерация, LLM, анализ изображений, субтитры, управление состоянием. + +**10. Реализация:** + +* **Frontend:** React, React Flow, Dagre, React Autosuggest. +* **Backend:** API для LLM, субтитров, bingart, LangGraph, база данных (хранение истории графов). + +**11. Язык:** + +* Русский. + +**Типичный workflow:** + +1. Пользователь вводит `/команда`. +2. Frontend отправляет запрос на бэкенд. +3. Бэкенд запускает LangGraph workflow, соответствующий команде: + * Подставляет релевантный команде промпт (если необходимо). + * Вызывает LLM (Gemini или Mistral). + * Анализирует изображения (если необходимо). + * Запрашивает текущие субтитры (если необходимо в рамках команды, это одна из команд). + * Запрашивает генерацию изображений на удалённом эндпоинте и ждёт ответа. + * Обрабатывает результаты и формирует ответ. +4. Бэкенд сохраняет новый граф запроса в базе данных (или обновляет существующий). +5. Бэкенд отправляет данные активного графа на фронтенд. +6. Frontend отображает граф и чат. + +**Приоритеты:** + +1. Базовая функциональность чата с Gemini/Mistral и визуализацией графа. +2. Реализация слеш-команд через LangGraph. +3. Интеграция с Google Meet/MS Teams (получение текущих субтитров). + +**Использовать:** + +* React Flow (с примерами Dagre). +* LangGraph. +* dexie.js или idb (опционально, для временного хранения на фронтенде). +* React Autosuggest. diff --git a/app/api.py b/app/api.py new file mode 100644 index 0000000..763b096 --- /dev/null +++ b/app/api.py @@ -0,0 +1,73 @@ +""" +Этот файл содержит API endpoints, созданные с использованием Flask. +Он обеспечивает взаимодействие с агентом через HTTP запросы, +включая обработку сообщений, управление графами и получение истории. +""" + +from flask import Flask, request, jsonify +from flask_cors import CORS +from workflows import run_agent, graph_history_manager + +app2 = Flask(__name__) +CORS( + app2 +) # Разрешаем CORS для всех доменов (в production нужно настроить более строго) + + +@app2.route('/api/chat', methods=['POST']) +def chat(): + """API endpoint для обработки сообщений и возврата ответа.""" + data = request.get_json() + message = data.get("message") + graph_id = data.get("graph_id") + parent_node_id = data.get( + "parent_node_id") # Получаем parent_node_id из запроса + + if not message: + return jsonify({"error": "Сообщение не может быть пустым."}, 400) + + result = run_agent(message, graph_id, + parent_node_id) # Передаем parent_node_id в run_agent + return jsonify(result) + + +@app2.route('/api/graphs', methods=['GET']) +def get_graphs(): + """API endpoint для получения списка графов.""" + graphs = graph_history_manager.get_all_graphs_summary() + print("get_graphs response:", graphs) # Добавлено логирование + return jsonify(graphs) + + +@app2.route('/api/graphs/', methods=['GET']) +def get_graph_by_id(graph_id): + """API endpoint для получения данных конкретного графа по ID.""" + graph_data = graph_history_manager.get_graph(graph_id) + if graph_data: + return jsonify(graph_data) + else: + return jsonify({"error": f"Граф с ID {graph_id} не найден."}, 404) + + +@app2.route('/api/graphs/', methods=['DELETE']) +def delete_graph(graph_id): + """API endpoint для удаления графа.""" + if not graph_id: + return jsonify({"error": "Не указан ID графа для удаления."}, 400) + + if graph_history_manager.delete_graph(graph_id): + return jsonify({"message": f"Граф {graph_id} успешно удален."}) + else: + return jsonify({"error": f"Не удалось удалить граф {graph_id}."}, 500) + + +@app2.route('/api/messages//', methods=['GET']) +def get_messages_from_root_to_node(graph_id, node_id): + """API endpoint для получения сообщений от корня до выбранной ноды.""" + graph_data = graph_history_manager.get_graph(graph_id) + if not graph_data: + return jsonify({"error": "Граф не найден."}, 404) + + messages = graph_history_manager.get_messages_from_root_to_node( + graph_data, node_id) + return jsonify(messages) diff --git a/app/graph_history_manager.py b/app/graph_history_manager.py new file mode 100644 index 0000000..d0ff3da --- /dev/null +++ b/app/graph_history_manager.py @@ -0,0 +1,190 @@ +""" +Этот модуль управляет сохранением и извлечением истории графов +запросов, используя SQLite базу данных для хранения данных. +""" + +from typing import Dict, Any, List, Optional + +import json + +import sqlite3 + + +class GraphHistoryManager: + """ + Менеджер для хранения истории графов запросов в SQLite. + """ + + def __init__(self, db_path="graph_history.db"): + self.db_path = db_path + + def _get_connection(self): + """Получает соединение с базой данных.""" + return sqlite3.connect(self.db_path) + + def _create_table(self, conn): + """Создает таблицу для хранения графов, если она не существует.""" + cursor = conn.cursor() + cursor.execute(""" + CREATE TABLE IF NOT EXISTS graphs ( + id TEXT PRIMARY KEY, + messages TEXT, + graph_nodes TEXT, + graph_edges TEXT, + current_node_id TEXT + ) + """) + conn.commit() + + def _get_max_graph_id(self, conn): + """Получает максимальный ID графа из базы данных.""" + cursor = conn.cursor() + cursor.execute( + "SELECT MAX(CAST(SUBSTR(id, 7) AS INTEGER)) FROM graphs WHERE id LIKE 'graph_%'" + ) + result = cursor.fetchone()[0] + return result if result is not None else 0 + + def save_graph(self, graph_data: Any) -> str: + """Сохраняет или обновляет данные графа в базе данных и возвращает ID.""" + conn = self._get_connection() + cursor = conn.cursor() + try: + graph_id = graph_data.get("id") + if not graph_id: + max_graph_id = self._get_max_graph_id(conn) + next_graph_id = max_graph_id + 1 + graph_id = f"graph_{next_graph_id}" + graph_data["id"] = graph_id + + # Преобразуем структуры данных в JSON-строки для хранения в SQLite + messages_json = json.dumps(graph_data.get("messages", [])) + graph_nodes_json = json.dumps(graph_data.get("graph_nodes", [])) + graph_edges_json = json.dumps(graph_data.get("graph_edges", [])) + current_node_id = graph_data.get("current_node_id", "") + + # Проверяем, существует ли уже запись с таким ID + cursor.execute("SELECT id FROM graphs WHERE id = ?", (graph_id, )) + existing_record = cursor.fetchone() + + if existing_record: + # Обновляем существующую запись + cursor.execute( + """ + UPDATE graphs SET + messages = ?, + graph_nodes = ?, + graph_edges = ?, + current_node_id = ? + WHERE id = ? + """, (messages_json, graph_nodes_json, graph_edges_json, + current_node_id, graph_id)) + else: + # Вставляем новую запись + cursor.execute( + """ + INSERT INTO graphs (id, messages, graph_nodes, graph_edges, current_node_id) + VALUES (?, ?, ?, ?, ?) + """, (graph_id, messages_json, graph_nodes_json, + graph_edges_json, current_node_id)) + + conn.commit() + print(f"Граф сохранен/обновлен: {graph_id}") + return graph_id + finally: + conn.close() + + def get_graph(self, graph_id: str) -> Optional[Any]: + """Получает данные графа по ID из базы данных.""" + conn = self._get_connection() + cursor = conn.cursor() + try: + cursor.execute( + "SELECT messages, graph_nodes, graph_edges, current_node_id FROM graphs WHERE id = ?", + (graph_id, )) + result = cursor.fetchone() + + if result: + messages_json, graph_nodes_json, graph_edges_json, current_node_id = result + # Преобразуем JSON-строки обратно в структуры данных Python + messages = json.loads(messages_json) + graph_nodes = json.loads(graph_nodes_json) + graph_edges = json.loads(graph_edges_json) + + return { + "id": graph_id, + "messages": messages, + "graph_nodes": graph_nodes, + "graph_edges": graph_edges, + "current_node_id": current_node_id + } + else: + return None + finally: + conn.close() + + def get_all_graphs_summary(self) -> List[Dict[str, str]]: + """Возвращает краткий список всех сохраненных графов.""" + conn = self._get_connection() + cursor = conn.cursor() + try: + cursor.execute("SELECT id, messages FROM graphs") + graphs_data = cursor.fetchall() + return [{ + "id": + gid, + "first_message": + json.loads(messages)[0].get("content", "No content") + if messages else "No content" + } for gid, messages in graphs_data] + finally: + conn.close() + + def get_messages_from_root_to_node( + self, graph_data: Dict[str, Any], + target_node_id: str) -> List[Dict[str, str]]: + """Получает список сообщений от корневого узла до указанного узла.""" + nodes = graph_data.get("graph_nodes", []) + edges = graph_data.get("graph_edges", []) + messages = graph_data.get("messages", []) + + # Создаем словарь для быстрого поиска родительских узлов + parent_map = {edge['target']: edge['source'] for edge in edges} + + # Функция для рекурсивного подъема по дереву до корневого узла + def get_path_to_root(node_id: str) -> List[str]: + path = [node_id] + while node_id in parent_map: + node_id = parent_map[node_id] + path.append(node_id) + return path[::-1] # Инвертируем, чтобы получить путь от корня + + # Получаем путь от корня до целевого узла + path_to_root = get_path_to_root(target_node_id) + + # Собираем сообщения, соответствующие узлам в пути + messages_for_path = [] + node_ids_in_path = set(path_to_root) + + for message in messages: + if "node_id" in message and message["node_id"] in node_ids_in_path: + messages_for_path.append({ + "role": message["role"], + "content": message["content"] + }) + + return messages_for_path + + def delete_graph(self, graph_id: str) -> bool: + """Удаляет граф из базы данных.""" + conn = self._get_connection() + cursor = conn.cursor() + try: + cursor.execute("DELETE FROM graphs WHERE id = ?", (graph_id, )) + conn.commit() + return True + except Exception as e: + print(f"Ошибка при удалении графа: {e}") + return False + finally: + conn.close() diff --git a/app/llm_client.py b/app/llm_client.py new file mode 100644 index 0000000..30cdc52 --- /dev/null +++ b/app/llm_client.py @@ -0,0 +1,196 @@ +""" +Этот модуль предоставляет инструменты для взаимодействия с различными LLM, +включая OpenAI (Gemini) и MistralAI, обеспечивая унифицированный интерфейс. +""" + +import requests +from typing import Dict, Any, List +from langchain_core.messages import HumanMessage, SystemMessage + +# --- Конфигурация LLM --- +# Пустой словарь для локальных моделей (Ollama) +LOCAL_MODELS: Dict[str, Any] = {} + +# Конфигурация внешних моделей +MODELS: Dict[str, Dict[str, Any]] = { + "gemini-2.0-flash": { + "name": "gemini-2.0-flash", + "provider": "openai", # Изменено на "openai" + "model_name": "gemini-2.0-flash", # Добавлено имя модели для LangChain + "apiBase": "https://render-service-gsu7.onrender.com/g/v1beta", + "apiKey": "AIzaSyDpueKFWVqknVKlQn6TdasLmJ2lvAUiBik", + "stream": True, + "capabilities": ["vision"], + }, + "gemini-2.5-flash": { + "name": "gemini-2.5-flash", + "provider": "openai", # Изменено на "openai" + "model_name": "gemini-2.5-flash", # Добавлено имя модели для LangChain + "apiBase": "https://render-service-gsu7.onrender.com/g/v1beta", + "apiKey": "AIzaSyDpueKFWVqknVKlQn6TdasLmJ2lvAUiBik", + "stream": True, + "capabilities": ["vision"], + }, + "gemini-2.5-flash-preview-05-20": { + "name": "gemini-2.5-flash-preview-05-20", + "provider": "openai", # Изменено на "openai" + "model_name": + "gemini-2.5-flash-preview-05-20", # Добавлено имя модели для LangChain + "apiBase": "https://render-service-gsu7.onrender.com/g/v1beta", + "apiKey": "AIzaSyDpueKFWVqknVKlQn6TdasLmJ2lvAUiBik", + "stream": True, + "capabilities": ["vision"], + }, + "mistral-small-latest": { + "name": "mistral-small-latest", + "provider": "mistralai", + "baseUrl": "https://render-service-gsu7.onrender.com/m", + "apiKey": "Q0m29fvxBY0Cfdj4sjHaKqccy1NjonLW", + "stream": True, + "capabilities": ["vision"], + }, +} + + +class CustomLLM: + """ + Класс-обертка для взаимодействия с различными LLM. + """ + + def __init__(self, config: Dict[str, Any]): + self.name = config["name"] + self.provider = config["provider"] + self.config = config + + if self.provider == "openai": + # Инициализация Gemini через OpenAI API + from openai import OpenAI + self.client = OpenAI(api_key=config["apiKey"], + base_url=config["apiBase"]) + self.model_name = config["model_name"] + self.stream = config["stream"] + + elif self.provider == "mistralai": + # Для Mistral используем прямые HTTP-запросы + self.base_url = config["baseUrl"] + self.api_key = config["apiKey"] + self.stream = config.get("stream", False) + self.capabilities = config.get("capabilities", []) + else: + raise ValueError(f"Неизвестный провайдер LLM: {self.provider}") + + def invoke(self, messages: List[Any]) -> str: + """ + Отправляет запрос к LLM и возвращает ответ. + Messages могут быть строкой или списком объектов Langchain Message. + """ + if self.provider == "openai": + # OpenAI ожидает список сообщений в определенном формате + openai_messages = [] + if isinstance(messages, str): + openai_messages.append({"role": "user", "content": messages}) + elif isinstance(messages, list): + for msg in messages: + if isinstance(msg, HumanMessage): + openai_messages.append({ + "role": "user", + "content": msg.content + }) + elif isinstance(msg, SystemMessage): + openai_messages.append({ + "role": "system", + "content": msg.content + }) + else: # Предполагаем, что это другие типы сообщений Langchain или словари + openai_messages.append({ + "role": + msg.type if hasattr(msg, 'type') else 'user', + "content": + msg.content + }) + + try: + response = self.client.chat.completions.create( + model=self.model_name, + messages=openai_messages, + stream=self.stream, + ) + + if self.stream: + # Handle streaming responses + collected_chunks = [] + collected_messages = [] + for chunk in response: + collected_chunks.append(chunk) + chunk_message = chunk.choices[0].delta.content + if chunk_message is not None: + collected_messages.append(chunk_message) + full_reply_content = ''.join(collected_messages) + return full_reply_content + else: + return response.choices[0].message.content + + except Exception as e: + print(f"Ошибка при вызове OpenAI API: {e}") + raise + elif self.provider == "mistralai": + # Отступы исправлены + headers = { + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + } + # Формируем сообщения для Mistral API + mistral_messages = [] + if isinstance(messages, str): + mistral_messages.append({"role": "user", "content": messages}) + elif isinstance(messages, list): + for msg in messages: + if isinstance(msg, HumanMessage): + mistral_messages.append({ + "role": "user", + "content": msg.content + }) + elif isinstance(msg, SystemMessage): + mistral_messages.append({ + "role": "system", + "content": msg.content + }) + else: # Предполагаем, что это другие типы сообщений Langchain или словари + mistral_messages.append({ + "role": + msg.type if hasattr(msg, 'type') else 'user', + "content": + msg.content + }) + + payload = { + "model": self.name, + "messages": mistral_messages, + "stream": self.stream, + } + try: + response = requests.post(self.base_url, + json=payload, + headers=headers) + response.raise_for_status( + ) # Вызывает исключение для HTTP ошибок + data = response.json() + # Извлекаем контент ответа + return data["choices"][0]["message"]["content"] + except requests.exceptions.RequestException as e: + print(f"Ошибка при вызове Mistral API: {e}") + raise + except KeyError: + print(f"Неверный формат ответа от Mistral API: {data}") + raise ValueError("Неверный формат ответа от Mistral API") + return "" + + +def get_llm(name: str) -> CustomLLM: + """ + Возвращает экземпляр CustomLLM для заданной модели. + """ + config = MODELS.get(name) + if not config: + raise ValueError(f"Модель '{name}' не сконфигурирована.") + return CustomLLM(config) diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..71cb887 --- /dev/null +++ b/app/models.py @@ -0,0 +1,39 @@ +""" +Определения моделей данных (pydantic) для представления состояния агента LangGraph. +Включают схему для отслеживания хода выполнения диалога и визуализации графа. +""" + +from typing import Dict, Any, List, Optional +from langchain_core.pydantic_v1 import BaseModel, Field + + +# Определяем состояние нашего агента (LangGraph) +class AgentState(BaseModel): + """Состояние графа агента.""" + input: str = Field(description="Исходный запрос пользователя.") + command: Optional[str] = Field(None, + description="Распознанная слеш-команда.") + command_args: List[str] = Field([], description="Аргументы команды.") + chat_history: List[Dict[str, str]] = Field( + [], + description= + "Полная история сообщений в текущей ветке диалога. Формат: [{'role': 'user/assistant', 'content': 'message'}]" + ) + llm_response: Optional[str] = Field(None, description="Ответ от LLM.") + image_urls: List[str] = Field( + [], description="URL сгенерированных изображений.") + analysis_result: Optional[str] = Field( + None, description="Результат анализа изображения.") + subtitles: Optional[str] = Field(None, description="Полученные субтитры.") + summarized_history_text: Optional[str] = Field( + None, description="Суммированная история.") + error: Optional[str] = Field(None, description="Сообщение об ошибке.") + # Для визуализации графа (имитация узлов и ребер) + graph_nodes: List[Dict[str, Any]] = Field( + [], description="Узлы для визуализации графа.") + graph_edges: List[Dict[str, Any]] = Field( + [], description="Ребра для визуализации графа.") + current_node_id: str = Field( + "start", description="ID текущего узла графа для фронтенда.") + parent_node_id: Optional[str] = Field(None, + description="ID родительского узла.") diff --git a/app/nodes.py b/app/nodes.py new file mode 100644 index 0000000..5948292 --- /dev/null +++ b/app/nodes.py @@ -0,0 +1,469 @@ +""" +Этот файл содержит узлы для графа состояний агента. +Каждый узел выполняет определенную функцию, такую как +разбор команд, вызов LLM или выполнение сервисов. +""" + +from langchain_core.messages import HumanMessage, SystemMessage + +from llm_client import get_llm +from services import ImageGenerationService, SubtitleService, ImageAnalysisService, SummarizationService +from models import AgentState + +from typing import Dict, Any, Optional + + +class CommandManager: + """ + Менеджер для хранения и получения слеш-команд. + В реальном приложении команды будут храниться в БД. + """ + + def __init__(self): + self._commands = { + "help": { + "description": "Показывает список доступных команд.", + "workflow": "help_workflow" + }, + "imagine": { + "description": + "Генерирует изображения по промпту. Использование: /imagine [prompt]", + "workflow": "image_generation_workflow" + }, + "analyze": { + "description": + "Анализирует изображение по URL и промпту. Использование: /analyze [url] [prompt]", + "workflow": "image_analysis_workflow" + }, + "subtitles_meet": { + "description": "Получает текущие субтитры из Google Meet.", + "workflow": "get_meet_subtitles_workflow" + }, + "subtitles_teams": { + "description": "Получает текущие субтитры из MS Teams.", + "workflow": "get_teams_subtitles_workflow" + }, + "summarize": { + "description": "Суммирует текущую историю диалога.", + "workflow": "summarize_history_workflow" + }, + "chat": { + "description": "Ведет обычный диалог с LLM.", + "workflow": "chat_workflow" + }, + } + + def get_commands(self) -> Dict[str, Any]: + """Возвращает все доступные команды.""" + return self._commands + + def get_command_workflow(self, command_name: str) -> Optional[str]: + """Возвращает имя workflow для заданной команды.""" + return self._commands.get(command_name, {}).get("workflow") + + +# Инициализация сервисов (пример, убедитесь, что они доступны в этом файле или импортированы) +command_manager = CommandManager() + + +def parse_command_node(state: AgentState) -> AgentState: + """Парсит вход пользователя на предмет слеш-команды.""" + print("Выполняется узел: parse_command_node") + user_input = state.input.strip() + + # Добавляем узел пользователя в граф для фронтенда + user_node_id = f"user_{len(state.graph_nodes) + 1}" + state.graph_nodes.append({ + "id": user_node_id, + "type": "user", + "data": { + "label": + user_input[:30] + "..." if len(user_input) > 30 else user_input + } + }) + if state.parent_node_id: + state.graph_edges.append({ + "id": f"e{state.parent_node_id}-{user_node_id}", + "source": state.parent_node_id, + "target": user_node_id + }) + state.parent_node_id = user_node_id + state.current_node_id = user_node_id + + state.chat_history.append({ + "role": "user", + "content": user_input, + "node_id": user_node_id # Сохраняем ID узла + }) + + if user_input.startswith('/'): + parts = user_input[1:].split(' ', 1) + command_name = parts[0].lower() + command_args = parts[1].split(' ') if len(parts) > 1 else [] + if command_name in command_manager.get_commands(): + state.command = command_name + state.command_args = command_args + print( + f"Команда распознана: /{state.command} с аргументами: {state.command_args}" + ) + else: + state.error = f"Неизвестная команда: /{command_name}. Введите /help для списка команд." + print(state.error) + state.command = "error" # Специальный флаг для обработки ошибок команд + else: + state.command = "chat" # Обычный чат + return state + + +def execute_command_node(state: AgentState) -> AgentState: + """Выполняет соответствующий workflow для команды.""" + print( + f"Выполняется узел: execute_command_node для команды: {state.command}") + # Этот узел будет выступать в роли маршрутизатора + # Фактическая логика команды будет в отдельных узлах, вызываемых по условию + return state # Просто передаем состояние дальше, чтобы маршрутизатор смог выбрать следующий узел + + +# Предполагаем, что используем Gemini для основного LLM и суммаризации/анализа +# В реальной системе можно выбрать динамически +DEFAULT_LLM_NAME = "gemini-2.5-flash" +main_llm = get_llm(DEFAULT_LLM_NAME) + + +def call_llm_node(state: AgentState) -> AgentState: + """Вызывает LLM для обработки обычного диалога или генерации ответа.""" + print("Выполняется узел: call_llm_node (для обычного чата)") + try: + # Для обычного чата LLM использует всю историю + messages_for_llm = [] + for msg in state.chat_history: + if msg["role"] == "user": + messages_for_llm.append(HumanMessage(content=msg["content"])) + elif msg["role"] == "assistant": + messages_for_llm.append(SystemMessage(content=msg["content"])) + + response = main_llm.invoke(messages_for_llm) + + # Добавляем узел LLM в граф для фронтенда + llm_node_id = f"llm_{len(state.graph_nodes) + 1}" + state.graph_nodes.append({ + "id": llm_node_id, + "type": "llm", + "data": { + "label": + response[:30] + "..." if len(response) > 30 else response + } + }) + state.graph_edges.append({ + "id": f"e{state.parent_node_id}-{llm_node_id}", + "source": state.parent_node_id, + "target": llm_node_id + }) + state.parent_node_id = llm_node_id + state.current_node_id = llm_node_id + + state.chat_history.append({ + "role": "assistant", + "content": response, + "node_id": llm_node_id # Сохраняем ID узла + }) + state.llm_response = response + + except Exception as e: + state.error = f"Ошибка при вызове LLM: {e}" + state.llm_response = f"Произошла ошибка: {e}" + print(state.error) + return state + + +image_gen_service = ImageGenerationService( + base_url="https://render-service-gsu7.onrender.com/g2" +) # TODO: Замените на реальный URL + + +def generate_images_node(state: AgentState) -> AgentState: + """Генерирует изображения по промпту.""" + print("Выполняется узел: generate_images_node") + prompt = " ".join(state.command_args) + if not prompt: + state.error = "Для команды /imagine требуется промпт. Пример: /imagine кошка на луне" + state.llm_response = state.error + return state + + try: + urls = image_gen_service.generate_images(prompt) + state.image_urls = urls + + # Формируем текст ответа с использованием Markdown для отображения изображений + image_markdown = "\n".join([ + f'image{i}' + for i, url in enumerate(urls) + ]) + response_text = f"Сгенерировано {len(urls)} изображений:\n{image_markdown}" + state.llm_response = response_text + + # Добавляем узел генерации изображений в граф + img_gen_node_id = f"img_gen_{len(state.graph_nodes) + 1}" + state.graph_nodes.append({ + "id": img_gen_node_id, + "type": "tool", + "data": { + "label": "Генерация изображений", + "details": prompt + } + }) + state.graph_edges.append({ + "id": f"e{state.parent_node_id}-{img_gen_node_id}", + "source": state.parent_node_id, + "target": img_gen_node_id + }) + state.parent_node_id = img_gen_node_id + state.current_node_id = img_gen_node_id + + state.llm_response = "Сгенерированы изображения." + state.chat_history.append({ + "role": "assistant", + "content": response_text, + "node_id": img_gen_node_id # Привязываем к ID узла + }) + + except Exception as e: + state.error = f"Ошибка генерации изображений: {e}" + state.llm_response = f"Произошла ошибка при генерации изображений: {e}" + print(state.error) + return state + + +image_analysis_service = ImageAnalysisService( + llm=get_llm("gemini-2.5-flash")) # Gemini для анализа изображений + + +def analyze_image_node(state: AgentState) -> AgentState: + """Анализирует изображение по URL или данным.""" + print("Выполняется узел: analyze_image_node") + if len(state.command_args) < 2: + state.error = "Для команды /analyze требуется URL изображения и промпт. Пример: /analyze [url] 'что на изображении?'" + state.llm_response = state.error + return state + + image_url = state.command_args[0] + prompt = " ".join(state.command_args[1:]) + + try: + analysis_result = image_analysis_service.analyze_image( + image_url, prompt) + state.analysis_result = analysis_result + state.llm_response = f"Результат анализа изображения '{image_url}': {analysis_result}" + state.chat_history.append({ + "role": "assistant", + "content": state.llm_response + }) + + # Добавляем узел анализа изображения в граф + img_analysis_node_id = f"img_analysis_{len(state.graph_nodes) + 1}" + state.graph_nodes.append({ + "id": img_analysis_node_id, + "type": "tool", + "data": { + "label": "Анализ изображения", + "details": f"URL: {image_url}, Промпт: {prompt}" + } + }) + state.graph_edges.append({ + "id": f"e{state.parent_node_id}-{img_analysis_node_id}", + "source": state.parent_node_id, + "target": img_analysis_node_id + }) + state.parent_node_id = img_analysis_node_id + state.current_node_id = img_analysis_node_id + + except Exception as e: + state.error = f"Ошибка анализа изображения: {e}" + state.llm_response = f"Произошла ошибка при анализе изображения: {e}" + print(state.error) + return state + + +subtitle_service = SubtitleService() + + +def get_meet_subtitles_node(state: AgentState) -> AgentState: + """Получает субтитры из Google Meet.""" + print("Выполняется узел: get_meet_subtitles_node") + try: + subtitles = subtitle_service.get_google_meet_subtitles() + state.subtitles = subtitles + state.llm_response = f"Текущие субтитры из Google Meet: {subtitles}" + state.chat_history.append({ + "role": "assistant", + "content": state.llm_response + }) + + # Добавляем узел субтитров Meet в граф + meet_subtitles_node_id = f"meet_subtitles_{len(state.graph_nodes) + 1}" + state.graph_nodes.append({ + "id": meet_subtitles_node_id, + "type": "tool", + "data": { + "label": "Субтитры Google Meet", + "details": subtitles[:30] + "..." if subtitles else "" + } + }) + state.graph_edges.append({ + "id": f"e{state.parent_node_id}-{meet_subtitles_node_id}", + "source": state.parent_node_id, + "target": meet_subtitles_node_id + }) + state.parent_node_id = meet_subtitles_node_id + state.current_node_id = meet_subtitles_node_id + + except Exception as e: + state.error = f"Ошибка получения субтитров Google Meet: {e}" + state.llm_response = f"Произошла ошибка при получении субтитров Google Meet: {e}" + print(state.error) + return state + + +def get_teams_subtitles_node(state: AgentState) -> AgentState: + """Получает субтитры из MS Teams.""" + print("Выполняется узел: get_teams_subtitles_node") + try: + subtitles = subtitle_service.get_ms_teams_subtitles() + state.subtitles = subtitles + state.llm_response = f"Текущие субтитры из MS Teams: {subtitles}" + state.chat_history.append({ + "role": "assistant", + "content": state.llm_response + }) + + # Добавляем узел субтитров Teams в граф + teams_subtitles_node_id = f"teams_subtitles_{len(state.graph_nodes) + 1}" + state.graph_nodes.append({ + "id": teams_subtitles_node_id, + "type": "tool", + "data": { + "label": "Субтитры MS Teams", + "details": subtitles[:30] + "..." if subtitles else "" + } + }) + state.graph_edges.append({ + "id": f"e{state.parent_node_id}-{teams_subtitles_node_id}", + "source": state.parent_node_id, + "target": teams_subtitles_node_id + }) + state.parent_node_id = teams_subtitles_node_id + state.current_node_id = teams_subtitles_node_id + + except Exception as e: + state.error = f"Ошибка получения субтитров MS Teams: {e}" + state.llm_response = f"Произошла ошибка при получении субтитров MS Teams: {e}" + print(state.error) + return state + + +summarization_service = SummarizationService( + llm=get_llm("gemini-2.5-flash")) # Gemini для суммаризации + + +def summarize_history_node(state: AgentState) -> AgentState: + """Суммирует историю диалога.""" + print("Выполняется узел: summarize_history_node") + try: + # Для суммаризации используем полную историю, которую ведет AgentState + summarized_text = summarization_service.summarize_history( + state.chat_history) + state.summarized_history_text = summarized_text + state.llm_response = f"История диалога суммирована:\n{summarized_text}" + state.chat_history.append({ + "role": "assistant", + "content": state.llm_response + }) + + # Добавляем узел суммаризации в граф + summary_node_id = f"summary_{len(state.graph_nodes) + 1}" + state.graph_nodes.append({ + "id": summary_node_id, + "type": "tool", + "data": { + "label": "Суммаризация истории", + "details": + summarized_text[:30] + "..." if summarized_text else "" + } + }) + state.graph_edges.append({ + "id": f"e{state.parent_node_id}-{summary_node_id}", + "source": state.parent_node_id, + "target": summary_node_id + }) + state.parent_node_id = summary_node_id + state.current_node_id = summary_node_id + + except Exception as e: + state.error = f"Ошибка суммаризации истории: {e}" + state.llm_response = f"Произошла ошибка при суммаризации истории: {e}" + print(state.error) + return state + + +def handle_error_node(state: AgentState) -> AgentState: + """Обрабатывает ошибки команд.""" + print(f"Выполняется узел: handle_error_node. Ошибка: {state.error}") + state.llm_response = state.error + state.chat_history.append({ + "role": "assistant", + "content": state.llm_response + }) + + # Добавляем узел ошибки в граф + error_node_id = f"error_{len(state.graph_nodes) + 1}" + state.graph_nodes.append({ + "id": error_node_id, + "type": "error", + "data": { + "label": "Ошибка", + "details": state.error + } + }) + state.graph_edges.append({ + "id": f"e{state.parent_node_id}-{error_node_id}", + "source": state.parent_node_id, + "target": error_node_id + }) + state.parent_node_id = error_node_id + state.current_node_id = error_node_id + + return state + + +def help_node(state: AgentState) -> AgentState: + """Предоставляет информацию о доступных командах.""" + print("Выполняется узел: help_node") + commands_info = command_manager.get_commands() + help_text = "Доступные команды:\n" + for cmd, info in commands_info.items(): + help_text += f"/{cmd}: {info['description']}\n" + state.llm_response = help_text + state.chat_history.append({ + "role": "assistant", + "content": state.llm_response + }) + + # Добавляем узел справки в граф + help_node_id = f"help_{len(state.graph_nodes) + 1}" + state.graph_nodes.append({ + "id": help_node_id, + "type": "info", + "data": { + "label": "Справка", + "details": "Список команд" + } + }) + state.graph_edges.append({ + "id": f"e{state.parent_node_id}-{help_node_id}", + "source": state.parent_node_id, + "target": help_node_id + }) + state.parent_node_id = help_node_id + state.current_node_id = help_node_id + + return state diff --git a/app/services.py b/app/services.py new file mode 100644 index 0000000..7cd4eb5 --- /dev/null +++ b/app/services.py @@ -0,0 +1,158 @@ +""" +Этот файл содержит классы сервисов для выполнения различных задач: +- Получение субтитров, +- Анализ изображений, +- Суммаризация текста, +- Генерация изображений. +""" + +from llm_client import CustomLLM +from typing import Dict, Any, List, Optional +import requests + +# --- Сервисы для внешних операций --- + + +class SubtitleService: + """ + Сервис для получения текущих субтитров. Заглушки. + """ + + def get_google_meet_subtitles(self) -> Optional[str]: + """ + Получает текущие, не финальные субтитры из Google Meet. + (Реализация требует интеграции с Google Workspace API или другим методом.) + """ + print("Заглушка: Получение субтитров из Google Meet...") + # TODO: Реальная интеграция с Google Meet API + return "Это текущие субтитры из Google Meet: 'Привет, как дела?'" + + def get_ms_teams_subtitles(self) -> Optional[str]: + """ + Получает текущие, не финальные субтитры из Microsoft Teams. + (Реализация требует интеграции с Microsoft Graph API или другим методом.) + """ + print("Заглушка: Получение субтитров из Microsoft Teams...") + # TODO: Реальная интеграция с Microsoft Teams API + return "Это текущие субтитры из MS Teams: 'Отлично, спасибо!'" + + +class ImageAnalysisService: + """ + Сервис для анализа изображений с использованием Gemini Vision API. + """ + + def __init__(self, llm: CustomLLM): + self.llm = llm # Ожидаем, что это будет экземпляр Gemini LLM + + def analyze_image(self, image_url_or_bytes: Any, prompt: str) -> str: + """ + Анализирует изображение с помощью Gemini Vision. + `image_url_or_bytes` может быть URL, путем к файлу или байтами изображения. + LangChain Gemini Vision принимает это как часть сообщения. + """ + if "vision" not in self.llm.config.get("capabilities", []): + raise ValueError( + "Для анализа изображений требуется LLM с поддержкой 'vision' (например, Gemini)." + ) + + print(f"Анализ изображения: {image_url_or_bytes} с промптом: {prompt}") + + # LangChain GeminiVision ожидает контент в определенном формате + # Для простоты, здесь заглушка, имитирующая обработку изображения. + # В реальной реализации, image_url_or_bytes нужно будет соответствующим образом загрузить/передать. + # Пример: [HumanMessage(content=[{"type": "text", "text": prompt}, {"type": "image_url", "image_url": {"url": image_url_or_bytes}}])] + # Или для локальных файлов: [{"type": "image_bytes", "image_bytes": {"data": base64_encoded_bytes}}] + + # Поскольку у нас нет реального механизма загрузки изображения здесь, + # имитируем ответ LLM для демо-целей. + # В реальном приложении: + # messages = [ + # HumanMessage( + # content=[ + # {"type": "text", "text": prompt}, + # {"type": "image_url", "image_url": {"url": image_url_or_bytes}} # Или image_bytes + # ] + # ) + # ] + # return self.llm.invoke(messages) + + return f"Заглушка анализа изображения: Изображение '{image_url_or_bytes}' содержит (по результатам анализа Gemini): 'яркий пейзаж с горами и рекой' по запросу '{prompt}'." + + +class SummarizationService: + """ + Сервис для суммаризации истории диалога с использованием Gemini LLM. + """ + + def __init__(self, llm: CustomLLM): + self.llm = llm # Ожидаем, что это будет экземпляр Gemini LLM + + def summarize_history(self, + history: List[Dict[str, str]], + num_recent_messages: int = 5) -> str: + """ + Суммирует старую историю диалога с использованием LLM. + Комбинация: последние N сообщений + суммаризация старой истории. + """ + if self.llm.provider != "google": + print( + "Предупреждение: Суммаризация рекомендуется с Gemini для лучшего качества." + ) + + # Последние N сообщений + recent_messages = history[-num_recent_messages:] + old_history = history[:-num_recent_messages] + + summarized_old_history = "" + if old_history: + # Формируем промпт для суммаризации старой истории + old_messages_text = "\n".join( + [f"{msg['role']}: {msg['content']}" for msg in old_history]) + summary_prompt = f"Суммируй следующий диалог максимально кратко, сохранив ключевые моменты и темы:\n---\n{old_messages_text}\n---" + try: + summarized_old_history = self.llm.invoke(summary_prompt) + except Exception as e: + print(f"Ошибка при суммаризации старой истории: {e}") + summarized_old_history = "Ошибка суммаризации старой истории." + + # Объединяем суммированную старую историю с последними сообщениями + full_context = [] + if summarized_old_history: + full_context.append( + f"Предшествующая история (краткое содержание): {summarized_old_history}" + ) + for msg in recent_messages: + full_context.append(f"{msg['role']}: {msg['content']}") + + return "\n".join(full_context) + + +class ImageGenerationService: + """ + Сервис для генерации изображений через удаленный эндпоинт (bingart). + """ + + def __init__(self, base_url: str): + self.base_url = base_url + + def generate_images(self, prompt: str) -> List[str]: + """ + Отправляет запрос на генерацию изображений. + """ + url = f"{self.base_url}/generate-images" + try: + # Для простоты используем GET, как указано в ТЗ, хотя POST с body более типичен для prompt + response = requests.get(url, params={"prompt": prompt}) + response.raise_for_status() + data = response.json() + if "error" in data: + raise Exception( + f"Ошибка генерации изображений: {data['error']}") + return data.get("image_urls", []) + except requests.exceptions.RequestException as e: + print(f"Ошибка при вызове сервиса генерации изображений: {e}") + raise + except Exception as e: + print(f"Не удалось получить изображения: {e}") + raise diff --git a/app/workflows.py b/app/workflows.py new file mode 100644 index 0000000..ff214ca --- /dev/null +++ b/app/workflows.py @@ -0,0 +1,147 @@ +""" +Определяет логику рабочих процессов агента, +используя LangGraph для управления состояниями +и переходами между узлами обработки. +""" + +from typing import Dict, Any, Optional +from langgraph.graph import StateGraph, END +from graph_history_manager import GraphHistoryManager +from models import AgentState +from nodes import parse_command_node, execute_command_node, call_llm_node, generate_images_node, analyze_image_node, get_meet_subtitles_node, get_teams_subtitles_node, summarize_history_node, handle_error_node, help_node +# --- LangGraph: Построение графа --- + +graph_history_manager = GraphHistoryManager() + +workflow = StateGraph(AgentState) + +# Добавляем узлы +workflow.add_node("parse_command", parse_command_node) +workflow.add_node("execute_command", + execute_command_node) # Это по сути роутер +workflow.add_node("call_llm", call_llm_node) +workflow.add_node("generate_images", generate_images_node) +workflow.add_node("analyze_image", analyze_image_node) +workflow.add_node("get_meet_subtitles", get_meet_subtitles_node) +workflow.add_node("get_teams_subtitles", get_teams_subtitles_node) +workflow.add_node("summarize_history", summarize_history_node) +workflow.add_node("handle_error", handle_error_node) +workflow.add_node("help", help_node) + +# Устанавливаем точку входа +workflow.set_entry_point("parse_command") + + +# Определяем маршрутизацию после parse_command +def route_command(state: AgentState) -> str: + """Маршрутизирует выполнение на основе распознанной команды.""" + if state.error: + return "handle_error" # Если была ошибка парсинга команды + elif state.command == "imagine": + return "generate_images" + elif state.command == "analyze": + return "analyze_image" + elif state.command == "subtitles_meet": + return "get_meet_subtitles" + elif state.command == "subtitles_teams": + return "get_teams_subtitles" + elif state.command == "summarize": + return "summarize_history" + elif state.command == "help": + return "help" + elif state.command == "chat": + return "call_llm" + elif state.command == "error": # Команда была, но не известная + return "handle_error" + else: + # Fallback на LLM, если команда не распознана или пуста (хотя parse_command должен был бы ее поймать) + return "call_llm" + + +workflow.add_conditional_edges( + "parse_command", # Откуда + route_command, # Функция, определяющая куда идти + { + "generate_images": "generate_images", + "analyze_image": "analyze_image", + "get_meet_subtitles": "get_meet_subtitles", + "get_teams_subtitles": "get_teams_subtitles", + "summarize_history": "summarize_history", + "help": "help", + "call_llm": "call_llm", + "handle_error": + "handle_error", # Для ошибок, найденных в parse_command + }, +) + +# Все узлы операций (кроме ошибок) ведут к END после завершения +workflow.add_edge("call_llm", END) +workflow.add_edge("generate_images", END) +workflow.add_edge("analyze_image", END) +workflow.add_edge("get_meet_subtitles", END) +workflow.add_edge("get_teams_subtitles", END) +workflow.add_edge("summarize_history", END) +workflow.add_edge("help", END) +workflow.add_edge("handle_error", + END) # Ошибка - это тоже конечный пункт для текущего запроса + +# Компилируем граф +app = workflow.compile() + + +def run_agent(user_input: str, + existing_graph_id: Optional[str] = None, + parent_node_id: Optional[str] = None) -> Dict[str, Any]: + """ + Запускает агента с заданным пользовательским вводом. + Может продолжить существующий граф по graph_id. + Возвращает обновленное состояние и ID графа. + """ + initial_state = AgentState(input=user_input, parent_node_id=parent_node_id) + + # Если есть существующий ID графа, загружаем его историю + if existing_graph_id: + existing_graph_data = graph_history_manager.get_graph( + existing_graph_id) + if existing_graph_data: + print(f"Продолжаю существующий граф {existing_graph_id}") + initial_state.chat_history = existing_graph_data.get( + "messages", []) + initial_state.graph_nodes = existing_graph_data.get( + "graph_nodes", []) + initial_state.graph_edges = existing_graph_data.get( + "graph_edges", []) + initial_state.parent_node_id = parent_node_id or existing_graph_data.get( + "current_node_id") + + # Запускаем граф + result = app.invoke(initial_state) + final_state = AgentState(**result) + + # Сохраняем обновленное состояние графа + graph_data_to_save = { + "id": existing_graph_id, + "messages": final_state.chat_history, + "graph_nodes": final_state.graph_nodes, + "graph_edges": final_state.graph_edges, + "current_node_id": final_state.current_node_id + } + new_graph_id = graph_history_manager.save_graph(graph_data_to_save) + + # Формируем ответ для фронтенда + response_data = { + "graph_id": new_graph_id, + "messages": final_state.chat_history, + "llm_response": final_state.llm_response, + "image_urls": final_state.image_urls, + "analysis_result": final_state.analysis_result, + "subtitles": final_state.subtitles, + "summarized_history_text": final_state.summarized_history_text, + "error": final_state.error, + "graph_visualization_data": { + "nodes": final_state.graph_nodes, + "edges": final_state.graph_edges, + "current_node_id": final_state.current_node_id + } + } + return response_data diff --git a/graph_history.db b/graph_history.db new file mode 100644 index 0000000..bfc11bc Binary files /dev/null and b/graph_history.db differ diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..b9434fe --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +langgraph +langchain +langchain_openai + +langchain_google_genai + +flask +flask_cors \ No newline at end of file diff --git a/run.py b/run.py new file mode 100644 index 0000000..7bf48c8 --- /dev/null +++ b/run.py @@ -0,0 +1,21 @@ +# ----------------------------------------------- Imports ------------------------------------------------------------ +import os +import sys + +# ----------------------------------------------- Main ------------------------------------------------------------ +if __name__ == "__main__": + # Добавляем папку app в Python path + sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'app')) + from app.api import app2 + + print("🚀 Запуск LLM Agent Backend сервера...") + print("📍 API будет доступно на: http://localhost:5000/api") + print("📊 Список графов: http://localhost:5000/api/graphs") + print("💬 Чат эндпоинт: http://localhost:5000/api/chat") + print("\n⚠️ Для остановки нажмите Ctrl+C\n") + + app2.run( + debug=True, + port=5000, + host='localhost' + ) \ No newline at end of file