Compare commits
No commits in common. "52d5196a480643609de6b203691c8b566e273b2a" and "d3449bb50da92bb1704364972493a1e8394ea6dc" have entirely different histories.
52d5196a48
...
d3449bb50d
7
.gitignore
vendored
7
.gitignore
vendored
|
|
@ -8,9 +8,6 @@ __pycache__
|
|||
*.db
|
||||
*.db-journal
|
||||
|
||||
audio-logs/
|
||||
build/
|
||||
|
||||
dist/
|
||||
|
||||
|
||||
*.pyd
|
||||
audio-logs/
|
||||
4
.vscode/launch.json
vendored
4
.vscode/launch.json
vendored
|
|
@ -7,13 +7,13 @@
|
|||
"request": "launch",
|
||||
"python": "${workspaceFolder}/env/Scripts/python.exe",
|
||||
"cwd": "${workspaceFolder}",
|
||||
"program": "${workspaceFolder}/app/run.py",
|
||||
"program": "${workspaceFolder}/run.py",
|
||||
"env": {
|
||||
"FLASK_APP": "run.py",
|
||||
"FLASK_ENV": "development",
|
||||
"FLASK_DEBUG": "1"
|
||||
},
|
||||
"justMyCode": false,
|
||||
"justMyCode": true,
|
||||
"console": "integratedTerminal"
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,9 +0,0 @@
|
|||
Для сборки с компиляцией
|
||||
```
|
||||
build.bat
|
||||
```
|
||||
|
||||
В разработке - использовать
|
||||
```
|
||||
run-llm-backend.bat
|
||||
```
|
||||
63
README.md
63
README.md
|
|
@ -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 и кастомные инструменты.
|
||||
- **Память**: Сохранение состояния графов, что позволяет продолжать диалог с любого места.
|
||||
- **Голосовое управление**: Возможность диктовать команды напрямую в микрофон с транскрибацией в реальном времени.
|
||||
173
app/api.py
173
app/api.py
|
|
@ -10,10 +10,6 @@ from flask_cors import CORS
|
|||
from flask_socketio import SocketIO
|
||||
from workflows import graph_history_manager, run_agent_streaming
|
||||
from llm_client import DEFAULT_TEMPERATURE, MODELS, get_llm # Добавляем импорт списка моделей
|
||||
import base64
|
||||
from mcp_tools import get_raw_mcp_tools_list
|
||||
import threading
|
||||
import uuid as uuid_lib
|
||||
|
||||
api = Flask(__name__)
|
||||
CORS(
|
||||
|
|
@ -236,7 +232,7 @@ def chat_stream():
|
|||
cache_folder = data.get("cache_folder")
|
||||
temperature = data.get("temperature", DEFAULT_TEMPERATURE)
|
||||
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")
|
||||
|
||||
|
|
@ -264,11 +260,6 @@ def chat_stream():
|
|||
# Запускаем стриминг ответа от LLM (используем обновленный агент)
|
||||
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:
|
||||
for chunk_data in run_agent_streaming(graph_id, user_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") == "chunk":
|
||||
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"
|
||||
|
||||
# Обработка ошибки во время стриминга (крашим узел на фронте)
|
||||
|
|
@ -417,22 +387,7 @@ def regenerate_message():
|
|||
return jsonify({"error": str(e)}, 500)
|
||||
|
||||
|
||||
@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
|
||||
from app.voice_service import VoiceService, VOICE_COMMANDS_RESPONSE_TO_STORE
|
||||
|
||||
# 1. Создаем экземпляр (обязательно укажите путь к вашей модели Vosk)
|
||||
voice_inst = VoiceService()
|
||||
|
|
@ -491,126 +446,4 @@ def sync_settings():
|
|||
graph_history_manager.title_generator.voice_llm = get_llm(current_obsidian_settings['voiceCommandModel'])
|
||||
|
||||
print(f"✅⚙️ Settings synced: {current_obsidian_settings}")
|
||||
return jsonify({"status": "synced"})
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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
|
||||
return jsonify({"status": "synced"})
|
||||
|
|
@ -28,48 +28,62 @@ MODELS: Dict[str, Dict[str, Any]] = {
|
|||
"stream": True,
|
||||
"capabilities": ["vision"],
|
||||
},
|
||||
"gemini-2.5-flash-lite-polza": {
|
||||
"gemini-2.5-flash-lite-openrouter": {
|
||||
"name": "google/gemini-2.5-flash-lite",
|
||||
"provider": "openai",
|
||||
"model_name": "google/gemini-2.5-flash-lite",
|
||||
"apiBase": "https://polza.ai/api/v1", # Добавлено /api/v1
|
||||
"apiKey": "pza_qPk0xSBItQkUs18xoBUlyBBeYiHAxasy",
|
||||
"apiBase": "https://openrouter.ai/api/v1", # Добавлено /api/v1
|
||||
"apiKey":
|
||||
"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",
|
||||
"stream": True,
|
||||
"capabilities": ["vision", "reasoning"],
|
||||
"model_kwargs": {
|
||||
"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",
|
||||
"provider": "openai",
|
||||
"model_name": "google/gemini-2.5-flash",
|
||||
"apiBase": "https://polza.ai/api/v1", # Добавлено /api/v1
|
||||
"apiKey": "pza_qPk0xSBItQkUs18xoBUlyBBeYiHAxasy",
|
||||
"apiBase": "https://openrouter.ai/api/v1", # Добавлено /api/v1
|
||||
"apiKey":
|
||||
"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",
|
||||
"stream": True,
|
||||
"capabilities": ["vision", "reasoning"],
|
||||
"model_kwargs": {
|
||||
"include_reasoning": True
|
||||
}
|
||||
},
|
||||
"gemini-3.0-flash-polza": {
|
||||
"gemini-3.0-flash-openrouter": {
|
||||
"name": "google/gemini-3-flash-preview",
|
||||
"provider": "openai",
|
||||
"model_name": "google/gemini-3-flash-preview",
|
||||
"apiBase": "https://polza.ai/api/v1",
|
||||
"apiKey": "pza_qPk0xSBItQkUs18xoBUlyBBeYiHAxasy",
|
||||
"apiBase": "https://openrouter.ai/api/v1", # Добавлено /api/v1
|
||||
"apiKey":
|
||||
"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",
|
||||
"stream": True,
|
||||
"capabilities": ["vision", "reasoning"],
|
||||
"model_kwargs": {
|
||||
"include_reasoning": True
|
||||
}
|
||||
},
|
||||
"gemini-3.1-pro-polza": {
|
||||
"gemini-3.1-pro-openrouter": {
|
||||
"name": "google/gemini-3.1-pro-preview",
|
||||
"provider": "openai",
|
||||
"model_name": "google/gemini-3.1-pro-preview",
|
||||
"apiBase": "https://polza.ai/api/v1",
|
||||
"apiKey": "pza_qPk0xSBItQkUs18xoBUlyBBeYiHAxasy",
|
||||
"apiBase": "https://openrouter.ai/api/v1", # Добавлено /api/v1
|
||||
"apiKey":
|
||||
"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",
|
||||
"stream": True,
|
||||
"capabilities": ["vision", "reasoning"],
|
||||
"model_kwargs": {
|
||||
|
|
@ -97,6 +111,16 @@ MODELS: Dict[str, Dict[str, Any]] = {
|
|||
"stream": True,
|
||||
"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": {
|
||||
"name": "mistral-small-latest",
|
||||
"provider": "mistralai",
|
||||
|
|
@ -107,56 +131,31 @@ MODELS: Dict[str, Dict[str, Any]] = {
|
|||
"stream": True,
|
||||
"capabilities": ["vision"],
|
||||
},
|
||||
"claude-sonnet-5-polza": {
|
||||
"name": "anthropic/claude-sonnet-5",
|
||||
"claude-sonnet-4.6-openrouter": {
|
||||
"name": "anthropic/claude-sonnet-4.6",
|
||||
"provider": "openai",
|
||||
"model_name": "anthropic/claude-sonnet-5",
|
||||
"apiBase": "https://polza.ai/api/v1",
|
||||
"apiKey": "pza_qPk0xSBItQkUs18xoBUlyBBeYiHAxasy",
|
||||
"model_name": "anthropic/claude-sonnet-4.6",
|
||||
"apiBase": "https://openrouter.ai/api/v1", # Добавлено /api/v1
|
||||
"apiKey":
|
||||
"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",
|
||||
"stream": True,
|
||||
"capabilities": ["vision", "reasoning"],
|
||||
"model_kwargs": {
|
||||
"include_reasoning": True
|
||||
}
|
||||
},
|
||||
"deepseek-v4-flash-polza": {
|
||||
"name": "deepseek/deepseek-chat",
|
||||
"deepseek-v4-flash-openrouter": {
|
||||
"name": "deepseek/deepseek-v4-flash",
|
||||
"provider": "openai",
|
||||
"model_name": "deepseek/deepseek-chat",
|
||||
"apiBase": "https://polza.ai/api/v1",
|
||||
"apiKey": "pza_qPk0xSBItQkUs18xoBUlyBBeYiHAxasy",
|
||||
"model_name": "deepseek/deepseek-v4-flash",
|
||||
"apiBase": "https://openrouter.ai/api/v1", # Добавлено /api/v1
|
||||
"apiKey":
|
||||
"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",
|
||||
"stream": True,
|
||||
"capabilities": ["vision", "reasoning"],
|
||||
"model_kwargs": {
|
||||
"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"]
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -234,7 +233,7 @@ class CustomLLM:
|
|||
kwargs = {"temperature": temperature}
|
||||
if max_tokens:
|
||||
kwargs["max_tokens"] = int(max_tokens)
|
||||
|
||||
|
||||
response = self.client.chat.completions.create(
|
||||
model=self.model_name,
|
||||
messages=openai_messages,
|
||||
|
|
@ -296,7 +295,7 @@ class CustomLLM:
|
|||
kwargs = {"temperature": temperature}
|
||||
if max_tokens:
|
||||
kwargs["max_tokens"] = int(max_tokens)
|
||||
|
||||
|
||||
response = self.client.chat.stream(model=self.model_name,
|
||||
messages=mistralai_messages,
|
||||
**kwargs)
|
||||
|
|
|
|||
228
app/mcp_tools.py
228
app/mcp_tools.py
|
|
@ -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 []
|
||||
|
|
@ -16,11 +16,10 @@ import json
|
|||
import os
|
||||
import sys
|
||||
from typing import Any, get_type_hints
|
||||
from langchain_core.tools import StructuredTool
|
||||
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, debug_callback=None):
|
||||
def build_react_agent(model_name: str, temperature: float, max_tokens: int, agency_mode: bool, obsidian_settings: dict):
|
||||
"""Инициализация ядра на базе LangGraph."""
|
||||
m_cfg = MODELS.get(model_name)
|
||||
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,
|
||||
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)
|
||||
|
||||
# Специфическая Pydantic-модель для инструментов без скрипта
|
||||
|
|
@ -42,7 +41,7 @@ class SystemHelpSchema(BaseModel):
|
|||
dummy: str = Field(default="", description="Необязательный параметр, оставь пустым.")
|
||||
|
||||
def create_subprocess_tool(script_path: str, method_name: str,
|
||||
hidden_prompt: str, debug_callback=None):
|
||||
hidden_prompt: str):
|
||||
"""Вид 2: Инструмент со скриптом + пост-процессинг."""
|
||||
|
||||
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)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
if not hasattr(module, method_name):
|
||||
error_msg = f"в файле '{script_path}' не найден метод '{method_name}'"
|
||||
print(f"❌ {error_msg}")
|
||||
raise AttributeError(error_msg)
|
||||
|
||||
func = getattr(module, method_name)
|
||||
docstring = inspect.getdoc(func) or f"Выполняет скрипт {method_name}"
|
||||
|
||||
|
|
@ -76,19 +69,12 @@ def create_subprocess_tool(script_path: str, method_name: str,
|
|||
|
||||
@functools.wraps(func)
|
||||
def wrapper(**kwargs: Any) -> str:
|
||||
input_json = json.dumps(kwargs, indent=2, ensure_ascii=False)
|
||||
runner_code = f"""
|
||||
import json
|
||||
import sys
|
||||
import importlib.util
|
||||
import os
|
||||
|
||||
# ПРИНУДИТЕЛЬНО устанавливаем кодировку UTF-8 для потоков вывода и ошибок, чтобы могло выводить эмодзи, например, без ошибок
|
||||
if hasattr(sys.stdout, 'reconfigure'):
|
||||
sys.stdout.reconfigure(encoding='utf-8')
|
||||
if hasattr(sys.stderr, 'reconfigure'):
|
||||
sys.stderr.reconfigure(encoding='utf-8')
|
||||
|
||||
# Добавляем папку со скриптом в sys.path, чтобы работали локальные импорты (например, norminv)
|
||||
path_to_add = os.path.dirname(os.path.abspath(r'{script_path}'))
|
||||
original_path = sys.path.copy()
|
||||
|
|
@ -120,10 +106,6 @@ finally:
|
|||
)
|
||||
raw_output = process.stdout.strip()
|
||||
|
||||
# ВЫЗЫВАЕМ КОЛБЭК, если он передан
|
||||
if debug_callback:
|
||||
debug_callback(input_json, raw_output)
|
||||
|
||||
raw_stripped = raw_output.strip() if raw_output else ""
|
||||
prompt_stripped = hidden_prompt.strip() if hidden_prompt else ""
|
||||
|
||||
|
|
@ -176,25 +158,38 @@ finally:
|
|||
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."""
|
||||
tools = []
|
||||
|
||||
# Инициализация внутренних инструментов (скриптов)
|
||||
custom_tools = obsidian_settings.get("customTools", [])
|
||||
|
||||
for ct in custom_tools:
|
||||
name = ct.get("name")
|
||||
desc = ct.get("description")
|
||||
full_path = ct.get("scriptPath", "")
|
||||
# Поддерживаем оба ключа, если в JS/TS они не раскрылись
|
||||
hidden_prompt = ct.get("hiddenPromptExpanded") or ct.get("hiddenPrompt", "")
|
||||
|
||||
if not name:
|
||||
continue # Инструмент без имени создать нельзя
|
||||
|
||||
# Достаем путь и метод из отдельных полей, присланных фронтендом
|
||||
script_path = ct.get("scriptPath", "")
|
||||
method_name = ct.get("methodName") or "main"
|
||||
# Парсим путь к скрипту (учитываем Windows-пути с буквой диска)
|
||||
script_path = ""
|
||||
method_name = "main"
|
||||
|
||||
if full_path:
|
||||
parts = full_path.rsplit(":", 1) # Разделяем строку с конца 1 раз
|
||||
|
||||
if len(parts) == 2:
|
||||
# Если слева от разделения ровно 1 символ-буква (например, 'D'),
|
||||
# значит это путь без метода, а двоеточие — от диска Windows.
|
||||
if len(parts[0]) == 1 and parts[0].isalpha():
|
||||
script_path = full_path
|
||||
else:
|
||||
script_path = parts[0]
|
||||
method_name = parts[1]
|
||||
else:
|
||||
script_path = full_path
|
||||
|
||||
# ПРОВЕРКА: Есть ли физический скрипт?
|
||||
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: Скрипт + Прилизывание
|
||||
# ----------------------------------------------------
|
||||
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
|
||||
if desc:
|
||||
tool.description = desc
|
||||
|
|
@ -247,18 +242,6 @@ def get_dynamic_tools(obsidian_settings: dict, debug_callback=None):
|
|||
)
|
||||
tools.append(help_tool)
|
||||
|
||||
vault_cwd = obsidian_settings.get("vaultAbsolutePath")
|
||||
|
||||
# Инициализация внутренних MCP серверов
|
||||
mcp_servers = obsidian_settings.get("mcpServers", [])
|
||||
for mcp_server in mcp_servers:
|
||||
# Пропускаем отключенные серверы
|
||||
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
|
||||
|
||||
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:
|
||||
return "В данный момент нет доступных дополнительных команд. Ты работаешь как обычный собеседник."
|
||||
|
||||
tool_raw_list = ["Я могу использовать следующие инструменты/команды:\n"]
|
||||
lines = ["Я могу использовать следующие инструменты/команды:\n"]
|
||||
for ct in custom_tools:
|
||||
name = ct.get("name")
|
||||
desc = ct.get("description", "Описание отсутствует")
|
||||
if name:
|
||||
tool_raw_list.append(f"- **{name}**: {desc}")
|
||||
lines.append(f"- **{name}**: {desc}")
|
||||
|
||||
# Приаттачиваем инструкцию формата вывода (hidden prompt для системы)
|
||||
prompt = f"""
|
||||
СПИСОК ДОСТУПНЫХ ИНСТРУМЕНТОВ:
|
||||
{tool_raw_list}
|
||||
|
||||
ТВОЯ ЗАДАЧА:
|
||||
Сгруппируй инструменты по уровням абстракции и выведи их в формате Markdown-таблиц.
|
||||
|
||||
ИСПОЛЬЗУЙ СЛЕДУЮЩИЕ КАТЕГОРИИ:
|
||||
1. 🧩 [Микро-уровень]: Атомарные сущности, единичные объекты, конкретные детали или расчеты.
|
||||
2. 🏗️ [Мезо-уровень]: Процессы, сложные узлы, локальные системы, взаимодействия или окружение.
|
||||
3. 🗺️ [Макро-уровень]: Глобальные структуры, архитектура системы, высокоуровневая логика или долгосрочные стратегии.
|
||||
4. ⚙️ [Мета-уровень]: Управление сессией, диагностика, системная помощь, логирование или работа с контекстом.
|
||||
|
||||
ФОРМАТ ВЫВОДА:
|
||||
Для каждой категории создай заголовок и таблицу:
|
||||
| Команда | Функциональное назначение |
|
||||
|:---|:---|
|
||||
| **название_команды** | Суть работы (1 краткое предложение) |
|
||||
|
||||
Не добавляй никаких приветствий или пояснений вне блока кода. Будь предельно лаконичен.
|
||||
"""
|
||||
return prompt
|
||||
lines.append("\nПерескажи этот список пользователю в удобном формате.")
|
||||
return "\n".join(lines)
|
||||
|
|
@ -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())
|
||||
|
|
@ -11,8 +11,8 @@ from llm_client import DEFAULT_TEMPERATURE, get_llm
|
|||
from langchain_core.messages import SystemMessage, HumanMessage
|
||||
import uuid
|
||||
|
||||
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-polza" # "gemini-3.0-flash-openrouter"
|
||||
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-openrouter" # "gemini-3.0-flash-openrouter"
|
||||
|
||||
MAX_TITLE_GENERATION_CONTENT_LENGTH = 5000 # Максимальное количество символов для генерации заголовков
|
||||
QUEUE_POLLING_INTERVAL = 4
|
||||
|
|
@ -20,7 +20,7 @@ QUEUE_POLLING_INTERVAL = 4
|
|||
TITLE_TEMPERATURE = 0.1
|
||||
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:
|
||||
"""Сервис для асинхронной генерации заголовков."""
|
||||
|
|
|
|||
|
|
@ -40,21 +40,8 @@ def run_agent_streaming(graph_id: str,
|
|||
|
||||
# 2. Формируем контекст LangChain с учетом файлов и изображений (вложений)
|
||||
messages_for_llm = []
|
||||
|
||||
# Собираем над-инструкции для 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()))
|
||||
if system_prompt:
|
||||
messages_for_llm.append(SystemMessage(content=system_prompt))
|
||||
|
||||
# Обрабатываем вложения
|
||||
for i, msg in enumerate(messages):
|
||||
|
|
@ -100,61 +87,34 @@ def run_agent_streaming(graph_id: str,
|
|||
elif role == "assistant":
|
||||
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.
|
||||
agent_executor = build_react_agent(
|
||||
model_name=model,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
agency_mode=agency_mode,
|
||||
obsidian_settings=obsidian_settings or {},
|
||||
debug_callback=capture_debug
|
||||
obsidian_settings=obsidian_settings or {}
|
||||
)
|
||||
|
||||
# Буфер для вызовов тулов от LLM
|
||||
step_counter = 0
|
||||
tool_mapping = {} # Хранит связку: tc_id -> Номер шага
|
||||
active_tool_calls = {} # Хранит прогресс для конкретных инструментов { tc_id: {"name": ..., "args": ...} }
|
||||
current_idx_map = {} # Временный маппинг: index (из чанков текущего ответа) -> глобальный tc_id
|
||||
|
||||
# 4. Выполняем цикл с потоковой передачей событий
|
||||
step_counter = 0
|
||||
|
||||
# LangGraph Stream Mode 'messages': отдает чанки токенов и вызовы функций
|
||||
for chunk, metadata in agent_executor.stream({"messages": messages_for_llm}, stream_mode="messages"):
|
||||
|
||||
if isinstance(chunk, AIMessageChunk):
|
||||
# Агент запросил вызов инструмента (Формирование JSON аргументов)
|
||||
if hasattr(chunk, "tool_call_chunks") and chunk.tool_call_chunks:
|
||||
for tc in chunk.tool_call_chunks:
|
||||
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
|
||||
|
||||
if "name" in tc and tc["name"]:
|
||||
step_counter += 1
|
||||
tool_mapping[tc_id] = step_counter
|
||||
active_tool_calls[tc_id] = {"name": tc.get("name", "unknown_tool"), "args": ""}
|
||||
|
||||
yield {
|
||||
"type": "tool_start",
|
||||
"name": active_tool_calls[tc_id]["name"],
|
||||
"name": tc["name"],
|
||||
"step": step_counter,
|
||||
"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:
|
||||
yield {
|
||||
|
|
@ -165,38 +125,11 @@ def run_agent_streaming(graph_id: str,
|
|||
|
||||
# Тул отработал и принес ответ
|
||||
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 {
|
||||
"type": "tool_end",
|
||||
"name": getattr(chunk, "name", "tool"),
|
||||
"step": actual_step,
|
||||
"node_id": assistant_node_id,
|
||||
"request": req,
|
||||
"response": res,
|
||||
"is_error": is_error
|
||||
"name": chunk.name,
|
||||
"step": step_counter,
|
||||
"node_id": assistant_node_id
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
|
|
|
|||
28
build.bat
28
build.bat
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
@ -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
BIN
geckodriver.exe
Normal file
Binary file not shown.
65
obfuscate.txt
Normal file
65
obfuscate.txt
Normal 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/)
|
||||
|
|
@ -6,8 +6,6 @@ langchain_core==0.3.76
|
|||
|
||||
langchain_google_genai==2.1.12
|
||||
|
||||
mcp==1.27.2
|
||||
|
||||
flask==3.1.2
|
||||
flask_cors==6.0.1
|
||||
flask_socketio
|
||||
|
|
@ -21,13 +19,4 @@ sounddevice==0.5.1
|
|||
numpy==2.1.0
|
||||
|
||||
matplotlib==3.8.4
|
||||
pandas==2.2.2
|
||||
|
||||
symspellpy==6.7.7
|
||||
|
||||
# обфускация, сборка
|
||||
|
||||
# stickytape # сшивка в один файл?
|
||||
# python-minifier
|
||||
cython
|
||||
setuptools
|
||||
pandas==2.2.2
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
chcp 65001
|
||||
cd /d "%~dp0"
|
||||
call "env\Scripts\activate.bat"
|
||||
python "app/run.py"
|
||||
python run.py
|
||||
pause
|
||||
|
|
@ -9,13 +9,12 @@ logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(level
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ----------------------------------------------- Main ------------------------------------------------------------
|
||||
# Создаем явную точку входа
|
||||
def start_app():
|
||||
if __name__ == "__main__":
|
||||
# Добавляем папку app в Python path
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'app'))
|
||||
from api import api, voice_inst, current_obsidian_settings
|
||||
from heartbeat_monitor import HeartbeatMonitor
|
||||
from workflows import graph_history_manager
|
||||
from app.api import api, voice_inst, current_obsidian_settings
|
||||
from app.heartbeat_monitor import HeartbeatMonitor
|
||||
from app.workflows import graph_history_manager
|
||||
|
||||
logger.info("🚀 Запуск LLM Agent Backend сервера...")
|
||||
logger.info("📍 API будет доступно на: http://localhost:5000/api")
|
||||
|
|
@ -34,7 +33,7 @@ def start_app():
|
|||
logger.info("HeartbeatMonitor запущен.")
|
||||
|
||||
|
||||
from heartbeat_monitor import VoiceHeartbeatMonitor
|
||||
from app.heartbeat_monitor import VoiceHeartbeatMonitor
|
||||
|
||||
v_monitor = VoiceHeartbeatMonitor(
|
||||
voice_inst,
|
||||
|
|
@ -49,8 +48,4 @@ def start_app():
|
|||
port=5000,
|
||||
host='localhost',
|
||||
threaded=True
|
||||
)
|
||||
|
||||
# Оставляем этот блок для локальных тестов, если запускаете исходник напрямую
|
||||
if __name__ == "__main__":
|
||||
start_app()
|
||||
)
|
||||
38
run.spec
Normal file
38
run.spec
Normal 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
8
src/types/electron.d.ts
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
// Добавьте этот файл, если его нет
|
||||
interface Window {
|
||||
electron?: {
|
||||
shell: {
|
||||
openPath: (path: string) => Promise<string>;
|
||||
};
|
||||
};
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user