438 lines
20 KiB
Python
438 lines
20 KiB
Python
"""
|
||
Этот файл содержит 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 # Добавляем импорт списка моделей
|
||
|
||
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."""
|
||
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")
|
||
# Необязательный параметр для существующего узла ассистента при регенерации
|
||
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)
|
||
|
||
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 = ""
|
||
|
||
try:
|
||
for chunk_data in run_agent_streaming(graph_id, user_node_id,
|
||
assistant_node_id,
|
||
system_prompt, model,
|
||
cache_folder, temperature, max_tokens):
|
||
if chunk_data.get("type") == "chunk":
|
||
accumulated_content += chunk_data.get("content", "")
|
||
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:
|
||
# НОВОЕ: Обработка прерывания генератора (клиент отключился)
|
||
print(f"⚠️ Клиент отменил стрим для узла {assistant_node_id}")
|
||
|
||
# После завершения стриминга обновляем узел полным контентом
|
||
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:
|
||
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)
|
||
|
||
|
||
from app.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"}) |