diff --git a/README.md b/README.md new file mode 100644 index 0000000..dd81cc2 --- /dev/null +++ b/README.md @@ -0,0 +1,63 @@ +# LLM Agent Backend + +Бэкенд-сервер на Python для обеспечения работы интеллектуального агента в Obsidian. Использует LangGraph для управления логикой рассуждений и Flask для взаимодействия с плагином. + +## Структура проекта + +├── llm-agent-backend/ # Корневая директория бэкенда +│ ├── app/ # Исходный код приложения +│ │ ├── api.py # Flask сервер, HTTP эндпоинты и WebSocket (SocketIO) +│ │ ├── run.py # Точка входа для запуска приложения +│ │ ├── workflows.py # Определение графов LangGraph и логики потоков +│ │ ├── react_agent.py # Реализация ReAct (Reasoning and Acting) агента +│ │ ├── llm_client.py # Клиент для работы с моделями (OpenAI, Google, Mistral) +│ │ ├── mcp_tools.py # Интеграция с Model Context Protocol (MCP) инструментами +│ │ ├── graph_history_manager.py # Управление историей диалогов и состоянием графов в SQLite +│ │ ├── voice_service.py # Сервис распознавания речи (Vosk) +│ │ ├── title_generator.py # Генерация названий для чатов на основе контекста +│ │ ├── heartbeat_monitor.py # Мониторинг активности и состояния подключения +│ │ ├── models.py # Описание структур данных и схем +│ │ └── services.py # Вспомогательные сервисы +│ ├── docs/ # Техническая документация и скриншоты настроек +│ ├── env/ # Виртуальное окружение Python (venv) +│ ├── build.bat # Скрипт сборки проекта +│ ├── requirements.txt # Список зависимостей Python +│ ├── run-llm-backend.bat # Батник для быстрого запуска сервера +│ └── graph_history.db # БД SQLite с историей графов и сообщений + +## Основные технологии +- **Python 3.11+** +- **LangChain / LangGraph**: Управление состоянием агента и цепочками рассуждений. +- **Flask / Flask-SocketIO**: API и real-time взаимодействие с Obsidian. +- **Vosk / SoundDevice**: Локальное распознавание речи. +- **SQLite**: Хранение истории взаимодействий. + +## Установка + +1. Убедитесь, что установлен Python 3.11. +2. Создайте виртуальное окружение в папке `env`: + ```bash + python -m venv env + ``` +3. Активируйте окружение и установите зависимости: + ```bash + env\Scripts\activate + pip install -r requirements.txt + ``` + +## Запуск + +### Вариант 1: Через батник (рекомендуется) +Запустите `run-llm-backend.bat` из корня проекта. + +### Вариант 2: Вручную из терминала +```bash +python app/run.py +``` +Сервер будет доступен по адресу: `http://localhost:5000` + +## Ключевые возможности +- **Двустороннее общение**: Агент может инициировать запросы к Obsidian (через API плагина). +- **Инструменты (Tools)**: Поддержка вызова функций через MCP и кастомные инструменты. +- **Память**: Сохранение состояния графов, что позволяет продолжать диалог с любого места. +- **Голосовое управление**: Возможность диктовать команды напрямую в микрофон с транскрибацией в реальном времени. diff --git a/app/api.py b/app/api.py index 67d0e5e..df065c7 100644 --- a/app/api.py +++ b/app/api.py @@ -12,6 +12,8 @@ from workflows import graph_history_manager, run_agent_streaming from llm_client import DEFAULT_TEMPERATURE, MODELS, get_llm # Добавляем импорт списка моделей import base64 from mcp_tools import get_raw_mcp_tools_list +import threading +import uuid as uuid_lib api = Flask(__name__) CORS( @@ -423,7 +425,8 @@ def fetch_mcp_tools_route(): return jsonify({"error": "Пустой конфиг сервера"}), 400 try: - tools_list = get_raw_mcp_tools_list(server_config) + vault_cwd = current_obsidian_settings.get("vaultAbsolutePath") + tools_list = get_raw_mcp_tools_list(server_config, vault_cwd=vault_cwd) return jsonify(tools_list) except Exception as e: return jsonify({"error": str(e)}), 500 @@ -488,4 +491,126 @@ def sync_settings(): graph_history_manager.title_generator.voice_llm = get_llm(current_obsidian_settings['voiceCommandModel']) print(f"✅⚙️ Settings synced: {current_obsidian_settings}") - return jsonify({"status": "synced"}) \ No newline at end of file + return jsonify({"status": "synced"}) + +# --------------------------------------------------------------------------- +# Vault Query Bridge +# Позволяет Python-скриптам читать/писать файлы vault через Obsidian. +# Схема: POST /api/vault/query → socketio emit → Obsidian отвечает → возврат. +# --------------------------------------------------------------------------- + +import threading +import uuid as uuid_lib + +# Хранилище ожидающих ответа запросов. +# Ключ: request_id (str UUID) +# Значение: (threading.Event, dict результата) +_vault_query_pending: dict[str, tuple[threading.Event, dict]] = {} + + +@api.route('/api/vault/query', methods=['POST']) +def vault_query_endpoint(): + """ + Проксирует запрос к Obsidian vault через WebSocket. + + Принимает JSON: + { + "type": "get_frontmatter" | "get_all_factions" | ..., + "payload": { ... параметры операции ... } + } + + Поддерживаемые типы (обрабатываются на стороне Obsidian VaultQueryHandler): + get_frontmatter — YAML + тело одного файла + get_all_factions — все файлы в папке (с фильтром по тегу) + get_recent_files — последние N файлов (с фильтром по тегам) + resolve_wikilink — wikilink → путь к файлу + write_frontmatter — перезаписать YAML + тело файла + create_file — создать или перезаписать файл + + Блокируется до ответа Obsidian или до истечения timeout (10с). + При timeout возвращает 504. + """ + data = request.get_json(silent=True) + if not data: + return jsonify({"error": "Пустое тело запроса"}), 400 + + query_type = data.get("type") + payload = data.get("payload", {}) + + if not query_type: + return jsonify({"error": "Поле 'type' обязательно"}), 400 + + # Генерируем уникальный ID для сопоставления запрос↔ответ + request_id = str(uuid_lib.uuid4()) + + # Регистрируем ожидание ответа + event = threading.Event() + result_holder: dict = {} + _vault_query_pending[request_id] = (event, result_holder) + + try: + # Отправляем запрос в Obsidian через WebSocket + socketio.emit('vault_query_request', { + "request_id": request_id, + "type": query_type, + "payload": payload + }) + + # Блокируемся — ждём пока handle_vault_query_response вызовет event.set() + answered = event.wait(timeout=10.0) + + finally: + # Убираем из pending независимо от результата + _vault_query_pending.pop(request_id, None) + + if not answered: + return jsonify({ + "error": ( + "Timeout: Obsidian не ответил за 10 секунд. " + "Проверьте что плагин LLM Agent запущен и подключён к WebSocket." + ) + }), 504 + + if "error" in result_holder: + return jsonify({"error": result_holder["error"]}), 500 + + return jsonify(result_holder.get("data")) + + +@socketio.on('vault_query_response') +def handle_vault_query_response(data): + """ + Получает ответ от Obsidian на vault-запрос. + Будит заблокированный поток в vault_query_endpoint(). + + data ожидается вида: + { "request_id": "...", "result": <любые данные> } + или + { "request_id": "...", "error": "описание ошибки" } + """ + if not isinstance(data, dict): + print(f"⚠️ vault_query_response: неожиданный формат: {type(data)}") + return + + request_id = data.get("request_id") + if not request_id: + print("⚠️ vault_query_response: отсутствует request_id") + return + + pending = _vault_query_pending.get(request_id) + if not pending: + # Дубль после таймаута или успешной обработки — это нормально + # если исправлен WebSocketService. До исправления — признак бага. + print(f"⚠️ дубль vault_query_response [{request_id[:8]}] — игнорируем") + return + + event, result_holder = pending + + if "error" in data: + result_holder["error"] = data["error"] + else: + result_holder["data"] = data.get("result") + print(f"✅ vault_query_response [{request_id[:8]}]: тип={type(result_holder['data']).__name__}") + + event.set() + # pending будет удалён в finally блока vault_query_endpoint \ No newline at end of file diff --git a/app/llm_client.py b/app/llm_client.py index e74548f..258cecc 100644 --- a/app/llm_client.py +++ b/app/llm_client.py @@ -77,6 +77,18 @@ MODELS: Dict[str, Dict[str, Any]] = { "include_reasoning": True } }, + "gemini-3.0-flash-routerai": { + "name": "google/gemini-3-flash-preview", + "provider": "openai", + "model_name": "google/gemini-3-flash-preview", + "apiBase": "https://routerai.ru/api/v1", + "apiKey": "sk-QXEteDDGCbAgfyXgSlGFdU7WXc7jCbwB", + "stream": True, + "capabilities": ["vision", "reasoning"], + "model_kwargs": { + "include_reasoning": True + } + }, "gemini-3.1-pro-openrouter": { "name": "google/gemini-3.1-pro-preview", "provider": "openai", @@ -144,6 +156,18 @@ MODELS: Dict[str, Dict[str, Any]] = { "include_reasoning": True } }, + "claude-sonnet-5-routerai": { + "name": "anthropic/claude-sonnet-5", + "provider": "openai", + "model_name": "anthropic/claude-sonnet-5", + "apiBase": "https://routerai.ru/api/v1", + "apiKey": "sk-QXEteDDGCbAgfyXgSlGFdU7WXc7jCbwB", + "stream": True, + "capabilities": ["vision", "reasoning"], + "model_kwargs": { + "include_reasoning": True + } + }, "deepseek-v4-flash-openrouter": { "name": "deepseek/deepseek-v4-flash", "provider": "openai", @@ -156,6 +180,27 @@ MODELS: Dict[str, Dict[str, Any]] = { "model_kwargs": { "include_reasoning": True } + }, + "deepseek-v4-flash-routerai": { + "name": "deepseek/deepseek-chat", + "provider": "openai", + "model_name": "deepseek/deepseek-chat", + "apiBase": "https://routerai.ru/api/v1", + "apiKey": "sk-QXEteDDGCbAgfyXgSlGFdU7WXc7jCbwB", + "stream": True, + "capabilities": ["vision", "reasoning"], + "model_kwargs": { + "include_reasoning": True + } + }, + "deepseek-v3.2-exp-openrouter": { + "name": "deepseek/deepseek-v3.2-exp", + "provider": "openrouter", + "model_name": "deepseek/deepseek-v3.2-exp", + "apiBase": "https://openrouter.ai/api/v1", + "apiKey": "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", + "stream": True, + "capabilities": ["chat", "reasoning", "code"] } } diff --git a/app/mcp_tools.py b/app/mcp_tools.py index 77f063d..b848a56 100644 --- a/app/mcp_tools.py +++ b/app/mcp_tools.py @@ -37,7 +37,7 @@ def json_schema_to_pydantic(schema: dict, model_name: str) -> type[BaseModel]: return create_model(model_name, **fields) -def create_mcp_tool(server_config, tool_name, tool_desc, json_schema, full_env, args, debug_callback=None): +def create_mcp_tool(server_config, tool_name, tool_desc, json_schema, full_env, args, debug_callback=None, vault_cwd=None): """Обертка, которая поднимает контейнер/процесс ровно на 1 вызов тула и убивает его""" args_schema = json_schema_to_pydantic(json_schema, f"MCP_{tool_name.replace('-','_')}_Schema") command = server_config.get("command") @@ -48,7 +48,7 @@ def create_mcp_tool(server_config, tool_name, tool_desc, json_schema, full_env, input_json = json.dumps(kwargs, indent=2, ensure_ascii=False) async def _run(): - server_params = StdioServerParameters(command=command, args=args, env=full_env) + server_params = StdioServerParameters(command=command, args=args, env=full_env, cwd=vault_cwd) async with stdio_client(server_params) as (read, write): async with ClientSession(read, write) as session: await session.initialize() @@ -115,7 +115,7 @@ def create_mcp_tool(server_config, tool_name, tool_desc, json_schema, full_env, handle_tool_error=True # Разрешает агенту "выжить" после ошибки тула и сгенерировать ответ ) -def fetch_mcp_tools(server_config, debug_callback=None): +def fetch_mcp_tools(server_config, debug_callback=None, vault_cwd=None): """Один раз читает список тулов от сервера и кеширует их схемы""" if not MCP_AVAILABLE: print("⚠️ MCP серверы настроены, но библиотека 'mcp' не установлена. Выполните: pip install mcp") @@ -151,7 +151,7 @@ def fetch_mcp_tools(server_config, debug_callback=None): env_dict[k.strip()] = v.strip() full_env = {**os.environ.copy(), **env_dict} - server_params = StdioServerParameters(command=command, args=args, env=full_env) + server_params = StdioServerParameters(command=command, args=args, env=full_env, cwd=vault_cwd) async with stdio_client(server_params) as (read, write): async with ClientSession(read, write) as session: @@ -180,7 +180,7 @@ def fetch_mcp_tools(server_config, debug_callback=None): else: final_description = t.description - wrapped_tools.append(create_mcp_tool(server_config, t.name, final_description, t.inputSchema, full_env, args, debug_callback)) + wrapped_tools.append(create_mcp_tool(server_config, t.name, final_description, t.inputSchema, full_env, args, debug_callback, vault_cwd)) _MCP_CACHED_TOOLS[config_hash] = wrapped_tools print(f"🔌 Успешно загружено {len(wrapped_tools)} инструментов от MCP-сервера '{server_config.get('name')}'") @@ -189,7 +189,7 @@ def fetch_mcp_tools(server_config, debug_callback=None): print(f"❌ Ошибка инициализации MCP сервера {server_config.get('name')}: {e}") return [] -def get_raw_mcp_tools_list(server_config): +def get_raw_mcp_tools_list(server_config, vault_cwd=None): """Метод для UI: просто возвращает список доступных инструментов в JSON-friendly формате""" if not MCP_AVAILABLE: return [] @@ -212,7 +212,7 @@ def get_raw_mcp_tools_list(server_config): env_dict[k.strip()] = v.strip() full_env = {**os.environ.copy(), **env_dict} - server_params = StdioServerParameters(command=command, args=args, env=full_env) + server_params = StdioServerParameters(command=command, args=args, env=full_env, cwd=vault_cwd) async with stdio_client(server_params) as (read, write): async with ClientSession(read, write) as session: diff --git a/app/react_agent.py b/app/react_agent.py index 457815f..cd05988 100644 --- a/app/react_agent.py +++ b/app/react_agent.py @@ -55,6 +55,12 @@ def create_subprocess_tool(script_path: str, method_name: str, spec = importlib.util.spec_from_file_location("user_dynamic_tool", script_path) module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) + + if not hasattr(module, method_name): + error_msg = f"в файле '{script_path}' не найден метод '{method_name}'" + print(f"❌ {error_msg}") + raise AttributeError(error_msg) + func = getattr(module, method_name) docstring = inspect.getdoc(func) or f"Выполняет скрипт {method_name}" @@ -77,6 +83,12 @@ import sys import importlib.util import os +# ПРИНУДИТЕЛЬНО устанавливаем кодировку UTF-8 для потоков вывода и ошибок, чтобы могло выводить эмодзи, например, без ошибок +if hasattr(sys.stdout, 'reconfigure'): + sys.stdout.reconfigure(encoding='utf-8') +if hasattr(sys.stderr, 'reconfigure'): + sys.stderr.reconfigure(encoding='utf-8') + # Добавляем папку со скриптом в sys.path, чтобы работали локальные импорты (например, norminv) path_to_add = os.path.dirname(os.path.abspath(r'{script_path}')) original_path = sys.path.copy() @@ -174,30 +186,15 @@ def get_dynamic_tools(obsidian_settings: dict, debug_callback=None): for ct in custom_tools: name = ct.get("name") desc = ct.get("description") - full_path = ct.get("scriptPath", "") # Поддерживаем оба ключа, если в JS/TS они не раскрылись hidden_prompt = ct.get("hiddenPromptExpanded") or ct.get("hiddenPrompt", "") if not name: continue # Инструмент без имени создать нельзя - # Парсим путь к скрипту (учитываем Windows-пути с буквой диска) - script_path = "" - method_name = "main" - - if full_path: - parts = full_path.rsplit(":", 1) # Разделяем строку с конца 1 раз - - if len(parts) == 2: - # Если слева от разделения ровно 1 символ-буква (например, 'D'), - # значит это путь без метода, а двоеточие — от диска Windows. - if len(parts[0]) == 1 and parts[0].isalpha(): - script_path = full_path - else: - script_path = parts[0] - method_name = parts[1] - else: - script_path = full_path + # Достаем путь и метод из отдельных полей, присланных фронтендом + script_path = ct.get("scriptPath", "") + method_name = ct.get("methodName") or "main" # ПРОВЕРКА: Есть ли физический скрипт? is_valid_script = script_path and os.path.exists(script_path) @@ -250,6 +247,8 @@ def get_dynamic_tools(obsidian_settings: dict, debug_callback=None): ) tools.append(help_tool) + vault_cwd = obsidian_settings.get("vaultAbsolutePath") + # Инициализация внутренних MCP серверов mcp_servers = obsidian_settings.get("mcpServers", []) for mcp_server in mcp_servers: @@ -257,7 +256,7 @@ def get_dynamic_tools(obsidian_settings: dict, debug_callback=None): if not mcp_server.get("isEnabled", True): continue - mcp_tools = fetch_mcp_tools(mcp_server, debug_callback) + mcp_tools = fetch_mcp_tools(mcp_server, debug_callback, vault_cwd=vault_cwd) tools.extend(mcp_tools) return tools @@ -316,12 +315,33 @@ def _generate_commands_list(custom_tools: list[dict[str, Any]]) -> str: if not custom_tools: return "В данный момент нет доступных дополнительных команд. Ты работаешь как обычный собеседник." - lines = ["Я могу использовать следующие инструменты/команды:\n"] + tool_raw_list = ["Я могу использовать следующие инструменты/команды:\n"] for ct in custom_tools: name = ct.get("name") desc = ct.get("description", "Описание отсутствует") if name: - lines.append(f"- **{name}**: {desc}") + tool_raw_list.append(f"- **{name}**: {desc}") - lines.append("\nПерескажи этот список пользователю в удобном формате.") - return "\n".join(lines) \ No newline at end of file + # Приаттачиваем инструкцию формата вывода (hidden prompt для системы) + prompt = f""" +СПИСОК ДОСТУПНЫХ ИНСТРУМЕНТОВ: +{tool_raw_list} + +ТВОЯ ЗАДАЧА: +Сгруппируй инструменты по уровням абстракции и выведи их в формате Markdown-таблиц. + +ИСПОЛЬЗУЙ СЛЕДУЮЩИЕ КАТЕГОРИИ: +1. 🧩 [Микро-уровень]: Атомарные сущности, единичные объекты, конкретные детали или расчеты. +2. 🏗️ [Мезо-уровень]: Процессы, сложные узлы, локальные системы, взаимодействия или окружение. +3. 🗺️ [Макро-уровень]: Глобальные структуры, архитектура системы, высокоуровневая логика или долгосрочные стратегии. +4. ⚙️ [Мета-уровень]: Управление сессией, диагностика, системная помощь, логирование или работа с контекстом. + +ФОРМАТ ВЫВОДА: +Для каждой категории создай заголовок и таблицу: +| Команда | Функциональное назначение | +|:---|:---| +| **название_команды** | Суть работы (1 краткое предложение) | + +Не добавляй никаких приветствий или пояснений вне блока кода. Будь предельно лаконичен. +""" + return prompt \ No newline at end of file