llm-agent-backend/app/api.py
2026-07-06 01:49:31 +03:00

616 lines
29 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Этот файл содержит API endpoints, созданные с использованием Flask.
Он обеспечивает взаимодействие с агентом через HTTP запросы,
включая обработку сообщений, управление графами и получение истории.
"""
import json
from flask import Flask, request, jsonify, Response, stream_with_context
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(
api
) # Разрешаем CORS для всех доменов (в production нужно настроить более строго)
# ----------------------------------------------- WebSocket Initialization ---------------------------------------------------------------
socketio = SocketIO(
api, cors_allowed_origins="*") # В production настроить CORS строже # , async_mode='eventlet' для компиляции в exe
graph_history_manager.set_socketio(socketio)
# ----------------------------------------------- WebSocket Events ---------------------------------------------------------------
@socketio.on('connect')
def handle_connect():
"""Обработчик подключения клиента к WebSocket."""
print(f"Client connected: {request.sid}")
@socketio.on('disconnect')
def handle_disconnect():
"""Обработчик отключения клиента от WebSocket."""
print(f"Client disconnected: {request.sid}")
@socketio.on('client_proactive_ping')
def handle_client_proactive_ping(data=None):
"""Обработчик проактивного пинга от клиента."""
print(f"Received proactive ping from client: {request.sid}. Sending pong.")
# Возвращаем словарь, который будет преобразован в JSON и отправлен как ответ на callback
return {'status': 'pong'}
# ----------------------------------------------- API Endpoints ---------------------------------------------------------------
@api.route('/api/graphs', methods=['GET'])
def get_graphs():
"""API endpoint для получения списка графов."""
graphs = graph_history_manager.get_all_graphs_summary()
print("get_graphs response:", graphs) # Добавлено логирование
return jsonify(graphs)
@api.route('/api/graphs/new', methods=['POST'])
def create_new_graph_endpoint():
"""API endpoint для создания нового пустого графа."""
try:
new_graph_id = graph_history_manager.create_new_graph()
return jsonify({"graph_id": new_graph_id}), 201
except Exception as e:
print(f"Ошибка при создании нового графа через API: {e}")
return jsonify({"error": f"Не удалось создать новый граф: {str(e)}"},
500)
@api.route('/api/graphs/<graph_id>', methods=['GET'])
def get_graph_by_id(graph_id):
"""API endpoint для получения данных конкретного графа по ID."""
graph_data = graph_history_manager.get_graph(graph_id)
if graph_data:
return jsonify(graph_data)
else:
return jsonify({"error": f"Граф с ID {graph_id} не найден."}, 404)
@api.route('/api/graphs/<graph_id>', methods=['DELETE'])
def delete_graph(graph_id):
"""API endpoint для удаления графа."""
if not graph_id:
return jsonify({"error": "Не указан ID графа для удаления."}, 400)
if graph_history_manager.delete_graph(graph_id):
return jsonify({"message": f"Граф {graph_id} успешно удален."})
else:
return jsonify({"error": f"Не удалось удалить граф {graph_id}."}, 500)
@api.route('/api/graphs', methods=['DELETE'])
def delete_all_graphs():
"""API endpoint для удаления всех графов."""
if graph_history_manager.delete_all_graphs():
return jsonify({"message": "Все графы успешно удалены."})
else:
return jsonify({"error": "Не удалось удалить все графы."}, 500)
@api.route('/api/graphs/<graph_id>/rename', methods=['PATCH'])
def rename_graph(graph_id):
"""API endpoint для переименования графа."""
if not graph_id:
return jsonify({"error": "Не указан ID графа для переименования."},
400)
data = request.get_json()
new_title = data.get("title")
if not new_title or not new_title.strip():
return jsonify({"error": "Название не может быть пустым."}, 400)
if graph_history_manager.rename_graph(graph_id, new_title.strip()):
return jsonify({"message": f"Граф {graph_id} успешно переименован."})
else:
return jsonify({"error": f"Не удалось переименовать граф {graph_id}."},
500)
@api.route('/api/graphs/<graph_id>/nodes/<node_id>', methods=['DELETE'])
def delete_graph_node(graph_id, node_id):
"""API endpoint для удаления узла графа."""
if not graph_id or not node_id:
return jsonify({"error": "Не указаны ID графа или узла для удаления."},
400)
try:
if graph_history_manager.delete_node(graph_id, node_id):
return jsonify({
"message":
f"Узел {node_id} успешно удален из графа {graph_id}."
})
else:
return jsonify(
{
"error":
f"Не удалось удалить узел {node_id} из графа {graph_id}."
}, 500)
except Exception as e:
print(f"Ошибка при удалении узла {node_id} из графа {graph_id}: {e}")
return jsonify(
{"error": f"Ошибка сервера при удалении узла: {str(e)}"}, 500)
@api.route('/api/graphs/<graph_id>/settings', methods=['PATCH'])
def update_graph_settings_endpoint(graph_id):
"""API endpoint для обновления настроек графа (например, пользовательского системного промпта)."""
data = request.get_json()
custom_system_prompt = data.get(
"custom_system_prompt") # Может быть None для сброса
if not graph_id:
return jsonify(
{"error": "Не указан ID графа для обновления настроек."}, 400)
try:
if graph_history_manager.update_graph_settings(graph_id,
custom_system_prompt):
return jsonify(
{"message": f"Настройки графа {graph_id} успешно обновлены."})
else:
return jsonify(
{"error": f"Не удалось обновить настройки графа {graph_id}."},
500)
except Exception as e:
print(f"Ошибка при обновлении настроек графа {graph_id}: {e}")
return jsonify({"error": f"Ошибка сервера: {str(e)}"}, 500)
@api.route('/api/messages/<graph_id>/<node_id>', methods=['GET'])
def get_messages_from_root_to_node(graph_id, node_id):
"""API endpoint для получения сообщений от корня до выбранной ноды."""
graph_data = graph_history_manager.get_graph(graph_id)
if not graph_data:
return jsonify({"error": "Граф не найден."}, 404)
target_node_id = node_id
if node_id == 'last':
target_node_id = graph_data.get('current_node_id')
if not target_node_id:
# Если запрошен 'last', но активного узла нет, возвращаем пустой список
return jsonify([])
messages = graph_history_manager.get_messages_from_root_to_node(
graph_id, graph_data, target_node_id)
return jsonify(messages)
@api.route('/api/models', methods=['GET'])
def get_available_models():
"""API endpoint для получения списка доступных моделей."""
return jsonify(list(MODELS.keys()))
@api.route('/api/chat/send', methods=['POST'])
def send_user_message():
"""API endpoint для создания узла пользователя с вложениями."""
data = request.get_json()
message = data.get("message", "")
graph_id = data.get("graph_id")
parent_node_id = data.get("parent_node_id")
attachments = data.get("attachments", [])
cache_folder = data.get("cache_folder")
# Блокируем только если нет и текста, и вложений
if not message and not attachments:
return jsonify({"error": "Сообщение или вложение не могут быть пустыми."}), 400
try:
result = graph_history_manager.create_user_node(
message, graph_id, parent_node_id, attachments, cache_folder
)
graph_history_manager.title_generator.add_node_to_queue_direct(
result.get("graph_id"), result.get("node_id"))
return jsonify(result)
except Exception as e:
print(f"Ошибка при создании узла пользователя: {e}")
return jsonify({"error": str(e)}, 500)
@api.route('/api/chat/stream', methods=['POST'])
def chat_stream():
"""API endpoint для стриминга ответа LLM и работы ReAct-агента."""
data = request.get_json()
graph_id = data.get("graph_id")
user_node_id = data.get("user_node_id")
system_prompt = data.get("system_prompt")
model = data.get("model")
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)
# Необязательный параметр для существующего узла ассистента при регенерации
existing_assistant_node_id = data.get("existing_assistant_node_id")
if not graph_id or not user_node_id:
return jsonify({"error": "Не указаны graph_id или user_node_id."}, 400)
# Подтягиваем глобальные настройки Obsidian (синхронизируются через /settings/sync)
global current_obsidian_settings
def generate():
assistant_node_id = None
try:
if existing_assistant_node_id:
assistant_node_id = existing_assistant_node_id
graph_history_manager.mark_node_as_placeholder_and_clear_content(
graph_id, assistant_node_id, user_node_id)
yield f"data: {json.dumps({'type': 'node_created', 'node_id': assistant_node_id, 'reused': True})}\n\n"
else:
# Создаём узел-заглушку для ответа LLM
assistant_node_id = graph_history_manager.create_assistant_placeholder_node(
graph_id, user_node_id)
# Отправляем ID нового узла клиенту
yield f"data: {json.dumps({'type': 'node_created', 'node_id': assistant_node_id, 'reused': False})}\n\n"
# Запускаем стриминг ответа от 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,
system_prompt, model,
cache_folder, temperature, max_tokens,
agency_mode, current_obsidian_settings):
# Прокидываем любые успешные этапы: куски текста или статусы работы тулов
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"
# Обработка ошибки во время стриминга (крашим узел на фронте)
elif chunk_data.get("type") == "error":
if assistant_node_id:
graph_history_manager.mark_node_as_error(
graph_id, assistant_node_id,
chunk_data.get("error", "Неизвестная ошибка при стриминге"))
if graph_history_manager.socketio:
graph_history_manager.socketio.emit(
'node_type_changed', {
'graph_id': graph_id,
'node_id': assistant_node_id,
'node_type': 'error'
})
yield f"data: {json.dumps(chunk_data)}\n\n"
return
except GeneratorExit:
# Обработка прерывания генератора (если клиент нажал Stop на фронте или разорвал коннект)
print(f"⚠️ Клиент отменил стрим для узла {assistant_node_id}")
# После завершения стриминга (успешного) обновляем узел полным контентом в базе SQLite
if assistant_node_id:
graph_history_manager.update_assistant_node_content(
graph_id, assistant_node_id, accumulated_content)
# Инициируем фоновую генерацию заголовка
graph_history_manager.title_generator.add_node_to_queue_direct(
graph_id, assistant_node_id)
yield f"data: {json.dumps({'type': 'done', 'node_id': assistant_node_id})}\n\n"
except Exception as e:
# Отлов непредсказуемых внешних ошибок в самом генераторе API
print(f"Ошибка при стриминге ответа: {e}")
error_message = str(e)
if assistant_node_id:
graph_history_manager.mark_node_as_error(
graph_id, assistant_node_id,
f"Ошибка при стриминге: {error_message}")
if graph_history_manager.socketio:
graph_history_manager.socketio.emit(
'node_type_changed', {
'graph_id': graph_id,
'node_id': assistant_node_id,
'node_type': 'error'
})
error_data = {
'type': 'error',
'error': f"Ошибка при стриминге: {error_message}"
}
yield f"data: {json.dumps(error_data)}\n\n"
return Response(stream_with_context(generate()),
content_type='text/event-stream')
@api.route('/api/chat/regenerate', methods=['POST'])
def regenerate_message():
"""API endpoint для регенерации сообщения.
При ошибке LLM-узла, переиспользует его. В противном случае, создает новый узел.
"""
data = request.get_json()
graph_id = data.get("graph_id")
node_id = data.get(
"node_id") # Это ID узла, который нужно регенерировать (llm-ответ)
model = data.get("model")
system_prompt = data.get("system_prompt")
cache_folder = data.get("cache_folder")
temperature = data.get("temperature", DEFAULT_TEMPERATURE)
max_tokens = data.get("max_tokens")
if not graph_id or not node_id:
return jsonify({"error": "Не указаны graph_id или node_id."}, 400)
try:
# Определяем родительский узел для LLM-ответа. Это должен быть user-узел.
parent_node_id = graph_history_manager.get_parent_node_id(
graph_id, node_id)
if not parent_node_id:
return jsonify(
{
"error":
"Не удалось найти родительский узел для регенерации."
}, 404)
# НОВОЕ: Проверяем тип узла
node_type = graph_history_manager.get_node_type(graph_id, node_id)
assistant_node_id_to_use = None
if node_type == 'error':
# Если узел был ошибочным, переиспользуем его
# Очищаем его содержимое и помечаем как заглушку сразу же
assistant_node_id_to_use = node_id # Используем тот же ID
else:
# Если узел не был ошибочным, создаем новый узел LLM-ответа
assistant_node_id_to_use = graph_history_manager.create_assistant_placeholder_node(
graph_id, parent_node_id)
return jsonify({
"existing_assistant_node_id": assistant_node_id_to_use,
"user_node_id": parent_node_id,
"graph_id": graph_id,
"model": model,
"system_prompt": system_prompt,
"cache_folder": cache_folder,
"temperature": temperature,
"max_tokens": max_tokens
})
except Exception as e:
print(f"Ошибка при регенерации сообщения: {e}")
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
# 1. Создаем экземпляр (обязательно укажите путь к вашей модели Vosk)
voice_inst = VoiceService()
current_obsidian_settings = {}
obsidian_settings_fetched = False
@api.route('/api/voice/status', methods=['GET'])
def get_voice_status():
# 2. Вызываем метод у экземпляра voice_inst, а не у модуля
pure_gm_text, session_id = voice_inst.get_pure_gm_text()
return jsonify({
"transcription": voice_inst.get_context_by_limit(current_obsidian_settings.get('voiceDisplayLimit', 0), order='reversed'),
"pure_gm_text": pure_gm_text,
"session_id": session_id,
"response1": list(voice_inst.responses["regular"]),
"response2": list(voice_inst.responses["commands"]),
"metrics": voice_inst.metrics_data,
"is_running": voice_inst.is_running,
"obsidian_settings_fetched": obsidian_settings_fetched
})
@api.route('/api/voice/control', methods=['POST'])
def control_voice():
action = request.json.get("action")
if action == "start": voice_inst.start_session(False)
elif action == "continue": voice_inst.start_session(True)
elif action == "stop": voice_inst.stop_session()
return jsonify({"status": "ok"})
@api.route('/api/settings/sync', methods=['POST'])
def sync_settings():
global current_obsidian_settings, obsidian_settings_fetched
obsidian_settings_fetched = True
data = request.json
if data:
current_obsidian_settings.update(data)
# Передаем настройки в voice_service
if data.get('absoluteLogsPath'):
# Если фронт прислал вычисленный абсолютный путь
voice_inst.update_logs_config(
abs_path=data.get('absoluteLogsPath')
)
# Инициализируем модель Vosk, если передан путь
vosk_path = data.get('voskModelPath')
if vosk_path:
# Запускаем в фоновом потоке, чтобы не подвесить фронтенд Obsidian (загрузка занимает время)
import threading
threading.Thread(target=voice_inst.load_model, args=(vosk_path,), daemon=True).start()
# TODO: Скорее всего, здесь нужно какое-то событие эмитировать об изменении настроек, и в другом месте ловить
graph_history_manager.title_generator.llm = get_llm(current_obsidian_settings['summarizationModel'])
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