Compare commits

..

No commits in common. "52d5196a480643609de6b203691c8b566e273b2a" and "d3449bb50da92bb1704364972493a1e8394ea6dc" have entirely different histories.

26 changed files with 218 additions and 914 deletions

7
.gitignore vendored
View File

@ -8,9 +8,6 @@ __pycache__
*.db *.db
*.db-journal *.db-journal
build/
audio-logs/ audio-logs/
dist/
*.pyd

4
.vscode/launch.json vendored
View File

@ -7,13 +7,13 @@
"request": "launch", "request": "launch",
"python": "${workspaceFolder}/env/Scripts/python.exe", "python": "${workspaceFolder}/env/Scripts/python.exe",
"cwd": "${workspaceFolder}", "cwd": "${workspaceFolder}",
"program": "${workspaceFolder}/app/run.py", "program": "${workspaceFolder}/run.py",
"env": { "env": {
"FLASK_APP": "run.py", "FLASK_APP": "run.py",
"FLASK_ENV": "development", "FLASK_ENV": "development",
"FLASK_DEBUG": "1" "FLASK_DEBUG": "1"
}, },
"justMyCode": false, "justMyCode": true,
"console": "integratedTerminal" "console": "integratedTerminal"
}, },
{ {

View File

@ -1,9 +0,0 @@
Для сборки с компиляцией
```
build.bat
```
В разработке - использовать
```
run-llm-backend.bat
```

View File

@ -1,63 +0,0 @@
# 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 и кастомные инструменты.
- **Память**: Сохранение состояния графов, что позволяет продолжать диалог с любого места.
- **Голосовое управление**: Возможность диктовать команды напрямую в микрофон с транскрибацией в реальном времени.

View File

@ -10,10 +10,6 @@ from flask_cors import CORS
from flask_socketio import SocketIO from flask_socketio import SocketIO
from workflows import graph_history_manager, run_agent_streaming from workflows import graph_history_manager, run_agent_streaming
from llm_client import DEFAULT_TEMPERATURE, MODELS, get_llm # Добавляем импорт списка моделей 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__) api = Flask(__name__)
CORS( CORS(
@ -236,7 +232,7 @@ def chat_stream():
cache_folder = data.get("cache_folder") cache_folder = data.get("cache_folder")
temperature = data.get("temperature", DEFAULT_TEMPERATURE) temperature = data.get("temperature", DEFAULT_TEMPERATURE)
max_tokens = data.get("max_tokens") max_tokens = data.get("max_tokens")
agency_mode = data.get("agency_mode", False) agency_mode = data.get("agency_mode", False) # НОВОЕ: Перехватываем режим тумблера "Агент"
# Необязательный параметр для существующего узла ассистента при регенерации # Необязательный параметр для существующего узла ассистента при регенерации
existing_assistant_node_id = data.get("existing_assistant_node_id") existing_assistant_node_id = data.get("existing_assistant_node_id")
@ -264,11 +260,6 @@ def chat_stream():
# Запускаем стриминг ответа от LLM (используем обновленный агент) # Запускаем стриминг ответа от LLM (используем обновленный агент)
accumulated_content = "" accumulated_content = ""
# Вспомогательная функция для безопасной передачи JSON в HTML-атрибутах
def safe_b64(text):
if not text: return "e30=" # пустой json '{}'
return base64.b64encode(str(text).encode('utf-8')).decode('utf-8')
try: try:
for chunk_data in run_agent_streaming(graph_id, user_node_id, for chunk_data in run_agent_streaming(graph_id, user_node_id,
assistant_node_id, assistant_node_id,
@ -280,27 +271,6 @@ def chat_stream():
if chunk_data.get("type") in ["chunk", "tool_start", "tool_end"]: if chunk_data.get("type") in ["chunk", "tool_start", "tool_end"]:
if chunk_data.get("type") == "chunk": if chunk_data.get("type") == "chunk":
accumulated_content += chunk_data.get("content", "") accumulated_content += chunk_data.get("content", "")
elif chunk_data.get("type") == "tool_start":
# Встраиваем стартовый маркер в память базы данных
start_marker = f'\n\n<div class="agent-tool-call" data-name="{chunk_data.get("name")}" data-step="{chunk_data.get("step")}" data-status="start"></div>\n\n'
accumulated_content += start_marker
elif chunk_data.get("type") == "tool_end":
# Находим стартовый маркер и меняем его на финальный с данными
search_marker = f'<div class="agent-tool-call" data-name="{chunk_data.get("name")}" data-step="{chunk_data.get("step")}" data-status="start"></div>'
req_b64 = safe_b64(chunk_data.get("request"))
res_b64 = safe_b64(chunk_data.get("response"))
status = "error" if chunk_data.get("is_error") else "success"
end_marker = f'<div class="agent-tool-call" data-name="{chunk_data.get("name")}" data-step="{chunk_data.get("step")}" data-status="{status}" data-req="{req_b64}" data-res="{res_b64}"></div>'
if search_marker in accumulated_content:
accumulated_content = accumulated_content.replace(search_marker, end_marker)
else:
accumulated_content += f'\n\n{end_marker}\n\n'
yield f"data: {json.dumps(chunk_data)}\n\n" yield f"data: {json.dumps(chunk_data)}\n\n"
# Обработка ошибки во время стриминга (крашим узел на фронте) # Обработка ошибки во время стриминга (крашим узел на фронте)
@ -417,22 +387,7 @@ def regenerate_message():
return jsonify({"error": str(e)}, 500) return jsonify({"error": str(e)}, 500)
@api.route('/api/mcp/fetch', methods=['POST']) from app.voice_service import VoiceService, VOICE_COMMANDS_RESPONSE_TO_STORE
def fetch_mcp_tools_route():
"""API endpoint для получения списка инструментов MCP сервера (без создания LangChain тулов)"""
server_config = request.get_json()
if not server_config:
return jsonify({"error": "Пустой конфиг сервера"}), 400
try:
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
from voice_service import VoiceService, VOICE_COMMANDS_RESPONSE_TO_STORE
# 1. Создаем экземпляр (обязательно укажите путь к вашей модели Vosk) # 1. Создаем экземпляр (обязательно укажите путь к вашей модели Vosk)
voice_inst = VoiceService() voice_inst = VoiceService()
@ -492,125 +447,3 @@ def sync_settings():
print(f"✅⚙️ Settings synced: {current_obsidian_settings}") print(f"✅⚙️ Settings synced: {current_obsidian_settings}")
return jsonify({"status": "synced"}) 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

View File

@ -28,48 +28,62 @@ MODELS: Dict[str, Dict[str, Any]] = {
"stream": True, "stream": True,
"capabilities": ["vision"], "capabilities": ["vision"],
}, },
"gemini-2.5-flash-lite-polza": { "gemini-2.5-flash-lite-openrouter": {
"name": "google/gemini-2.5-flash-lite", "name": "google/gemini-2.5-flash-lite",
"provider": "openai", "provider": "openai",
"model_name": "google/gemini-2.5-flash-lite", "model_name": "google/gemini-2.5-flash-lite",
"apiBase": "https://polza.ai/api/v1", # Добавлено /api/v1 "apiBase": "https://openrouter.ai/api/v1", # Добавлено /api/v1
"apiKey": "pza_qPk0xSBItQkUs18xoBUlyBBeYiHAxasy", "apiKey":
"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",
"stream": True, "stream": True,
"capabilities": ["vision", "reasoning"], "capabilities": ["vision", "reasoning"],
"model_kwargs": { "model_kwargs": {
"include_reasoning": True "include_reasoning": True
} }
}, },
"gemini-2.5-flash-polza": { "gemini-2.5-flash-r": {
"name": "gemini-2.5-flash-r",
"provider": "openai", # Изменено на "openai"
"model_name": "gemini-2.5-flash", # Добавлено имя модели для LangChain
"apiBase":
"https://render-service-gsu7.onrender.com/g/v1beta", # "generativelanguage.googleapis.com", # "https://render-service-gsu7.onrender.com/g/v1beta",
"apiKey": "AIzaSyDpueKFWVqknVKlQn6TdasLmJ2lvAUiBik",
"stream": True,
"capabilities": ["vision"],
},
"gemini-2.5-flash-openrouter": {
"name": "google/gemini-2.5-flash", "name": "google/gemini-2.5-flash",
"provider": "openai", "provider": "openai",
"model_name": "google/gemini-2.5-flash", "model_name": "google/gemini-2.5-flash",
"apiBase": "https://polza.ai/api/v1", # Добавлено /api/v1 "apiBase": "https://openrouter.ai/api/v1", # Добавлено /api/v1
"apiKey": "pza_qPk0xSBItQkUs18xoBUlyBBeYiHAxasy", "apiKey":
"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",
"stream": True, "stream": True,
"capabilities": ["vision", "reasoning"], "capabilities": ["vision", "reasoning"],
"model_kwargs": { "model_kwargs": {
"include_reasoning": True "include_reasoning": True
} }
}, },
"gemini-3.0-flash-polza": { "gemini-3.0-flash-openrouter": {
"name": "google/gemini-3-flash-preview", "name": "google/gemini-3-flash-preview",
"provider": "openai", "provider": "openai",
"model_name": "google/gemini-3-flash-preview", "model_name": "google/gemini-3-flash-preview",
"apiBase": "https://polza.ai/api/v1", "apiBase": "https://openrouter.ai/api/v1", # Добавлено /api/v1
"apiKey": "pza_qPk0xSBItQkUs18xoBUlyBBeYiHAxasy", "apiKey":
"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",
"stream": True, "stream": True,
"capabilities": ["vision", "reasoning"], "capabilities": ["vision", "reasoning"],
"model_kwargs": { "model_kwargs": {
"include_reasoning": True "include_reasoning": True
} }
}, },
"gemini-3.1-pro-polza": { "gemini-3.1-pro-openrouter": {
"name": "google/gemini-3.1-pro-preview", "name": "google/gemini-3.1-pro-preview",
"provider": "openai", "provider": "openai",
"model_name": "google/gemini-3.1-pro-preview", "model_name": "google/gemini-3.1-pro-preview",
"apiBase": "https://polza.ai/api/v1", "apiBase": "https://openrouter.ai/api/v1", # Добавлено /api/v1
"apiKey": "pza_qPk0xSBItQkUs18xoBUlyBBeYiHAxasy", "apiKey":
"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",
"stream": True, "stream": True,
"capabilities": ["vision", "reasoning"], "capabilities": ["vision", "reasoning"],
"model_kwargs": { "model_kwargs": {
@ -97,6 +111,16 @@ MODELS: Dict[str, Dict[str, Any]] = {
"stream": True, "stream": True,
"capabilities": ["vision"], "capabilities": ["vision"],
}, },
"mistral-small-latest-r": {
"name": "mistral-small-latest",
"provider": "mistralai",
"model_name":
"mistral-small-latest", # Добавлено имя модели для LangChain
"apiBase": "https://render-service-gsu7.onrender.com/m",
"apiKey": "Q0m29fvxBY0Cfdj4sjHaKqccy1NjonLW",
"stream": True,
"capabilities": ["vision"],
},
"mistral-small-latest-error": { "mistral-small-latest-error": {
"name": "mistral-small-latest", "name": "mistral-small-latest",
"provider": "mistralai", "provider": "mistralai",
@ -107,56 +131,31 @@ MODELS: Dict[str, Dict[str, Any]] = {
"stream": True, "stream": True,
"capabilities": ["vision"], "capabilities": ["vision"],
}, },
"claude-sonnet-5-polza": { "claude-sonnet-4.6-openrouter": {
"name": "anthropic/claude-sonnet-5", "name": "anthropic/claude-sonnet-4.6",
"provider": "openai", "provider": "openai",
"model_name": "anthropic/claude-sonnet-5", "model_name": "anthropic/claude-sonnet-4.6",
"apiBase": "https://polza.ai/api/v1", "apiBase": "https://openrouter.ai/api/v1", # Добавлено /api/v1
"apiKey": "pza_qPk0xSBItQkUs18xoBUlyBBeYiHAxasy", "apiKey":
"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",
"stream": True, "stream": True,
"capabilities": ["vision", "reasoning"], "capabilities": ["vision", "reasoning"],
"model_kwargs": { "model_kwargs": {
"include_reasoning": True "include_reasoning": True
} }
}, },
"deepseek-v4-flash-polza": { "deepseek-v4-flash-openrouter": {
"name": "deepseek/deepseek-chat", "name": "deepseek/deepseek-v4-flash",
"provider": "openai", "provider": "openai",
"model_name": "deepseek/deepseek-chat", "model_name": "deepseek/deepseek-v4-flash",
"apiBase": "https://polza.ai/api/v1", "apiBase": "https://openrouter.ai/api/v1", # Добавлено /api/v1
"apiKey": "pza_qPk0xSBItQkUs18xoBUlyBBeYiHAxasy", "apiKey":
"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",
"stream": True, "stream": True,
"capabilities": ["vision", "reasoning"], "capabilities": ["vision", "reasoning"],
"model_kwargs": { "model_kwargs": {
"include_reasoning": True "include_reasoning": True
} }
},
"deepseek-v3.2-exp-polza": {
"name": "deepseek/deepseek-v3.2-exp",
"provider": "openrouter",
"model_name": "deepseek/deepseek-v3.2-exp",
"apiBase": "https://polza.ai/api/v1",
"apiKey": "pza_qPk0xSBItQkUs18xoBUlyBBeYiHAxasy",
"stream": True,
"capabilities": ["chat", "reasoning", "code"]
},
"gigachat-2": {
"name": "GigaChat-2",
"provider": "custom",
"model_name": "GigaChat-2",
"apiBase": "http://localhost:8090/v1",
"apiKey": "my_super_secret_key_123",
"stream": True,
"capabilities": ["chat"]
},
"gigachat-2-max": {
"name": "GigaChat-2-Max",
"provider": "custom",
"model_name": "GigaChat-2-Max",
"apiBase": "http://localhost:8090/v1",
"apiKey": "my_super_secret_key_123",
"stream": True,
"capabilities": ["chat"]
} }
} }

View File

@ -1,228 +0,0 @@
from pydantic import BaseModel, Field, create_model
from langchain_core.tools import StructuredTool, ToolException
# ----------------- MCP MANAGER -----------------
try:
from mcp.client.stdio import stdio_client, StdioServerParameters
from mcp.client.session import ClientSession
MCP_AVAILABLE = True
except ImportError:
MCP_AVAILABLE = False
_MCP_CACHED_TOOLS = {}
def json_schema_to_pydantic(schema: dict, model_name: str) -> type[BaseModel]:
"""Преобразует JSON Schema от MCP сервера в Pydantic модель для LangChain"""
fields = {}
properties = schema.get("properties", {})
required = schema.get("required", [])
for key, val in properties.items():
t = val.get("type", "string")
py_type = str
if t == "integer": py_type = int
elif t == "number": py_type = float
elif t == "boolean": py_type = bool
elif t == "array": py_type = list
elif t == "object": py_type = dict
desc = val.get("description", "")
if key in required:
fields[key] = (py_type, Field(..., description=desc))
else:
fields[key] = (py_type, Field(default=None, description=desc))
if not fields:
fields["kwargs"] = (dict, Field(default_factory=dict, description="Аргументы"))
return create_model(model_name, **fields)
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")
def mcp_tool_runner(**kwargs):
import asyncio
import json
input_json = json.dumps(kwargs, indent=2, ensure_ascii=False)
async def _run():
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()
result = await session.call_tool(tool_name, arguments=kwargs)
text_outputs = []
# Проверяем, не вернул ли сам MCP-пакет флаг ошибки (isError)
# Если да, мы тоже должны это воспринимать как падение
is_mcp_error = getattr(result, 'isError', False)
for c in result.content:
if hasattr(c, 'text'):
text_outputs.append(c.text)
else:
text_outputs.append(str(c))
output_text = "\n".join(text_outputs)
# Если сервер MCP явно сказал об ошибке, кидаем Exception
if is_mcp_error:
raise Exception(output_text)
return output_text
# Оборачиваем попытку запуска в try..except
try:
raw_output = asyncio.run(_run())
except Exception as e:
def extract_root_errors(exc):
# Если ошибка содержит вложенные ошибки (ExceptionGroup)
if hasattr(exc, 'exceptions'):
msgs = []
for child_exc in exc.exceptions:
msgs.append(extract_root_errors(child_exc))
# Объединяем сообщения, убирая пустые
return " | ".join(filter(bool, msgs))
# Если это базовая ошибка (например, McpError), возвращаем ее текст
return str(exc)
clean_error_msg = extract_root_errors(e)
# 1. Поймали обрыв связи (сервер не запущен, отвалился stdio и т.д.)
error_msg = f"Отсутствует связь с сервером или ошибка выполнения: {clean_error_msg}"
# 2. Фиксируем kwargs (запрос), чтобы фронтенд в блоке Response/Request показал JSON!
if debug_callback:
debug_callback(kwargs, error_msg)
# 3. ВАЖНО: выбрасываем ошибку дальше. Бэкенд LangChain ее перехватит
# и отправит на фронтенд SSE-событие с `is_error: true` -> появится КРАСНЫЙ КРЕСТ.
raise ToolException(error_msg)
# Выполняется только при успехе (зеленый чекмарк)
if debug_callback:
debug_callback(kwargs, raw_output)
return f"Инструмент '{tool_name}' вернул следующий результат:\n<tool_output>\n{raw_output}\n</tool_output>"
return StructuredTool.from_function(
func=mcp_tool_runner,
name=tool_name.replace('-','_'), # Langchain не любит тире в именах
description=tool_desc or f"MCP Tool {tool_name}",
args_schema=args_schema,
handle_tool_error=True # Разрешает агенту "выжить" после ошибки тула и сгенерировать ответ
)
def fetch_mcp_tools(server_config, debug_callback=None, vault_cwd=None):
"""Один раз читает список тулов от сервера и кеширует их схемы"""
if not MCP_AVAILABLE:
print("⚠️ MCP серверы настроены, но библиотека 'mcp' не установлена. Выполните: pip install mcp")
return []
import asyncio
import shlex
import os
config_hash = str(server_config) # primitive hash
if config_hash in _MCP_CACHED_TOOLS:
return _MCP_CACHED_TOOLS[config_hash]
async def _fetch():
command = server_config.get("command", "")
args_str = server_config.get("args", "")
args = shlex.split(args_str) if args_str else []
env_dict = {}
env_str = server_config.get("envString", "")
if env_str:
import re
# Регулярка ищет: КЛЮЧ = "ЗНАЧЕНИЕ" | 'ЗНАЧЕНИЕ' | ЗНАЧЕНИЕО_ЗАПЯТОЙ
pattern = r'([^,= \t]+)\s*=\s*(?:"([^"]*)"|\'([^\']*)\'|([^,]*))'
for m in re.finditer(pattern, env_str):
k = m.group(1)
# Берем то совпадение, которое сработало (2 - двойные кавычки, 3 - одинарные, 4 - без кавычек)
v = m.group(2) if m.group(2) is not None else \
m.group(3) if m.group(3) is not None else \
m.group(4) if m.group(4) is not None else ""
env_dict[k.strip()] = v.strip()
full_env = {**os.environ.copy(), **env_dict}
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()
tools_resp = await session.list_tools()
return tools_resp.tools, full_env, args
try:
raw_tools, full_env, args = asyncio.run(_fetch())
wrapped_tools = []
# Получаем кастомные описания из конфига
fetched_tools_ui = server_config.get("fetchedTools", [])
custom_descriptions = {t.get("name"): t.get("customDescription") for t in fetched_tools_ui}
enabled_tools = {t.get("name"): t.get("isEnabled", True) for t in fetched_tools_ui}
for t in raw_tools:
# Если инструмент отключен в настройках плагина — пропускаем
if not enabled_tools.get(t.name, True):
continue
# Если есть кастомное описание и оно не пустое - используем его
c_desc = custom_descriptions.get(t.name)
if c_desc and c_desc.strip():
final_description = f"{t.description}\n\nДОПОЛНИТЕЛЬНЫЕ ИНСТРУКЦИИ:\n{c_desc.strip()}"
else:
final_description = t.description
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')}'")
return wrapped_tools
except Exception as e:
print(f"❌ Ошибка инициализации MCP сервера {server_config.get('name')}: {e}")
return []
def get_raw_mcp_tools_list(server_config, vault_cwd=None):
"""Метод для UI: просто возвращает список доступных инструментов в JSON-friendly формате"""
if not MCP_AVAILABLE:
return []
import asyncio
import shlex
import os
async def _fetch_raw():
command = server_config.get("command", "")
args_str = server_config.get("args", "")
args = shlex.split(args_str) if args_str else []
env_dict = {}
env_str = server_config.get("envString", "")
if env_str:
for pair in env_str.split(','):
if '=' in pair:
k, v = pair.split('=', 1)
env_dict[k.strip()] = v.strip()
full_env = {**os.environ.copy(), **env_dict}
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()
tools_resp = await session.list_tools()
return tools_resp.tools
try:
raw_tools = asyncio.run(_fetch_raw())
return [{"name": t.name, "description": t.description} for t in raw_tools]
except Exception as e:
print(f"Ошибка получения списка тулов: {e}")
return []

View File

@ -16,11 +16,10 @@ import json
import os import os
import sys import sys
from typing import Any, get_type_hints from typing import Any, get_type_hints
from langchain_core.tools import StructuredTool
from pydantic import BaseModel, Field, create_model from pydantic import BaseModel, Field, create_model
from mcp_tools import fetch_mcp_tools def build_react_agent(model_name: str, temperature: float, max_tokens: int, agency_mode: bool, obsidian_settings: dict):
def build_react_agent(model_name: str, temperature: float, max_tokens: int, agency_mode: bool, obsidian_settings: dict, debug_callback=None):
"""Инициализация ядра на базе LangGraph.""" """Инициализация ядра на базе LangGraph."""
m_cfg = MODELS.get(model_name) m_cfg = MODELS.get(model_name)
llm = ChatOpenAI( llm = ChatOpenAI(
@ -31,7 +30,7 @@ def build_react_agent(model_name: str, temperature: float, max_tokens: int, agen
max_tokens=max_tokens if max_tokens else None, max_tokens=max_tokens if max_tokens else None,
streaming=True streaming=True
) )
tools = get_dynamic_tools(obsidian_settings, debug_callback) if agency_mode else [] tools = get_dynamic_tools(obsidian_settings) if agency_mode else []
return create_react_agent(llm, tools=tools) return create_react_agent(llm, tools=tools)
# Специфическая Pydantic-модель для инструментов без скрипта # Специфическая Pydantic-модель для инструментов без скрипта
@ -42,7 +41,7 @@ class SystemHelpSchema(BaseModel):
dummy: str = Field(default="", description="Необязательный параметр, оставь пустым.") dummy: str = Field(default="", description="Необязательный параметр, оставь пустым.")
def create_subprocess_tool(script_path: str, method_name: str, def create_subprocess_tool(script_path: str, method_name: str,
hidden_prompt: str, debug_callback=None): hidden_prompt: str):
"""Вид 2: Инструмент со скриптом + пост-процессинг.""" """Вид 2: Инструмент со скриптом + пост-процессинг."""
script_dir = os.path.dirname(os.path.abspath(script_path)) script_dir = os.path.dirname(os.path.abspath(script_path))
@ -55,12 +54,6 @@ def create_subprocess_tool(script_path: str, method_name: str,
spec = importlib.util.spec_from_file_location("user_dynamic_tool", script_path) spec = importlib.util.spec_from_file_location("user_dynamic_tool", script_path)
module = importlib.util.module_from_spec(spec) module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module) 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) func = getattr(module, method_name)
docstring = inspect.getdoc(func) or f"Выполняет скрипт {method_name}" docstring = inspect.getdoc(func) or f"Выполняет скрипт {method_name}"
@ -76,19 +69,12 @@ def create_subprocess_tool(script_path: str, method_name: str,
@functools.wraps(func) @functools.wraps(func)
def wrapper(**kwargs: Any) -> str: def wrapper(**kwargs: Any) -> str:
input_json = json.dumps(kwargs, indent=2, ensure_ascii=False)
runner_code = f""" runner_code = f"""
import json import json
import sys import sys
import importlib.util import importlib.util
import os 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) # Добавляем папку со скриптом в sys.path, чтобы работали локальные импорты (например, norminv)
path_to_add = os.path.dirname(os.path.abspath(r'{script_path}')) path_to_add = os.path.dirname(os.path.abspath(r'{script_path}'))
original_path = sys.path.copy() original_path = sys.path.copy()
@ -120,10 +106,6 @@ finally:
) )
raw_output = process.stdout.strip() raw_output = process.stdout.strip()
# ВЫЗЫВАЕМ КОЛБЭК, если он передан
if debug_callback:
debug_callback(input_json, raw_output)
raw_stripped = raw_output.strip() if raw_output else "" raw_stripped = raw_output.strip() if raw_output else ""
prompt_stripped = hidden_prompt.strip() if hidden_prompt else "" prompt_stripped = hidden_prompt.strip() if hidden_prompt else ""
@ -176,25 +158,38 @@ finally:
args_schema=args_schema, # ← вместо infer_schema=True args_schema=args_schema, # ← вместо infer_schema=True
) )
def get_dynamic_tools(obsidian_settings: dict, debug_callback=None): def get_dynamic_tools(obsidian_settings: dict):
"""Собирает инструменты двух видов на основе настроек Obsidian.""" """Собирает инструменты двух видов на основе настроек Obsidian."""
tools = [] tools = []
# Инициализация внутренних инструментов (скриптов)
custom_tools = obsidian_settings.get("customTools", []) custom_tools = obsidian_settings.get("customTools", [])
for ct in custom_tools: for ct in custom_tools:
name = ct.get("name") name = ct.get("name")
desc = ct.get("description") desc = ct.get("description")
full_path = ct.get("scriptPath", "")
# Поддерживаем оба ключа, если в JS/TS они не раскрылись # Поддерживаем оба ключа, если в JS/TS они не раскрылись
hidden_prompt = ct.get("hiddenPromptExpanded") or ct.get("hiddenPrompt", "") hidden_prompt = ct.get("hiddenPromptExpanded") or ct.get("hiddenPrompt", "")
if not name: if not name:
continue # Инструмент без имени создать нельзя continue # Инструмент без имени создать нельзя
# Достаем путь и метод из отдельных полей, присланных фронтендом # Парсим путь к скрипту (учитываем Windows-пути с буквой диска)
script_path = ct.get("scriptPath", "") script_path = ""
method_name = ct.get("methodName") or "main" 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
# ПРОВЕРКА: Есть ли физический скрипт? # ПРОВЕРКА: Есть ли физический скрипт?
is_valid_script = script_path and os.path.exists(script_path) is_valid_script = script_path and os.path.exists(script_path)
@ -226,7 +221,7 @@ def get_dynamic_tools(obsidian_settings: dict, debug_callback=None):
# ВИД 2: Скрипт + Прилизывание # ВИД 2: Скрипт + Прилизывание
# ---------------------------------------------------- # ----------------------------------------------------
try: try:
tool = create_subprocess_tool(script_path, method_name, hidden_prompt, debug_callback) tool = create_subprocess_tool(script_path, method_name, hidden_prompt)
tool.name = name # Приоритет имени из UI Obsidian tool.name = name # Приоритет имени из UI Obsidian
if desc: if desc:
tool.description = desc tool.description = desc
@ -247,18 +242,6 @@ def get_dynamic_tools(obsidian_settings: dict, debug_callback=None):
) )
tools.append(help_tool) tools.append(help_tool)
vault_cwd = obsidian_settings.get("vaultAbsolutePath")
# Инициализация внутренних MCP серверов
mcp_servers = obsidian_settings.get("mcpServers", [])
for mcp_server in mcp_servers:
# Пропускаем отключенные серверы
if not mcp_server.get("isEnabled", True):
continue
mcp_tools = fetch_mcp_tools(mcp_server, debug_callback, vault_cwd=vault_cwd)
tools.extend(mcp_tools)
return tools return tools
def _build_pydantic_schema_from_func(func) -> type[BaseModel]: def _build_pydantic_schema_from_func(func) -> type[BaseModel]:
@ -315,33 +298,12 @@ def _generate_commands_list(custom_tools: list[dict[str, Any]]) -> str:
if not custom_tools: if not custom_tools:
return "В данный момент нет доступных дополнительных команд. Ты работаешь как обычный собеседник." return "В данный момент нет доступных дополнительных команд. Ты работаешь как обычный собеседник."
tool_raw_list = ["Я могу использовать следующие инструменты/команды:\n"] lines = ["Я могу использовать следующие инструменты/команды:\n"]
for ct in custom_tools: for ct in custom_tools:
name = ct.get("name") name = ct.get("name")
desc = ct.get("description", "Описание отсутствует") desc = ct.get("description", "Описание отсутствует")
if name: if name:
tool_raw_list.append(f"- **{name}**: {desc}") lines.append(f"- **{name}**: {desc}")
# Приаттачиваем инструкцию формата вывода (hidden prompt для системы) lines.append("\nПерескажи этот список пользователю в удобном формате.")
prompt = f""" return "\n".join(lines)
СПИСОК ДОСТУПНЫХ ИНСТРУМЕНТОВ:
{tool_raw_list}
ТВОЯ ЗАДАЧА:
Сгруппируй инструменты по уровням абстракции и выведи их в формате Markdown-таблиц.
ИСПОЛЬЗУЙ СЛЕДУЮЩИЕ КАТЕГОРИИ:
1. 🧩 [Микро-уровень]: Атомарные сущности, единичные объекты, конкретные детали или расчеты.
2. 🏗 [Мезо-уровень]: Процессы, сложные узлы, локальные системы, взаимодействия или окружение.
3. 🗺 [Макро-уровень]: Глобальные структуры, архитектура системы, высокоуровневая логика или долгосрочные стратегии.
4. [Мета-уровень]: Управление сессией, диагностика, системная помощь, логирование или работа с контекстом.
ФОРМАТ ВЫВОДА:
Для каждой категории создай заголовок и таблицу:
| Команда | Функциональное назначение |
|:---|:---|
| **названиеоманды** | Суть работы (1 краткое предложение) |
Не добавляй никаких приветствий или пояснений вне блока кода. Будь предельно лаконичен.
"""
return prompt

View File

@ -1,26 +0,0 @@
# test_mcp.py
import asyncio
import sys
print(f"Python: {sys.version}")
from mcp.client.stdio import stdio_client, StdioServerParameters
from mcp.client.session import ClientSession
async def test():
params = StdioServerParameters(
command="podman",
args=["run", "-i", "--rm", "ddg-mcp-server"],
env=None
)
print("opening stdio_client...")
async with stdio_client(params) as (read, write):
print("creating session...")
async with ClientSession(read, write) as session:
print("initializing...")
await session.initialize()
print("OK!")
tools = await session.list_tools()
for t in tools.tools:
print(f" - {t.name}")
asyncio.run(test())

View File

@ -11,8 +11,8 @@ from llm_client import DEFAULT_TEMPERATURE, get_llm
from langchain_core.messages import SystemMessage, HumanMessage from langchain_core.messages import SystemMessage, HumanMessage
import uuid import uuid
DEFAULT_SUMMARIZATION_LLM_NAME = "gemini-2.5-flash-lite-polza" # "mistral-small-latest" # "mistral-small-latest" #"gemini-2.0-flash-r" DEFAULT_SUMMARIZATION_LLM_NAME = "gemini-2.5-flash-lite-openrouter" # "mistral-small-latest" # "mistral-small-latest" #"gemini-2.0-flash-r"
DEFAULT_VOICE_LLM_NAME = "deepseek-v4-flash-polza" # "gemini-3.0-flash-openrouter" DEFAULT_VOICE_LLM_NAME = "deepseek-v4-flash-openrouter" # "gemini-3.0-flash-openrouter"
MAX_TITLE_GENERATION_CONTENT_LENGTH = 5000 # Максимальное количество символов для генерации заголовков MAX_TITLE_GENERATION_CONTENT_LENGTH = 5000 # Максимальное количество символов для генерации заголовков
QUEUE_POLLING_INTERVAL = 4 QUEUE_POLLING_INTERVAL = 4
@ -20,7 +20,7 @@ QUEUE_POLLING_INTERVAL = 4
TITLE_TEMPERATURE = 0.1 TITLE_TEMPERATURE = 0.1
MAX_TITLE_LENGTH = 100 MAX_TITLE_LENGTH = 100
from voice_service import VOICE_COMMANDS_RESPONSE_TO_STORE from app.voice_service import VOICE_COMMANDS_RESPONSE_TO_STORE
class TitleGenerator: class TitleGenerator:
"""Сервис для асинхронной генерации заголовков.""" """Сервис для асинхронной генерации заголовков."""

View File

@ -40,21 +40,8 @@ def run_agent_streaming(graph_id: str,
# 2. Формируем контекст LangChain с учетом файлов и изображений (вложений) # 2. Формируем контекст LangChain с учетом файлов и изображений (вложений)
messages_for_llm = [] messages_for_llm = []
if system_prompt:
# Собираем над-инструкции для MCP серверов messages_for_llm.append(SystemMessage(content=system_prompt))
mcp_instructions = []
if obsidian_settings and obsidian_settings.get("mcpServers"):
for server in obsidian_settings.get("mcpServers", []):
s_instr = server.get("serverInstruction")
if s_instr and s_instr.strip():
mcp_instructions.append(f"Инструкции для использования инструментов сервера '{server.get('name')}':\n{s_instr.strip()}")
final_system_prompt = system_prompt or ""
if mcp_instructions:
final_system_prompt += "\n\n" + "\n\n".join(mcp_instructions)
if final_system_prompt:
messages_for_llm.append(SystemMessage(content=final_system_prompt.strip()))
# Обрабатываем вложения # Обрабатываем вложения
for i, msg in enumerate(messages): for i, msg in enumerate(messages):
@ -100,61 +87,34 @@ def run_agent_streaming(graph_id: str,
elif role == "assistant": elif role == "assistant":
messages_for_llm.append(AIMessage(content=msg.get("content", ""))) messages_for_llm.append(AIMessage(content=msg.get("content", "")))
# Локальное хранилище для отладки текущего запуска (на случай кастомных тулов)
current_run_debug = []
def capture_debug(req, res):
current_run_debug.append({"req": req, "res": res})
# 3. Создаем агента. Если agency_mode == False, он соберет 0 тулов и отработает как обычная LLM. # 3. Создаем агента. Если agency_mode == False, он соберет 0 тулов и отработает как обычная LLM.
agent_executor = build_react_agent( agent_executor = build_react_agent(
model_name=model, model_name=model,
temperature=temperature, temperature=temperature,
max_tokens=max_tokens, max_tokens=max_tokens,
agency_mode=agency_mode, agency_mode=agency_mode,
obsidian_settings=obsidian_settings or {}, obsidian_settings=obsidian_settings or {}
debug_callback=capture_debug
) )
# Буфер для вызовов тулов от LLM
step_counter = 0
tool_mapping = {} # Хранит связку: tc_id -> Номер шага
active_tool_calls = {} # Хранит прогресс для конкретных инструментов { tc_id: {"name": ..., "args": ...} }
current_idx_map = {} # Временный маппинг: index (из чанков текущего ответа) -> глобальный tc_id
# 4. Выполняем цикл с потоковой передачей событий # 4. Выполняем цикл с потоковой передачей событий
step_counter = 0
# LangGraph Stream Mode 'messages': отдает чанки токенов и вызовы функций
for chunk, metadata in agent_executor.stream({"messages": messages_for_llm}, stream_mode="messages"): for chunk, metadata in agent_executor.stream({"messages": messages_for_llm}, stream_mode="messages"):
if isinstance(chunk, AIMessageChunk): if isinstance(chunk, AIMessageChunk):
# Агент запросил вызов инструмента (Формирование JSON аргументов) # Агент запросил вызов инструмента (Формирование JSON аргументов)
if hasattr(chunk, "tool_call_chunks") and chunk.tool_call_chunks: if hasattr(chunk, "tool_call_chunks") and chunk.tool_call_chunks:
for tc in chunk.tool_call_chunks: for tc in chunk.tool_call_chunks:
idx = tc.get("index", 0) if "name" in tc and tc["name"]:
# При поступлении самого первого чанка для инструмента, в нем есть УНИКАЛЬНЫЙ 'id'
if "id" in tc and tc["id"]:
tc_id = tc["id"]
# Запоминаем, что индекс idx в текущем цикле принадлежит уникальному tc_id
current_idx_map[idx] = tc_id
step_counter += 1 step_counter += 1
tool_mapping[tc_id] = step_counter
active_tool_calls[tc_id] = {"name": tc.get("name", "unknown_tool"), "args": ""}
yield { yield {
"type": "tool_start", "type": "tool_start",
"name": active_tool_calls[tc_id]["name"], "name": tc["name"],
"step": step_counter, "step": step_counter,
"node_id": assistant_node_id "node_id": assistant_node_id
} }
# Считываем сырой JSON кусочек за кусочком по мере их поступления
if "args" in tc and tc["args"]:
# Достаем сохраненный уникальный id из маппинга для текущего сообщения
tc_id = current_idx_map.get(idx)
if tc_id and tc_id in active_tool_calls:
active_tool_calls[tc_id]["args"] += tc["args"]
# Обычный стриминг текста финального ответа # Обычный стриминг текста финального ответа
if chunk.content: if chunk.content:
yield { yield {
@ -165,38 +125,11 @@ def run_agent_streaming(graph_id: str,
# Тул отработал и принес ответ # Тул отработал и принес ответ
elif hasattr(chunk, "type") and chunk.type == "tool": elif hasattr(chunk, "type") and chunk.type == "tool":
res = str(chunk.content)
tc_id = getattr(chunk, "tool_call_id", "")
# 1. Забираем готовую строку (JSON) из нашего прямого перехвата LLM по уникальному tc_id!
req = active_tool_calls.get(tc_id, {}).get("args", "")
# 2. Резервный механизм на старых колбеках (и форматирование в json если это dict)
if not req and current_run_debug:
dbg = current_run_debug.pop(0)
rq = dbg.get("req", "")
if isinstance(rq, dict):
import json
req = json.dumps(rq, indent=2, ensure_ascii=False)
else:
req = str(rq)
# Ловушка для определения ошибок
is_error = False
if getattr(chunk, "status", "") == "error" or "ОШИБКА ИСПОЛНЕНИЯ" in res or "SCRIPT RUNTIME ERROR" in res:
is_error = True
# Ищем шаг тула
actual_step = tool_mapping.get(tc_id, step_counter)
yield { yield {
"type": "tool_end", "type": "tool_end",
"name": getattr(chunk, "name", "tool"), "name": chunk.name,
"step": actual_step, "step": step_counter,
"node_id": assistant_node_id, "node_id": assistant_node_id
"request": req,
"response": res,
"is_error": is_error
} }
except Exception as e: except Exception as e:

View File

@ -1,28 +0,0 @@
@echo off
chcp 65001 > nul
:: 1. Включаем виртуальное окружение перед стартом
if exist env\Scripts\activate.bat (
echo 🔌 Активация виртуального окружения env...
call env\Scripts\activate.bat
) else (
echo ⚠️ Предупреждение: Папка окружения env\Scripts\activate.bat не найдена!
echo Попытка сборки в глобальном окружении...
)
:: 2. Запуск компиляции (скрипт сам создаст папку dist)
python build/compile.py build_ext --inplace
:: 3. Сохранение зависимостей в папку dist
pip freeze > dist/requirements_all.txt
:: 4. Копирование деплой-скрипта из папки build в папку dist
if exist build\deploy.bat (
copy build\deploy.bat dist\deploy.bat /y
echo ✅ Файл deploy.bat успешно скопирован в dist/
) else (
echo ⚠️ Предупреждение: Файл build\deploy.bat не найден!
)
echo ===== СБОРКА ПОЛНОСТЬЮ ЗАВЕРШЕНА =====
pause

View File

@ -1,93 +0,0 @@
import os
import shutil
from setuptools import setup, Extension
from Cython.Build import cythonize
def build_project():
# 1. Сначала определяем все абсолютные пути, пока мы в исходной директории
build_dir = os.path.dirname(os.path.abspath(__file__)) # папка build/
project_root = os.path.abspath(os.path.join(build_dir, "..")) # корень проекта
source_app = os.path.join(project_root, "app")
dist_dir = os.path.join(project_root, "dist")
dist_app = os.path.join(dist_dir, "app")
build_main_file = os.path.join(build_dir, "main.py")
dist_main_file = os.path.join(dist_dir, "main.py")
print("🚀 Старт сборки проекта...")
# 2. Пересоздаем чистую папку dist/
if os.path.exists(dist_dir):
shutil.rmtree(dist_dir)
os.makedirs(dist_dir)
# 3. Копируем исходный код app/ в dist/app/
shutil.copytree(source_app, dist_app)
# Сохраняем текущую директорию корня, чтобы вернуться в неё в конце
original_cwd = os.getcwd()
# СИСТЕМНЫЙ СДВИГ: Переносим рабочую директорию процесса внутрь dist/app/
# Теперь для компилятора корнем является именно dist/app/
os.chdir(dist_app)
# 4. Собираем модули для компиляции (сканируем текущую папку, так как мы уже внутри dist/app)
extensions = []
for root, _, files in os.walk("."):
for file in files:
if file.endswith('.py') and not file.startswith('__'):
full_path = os.path.join(root, file)
# Вычисляем путь относительно dist/app
clean_path = os.path.relpath(full_path, ".")
# Формируем имя модуля для Python (например, workflows.graph_manager)
module_name = clean_path.replace(os.sep, '.').rstrip('.py')
if module_name == "run":
extensions.append(Extension("run", [clean_path]))
else:
extensions.append(Extension(module_name, [clean_path]))
# 5. Запускаем компиляцию (теперь --inplace положит бинарники строго в dist/app/)
setup(
name="AppBinary",
script_args=["build_ext", "--inplace"],
ext_modules=cythonize(
extensions,
language_level="3",
compiler_directives={
'annotation_typing': False, # Отключаем строгую типизацию (фиксит max_tokens)
'binding': True, # Сохраняем Python-совместимость методов
'always_allow_keywords': True # Разрешаем передачу None в именованные аргументы
}
),
)
# 6. Очищаем dist/app/ от исходного кода
print("\n🧹 Удаление исходного кода из папки dist/app/...")
for root, _, files in os.walk("."):
for file in files:
if (file.endswith('.py') and not file.startswith('__')) or file.endswith('.c'):
try:
os.remove(os.path.join(root, file))
except OSError:
pass
# Удаляем временную папку сборки компилятора C++
if os.path.exists("build"):
shutil.rmtree("build")
# Возвращаем рабочую директорию обратно в корень проекта
os.chdir(original_cwd)
# 7. Копируем файл build/main.py в dist/main.py
if os.path.exists(build_main_file):
shutil.copy(build_main_file, dist_main_file)
print("📄 Файл build/main.py успешно скопирован в dist/main.py")
else:
print("⚠️ Ошибка: Файл build/main.py не найден, нечего копировать!")
print("\n✅ Сборка успешно завершена! Папка dist/ готова к деплою.")
if __name__ == "__main__":
build_project()

View File

@ -1,56 +0,0 @@
@echo off
chcp 65001 > nul
echo ===================================================
echo 🚀 Начинаем автоматическое развертывание проекта...
echo ===================================================
:: 1. Проверяем, установлен ли Python в системе
python --version >nul 2>&1
if %errorlevel% neq 0 (
echo ❌ Ошибка: Python не найден в системе!
echo Установите Python той же мажорной версии, на которой собирался проект.
goto error
)
:: 2. Создаем изолированное виртуальное окружение prod_env
if not exist prod_env (
echo 📦 Создание виртуального окружения prod_env...
python -m venv prod_env
if %errorlevel% neq 0 (
echoНе удалось создать виртуальное окружение!
goto error
)
echo ✅ Окружение prod_env успешно создано.
) else (
echo Окружение prod_env уже существует, пропускаем создание.
)
:: 3. Обновляем pip и устанавливаем зависимости в prod_env без его активации
echo 🗂️ Установка внешних библиотек из requirements_all.txt...
prod_env\Scripts\python.exe -m pip install --upgrade pip > nul
prod_env\Scripts\python.exe -m pip install -r requirements_all.txt
if %errorlevel% neq 0 (
echo ❌ Ошибка при установке зависимостей! Проверьте интернет-соединение.
goto error
)
echoВсе внешние библиотеки успешно установлены.
:: 4. Формируем удобный ярлык/скрипт для быстрого запуска сервера в будущем
echo @echo off > run_server.bat
echo chcp 65001 ^> nul >> run_server.bat
echo echo 🔌 Запуск защищенного бэкенд сервера... >> run_server.bat
echo prod_env\Scripts\python.exe main.py >> run_server.bat
echo pause >> run_server.bat
echo ===================================================
echo 🎉 ДЕПЛОЙ УСПЕШНО ЗАВЕРШЕН!
echo ===================================================
echo 💡 Для запуска сервера используйте созданный файл: run_server.bat
echo ===================================================
pause
exit /b 0
:error
echo 🟥 Деплой завершился с ошибкой!
pause
exit /b 1

View File

@ -1,12 +0,0 @@
import sys
import os
if __name__ == "__main__":
# Добавляем папку app в пути поиска, как в вашей оригинальной «голове»
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), 'app'))
# Python сам найдет файл run.cp311-win_amd64.pyd и импортирует его
import run
# Запускаем сервер
run.start_app()

BIN
geckodriver.exe Normal file

Binary file not shown.

65
obfuscate.txt Normal file
View File

@ -0,0 +1,65 @@
Для создания исполняемого файла (.exe), который запускается без установки, с обфусцированным кодом Python лучше всего использовать связку PyArmor + PyInstaller:
1. Обфускация с PyArmor:
- PyArmor шифрует и обфусцирует исходный Python-код, усложняя его анализ и обратную разработку.
- Используется командой:
```
pyarmor gen -O dist --recursive путь/к/вашему/проекту
```
или
```
pyarmor gen -O dist --recursive .
```
- Это создаст обфусцированные версии скриптов с сохранением структуры.
2. Упаковка в exe с PyInstaller:
- После обфускации используйте PyInstaller, чтобы упаковать проект в единый exe-файл с параметром `--onefile`:
```
pyinstaller --onefile --noconsole ваш_главный_скрипт.py
```
? python -m PyInstaller --onefile --noconsole run.py
? pyinstaller --onefile --noconsole --paths=./app run.py
? pyinstaller --onefile --hidden-import=eventlet.green --hidden-import=eventlet.green.thread --hidden-import=engineio.async_eventlet --hidden-import=engineio.async_drivers.eventlet --noconsole --paths=./app run.py
- Если нужно, добавляйте параметры `--add-data` для ресурсов.
3. Итог:
- Итоговый .exe-файл будет содержать интерпретатор Python, все зависимости и обфусцированный код.
- Запуск exe не требует предварительной установки Python или зависимостей.
- Такой файл удобно выкладывать в релизы GitHub и распространять пользователям.
4. Важные советы:
- Всегда тестируйте exe на чистой системе.
- PyArmor даёт хорошую защиту, но не абсолютную.
- Для повышения безопасности используйте активацию лицензий PyArmor (опционно).
- Настройте spec-файл PyInstaller, чтобы включить все необходимые модули и ресурсы.
Пример типичного workflow:
```bash
# Обфускация
pyarmor obfuscate --recursive your_project/
# Переход в обфусцированную папку
cd dist/your_project
# Создание exe
pyinstaller --onefile --noconsole main.py
```
Тогда в папке `dist` появится самодостаточный исполняемый файл.
Таким образом, PyArmor + PyInstaller позволяет создать exe с обфусцированным кодом, который не требует установки Python и готов к распространению через GitHub релизы.[1][7][9]
[1](https://pressanybutton.ru/post/poleznye-instrumenty/python-proektav-exe-fajl-i-obfuskaciya-koda/)
[2](https://www.youtube.com/watch?v=15ho_Zg_Kq4)
[3](https://www.youtube.com/watch?v=HtiYdCk0_J8)
[4](https://www.reddit.com/r/learnpython/comments/144tdhy/obfuscating_python_script/)
[5](https://stackoverflow.com/questions/58334936/pack-a-pyarmor-obfuscated-script-to-exe)
[6](https://qna.habr.com/q/1055278)
[7](https://pyarmor.readthedocs.io/en/v5.7.0/pack-obfuscated-scripts.html)
[8](https://github.com/dashingsoft/pyarmor)
[9](https://www.cyberforum.ru/python-ide/thread3151287.html)
[10](https://www.reddit.com/r/learnpython/comments/1bdsnce/which_one_is_best_for_code_obfuscation/)

View File

@ -6,8 +6,6 @@ langchain_core==0.3.76
langchain_google_genai==2.1.12 langchain_google_genai==2.1.12
mcp==1.27.2
flask==3.1.2 flask==3.1.2
flask_cors==6.0.1 flask_cors==6.0.1
flask_socketio flask_socketio
@ -22,12 +20,3 @@ numpy==2.1.0
matplotlib==3.8.4 matplotlib==3.8.4
pandas==2.2.2 pandas==2.2.2
symspellpy==6.7.7
# обфускация, сборка
# stickytape # сшивка в один файл?
# python-minifier
cython
setuptools

View File

@ -1,5 +1,5 @@
chcp 65001 chcp 65001
cd /d "%~dp0" cd /d "%~dp0"
call "env\Scripts\activate.bat" call "env\Scripts\activate.bat"
python "app/run.py" python run.py
pause pause

View File

@ -9,13 +9,12 @@ logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(level
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# ----------------------------------------------- Main ------------------------------------------------------------ # ----------------------------------------------- Main ------------------------------------------------------------
# Создаем явную точку входа if __name__ == "__main__":
def start_app():
# Добавляем папку app в Python path # Добавляем папку app в Python path
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'app')) sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'app'))
from api import api, voice_inst, current_obsidian_settings from app.api import api, voice_inst, current_obsidian_settings
from heartbeat_monitor import HeartbeatMonitor from app.heartbeat_monitor import HeartbeatMonitor
from workflows import graph_history_manager from app.workflows import graph_history_manager
logger.info("🚀 Запуск LLM Agent Backend сервера...") logger.info("🚀 Запуск LLM Agent Backend сервера...")
logger.info("📍 API будет доступно на: http://localhost:5000/api") logger.info("📍 API будет доступно на: http://localhost:5000/api")
@ -34,7 +33,7 @@ def start_app():
logger.info("HeartbeatMonitor запущен.") logger.info("HeartbeatMonitor запущен.")
from heartbeat_monitor import VoiceHeartbeatMonitor from app.heartbeat_monitor import VoiceHeartbeatMonitor
v_monitor = VoiceHeartbeatMonitor( v_monitor = VoiceHeartbeatMonitor(
voice_inst, voice_inst,
@ -50,7 +49,3 @@ def start_app():
host='localhost', host='localhost',
threaded=True threaded=True
) )
# Оставляем этот блок для локальных тестов, если запускаете исходник напрямую
if __name__ == "__main__":
start_app()

38
run.spec Normal file
View File

@ -0,0 +1,38 @@
# -*- mode: python ; coding: utf-8 -*-
a = Analysis(
['run.py'],
pathex=['./app'],
binaries=[],
datas=[],
hiddenimports=['eventlet.green', 'eventlet.green.thread', 'engineio.async_eventlet', 'engineio.async_drivers.eventlet'],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
noarchive=False,
optimize=0,
)
pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.datas,
[],
name='run',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
upx_exclude=[],
runtime_tmpdir=None,
console=False,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
)

8
src/types/electron.d.ts vendored Normal file
View File

@ -0,0 +1,8 @@
// Добавьте этот файл, если его нет
interface Window {
electron?: {
shell: {
openPath: (path: string) => Promise<string>;
};
};
}