Compare commits
10 Commits
d3449bb50d
...
52d5196a48
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52d5196a48 | ||
|
|
ee2848ab68 | ||
|
|
7cb54a1641 | ||
|
|
7d04a7227d | ||
|
|
4989a0898f | ||
|
|
c4995d5088 | ||
|
|
82891822a5 | ||
|
|
305f8813a8 | ||
|
|
f27a84c670 | ||
|
|
c1665f3788 |
7
.gitignore
vendored
7
.gitignore
vendored
|
|
@ -8,6 +8,9 @@ __pycache__
|
||||||
*.db
|
*.db
|
||||||
*.db-journal
|
*.db-journal
|
||||||
|
|
||||||
build/
|
audio-logs/
|
||||||
|
|
||||||
audio-logs/
|
dist/
|
||||||
|
|
||||||
|
|
||||||
|
*.pyd
|
||||||
4
.vscode/launch.json
vendored
4
.vscode/launch.json
vendored
|
|
@ -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}/run.py",
|
"program": "${workspaceFolder}/app/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": true,
|
"justMyCode": false,
|
||||||
"console": "integratedTerminal"
|
"console": "integratedTerminal"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
9
Compile.md
Normal file
9
Compile.md
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
Для сборки с компиляцией
|
||||||
|
```
|
||||||
|
build.bat
|
||||||
|
```
|
||||||
|
|
||||||
|
В разработке - использовать
|
||||||
|
```
|
||||||
|
run-llm-backend.bat
|
||||||
|
```
|
||||||
63
README.md
Normal file
63
README.md
Normal file
|
|
@ -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 и кастомные инструменты.
|
||||||
|
- **Память**: Сохранение состояния графов, что позволяет продолжать диалог с любого места.
|
||||||
|
- **Голосовое управление**: Возможность диктовать команды напрямую в микрофон с транскрибацией в реальном времени.
|
||||||
173
app/api.py
173
app/api.py
|
|
@ -10,6 +10,10 @@ 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(
|
||||||
|
|
@ -232,7 +236,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")
|
||||||
|
|
||||||
|
|
@ -260,6 +264,11 @@ 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,
|
||||||
|
|
@ -271,6 +280,27 @@ 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"
|
||||||
|
|
||||||
# Обработка ошибки во время стриминга (крашим узел на фронте)
|
# Обработка ошибки во время стриминга (крашим узел на фронте)
|
||||||
|
|
@ -387,7 +417,22 @@ def regenerate_message():
|
||||||
return jsonify({"error": str(e)}, 500)
|
return jsonify({"error": str(e)}, 500)
|
||||||
|
|
||||||
|
|
||||||
from app.voice_service import VoiceService, VOICE_COMMANDS_RESPONSE_TO_STORE
|
@api.route('/api/mcp/fetch', methods=['POST'])
|
||||||
|
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()
|
||||||
|
|
@ -446,4 +491,126 @@ def sync_settings():
|
||||||
graph_history_manager.title_generator.voice_llm = get_llm(current_obsidian_settings['voiceCommandModel'])
|
graph_history_manager.title_generator.voice_llm = get_llm(current_obsidian_settings['voiceCommandModel'])
|
||||||
|
|
||||||
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
|
||||||
|
|
@ -28,62 +28,48 @@ MODELS: Dict[str, Dict[str, Any]] = {
|
||||||
"stream": True,
|
"stream": True,
|
||||||
"capabilities": ["vision"],
|
"capabilities": ["vision"],
|
||||||
},
|
},
|
||||||
"gemini-2.5-flash-lite-openrouter": {
|
"gemini-2.5-flash-lite-polza": {
|
||||||
"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://openrouter.ai/api/v1", # Добавлено /api/v1
|
"apiBase": "https://polza.ai/api/v1", # Добавлено /api/v1
|
||||||
"apiKey":
|
"apiKey": "pza_qPk0xSBItQkUs18xoBUlyBBeYiHAxasy",
|
||||||
"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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-r": {
|
"gemini-2.5-flash-polza": {
|
||||||
"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://openrouter.ai/api/v1", # Добавлено /api/v1
|
"apiBase": "https://polza.ai/api/v1", # Добавлено /api/v1
|
||||||
"apiKey":
|
"apiKey": "pza_qPk0xSBItQkUs18xoBUlyBBeYiHAxasy",
|
||||||
"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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-openrouter": {
|
"gemini-3.0-flash-polza": {
|
||||||
"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://openrouter.ai/api/v1", # Добавлено /api/v1
|
"apiBase": "https://polza.ai/api/v1",
|
||||||
"apiKey":
|
"apiKey": "pza_qPk0xSBItQkUs18xoBUlyBBeYiHAxasy",
|
||||||
"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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-openrouter": {
|
"gemini-3.1-pro-polza": {
|
||||||
"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://openrouter.ai/api/v1", # Добавлено /api/v1
|
"apiBase": "https://polza.ai/api/v1",
|
||||||
"apiKey":
|
"apiKey": "pza_qPk0xSBItQkUs18xoBUlyBBeYiHAxasy",
|
||||||
"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",
|
|
||||||
"stream": True,
|
"stream": True,
|
||||||
"capabilities": ["vision", "reasoning"],
|
"capabilities": ["vision", "reasoning"],
|
||||||
"model_kwargs": {
|
"model_kwargs": {
|
||||||
|
|
@ -111,16 +97,6 @@ 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",
|
||||||
|
|
@ -131,31 +107,56 @@ MODELS: Dict[str, Dict[str, Any]] = {
|
||||||
"stream": True,
|
"stream": True,
|
||||||
"capabilities": ["vision"],
|
"capabilities": ["vision"],
|
||||||
},
|
},
|
||||||
"claude-sonnet-4.6-openrouter": {
|
"claude-sonnet-5-polza": {
|
||||||
"name": "anthropic/claude-sonnet-4.6",
|
"name": "anthropic/claude-sonnet-5",
|
||||||
"provider": "openai",
|
"provider": "openai",
|
||||||
"model_name": "anthropic/claude-sonnet-4.6",
|
"model_name": "anthropic/claude-sonnet-5",
|
||||||
"apiBase": "https://openrouter.ai/api/v1", # Добавлено /api/v1
|
"apiBase": "https://polza.ai/api/v1",
|
||||||
"apiKey":
|
"apiKey": "pza_qPk0xSBItQkUs18xoBUlyBBeYiHAxasy",
|
||||||
"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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-openrouter": {
|
"deepseek-v4-flash-polza": {
|
||||||
"name": "deepseek/deepseek-v4-flash",
|
"name": "deepseek/deepseek-chat",
|
||||||
"provider": "openai",
|
"provider": "openai",
|
||||||
"model_name": "deepseek/deepseek-v4-flash",
|
"model_name": "deepseek/deepseek-chat",
|
||||||
"apiBase": "https://openrouter.ai/api/v1", # Добавлено /api/v1
|
"apiBase": "https://polza.ai/api/v1",
|
||||||
"apiKey":
|
"apiKey": "pza_qPk0xSBItQkUs18xoBUlyBBeYiHAxasy",
|
||||||
"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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"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -233,7 +234,7 @@ class CustomLLM:
|
||||||
kwargs = {"temperature": temperature}
|
kwargs = {"temperature": temperature}
|
||||||
if max_tokens:
|
if max_tokens:
|
||||||
kwargs["max_tokens"] = int(max_tokens)
|
kwargs["max_tokens"] = int(max_tokens)
|
||||||
|
|
||||||
response = self.client.chat.completions.create(
|
response = self.client.chat.completions.create(
|
||||||
model=self.model_name,
|
model=self.model_name,
|
||||||
messages=openai_messages,
|
messages=openai_messages,
|
||||||
|
|
@ -295,7 +296,7 @@ class CustomLLM:
|
||||||
kwargs = {"temperature": temperature}
|
kwargs = {"temperature": temperature}
|
||||||
if max_tokens:
|
if max_tokens:
|
||||||
kwargs["max_tokens"] = int(max_tokens)
|
kwargs["max_tokens"] = int(max_tokens)
|
||||||
|
|
||||||
response = self.client.chat.stream(model=self.model_name,
|
response = self.client.chat.stream(model=self.model_name,
|
||||||
messages=mistralai_messages,
|
messages=mistralai_messages,
|
||||||
**kwargs)
|
**kwargs)
|
||||||
|
|
|
||||||
228
app/mcp_tools.py
Normal file
228
app/mcp_tools.py
Normal file
|
|
@ -0,0 +1,228 @@
|
||||||
|
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 []
|
||||||
|
|
@ -16,10 +16,11 @@ 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
|
||||||
|
|
||||||
def build_react_agent(model_name: str, temperature: float, max_tokens: int, agency_mode: bool, obsidian_settings: dict):
|
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, debug_callback=None):
|
||||||
"""Инициализация ядра на базе LangGraph."""
|
"""Инициализация ядра на базе LangGraph."""
|
||||||
m_cfg = MODELS.get(model_name)
|
m_cfg = MODELS.get(model_name)
|
||||||
llm = ChatOpenAI(
|
llm = ChatOpenAI(
|
||||||
|
|
@ -30,7 +31,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) if agency_mode else []
|
tools = get_dynamic_tools(obsidian_settings, debug_callback) if agency_mode else []
|
||||||
return create_react_agent(llm, tools=tools)
|
return create_react_agent(llm, tools=tools)
|
||||||
|
|
||||||
# Специфическая Pydantic-модель для инструментов без скрипта
|
# Специфическая Pydantic-модель для инструментов без скрипта
|
||||||
|
|
@ -41,7 +42,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):
|
hidden_prompt: str, debug_callback=None):
|
||||||
"""Вид 2: Инструмент со скриптом + пост-процессинг."""
|
"""Вид 2: Инструмент со скриптом + пост-процессинг."""
|
||||||
|
|
||||||
script_dir = os.path.dirname(os.path.abspath(script_path))
|
script_dir = os.path.dirname(os.path.abspath(script_path))
|
||||||
|
|
@ -54,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)
|
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}"
|
||||||
|
|
||||||
|
|
@ -69,12 +76,19 @@ 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()
|
||||||
|
|
@ -106,6 +120,10 @@ 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 ""
|
||||||
|
|
||||||
|
|
@ -158,38 +176,25 @@ finally:
|
||||||
args_schema=args_schema, # ← вместо infer_schema=True
|
args_schema=args_schema, # ← вместо infer_schema=True
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_dynamic_tools(obsidian_settings: dict):
|
def get_dynamic_tools(obsidian_settings: dict, debug_callback=None):
|
||||||
"""Собирает инструменты двух видов на основе настроек 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 = ""
|
script_path = ct.get("scriptPath", "")
|
||||||
method_name = "main"
|
method_name = ct.get("methodName") or "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)
|
||||||
|
|
@ -221,7 +226,7 @@ def get_dynamic_tools(obsidian_settings: dict):
|
||||||
# ВИД 2: Скрипт + Прилизывание
|
# ВИД 2: Скрипт + Прилизывание
|
||||||
# ----------------------------------------------------
|
# ----------------------------------------------------
|
||||||
try:
|
try:
|
||||||
tool = create_subprocess_tool(script_path, method_name, hidden_prompt)
|
tool = create_subprocess_tool(script_path, method_name, hidden_prompt, debug_callback)
|
||||||
tool.name = name # Приоритет имени из UI Obsidian
|
tool.name = name # Приоритет имени из UI Obsidian
|
||||||
if desc:
|
if desc:
|
||||||
tool.description = desc
|
tool.description = desc
|
||||||
|
|
@ -242,6 +247,18 @@ def get_dynamic_tools(obsidian_settings: dict):
|
||||||
)
|
)
|
||||||
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]:
|
||||||
|
|
@ -298,12 +315,33 @@ def _generate_commands_list(custom_tools: list[dict[str, Any]]) -> str:
|
||||||
if not custom_tools:
|
if not custom_tools:
|
||||||
return "В данный момент нет доступных дополнительных команд. Ты работаешь как обычный собеседник."
|
return "В данный момент нет доступных дополнительных команд. Ты работаешь как обычный собеседник."
|
||||||
|
|
||||||
lines = ["Я могу использовать следующие инструменты/команды:\n"]
|
tool_raw_list = ["Я могу использовать следующие инструменты/команды:\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:
|
||||||
lines.append(f"- **{name}**: {desc}")
|
tool_raw_list.append(f"- **{name}**: {desc}")
|
||||||
|
|
||||||
lines.append("\nПерескажи этот список пользователю в удобном формате.")
|
# Приаттачиваем инструкцию формата вывода (hidden prompt для системы)
|
||||||
return "\n".join(lines)
|
prompt = f"""
|
||||||
|
СПИСОК ДОСТУПНЫХ ИНСТРУМЕНТОВ:
|
||||||
|
{tool_raw_list}
|
||||||
|
|
||||||
|
ТВОЯ ЗАДАЧА:
|
||||||
|
Сгруппируй инструменты по уровням абстракции и выведи их в формате Markdown-таблиц.
|
||||||
|
|
||||||
|
ИСПОЛЬЗУЙ СЛЕДУЮЩИЕ КАТЕГОРИИ:
|
||||||
|
1. 🧩 [Микро-уровень]: Атомарные сущности, единичные объекты, конкретные детали или расчеты.
|
||||||
|
2. 🏗️ [Мезо-уровень]: Процессы, сложные узлы, локальные системы, взаимодействия или окружение.
|
||||||
|
3. 🗺️ [Макро-уровень]: Глобальные структуры, архитектура системы, высокоуровневая логика или долгосрочные стратегии.
|
||||||
|
4. ⚙️ [Мета-уровень]: Управление сессией, диагностика, системная помощь, логирование или работа с контекстом.
|
||||||
|
|
||||||
|
ФОРМАТ ВЫВОДА:
|
||||||
|
Для каждой категории создай заголовок и таблицу:
|
||||||
|
| Команда | Функциональное назначение |
|
||||||
|
|:---|:---|
|
||||||
|
| **название_команды** | Суть работы (1 краткое предложение) |
|
||||||
|
|
||||||
|
Не добавляй никаких приветствий или пояснений вне блока кода. Будь предельно лаконичен.
|
||||||
|
"""
|
||||||
|
return prompt
|
||||||
|
|
@ -9,12 +9,13 @@ 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 app.api import api, voice_inst, current_obsidian_settings
|
from api import api, voice_inst, current_obsidian_settings
|
||||||
from app.heartbeat_monitor import HeartbeatMonitor
|
from heartbeat_monitor import HeartbeatMonitor
|
||||||
from app.workflows import graph_history_manager
|
from 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")
|
||||||
|
|
@ -33,7 +34,7 @@ if __name__ == "__main__":
|
||||||
logger.info("HeartbeatMonitor запущен.")
|
logger.info("HeartbeatMonitor запущен.")
|
||||||
|
|
||||||
|
|
||||||
from app.heartbeat_monitor import VoiceHeartbeatMonitor
|
from heartbeat_monitor import VoiceHeartbeatMonitor
|
||||||
|
|
||||||
v_monitor = VoiceHeartbeatMonitor(
|
v_monitor = VoiceHeartbeatMonitor(
|
||||||
voice_inst,
|
voice_inst,
|
||||||
|
|
@ -48,4 +49,8 @@ if __name__ == "__main__":
|
||||||
port=5000,
|
port=5000,
|
||||||
host='localhost',
|
host='localhost',
|
||||||
threaded=True
|
threaded=True
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Оставляем этот блок для локальных тестов, если запускаете исходник напрямую
|
||||||
|
if __name__ == "__main__":
|
||||||
|
start_app()
|
||||||
26
app/test_mcp.py
Normal file
26
app/test_mcp.py
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
# 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())
|
||||||
|
|
@ -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-openrouter" # "mistral-small-latest" # "mistral-small-latest" #"gemini-2.0-flash-r"
|
DEFAULT_SUMMARIZATION_LLM_NAME = "gemini-2.5-flash-lite-polza" # "mistral-small-latest" # "mistral-small-latest" #"gemini-2.0-flash-r"
|
||||||
DEFAULT_VOICE_LLM_NAME = "deepseek-v4-flash-openrouter" # "gemini-3.0-flash-openrouter"
|
DEFAULT_VOICE_LLM_NAME = "deepseek-v4-flash-polza" # "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 app.voice_service import VOICE_COMMANDS_RESPONSE_TO_STORE
|
from voice_service import VOICE_COMMANDS_RESPONSE_TO_STORE
|
||||||
|
|
||||||
class TitleGenerator:
|
class TitleGenerator:
|
||||||
"""Сервис для асинхронной генерации заголовков."""
|
"""Сервис для асинхронной генерации заголовков."""
|
||||||
|
|
|
||||||
|
|
@ -40,8 +40,21 @@ def run_agent_streaming(graph_id: str,
|
||||||
|
|
||||||
# 2. Формируем контекст LangChain с учетом файлов и изображений (вложений)
|
# 2. Формируем контекст LangChain с учетом файлов и изображений (вложений)
|
||||||
messages_for_llm = []
|
messages_for_llm = []
|
||||||
if system_prompt:
|
|
||||||
messages_for_llm.append(SystemMessage(content=system_prompt))
|
# Собираем над-инструкции для MCP серверов
|
||||||
|
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):
|
||||||
|
|
@ -87,34 +100,61 @@ 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
|
||||||
)
|
)
|
||||||
|
|
||||||
# 4. Выполняем цикл с потоковой передачей событий
|
# Буфер для вызовов тулов от LLM
|
||||||
step_counter = 0
|
step_counter = 0
|
||||||
|
tool_mapping = {} # Хранит связку: tc_id -> Номер шага
|
||||||
|
active_tool_calls = {} # Хранит прогресс для конкретных инструментов { tc_id: {"name": ..., "args": ...} }
|
||||||
|
current_idx_map = {} # Временный маппинг: index (из чанков текущего ответа) -> глобальный tc_id
|
||||||
|
|
||||||
# LangGraph Stream Mode 'messages': отдает чанки токенов и вызовы функций
|
# 4. Выполняем цикл с потоковой передачей событий
|
||||||
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:
|
||||||
if "name" in tc and tc["name"]:
|
idx = tc.get("index", 0)
|
||||||
|
|
||||||
|
# При поступлении самого первого чанка для инструмента, в нем есть УНИКАЛЬНЫЙ '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": tc["name"],
|
"name": active_tool_calls[tc_id]["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 {
|
||||||
|
|
@ -125,11 +165,38 @@ 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": chunk.name,
|
"name": getattr(chunk, "name", "tool"),
|
||||||
"step": step_counter,
|
"step": actual_step,
|
||||||
"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:
|
||||||
|
|
|
||||||
28
build.bat
Normal file
28
build.bat
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
@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
|
||||||
93
build/compile.py
Normal file
93
build/compile.py
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
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()
|
||||||
56
build/deploy.bat
Normal file
56
build/deploy.bat
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
@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
|
||||||
12
build/main.py
Normal file
12
build/main.py
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
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
BIN
geckodriver.exe
Binary file not shown.
|
|
@ -1,65 +0,0 @@
|
||||||
Для создания исполняемого файла (.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/)
|
|
||||||
|
|
@ -6,6 +6,8 @@ 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
|
||||||
|
|
@ -19,4 +21,13 @@ sounddevice==0.5.1
|
||||||
numpy==2.1.0
|
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
|
||||||
|
|
@ -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 run.py
|
python "app/run.py"
|
||||||
pause
|
pause
|
||||||
38
run.spec
38
run.spec
|
|
@ -1,38 +0,0 @@
|
||||||
# -*- 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
8
src/types/electron.d.ts
vendored
|
|
@ -1,8 +0,0 @@
|
||||||
// Добавьте этот файл, если его нет
|
|
||||||
interface Window {
|
|
||||||
electron?: {
|
|
||||||
shell: {
|
|
||||||
openPath: (path: string) => Promise<string>;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue
Block a user