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
|
||||||
*.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}/app/run.py",
|
"program": "${workspaceFolder}/run.py",
|
||||||
"env": {
|
"env": {
|
||||||
"FLASK_APP": "run.py",
|
"FLASK_APP": "run.py",
|
||||||
"FLASK_ENV": "development",
|
"FLASK_ENV": "development",
|
||||||
"FLASK_DEBUG": "1"
|
"FLASK_DEBUG": "1"
|
||||||
},
|
},
|
||||||
"justMyCode": false,
|
"justMyCode": true,
|
||||||
"console": "integratedTerminal"
|
"console": "integratedTerminal"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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 и кастомные инструменты.
|
|
||||||
- **Память**: Сохранение состояния графов, что позволяет продолжать диалог с любого места.
|
|
||||||
- **Голосовое управление**: Возможность диктовать команды напрямую в микрофон с транскрибацией в реальном времени.
|
|
||||||
171
app/api.py
171
app/api.py
|
|
@ -10,10 +10,6 @@ from flask_cors import CORS
|
||||||
from flask_socketio import SocketIO
|
from flask_socketio import SocketIO
|
||||||
from workflows import graph_history_manager, run_agent_streaming
|
from workflows import graph_history_manager, run_agent_streaming
|
||||||
from llm_client import DEFAULT_TEMPERATURE, MODELS, get_llm # Добавляем импорт списка моделей
|
from llm_client import DEFAULT_TEMPERATURE, MODELS, get_llm # Добавляем импорт списка моделей
|
||||||
import base64
|
|
||||||
from mcp_tools import get_raw_mcp_tools_list
|
|
||||||
import threading
|
|
||||||
import uuid as uuid_lib
|
|
||||||
|
|
||||||
api = Flask(__name__)
|
api = Flask(__name__)
|
||||||
CORS(
|
CORS(
|
||||||
|
|
@ -236,7 +232,7 @@ def chat_stream():
|
||||||
cache_folder = data.get("cache_folder")
|
cache_folder = data.get("cache_folder")
|
||||||
temperature = data.get("temperature", DEFAULT_TEMPERATURE)
|
temperature = data.get("temperature", DEFAULT_TEMPERATURE)
|
||||||
max_tokens = data.get("max_tokens")
|
max_tokens = data.get("max_tokens")
|
||||||
agency_mode = data.get("agency_mode", False)
|
agency_mode = data.get("agency_mode", False) # НОВОЕ: Перехватываем режим тумблера "Агент"
|
||||||
# Необязательный параметр для существующего узла ассистента при регенерации
|
# Необязательный параметр для существующего узла ассистента при регенерации
|
||||||
existing_assistant_node_id = data.get("existing_assistant_node_id")
|
existing_assistant_node_id = data.get("existing_assistant_node_id")
|
||||||
|
|
||||||
|
|
@ -264,11 +260,6 @@ def chat_stream():
|
||||||
# Запускаем стриминг ответа от LLM (используем обновленный агент)
|
# Запускаем стриминг ответа от LLM (используем обновленный агент)
|
||||||
accumulated_content = ""
|
accumulated_content = ""
|
||||||
|
|
||||||
# Вспомогательная функция для безопасной передачи JSON в HTML-атрибутах
|
|
||||||
def safe_b64(text):
|
|
||||||
if not text: return "e30=" # пустой json '{}'
|
|
||||||
return base64.b64encode(str(text).encode('utf-8')).decode('utf-8')
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
for chunk_data in run_agent_streaming(graph_id, user_node_id,
|
for chunk_data in run_agent_streaming(graph_id, user_node_id,
|
||||||
assistant_node_id,
|
assistant_node_id,
|
||||||
|
|
@ -280,27 +271,6 @@ def chat_stream():
|
||||||
if chunk_data.get("type") in ["chunk", "tool_start", "tool_end"]:
|
if chunk_data.get("type") in ["chunk", "tool_start", "tool_end"]:
|
||||||
if chunk_data.get("type") == "chunk":
|
if chunk_data.get("type") == "chunk":
|
||||||
accumulated_content += chunk_data.get("content", "")
|
accumulated_content += chunk_data.get("content", "")
|
||||||
|
|
||||||
elif chunk_data.get("type") == "tool_start":
|
|
||||||
# Встраиваем стартовый маркер в память базы данных
|
|
||||||
start_marker = f'\n\n<div class="agent-tool-call" data-name="{chunk_data.get("name")}" data-step="{chunk_data.get("step")}" data-status="start"></div>\n\n'
|
|
||||||
accumulated_content += start_marker
|
|
||||||
|
|
||||||
elif chunk_data.get("type") == "tool_end":
|
|
||||||
# Находим стартовый маркер и меняем его на финальный с данными
|
|
||||||
search_marker = f'<div class="agent-tool-call" data-name="{chunk_data.get("name")}" data-step="{chunk_data.get("step")}" data-status="start"></div>'
|
|
||||||
|
|
||||||
req_b64 = safe_b64(chunk_data.get("request"))
|
|
||||||
res_b64 = safe_b64(chunk_data.get("response"))
|
|
||||||
status = "error" if chunk_data.get("is_error") else "success"
|
|
||||||
|
|
||||||
end_marker = f'<div class="agent-tool-call" data-name="{chunk_data.get("name")}" data-step="{chunk_data.get("step")}" data-status="{status}" data-req="{req_b64}" data-res="{res_b64}"></div>'
|
|
||||||
|
|
||||||
if search_marker in accumulated_content:
|
|
||||||
accumulated_content = accumulated_content.replace(search_marker, end_marker)
|
|
||||||
else:
|
|
||||||
accumulated_content += f'\n\n{end_marker}\n\n'
|
|
||||||
|
|
||||||
yield f"data: {json.dumps(chunk_data)}\n\n"
|
yield f"data: {json.dumps(chunk_data)}\n\n"
|
||||||
|
|
||||||
# Обработка ошибки во время стриминга (крашим узел на фронте)
|
# Обработка ошибки во время стриминга (крашим узел на фронте)
|
||||||
|
|
@ -417,22 +387,7 @@ def regenerate_message():
|
||||||
return jsonify({"error": str(e)}, 500)
|
return jsonify({"error": str(e)}, 500)
|
||||||
|
|
||||||
|
|
||||||
@api.route('/api/mcp/fetch', methods=['POST'])
|
from app.voice_service import VoiceService, VOICE_COMMANDS_RESPONSE_TO_STORE
|
||||||
def fetch_mcp_tools_route():
|
|
||||||
"""API endpoint для получения списка инструментов MCP сервера (без создания LangChain тулов)"""
|
|
||||||
server_config = request.get_json()
|
|
||||||
if not server_config:
|
|
||||||
return jsonify({"error": "Пустой конфиг сервера"}), 400
|
|
||||||
|
|
||||||
try:
|
|
||||||
vault_cwd = current_obsidian_settings.get("vaultAbsolutePath")
|
|
||||||
tools_list = get_raw_mcp_tools_list(server_config, vault_cwd=vault_cwd)
|
|
||||||
return jsonify(tools_list)
|
|
||||||
except Exception as e:
|
|
||||||
return jsonify({"error": str(e)}), 500
|
|
||||||
|
|
||||||
|
|
||||||
from voice_service import VoiceService, VOICE_COMMANDS_RESPONSE_TO_STORE
|
|
||||||
|
|
||||||
# 1. Создаем экземпляр (обязательно укажите путь к вашей модели Vosk)
|
# 1. Создаем экземпляр (обязательно укажите путь к вашей модели Vosk)
|
||||||
voice_inst = VoiceService()
|
voice_inst = VoiceService()
|
||||||
|
|
@ -492,125 +447,3 @@ def sync_settings():
|
||||||
|
|
||||||
print(f"✅⚙️ Settings synced: {current_obsidian_settings}")
|
print(f"✅⚙️ Settings synced: {current_obsidian_settings}")
|
||||||
return jsonify({"status": "synced"})
|
return jsonify({"status": "synced"})
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Vault Query Bridge
|
|
||||||
# Позволяет Python-скриптам читать/писать файлы vault через Obsidian.
|
|
||||||
# Схема: POST /api/vault/query → socketio emit → Obsidian отвечает → возврат.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
import threading
|
|
||||||
import uuid as uuid_lib
|
|
||||||
|
|
||||||
# Хранилище ожидающих ответа запросов.
|
|
||||||
# Ключ: request_id (str UUID)
|
|
||||||
# Значение: (threading.Event, dict результата)
|
|
||||||
_vault_query_pending: dict[str, tuple[threading.Event, dict]] = {}
|
|
||||||
|
|
||||||
|
|
||||||
@api.route('/api/vault/query', methods=['POST'])
|
|
||||||
def vault_query_endpoint():
|
|
||||||
"""
|
|
||||||
Проксирует запрос к Obsidian vault через WebSocket.
|
|
||||||
|
|
||||||
Принимает JSON:
|
|
||||||
{
|
|
||||||
"type": "get_frontmatter" | "get_all_factions" | ...,
|
|
||||||
"payload": { ... параметры операции ... }
|
|
||||||
}
|
|
||||||
|
|
||||||
Поддерживаемые типы (обрабатываются на стороне Obsidian VaultQueryHandler):
|
|
||||||
get_frontmatter — YAML + тело одного файла
|
|
||||||
get_all_factions — все файлы в папке (с фильтром по тегу)
|
|
||||||
get_recent_files — последние N файлов (с фильтром по тегам)
|
|
||||||
resolve_wikilink — wikilink → путь к файлу
|
|
||||||
write_frontmatter — перезаписать YAML + тело файла
|
|
||||||
create_file — создать или перезаписать файл
|
|
||||||
|
|
||||||
Блокируется до ответа Obsidian или до истечения timeout (10с).
|
|
||||||
При timeout возвращает 504.
|
|
||||||
"""
|
|
||||||
data = request.get_json(silent=True)
|
|
||||||
if not data:
|
|
||||||
return jsonify({"error": "Пустое тело запроса"}), 400
|
|
||||||
|
|
||||||
query_type = data.get("type")
|
|
||||||
payload = data.get("payload", {})
|
|
||||||
|
|
||||||
if not query_type:
|
|
||||||
return jsonify({"error": "Поле 'type' обязательно"}), 400
|
|
||||||
|
|
||||||
# Генерируем уникальный ID для сопоставления запрос↔ответ
|
|
||||||
request_id = str(uuid_lib.uuid4())
|
|
||||||
|
|
||||||
# Регистрируем ожидание ответа
|
|
||||||
event = threading.Event()
|
|
||||||
result_holder: dict = {}
|
|
||||||
_vault_query_pending[request_id] = (event, result_holder)
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Отправляем запрос в Obsidian через WebSocket
|
|
||||||
socketio.emit('vault_query_request', {
|
|
||||||
"request_id": request_id,
|
|
||||||
"type": query_type,
|
|
||||||
"payload": payload
|
|
||||||
})
|
|
||||||
|
|
||||||
# Блокируемся — ждём пока handle_vault_query_response вызовет event.set()
|
|
||||||
answered = event.wait(timeout=10.0)
|
|
||||||
|
|
||||||
finally:
|
|
||||||
# Убираем из pending независимо от результата
|
|
||||||
_vault_query_pending.pop(request_id, None)
|
|
||||||
|
|
||||||
if not answered:
|
|
||||||
return jsonify({
|
|
||||||
"error": (
|
|
||||||
"Timeout: Obsidian не ответил за 10 секунд. "
|
|
||||||
"Проверьте что плагин LLM Agent запущен и подключён к WebSocket."
|
|
||||||
)
|
|
||||||
}), 504
|
|
||||||
|
|
||||||
if "error" in result_holder:
|
|
||||||
return jsonify({"error": result_holder["error"]}), 500
|
|
||||||
|
|
||||||
return jsonify(result_holder.get("data"))
|
|
||||||
|
|
||||||
|
|
||||||
@socketio.on('vault_query_response')
|
|
||||||
def handle_vault_query_response(data):
|
|
||||||
"""
|
|
||||||
Получает ответ от Obsidian на vault-запрос.
|
|
||||||
Будит заблокированный поток в vault_query_endpoint().
|
|
||||||
|
|
||||||
data ожидается вида:
|
|
||||||
{ "request_id": "...", "result": <любые данные> }
|
|
||||||
или
|
|
||||||
{ "request_id": "...", "error": "описание ошибки" }
|
|
||||||
"""
|
|
||||||
if not isinstance(data, dict):
|
|
||||||
print(f"⚠️ vault_query_response: неожиданный формат: {type(data)}")
|
|
||||||
return
|
|
||||||
|
|
||||||
request_id = data.get("request_id")
|
|
||||||
if not request_id:
|
|
||||||
print("⚠️ vault_query_response: отсутствует request_id")
|
|
||||||
return
|
|
||||||
|
|
||||||
pending = _vault_query_pending.get(request_id)
|
|
||||||
if not pending:
|
|
||||||
# Дубль после таймаута или успешной обработки — это нормально
|
|
||||||
# если исправлен WebSocketService. До исправления — признак бага.
|
|
||||||
print(f"⚠️ дубль vault_query_response [{request_id[:8]}] — игнорируем")
|
|
||||||
return
|
|
||||||
|
|
||||||
event, result_holder = pending
|
|
||||||
|
|
||||||
if "error" in data:
|
|
||||||
result_holder["error"] = data["error"]
|
|
||||||
else:
|
|
||||||
result_holder["data"] = data.get("result")
|
|
||||||
print(f"✅ vault_query_response [{request_id[:8]}]: тип={type(result_holder['data']).__name__}")
|
|
||||||
|
|
||||||
event.set()
|
|
||||||
# pending будет удалён в finally блока vault_query_endpoint
|
|
||||||
|
|
@ -28,48 +28,62 @@ MODELS: Dict[str, Dict[str, Any]] = {
|
||||||
"stream": True,
|
"stream": True,
|
||||||
"capabilities": ["vision"],
|
"capabilities": ["vision"],
|
||||||
},
|
},
|
||||||
"gemini-2.5-flash-lite-polza": {
|
"gemini-2.5-flash-lite-openrouter": {
|
||||||
"name": "google/gemini-2.5-flash-lite",
|
"name": "google/gemini-2.5-flash-lite",
|
||||||
"provider": "openai",
|
"provider": "openai",
|
||||||
"model_name": "google/gemini-2.5-flash-lite",
|
"model_name": "google/gemini-2.5-flash-lite",
|
||||||
"apiBase": "https://polza.ai/api/v1", # Добавлено /api/v1
|
"apiBase": "https://openrouter.ai/api/v1", # Добавлено /api/v1
|
||||||
"apiKey": "pza_qPk0xSBItQkUs18xoBUlyBBeYiHAxasy",
|
"apiKey":
|
||||||
|
"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",
|
||||||
"stream": True,
|
"stream": True,
|
||||||
"capabilities": ["vision", "reasoning"],
|
"capabilities": ["vision", "reasoning"],
|
||||||
"model_kwargs": {
|
"model_kwargs": {
|
||||||
"include_reasoning": True
|
"include_reasoning": True
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"gemini-2.5-flash-polza": {
|
"gemini-2.5-flash-r": {
|
||||||
|
"name": "gemini-2.5-flash-r",
|
||||||
|
"provider": "openai", # Изменено на "openai"
|
||||||
|
"model_name": "gemini-2.5-flash", # Добавлено имя модели для LangChain
|
||||||
|
"apiBase":
|
||||||
|
"https://render-service-gsu7.onrender.com/g/v1beta", # "generativelanguage.googleapis.com", # "https://render-service-gsu7.onrender.com/g/v1beta",
|
||||||
|
"apiKey": "AIzaSyDpueKFWVqknVKlQn6TdasLmJ2lvAUiBik",
|
||||||
|
"stream": True,
|
||||||
|
"capabilities": ["vision"],
|
||||||
|
},
|
||||||
|
"gemini-2.5-flash-openrouter": {
|
||||||
"name": "google/gemini-2.5-flash",
|
"name": "google/gemini-2.5-flash",
|
||||||
"provider": "openai",
|
"provider": "openai",
|
||||||
"model_name": "google/gemini-2.5-flash",
|
"model_name": "google/gemini-2.5-flash",
|
||||||
"apiBase": "https://polza.ai/api/v1", # Добавлено /api/v1
|
"apiBase": "https://openrouter.ai/api/v1", # Добавлено /api/v1
|
||||||
"apiKey": "pza_qPk0xSBItQkUs18xoBUlyBBeYiHAxasy",
|
"apiKey":
|
||||||
|
"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",
|
||||||
"stream": True,
|
"stream": True,
|
||||||
"capabilities": ["vision", "reasoning"],
|
"capabilities": ["vision", "reasoning"],
|
||||||
"model_kwargs": {
|
"model_kwargs": {
|
||||||
"include_reasoning": True
|
"include_reasoning": True
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"gemini-3.0-flash-polza": {
|
"gemini-3.0-flash-openrouter": {
|
||||||
"name": "google/gemini-3-flash-preview",
|
"name": "google/gemini-3-flash-preview",
|
||||||
"provider": "openai",
|
"provider": "openai",
|
||||||
"model_name": "google/gemini-3-flash-preview",
|
"model_name": "google/gemini-3-flash-preview",
|
||||||
"apiBase": "https://polza.ai/api/v1",
|
"apiBase": "https://openrouter.ai/api/v1", # Добавлено /api/v1
|
||||||
"apiKey": "pza_qPk0xSBItQkUs18xoBUlyBBeYiHAxasy",
|
"apiKey":
|
||||||
|
"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",
|
||||||
"stream": True,
|
"stream": True,
|
||||||
"capabilities": ["vision", "reasoning"],
|
"capabilities": ["vision", "reasoning"],
|
||||||
"model_kwargs": {
|
"model_kwargs": {
|
||||||
"include_reasoning": True
|
"include_reasoning": True
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"gemini-3.1-pro-polza": {
|
"gemini-3.1-pro-openrouter": {
|
||||||
"name": "google/gemini-3.1-pro-preview",
|
"name": "google/gemini-3.1-pro-preview",
|
||||||
"provider": "openai",
|
"provider": "openai",
|
||||||
"model_name": "google/gemini-3.1-pro-preview",
|
"model_name": "google/gemini-3.1-pro-preview",
|
||||||
"apiBase": "https://polza.ai/api/v1",
|
"apiBase": "https://openrouter.ai/api/v1", # Добавлено /api/v1
|
||||||
"apiKey": "pza_qPk0xSBItQkUs18xoBUlyBBeYiHAxasy",
|
"apiKey":
|
||||||
|
"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",
|
||||||
"stream": True,
|
"stream": True,
|
||||||
"capabilities": ["vision", "reasoning"],
|
"capabilities": ["vision", "reasoning"],
|
||||||
"model_kwargs": {
|
"model_kwargs": {
|
||||||
|
|
@ -97,6 +111,16 @@ MODELS: Dict[str, Dict[str, Any]] = {
|
||||||
"stream": True,
|
"stream": True,
|
||||||
"capabilities": ["vision"],
|
"capabilities": ["vision"],
|
||||||
},
|
},
|
||||||
|
"mistral-small-latest-r": {
|
||||||
|
"name": "mistral-small-latest",
|
||||||
|
"provider": "mistralai",
|
||||||
|
"model_name":
|
||||||
|
"mistral-small-latest", # Добавлено имя модели для LangChain
|
||||||
|
"apiBase": "https://render-service-gsu7.onrender.com/m",
|
||||||
|
"apiKey": "Q0m29fvxBY0Cfdj4sjHaKqccy1NjonLW",
|
||||||
|
"stream": True,
|
||||||
|
"capabilities": ["vision"],
|
||||||
|
},
|
||||||
"mistral-small-latest-error": {
|
"mistral-small-latest-error": {
|
||||||
"name": "mistral-small-latest",
|
"name": "mistral-small-latest",
|
||||||
"provider": "mistralai",
|
"provider": "mistralai",
|
||||||
|
|
@ -107,56 +131,31 @@ MODELS: Dict[str, Dict[str, Any]] = {
|
||||||
"stream": True,
|
"stream": True,
|
||||||
"capabilities": ["vision"],
|
"capabilities": ["vision"],
|
||||||
},
|
},
|
||||||
"claude-sonnet-5-polza": {
|
"claude-sonnet-4.6-openrouter": {
|
||||||
"name": "anthropic/claude-sonnet-5",
|
"name": "anthropic/claude-sonnet-4.6",
|
||||||
"provider": "openai",
|
"provider": "openai",
|
||||||
"model_name": "anthropic/claude-sonnet-5",
|
"model_name": "anthropic/claude-sonnet-4.6",
|
||||||
"apiBase": "https://polza.ai/api/v1",
|
"apiBase": "https://openrouter.ai/api/v1", # Добавлено /api/v1
|
||||||
"apiKey": "pza_qPk0xSBItQkUs18xoBUlyBBeYiHAxasy",
|
"apiKey":
|
||||||
|
"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",
|
||||||
"stream": True,
|
"stream": True,
|
||||||
"capabilities": ["vision", "reasoning"],
|
"capabilities": ["vision", "reasoning"],
|
||||||
"model_kwargs": {
|
"model_kwargs": {
|
||||||
"include_reasoning": True
|
"include_reasoning": True
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"deepseek-v4-flash-polza": {
|
"deepseek-v4-flash-openrouter": {
|
||||||
"name": "deepseek/deepseek-chat",
|
"name": "deepseek/deepseek-v4-flash",
|
||||||
"provider": "openai",
|
"provider": "openai",
|
||||||
"model_name": "deepseek/deepseek-chat",
|
"model_name": "deepseek/deepseek-v4-flash",
|
||||||
"apiBase": "https://polza.ai/api/v1",
|
"apiBase": "https://openrouter.ai/api/v1", # Добавлено /api/v1
|
||||||
"apiKey": "pza_qPk0xSBItQkUs18xoBUlyBBeYiHAxasy",
|
"apiKey":
|
||||||
|
"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",
|
||||||
"stream": True,
|
"stream": True,
|
||||||
"capabilities": ["vision", "reasoning"],
|
"capabilities": ["vision", "reasoning"],
|
||||||
"model_kwargs": {
|
"model_kwargs": {
|
||||||
"include_reasoning": True
|
"include_reasoning": True
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"deepseek-v3.2-exp-polza": {
|
|
||||||
"name": "deepseek/deepseek-v3.2-exp",
|
|
||||||
"provider": "openrouter",
|
|
||||||
"model_name": "deepseek/deepseek-v3.2-exp",
|
|
||||||
"apiBase": "https://polza.ai/api/v1",
|
|
||||||
"apiKey": "pza_qPk0xSBItQkUs18xoBUlyBBeYiHAxasy",
|
|
||||||
"stream": True,
|
|
||||||
"capabilities": ["chat", "reasoning", "code"]
|
|
||||||
},
|
|
||||||
"gigachat-2": {
|
|
||||||
"name": "GigaChat-2",
|
|
||||||
"provider": "custom",
|
|
||||||
"model_name": "GigaChat-2",
|
|
||||||
"apiBase": "http://localhost:8090/v1",
|
|
||||||
"apiKey": "my_super_secret_key_123",
|
|
||||||
"stream": True,
|
|
||||||
"capabilities": ["chat"]
|
|
||||||
},
|
|
||||||
"gigachat-2-max": {
|
|
||||||
"name": "GigaChat-2-Max",
|
|
||||||
"provider": "custom",
|
|
||||||
"model_name": "GigaChat-2-Max",
|
|
||||||
"apiBase": "http://localhost:8090/v1",
|
|
||||||
"apiKey": "my_super_secret_key_123",
|
|
||||||
"stream": True,
|
|
||||||
"capabilities": ["chat"]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
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 os
|
||||||
import sys
|
import sys
|
||||||
from typing import Any, get_type_hints
|
from typing import Any, get_type_hints
|
||||||
|
from langchain_core.tools import StructuredTool
|
||||||
from pydantic import BaseModel, Field, create_model
|
from pydantic import BaseModel, Field, create_model
|
||||||
|
|
||||||
from mcp_tools import fetch_mcp_tools
|
def build_react_agent(model_name: str, temperature: float, max_tokens: int, agency_mode: bool, obsidian_settings: dict):
|
||||||
|
|
||||||
def build_react_agent(model_name: str, temperature: float, max_tokens: int, agency_mode: bool, obsidian_settings: dict, debug_callback=None):
|
|
||||||
"""Инициализация ядра на базе LangGraph."""
|
"""Инициализация ядра на базе LangGraph."""
|
||||||
m_cfg = MODELS.get(model_name)
|
m_cfg = MODELS.get(model_name)
|
||||||
llm = ChatOpenAI(
|
llm = ChatOpenAI(
|
||||||
|
|
@ -31,7 +30,7 @@ def build_react_agent(model_name: str, temperature: float, max_tokens: int, agen
|
||||||
max_tokens=max_tokens if max_tokens else None,
|
max_tokens=max_tokens if max_tokens else None,
|
||||||
streaming=True
|
streaming=True
|
||||||
)
|
)
|
||||||
tools = get_dynamic_tools(obsidian_settings, debug_callback) if agency_mode else []
|
tools = get_dynamic_tools(obsidian_settings) if agency_mode else []
|
||||||
return create_react_agent(llm, tools=tools)
|
return create_react_agent(llm, tools=tools)
|
||||||
|
|
||||||
# Специфическая Pydantic-модель для инструментов без скрипта
|
# Специфическая Pydantic-модель для инструментов без скрипта
|
||||||
|
|
@ -42,7 +41,7 @@ class SystemHelpSchema(BaseModel):
|
||||||
dummy: str = Field(default="", description="Необязательный параметр, оставь пустым.")
|
dummy: str = Field(default="", description="Необязательный параметр, оставь пустым.")
|
||||||
|
|
||||||
def create_subprocess_tool(script_path: str, method_name: str,
|
def create_subprocess_tool(script_path: str, method_name: str,
|
||||||
hidden_prompt: str, debug_callback=None):
|
hidden_prompt: str):
|
||||||
"""Вид 2: Инструмент со скриптом + пост-процессинг."""
|
"""Вид 2: Инструмент со скриптом + пост-процессинг."""
|
||||||
|
|
||||||
script_dir = os.path.dirname(os.path.abspath(script_path))
|
script_dir = os.path.dirname(os.path.abspath(script_path))
|
||||||
|
|
@ -55,12 +54,6 @@ def create_subprocess_tool(script_path: str, method_name: str,
|
||||||
spec = importlib.util.spec_from_file_location("user_dynamic_tool", script_path)
|
spec = importlib.util.spec_from_file_location("user_dynamic_tool", script_path)
|
||||||
module = importlib.util.module_from_spec(spec)
|
module = importlib.util.module_from_spec(spec)
|
||||||
spec.loader.exec_module(module)
|
spec.loader.exec_module(module)
|
||||||
|
|
||||||
if not hasattr(module, method_name):
|
|
||||||
error_msg = f"в файле '{script_path}' не найден метод '{method_name}'"
|
|
||||||
print(f"❌ {error_msg}")
|
|
||||||
raise AttributeError(error_msg)
|
|
||||||
|
|
||||||
func = getattr(module, method_name)
|
func = getattr(module, method_name)
|
||||||
docstring = inspect.getdoc(func) or f"Выполняет скрипт {method_name}"
|
docstring = inspect.getdoc(func) or f"Выполняет скрипт {method_name}"
|
||||||
|
|
||||||
|
|
@ -76,19 +69,12 @@ def create_subprocess_tool(script_path: str, method_name: str,
|
||||||
|
|
||||||
@functools.wraps(func)
|
@functools.wraps(func)
|
||||||
def wrapper(**kwargs: Any) -> str:
|
def wrapper(**kwargs: Any) -> str:
|
||||||
input_json = json.dumps(kwargs, indent=2, ensure_ascii=False)
|
|
||||||
runner_code = f"""
|
runner_code = f"""
|
||||||
import json
|
import json
|
||||||
import sys
|
import sys
|
||||||
import importlib.util
|
import importlib.util
|
||||||
import os
|
import os
|
||||||
|
|
||||||
# ПРИНУДИТЕЛЬНО устанавливаем кодировку UTF-8 для потоков вывода и ошибок, чтобы могло выводить эмодзи, например, без ошибок
|
|
||||||
if hasattr(sys.stdout, 'reconfigure'):
|
|
||||||
sys.stdout.reconfigure(encoding='utf-8')
|
|
||||||
if hasattr(sys.stderr, 'reconfigure'):
|
|
||||||
sys.stderr.reconfigure(encoding='utf-8')
|
|
||||||
|
|
||||||
# Добавляем папку со скриптом в sys.path, чтобы работали локальные импорты (например, norminv)
|
# Добавляем папку со скриптом в sys.path, чтобы работали локальные импорты (например, norminv)
|
||||||
path_to_add = os.path.dirname(os.path.abspath(r'{script_path}'))
|
path_to_add = os.path.dirname(os.path.abspath(r'{script_path}'))
|
||||||
original_path = sys.path.copy()
|
original_path = sys.path.copy()
|
||||||
|
|
@ -120,10 +106,6 @@ finally:
|
||||||
)
|
)
|
||||||
raw_output = process.stdout.strip()
|
raw_output = process.stdout.strip()
|
||||||
|
|
||||||
# ВЫЗЫВАЕМ КОЛБЭК, если он передан
|
|
||||||
if debug_callback:
|
|
||||||
debug_callback(input_json, raw_output)
|
|
||||||
|
|
||||||
raw_stripped = raw_output.strip() if raw_output else ""
|
raw_stripped = raw_output.strip() if raw_output else ""
|
||||||
prompt_stripped = hidden_prompt.strip() if hidden_prompt else ""
|
prompt_stripped = hidden_prompt.strip() if hidden_prompt else ""
|
||||||
|
|
||||||
|
|
@ -176,25 +158,38 @@ finally:
|
||||||
args_schema=args_schema, # ← вместо infer_schema=True
|
args_schema=args_schema, # ← вместо infer_schema=True
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_dynamic_tools(obsidian_settings: dict, debug_callback=None):
|
def get_dynamic_tools(obsidian_settings: dict):
|
||||||
"""Собирает инструменты двух видов на основе настроек Obsidian."""
|
"""Собирает инструменты двух видов на основе настроек Obsidian."""
|
||||||
tools = []
|
tools = []
|
||||||
|
|
||||||
# Инициализация внутренних инструментов (скриптов)
|
|
||||||
custom_tools = obsidian_settings.get("customTools", [])
|
custom_tools = obsidian_settings.get("customTools", [])
|
||||||
|
|
||||||
for ct in custom_tools:
|
for ct in custom_tools:
|
||||||
name = ct.get("name")
|
name = ct.get("name")
|
||||||
desc = ct.get("description")
|
desc = ct.get("description")
|
||||||
|
full_path = ct.get("scriptPath", "")
|
||||||
# Поддерживаем оба ключа, если в JS/TS они не раскрылись
|
# Поддерживаем оба ключа, если в JS/TS они не раскрылись
|
||||||
hidden_prompt = ct.get("hiddenPromptExpanded") or ct.get("hiddenPrompt", "")
|
hidden_prompt = ct.get("hiddenPromptExpanded") or ct.get("hiddenPrompt", "")
|
||||||
|
|
||||||
if not name:
|
if not name:
|
||||||
continue # Инструмент без имени создать нельзя
|
continue # Инструмент без имени создать нельзя
|
||||||
|
|
||||||
# Достаем путь и метод из отдельных полей, присланных фронтендом
|
# Парсим путь к скрипту (учитываем Windows-пути с буквой диска)
|
||||||
script_path = ct.get("scriptPath", "")
|
script_path = ""
|
||||||
method_name = ct.get("methodName") or "main"
|
method_name = "main"
|
||||||
|
|
||||||
|
if full_path:
|
||||||
|
parts = full_path.rsplit(":", 1) # Разделяем строку с конца 1 раз
|
||||||
|
|
||||||
|
if len(parts) == 2:
|
||||||
|
# Если слева от разделения ровно 1 символ-буква (например, 'D'),
|
||||||
|
# значит это путь без метода, а двоеточие — от диска Windows.
|
||||||
|
if len(parts[0]) == 1 and parts[0].isalpha():
|
||||||
|
script_path = full_path
|
||||||
|
else:
|
||||||
|
script_path = parts[0]
|
||||||
|
method_name = parts[1]
|
||||||
|
else:
|
||||||
|
script_path = full_path
|
||||||
|
|
||||||
# ПРОВЕРКА: Есть ли физический скрипт?
|
# ПРОВЕРКА: Есть ли физический скрипт?
|
||||||
is_valid_script = script_path and os.path.exists(script_path)
|
is_valid_script = script_path and os.path.exists(script_path)
|
||||||
|
|
@ -226,7 +221,7 @@ def get_dynamic_tools(obsidian_settings: dict, debug_callback=None):
|
||||||
# ВИД 2: Скрипт + Прилизывание
|
# ВИД 2: Скрипт + Прилизывание
|
||||||
# ----------------------------------------------------
|
# ----------------------------------------------------
|
||||||
try:
|
try:
|
||||||
tool = create_subprocess_tool(script_path, method_name, hidden_prompt, debug_callback)
|
tool = create_subprocess_tool(script_path, method_name, hidden_prompt)
|
||||||
tool.name = name # Приоритет имени из UI Obsidian
|
tool.name = name # Приоритет имени из UI Obsidian
|
||||||
if desc:
|
if desc:
|
||||||
tool.description = desc
|
tool.description = desc
|
||||||
|
|
@ -247,18 +242,6 @@ def get_dynamic_tools(obsidian_settings: dict, debug_callback=None):
|
||||||
)
|
)
|
||||||
tools.append(help_tool)
|
tools.append(help_tool)
|
||||||
|
|
||||||
vault_cwd = obsidian_settings.get("vaultAbsolutePath")
|
|
||||||
|
|
||||||
# Инициализация внутренних MCP серверов
|
|
||||||
mcp_servers = obsidian_settings.get("mcpServers", [])
|
|
||||||
for mcp_server in mcp_servers:
|
|
||||||
# Пропускаем отключенные серверы
|
|
||||||
if not mcp_server.get("isEnabled", True):
|
|
||||||
continue
|
|
||||||
|
|
||||||
mcp_tools = fetch_mcp_tools(mcp_server, debug_callback, vault_cwd=vault_cwd)
|
|
||||||
tools.extend(mcp_tools)
|
|
||||||
|
|
||||||
return tools
|
return tools
|
||||||
|
|
||||||
def _build_pydantic_schema_from_func(func) -> type[BaseModel]:
|
def _build_pydantic_schema_from_func(func) -> type[BaseModel]:
|
||||||
|
|
@ -315,33 +298,12 @@ def _generate_commands_list(custom_tools: list[dict[str, Any]]) -> str:
|
||||||
if not custom_tools:
|
if not custom_tools:
|
||||||
return "В данный момент нет доступных дополнительных команд. Ты работаешь как обычный собеседник."
|
return "В данный момент нет доступных дополнительных команд. Ты работаешь как обычный собеседник."
|
||||||
|
|
||||||
tool_raw_list = ["Я могу использовать следующие инструменты/команды:\n"]
|
lines = ["Я могу использовать следующие инструменты/команды:\n"]
|
||||||
for ct in custom_tools:
|
for ct in custom_tools:
|
||||||
name = ct.get("name")
|
name = ct.get("name")
|
||||||
desc = ct.get("description", "Описание отсутствует")
|
desc = ct.get("description", "Описание отсутствует")
|
||||||
if name:
|
if name:
|
||||||
tool_raw_list.append(f"- **{name}**: {desc}")
|
lines.append(f"- **{name}**: {desc}")
|
||||||
|
|
||||||
# Приаттачиваем инструкцию формата вывода (hidden prompt для системы)
|
lines.append("\nПерескажи этот список пользователю в удобном формате.")
|
||||||
prompt = f"""
|
return "\n".join(lines)
|
||||||
СПИСОК ДОСТУПНЫХ ИНСТРУМЕНТОВ:
|
|
||||||
{tool_raw_list}
|
|
||||||
|
|
||||||
ТВОЯ ЗАДАЧА:
|
|
||||||
Сгруппируй инструменты по уровням абстракции и выведи их в формате Markdown-таблиц.
|
|
||||||
|
|
||||||
ИСПОЛЬЗУЙ СЛЕДУЮЩИЕ КАТЕГОРИИ:
|
|
||||||
1. 🧩 [Микро-уровень]: Атомарные сущности, единичные объекты, конкретные детали или расчеты.
|
|
||||||
2. 🏗️ [Мезо-уровень]: Процессы, сложные узлы, локальные системы, взаимодействия или окружение.
|
|
||||||
3. 🗺️ [Макро-уровень]: Глобальные структуры, архитектура системы, высокоуровневая логика или долгосрочные стратегии.
|
|
||||||
4. ⚙️ [Мета-уровень]: Управление сессией, диагностика, системная помощь, логирование или работа с контекстом.
|
|
||||||
|
|
||||||
ФОРМАТ ВЫВОДА:
|
|
||||||
Для каждой категории создай заголовок и таблицу:
|
|
||||||
| Команда | Функциональное назначение |
|
|
||||||
|:---|:---|
|
|
||||||
| **название_команды** | Суть работы (1 краткое предложение) |
|
|
||||||
|
|
||||||
Не добавляй никаких приветствий или пояснений вне блока кода. Будь предельно лаконичен.
|
|
||||||
"""
|
|
||||||
return prompt
|
|
||||||
|
|
@ -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
|
from langchain_core.messages import SystemMessage, HumanMessage
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
DEFAULT_SUMMARIZATION_LLM_NAME = "gemini-2.5-flash-lite-polza" # "mistral-small-latest" # "mistral-small-latest" #"gemini-2.0-flash-r"
|
DEFAULT_SUMMARIZATION_LLM_NAME = "gemini-2.5-flash-lite-openrouter" # "mistral-small-latest" # "mistral-small-latest" #"gemini-2.0-flash-r"
|
||||||
DEFAULT_VOICE_LLM_NAME = "deepseek-v4-flash-polza" # "gemini-3.0-flash-openrouter"
|
DEFAULT_VOICE_LLM_NAME = "deepseek-v4-flash-openrouter" # "gemini-3.0-flash-openrouter"
|
||||||
|
|
||||||
MAX_TITLE_GENERATION_CONTENT_LENGTH = 5000 # Максимальное количество символов для генерации заголовков
|
MAX_TITLE_GENERATION_CONTENT_LENGTH = 5000 # Максимальное количество символов для генерации заголовков
|
||||||
QUEUE_POLLING_INTERVAL = 4
|
QUEUE_POLLING_INTERVAL = 4
|
||||||
|
|
@ -20,7 +20,7 @@ QUEUE_POLLING_INTERVAL = 4
|
||||||
TITLE_TEMPERATURE = 0.1
|
TITLE_TEMPERATURE = 0.1
|
||||||
MAX_TITLE_LENGTH = 100
|
MAX_TITLE_LENGTH = 100
|
||||||
|
|
||||||
from voice_service import VOICE_COMMANDS_RESPONSE_TO_STORE
|
from app.voice_service import VOICE_COMMANDS_RESPONSE_TO_STORE
|
||||||
|
|
||||||
class TitleGenerator:
|
class TitleGenerator:
|
||||||
"""Сервис для асинхронной генерации заголовков."""
|
"""Сервис для асинхронной генерации заголовков."""
|
||||||
|
|
|
||||||
|
|
@ -40,21 +40,8 @@ def run_agent_streaming(graph_id: str,
|
||||||
|
|
||||||
# 2. Формируем контекст LangChain с учетом файлов и изображений (вложений)
|
# 2. Формируем контекст LangChain с учетом файлов и изображений (вложений)
|
||||||
messages_for_llm = []
|
messages_for_llm = []
|
||||||
|
if system_prompt:
|
||||||
# Собираем над-инструкции для MCP серверов
|
messages_for_llm.append(SystemMessage(content=system_prompt))
|
||||||
mcp_instructions = []
|
|
||||||
if obsidian_settings and obsidian_settings.get("mcpServers"):
|
|
||||||
for server in obsidian_settings.get("mcpServers", []):
|
|
||||||
s_instr = server.get("serverInstruction")
|
|
||||||
if s_instr and s_instr.strip():
|
|
||||||
mcp_instructions.append(f"Инструкции для использования инструментов сервера '{server.get('name')}':\n{s_instr.strip()}")
|
|
||||||
|
|
||||||
final_system_prompt = system_prompt or ""
|
|
||||||
if mcp_instructions:
|
|
||||||
final_system_prompt += "\n\n" + "\n\n".join(mcp_instructions)
|
|
||||||
|
|
||||||
if final_system_prompt:
|
|
||||||
messages_for_llm.append(SystemMessage(content=final_system_prompt.strip()))
|
|
||||||
|
|
||||||
# Обрабатываем вложения
|
# Обрабатываем вложения
|
||||||
for i, msg in enumerate(messages):
|
for i, msg in enumerate(messages):
|
||||||
|
|
@ -100,61 +87,34 @@ def run_agent_streaming(graph_id: str,
|
||||||
elif role == "assistant":
|
elif role == "assistant":
|
||||||
messages_for_llm.append(AIMessage(content=msg.get("content", "")))
|
messages_for_llm.append(AIMessage(content=msg.get("content", "")))
|
||||||
|
|
||||||
# Локальное хранилище для отладки текущего запуска (на случай кастомных тулов)
|
|
||||||
current_run_debug = []
|
|
||||||
|
|
||||||
def capture_debug(req, res):
|
|
||||||
current_run_debug.append({"req": req, "res": res})
|
|
||||||
|
|
||||||
# 3. Создаем агента. Если agency_mode == False, он соберет 0 тулов и отработает как обычная LLM.
|
# 3. Создаем агента. Если agency_mode == False, он соберет 0 тулов и отработает как обычная LLM.
|
||||||
agent_executor = build_react_agent(
|
agent_executor = build_react_agent(
|
||||||
model_name=model,
|
model_name=model,
|
||||||
temperature=temperature,
|
temperature=temperature,
|
||||||
max_tokens=max_tokens,
|
max_tokens=max_tokens,
|
||||||
agency_mode=agency_mode,
|
agency_mode=agency_mode,
|
||||||
obsidian_settings=obsidian_settings or {},
|
obsidian_settings=obsidian_settings or {}
|
||||||
debug_callback=capture_debug
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Буфер для вызовов тулов от LLM
|
|
||||||
step_counter = 0
|
|
||||||
tool_mapping = {} # Хранит связку: tc_id -> Номер шага
|
|
||||||
active_tool_calls = {} # Хранит прогресс для конкретных инструментов { tc_id: {"name": ..., "args": ...} }
|
|
||||||
current_idx_map = {} # Временный маппинг: index (из чанков текущего ответа) -> глобальный tc_id
|
|
||||||
|
|
||||||
# 4. Выполняем цикл с потоковой передачей событий
|
# 4. Выполняем цикл с потоковой передачей событий
|
||||||
|
step_counter = 0
|
||||||
|
|
||||||
|
# LangGraph Stream Mode 'messages': отдает чанки токенов и вызовы функций
|
||||||
for chunk, metadata in agent_executor.stream({"messages": messages_for_llm}, stream_mode="messages"):
|
for chunk, metadata in agent_executor.stream({"messages": messages_for_llm}, stream_mode="messages"):
|
||||||
|
|
||||||
if isinstance(chunk, AIMessageChunk):
|
if isinstance(chunk, AIMessageChunk):
|
||||||
# Агент запросил вызов инструмента (Формирование JSON аргументов)
|
# Агент запросил вызов инструмента (Формирование JSON аргументов)
|
||||||
if hasattr(chunk, "tool_call_chunks") and chunk.tool_call_chunks:
|
if hasattr(chunk, "tool_call_chunks") and chunk.tool_call_chunks:
|
||||||
for tc in chunk.tool_call_chunks:
|
for tc in chunk.tool_call_chunks:
|
||||||
idx = tc.get("index", 0)
|
if "name" in tc and tc["name"]:
|
||||||
|
|
||||||
# При поступлении самого первого чанка для инструмента, в нем есть УНИКАЛЬНЫЙ 'id'
|
|
||||||
if "id" in tc and tc["id"]:
|
|
||||||
tc_id = tc["id"]
|
|
||||||
# Запоминаем, что индекс idx в текущем цикле принадлежит уникальному tc_id
|
|
||||||
current_idx_map[idx] = tc_id
|
|
||||||
|
|
||||||
step_counter += 1
|
step_counter += 1
|
||||||
tool_mapping[tc_id] = step_counter
|
|
||||||
active_tool_calls[tc_id] = {"name": tc.get("name", "unknown_tool"), "args": ""}
|
|
||||||
|
|
||||||
yield {
|
yield {
|
||||||
"type": "tool_start",
|
"type": "tool_start",
|
||||||
"name": active_tool_calls[tc_id]["name"],
|
"name": tc["name"],
|
||||||
"step": step_counter,
|
"step": step_counter,
|
||||||
"node_id": assistant_node_id
|
"node_id": assistant_node_id
|
||||||
}
|
}
|
||||||
|
|
||||||
# Считываем сырой JSON кусочек за кусочком по мере их поступления
|
|
||||||
if "args" in tc and tc["args"]:
|
|
||||||
# Достаем сохраненный уникальный id из маппинга для текущего сообщения
|
|
||||||
tc_id = current_idx_map.get(idx)
|
|
||||||
if tc_id and tc_id in active_tool_calls:
|
|
||||||
active_tool_calls[tc_id]["args"] += tc["args"]
|
|
||||||
|
|
||||||
# Обычный стриминг текста финального ответа
|
# Обычный стриминг текста финального ответа
|
||||||
if chunk.content:
|
if chunk.content:
|
||||||
yield {
|
yield {
|
||||||
|
|
@ -165,38 +125,11 @@ def run_agent_streaming(graph_id: str,
|
||||||
|
|
||||||
# Тул отработал и принес ответ
|
# Тул отработал и принес ответ
|
||||||
elif hasattr(chunk, "type") and chunk.type == "tool":
|
elif hasattr(chunk, "type") and chunk.type == "tool":
|
||||||
res = str(chunk.content)
|
|
||||||
tc_id = getattr(chunk, "tool_call_id", "")
|
|
||||||
|
|
||||||
# 1. Забираем готовую строку (JSON) из нашего прямого перехвата LLM по уникальному tc_id!
|
|
||||||
req = active_tool_calls.get(tc_id, {}).get("args", "")
|
|
||||||
|
|
||||||
# 2. Резервный механизм на старых колбеках (и форматирование в json если это dict)
|
|
||||||
if not req and current_run_debug:
|
|
||||||
dbg = current_run_debug.pop(0)
|
|
||||||
rq = dbg.get("req", "")
|
|
||||||
if isinstance(rq, dict):
|
|
||||||
import json
|
|
||||||
req = json.dumps(rq, indent=2, ensure_ascii=False)
|
|
||||||
else:
|
|
||||||
req = str(rq)
|
|
||||||
|
|
||||||
# Ловушка для определения ошибок
|
|
||||||
is_error = False
|
|
||||||
if getattr(chunk, "status", "") == "error" or "ОШИБКА ИСПОЛНЕНИЯ" in res or "SCRIPT RUNTIME ERROR" in res:
|
|
||||||
is_error = True
|
|
||||||
|
|
||||||
# Ищем шаг тула
|
|
||||||
actual_step = tool_mapping.get(tc_id, step_counter)
|
|
||||||
|
|
||||||
yield {
|
yield {
|
||||||
"type": "tool_end",
|
"type": "tool_end",
|
||||||
"name": getattr(chunk, "name", "tool"),
|
"name": chunk.name,
|
||||||
"step": actual_step,
|
"step": step_counter,
|
||||||
"node_id": assistant_node_id,
|
"node_id": assistant_node_id
|
||||||
"request": req,
|
|
||||||
"response": res,
|
|
||||||
"is_error": is_error
|
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
|
||||||
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
|
langchain_google_genai==2.1.12
|
||||||
|
|
||||||
mcp==1.27.2
|
|
||||||
|
|
||||||
flask==3.1.2
|
flask==3.1.2
|
||||||
flask_cors==6.0.1
|
flask_cors==6.0.1
|
||||||
flask_socketio
|
flask_socketio
|
||||||
|
|
@ -22,12 +20,3 @@ numpy==2.1.0
|
||||||
|
|
||||||
matplotlib==3.8.4
|
matplotlib==3.8.4
|
||||||
pandas==2.2.2
|
pandas==2.2.2
|
||||||
|
|
||||||
symspellpy==6.7.7
|
|
||||||
|
|
||||||
# обфускация, сборка
|
|
||||||
|
|
||||||
# stickytape # сшивка в один файл?
|
|
||||||
# python-minifier
|
|
||||||
cython
|
|
||||||
setuptools
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
chcp 65001
|
chcp 65001
|
||||||
cd /d "%~dp0"
|
cd /d "%~dp0"
|
||||||
call "env\Scripts\activate.bat"
|
call "env\Scripts\activate.bat"
|
||||||
python "app/run.py"
|
python run.py
|
||||||
pause
|
pause
|
||||||
|
|
@ -9,13 +9,12 @@ logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(level
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# ----------------------------------------------- Main ------------------------------------------------------------
|
# ----------------------------------------------- Main ------------------------------------------------------------
|
||||||
# Создаем явную точку входа
|
if __name__ == "__main__":
|
||||||
def start_app():
|
|
||||||
# Добавляем папку app в Python path
|
# Добавляем папку app в Python path
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'app'))
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'app'))
|
||||||
from api import api, voice_inst, current_obsidian_settings
|
from app.api import api, voice_inst, current_obsidian_settings
|
||||||
from heartbeat_monitor import HeartbeatMonitor
|
from app.heartbeat_monitor import HeartbeatMonitor
|
||||||
from workflows import graph_history_manager
|
from app.workflows import graph_history_manager
|
||||||
|
|
||||||
logger.info("🚀 Запуск LLM Agent Backend сервера...")
|
logger.info("🚀 Запуск LLM Agent Backend сервера...")
|
||||||
logger.info("📍 API будет доступно на: http://localhost:5000/api")
|
logger.info("📍 API будет доступно на: http://localhost:5000/api")
|
||||||
|
|
@ -34,7 +33,7 @@ def start_app():
|
||||||
logger.info("HeartbeatMonitor запущен.")
|
logger.info("HeartbeatMonitor запущен.")
|
||||||
|
|
||||||
|
|
||||||
from heartbeat_monitor import VoiceHeartbeatMonitor
|
from app.heartbeat_monitor import VoiceHeartbeatMonitor
|
||||||
|
|
||||||
v_monitor = VoiceHeartbeatMonitor(
|
v_monitor = VoiceHeartbeatMonitor(
|
||||||
voice_inst,
|
voice_inst,
|
||||||
|
|
@ -50,7 +49,3 @@ def start_app():
|
||||||
host='localhost',
|
host='localhost',
|
||||||
threaded=True
|
threaded=True
|
||||||
)
|
)
|
||||||
|
|
||||||
# Оставляем этот блок для локальных тестов, если запускаете исходник напрямую
|
|
||||||
if __name__ == "__main__":
|
|
||||||
start_app()
|
|
||||||
38
run.spec
Normal file
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