Add agency
This commit is contained in:
parent
91daa5b2bd
commit
d071d16bd2
27
app/api.py
27
app/api.py
|
|
@ -223,7 +223,7 @@ def send_user_message():
|
||||||
|
|
||||||
@api.route('/api/chat/stream', methods=['POST'])
|
@api.route('/api/chat/stream', methods=['POST'])
|
||||||
def chat_stream():
|
def chat_stream():
|
||||||
"""API endpoint для стриминга ответа LLM."""
|
"""API endpoint для стриминга ответа LLM и работы ReAct-агента."""
|
||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
graph_id = data.get("graph_id")
|
graph_id = data.get("graph_id")
|
||||||
user_node_id = data.get("user_node_id")
|
user_node_id = data.get("user_node_id")
|
||||||
|
|
@ -232,12 +232,16 @@ 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) # НОВОЕ: Перехватываем режим тумблера "Агент"
|
||||||
# Необязательный параметр для существующего узла ассистента при регенерации
|
# Необязательный параметр для существующего узла ассистента при регенерации
|
||||||
existing_assistant_node_id = data.get("existing_assistant_node_id")
|
existing_assistant_node_id = data.get("existing_assistant_node_id")
|
||||||
|
|
||||||
if not graph_id or not user_node_id:
|
if not graph_id or not user_node_id:
|
||||||
return jsonify({"error": "Не указаны graph_id или user_node_id."}, 400)
|
return jsonify({"error": "Не указаны graph_id или user_node_id."}, 400)
|
||||||
|
|
||||||
|
# Подтягиваем глобальные настройки Obsidian (синхронизируются через /settings/sync)
|
||||||
|
global current_obsidian_settings
|
||||||
|
|
||||||
def generate():
|
def generate():
|
||||||
assistant_node_id = None
|
assistant_node_id = None
|
||||||
try:
|
try:
|
||||||
|
|
@ -253,23 +257,28 @@ def chat_stream():
|
||||||
# Отправляем ID нового узла клиенту
|
# Отправляем ID нового узла клиенту
|
||||||
yield f"data: {json.dumps({'type': 'node_created', 'node_id': assistant_node_id, 'reused': False})}\n\n"
|
yield f"data: {json.dumps({'type': 'node_created', 'node_id': assistant_node_id, 'reused': False})}\n\n"
|
||||||
|
|
||||||
# Запускаем стриминг ответа от LLM
|
# Запускаем стриминг ответа от LLM (используем обновленный агент)
|
||||||
accumulated_content = ""
|
accumulated_content = ""
|
||||||
|
|
||||||
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,
|
||||||
system_prompt, model,
|
system_prompt, model,
|
||||||
cache_folder, temperature, max_tokens):
|
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":
|
if chunk_data.get("type") == "chunk":
|
||||||
accumulated_content += chunk_data.get("content", "")
|
accumulated_content += chunk_data.get("content", "")
|
||||||
yield f"data: {json.dumps(chunk_data)}\n\n"
|
yield f"data: {json.dumps(chunk_data)}\n\n"
|
||||||
|
|
||||||
|
# Обработка ошибки во время стриминга (крашим узел на фронте)
|
||||||
elif chunk_data.get("type") == "error":
|
elif chunk_data.get("type") == "error":
|
||||||
if assistant_node_id:
|
if assistant_node_id:
|
||||||
graph_history_manager.mark_node_as_error(
|
graph_history_manager.mark_node_as_error(
|
||||||
graph_id, assistant_node_id,
|
graph_id, assistant_node_id,
|
||||||
chunk_data.get("error",
|
chunk_data.get("error", "Неизвестная ошибка при стриминге"))
|
||||||
"Неизвестная ошибка при стриминге"))
|
|
||||||
if graph_history_manager.socketio:
|
if graph_history_manager.socketio:
|
||||||
graph_history_manager.socketio.emit(
|
graph_history_manager.socketio.emit(
|
||||||
'node_type_changed', {
|
'node_type_changed', {
|
||||||
|
|
@ -279,22 +288,24 @@ def chat_stream():
|
||||||
})
|
})
|
||||||
yield f"data: {json.dumps(chunk_data)}\n\n"
|
yield f"data: {json.dumps(chunk_data)}\n\n"
|
||||||
return
|
return
|
||||||
|
|
||||||
except GeneratorExit:
|
except GeneratorExit:
|
||||||
# НОВОЕ: Обработка прерывания генератора (клиент отключился)
|
# Обработка прерывания генератора (если клиент нажал Stop на фронте или разорвал коннект)
|
||||||
print(f"⚠️ Клиент отменил стрим для узла {assistant_node_id}")
|
print(f"⚠️ Клиент отменил стрим для узла {assistant_node_id}")
|
||||||
|
|
||||||
# После завершения стриминга обновляем узел полным контентом
|
# После завершения стриминга (успешного) обновляем узел полным контентом в базе SQLite
|
||||||
if assistant_node_id:
|
if assistant_node_id:
|
||||||
graph_history_manager.update_assistant_node_content(
|
graph_history_manager.update_assistant_node_content(
|
||||||
graph_id, assistant_node_id, accumulated_content)
|
graph_id, assistant_node_id, accumulated_content)
|
||||||
|
|
||||||
# Инициируем генерацию заголовка
|
# Инициируем фоновую генерацию заголовка
|
||||||
graph_history_manager.title_generator.add_node_to_queue_direct(
|
graph_history_manager.title_generator.add_node_to_queue_direct(
|
||||||
graph_id, assistant_node_id)
|
graph_id, assistant_node_id)
|
||||||
|
|
||||||
yield f"data: {json.dumps({'type': 'done', 'node_id': assistant_node_id})}\n\n"
|
yield f"data: {json.dumps({'type': 'done', 'node_id': assistant_node_id})}\n\n"
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
# Отлов непредсказуемых внешних ошибок в самом генераторе API
|
||||||
print(f"Ошибка при стриминге ответа: {e}")
|
print(f"Ошибка при стриминге ответа: {e}")
|
||||||
error_message = str(e)
|
error_message = str(e)
|
||||||
if assistant_node_id:
|
if assistant_node_id:
|
||||||
|
|
|
||||||
|
|
@ -348,134 +348,6 @@ class CustomLLM:
|
||||||
raise
|
raise
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
def stream2(self, messages: List[Any], temperature: float = DEFAULT_TEMPERATURE, max_tokens: Optional[int] = None):
|
|
||||||
"""
|
|
||||||
Генератор для стриминга ответа от LLM.
|
|
||||||
Возвращает чанки контента по мере их получения.
|
|
||||||
"""
|
|
||||||
if self.provider == "openai":
|
|
||||||
openai_messages = self._prepare_openai_messages(messages)
|
|
||||||
|
|
||||||
try:
|
|
||||||
kwargs = {"temperature": temperature}
|
|
||||||
if max_tokens:
|
|
||||||
kwargs["max_tokens"] = int(max_tokens)
|
|
||||||
|
|
||||||
response = self.client.chat.completions.create(
|
|
||||||
model=self.model_name,
|
|
||||||
messages=openai_messages,
|
|
||||||
stream=True,
|
|
||||||
**kwargs
|
|
||||||
)
|
|
||||||
|
|
||||||
for chunk in response:
|
|
||||||
chunk_message = chunk.choices[0].delta.content
|
|
||||||
if chunk_message is not None:
|
|
||||||
yield chunk_message
|
|
||||||
|
|
||||||
except GeneratorExit:
|
|
||||||
# НОВОЕ: Обработка прерывания генератора
|
|
||||||
print(f"⚠️ Стрим OpenAI прерван для модели {self.model_name}")
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Ошибка при стриминге OpenAI API: {e}")
|
|
||||||
raise
|
|
||||||
|
|
||||||
elif self.provider == "mistralai":
|
|
||||||
mistralai_messages = self._prepare_mistralai_messages(messages)
|
|
||||||
|
|
||||||
try:
|
|
||||||
kwargs = {"temperature": temperature}
|
|
||||||
if max_tokens:
|
|
||||||
kwargs["max_tokens"] = int(max_tokens)
|
|
||||||
|
|
||||||
response = self.client.chat.stream(model=self.model_name,
|
|
||||||
messages=mistralai_messages,
|
|
||||||
**kwargs)
|
|
||||||
|
|
||||||
for chunk in response:
|
|
||||||
delta = chunk.data.choices[0].delta
|
|
||||||
chunk_message = getattr(delta, "content", None)
|
|
||||||
|
|
||||||
if chunk_message is not None:
|
|
||||||
yield chunk_message
|
|
||||||
|
|
||||||
if getattr(delta, "finish_reason", None) is not None:
|
|
||||||
break
|
|
||||||
|
|
||||||
except GeneratorExit:
|
|
||||||
# НОВОЕ: Обработка прерывания генератора
|
|
||||||
print(f"⚠️ Стрим Mistral прерван для модели {self.model_name}")
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Ошибка при стриминге Mistral API: {e}")
|
|
||||||
raise
|
|
||||||
|
|
||||||
def _prepare_openai_messages(self,
|
|
||||||
messages: List[Any]) -> List[Dict[str, str]]:
|
|
||||||
"""Подготавливает сообщения в формате OpenAI."""
|
|
||||||
openai_messages = []
|
|
||||||
if isinstance(messages, str):
|
|
||||||
openai_messages.append({"role": "user", "content": messages})
|
|
||||||
elif isinstance(messages, list):
|
|
||||||
for msg in messages:
|
|
||||||
if isinstance(msg, HumanMessage):
|
|
||||||
openai_messages.append({
|
|
||||||
"role": "user",
|
|
||||||
"content": msg.content
|
|
||||||
})
|
|
||||||
elif isinstance(msg, SystemMessage):
|
|
||||||
openai_messages.append({
|
|
||||||
"role": "system",
|
|
||||||
"content": msg.content
|
|
||||||
})
|
|
||||||
elif isinstance(msg, AIMessage):
|
|
||||||
openai_messages.append({
|
|
||||||
"role": "assistant",
|
|
||||||
"content": msg.content
|
|
||||||
})
|
|
||||||
else:
|
|
||||||
openai_messages.append({
|
|
||||||
"role":
|
|
||||||
msg.type if hasattr(msg, 'type') else 'user',
|
|
||||||
"content":
|
|
||||||
msg.content
|
|
||||||
})
|
|
||||||
return openai_messages
|
|
||||||
|
|
||||||
def _prepare_mistralai_messages(
|
|
||||||
self, messages: List[Any]) -> List[Dict[str, str]]:
|
|
||||||
"""Подготавливает сообщения в формате Mistral AI."""
|
|
||||||
mistralai_messages = []
|
|
||||||
if isinstance(messages, str):
|
|
||||||
mistralai_messages.append({"role": "user", "content": messages})
|
|
||||||
elif isinstance(messages, list):
|
|
||||||
for msg in messages:
|
|
||||||
if isinstance(msg, HumanMessage):
|
|
||||||
mistralai_messages.append({
|
|
||||||
"role": "user",
|
|
||||||
"content": msg.content
|
|
||||||
})
|
|
||||||
elif isinstance(msg, SystemMessage):
|
|
||||||
mistralai_messages.append({
|
|
||||||
"role": "system",
|
|
||||||
"content": msg.content
|
|
||||||
})
|
|
||||||
elif isinstance(msg, AIMessage):
|
|
||||||
mistralai_messages.append({
|
|
||||||
"role": "assistant",
|
|
||||||
"content": msg.content
|
|
||||||
})
|
|
||||||
else:
|
|
||||||
mistralai_messages.append({
|
|
||||||
"role":
|
|
||||||
msg.type if hasattr(msg, 'type') else 'user',
|
|
||||||
"content":
|
|
||||||
msg.content
|
|
||||||
})
|
|
||||||
return mistralai_messages
|
|
||||||
|
|
||||||
|
|
||||||
def get_llm(name: str) -> CustomLLM:
|
def get_llm(name: str) -> CustomLLM:
|
||||||
"""
|
"""
|
||||||
Возвращает экземпляр CustomLLM для заданной модели.
|
Возвращает экземпляр CustomLLM для заданной модели.
|
||||||
|
|
@ -484,8 +356,3 @@ def get_llm(name: str) -> CustomLLM:
|
||||||
if not config:
|
if not config:
|
||||||
raise ValueError(f"Модель '{name}' не сконфигурирована.")
|
raise ValueError(f"Модель '{name}' не сконфигурирована.")
|
||||||
return CustomLLM(config)
|
return CustomLLM(config)
|
||||||
|
|
||||||
|
|
||||||
def get_available_models():
|
|
||||||
"""Возвращает список доступных моделей."""
|
|
||||||
return list(MODELS.keys())
|
|
||||||
|
|
|
||||||
558
app/nodes.py
558
app/nodes.py
|
|
@ -1,558 +0,0 @@
|
||||||
"""
|
|
||||||
Этот файл содержит узлы для графа состояний агента.
|
|
||||||
Каждый узел выполняет определенную функцию, такую как
|
|
||||||
разбор команд, вызов LLM или выполнение сервисов.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from langchain_core.messages import HumanMessage, SystemMessage, AIMessage
|
|
||||||
|
|
||||||
from llm_client import get_llm
|
|
||||||
from services import ImageGenerationService, SubtitleService, ImageAnalysisService, SummarizationService
|
|
||||||
from models import AgentState
|
|
||||||
|
|
||||||
from typing import Dict, Any, Optional
|
|
||||||
import uuid # Добавлено для генерации UUID
|
|
||||||
|
|
||||||
|
|
||||||
class CommandManager:
|
|
||||||
"""
|
|
||||||
Менеджер для хранения и получения слеш-команд.
|
|
||||||
В реальном приложении команды будут храниться в БД.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self._commands = {
|
|
||||||
"help": {
|
|
||||||
"description": "Показывает список доступных команд.",
|
|
||||||
"workflow": "help_workflow"
|
|
||||||
},
|
|
||||||
"imagine": {
|
|
||||||
"description":
|
|
||||||
"Генерирует изображения по промпту. Использование: /imagine [prompt]",
|
|
||||||
"workflow": "image_generation_workflow"
|
|
||||||
},
|
|
||||||
"analyze": {
|
|
||||||
"description":
|
|
||||||
"Анализирует изображение по URL и промпту. Использование: /analyze [url] [prompt]",
|
|
||||||
"workflow": "image_analysis_workflow"
|
|
||||||
},
|
|
||||||
"subtitles_meet": {
|
|
||||||
"description": "Получает текущие субтитры из Google Meet.",
|
|
||||||
"workflow": "get_meet_subtitles_workflow"
|
|
||||||
},
|
|
||||||
"subtitles_teams": {
|
|
||||||
"description": "Получает текущие субтитры из MS Teams.",
|
|
||||||
"workflow": "get_teams_subtitles_workflow"
|
|
||||||
},
|
|
||||||
"summarize": {
|
|
||||||
"description": "Суммирует текущую историю диалога.",
|
|
||||||
"workflow": "summarize_history_workflow"
|
|
||||||
},
|
|
||||||
"chat": {
|
|
||||||
"description": "Ведет обычный диалог с LLM.",
|
|
||||||
"workflow": "chat_workflow"
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
def get_commands(self) -> Dict[str, Any]:
|
|
||||||
"""Возвращает все доступные команды."""
|
|
||||||
return self._commands
|
|
||||||
|
|
||||||
def get_command_workflow(self, command_name: str) -> Optional[str]:
|
|
||||||
"""Возвращает имя workflow для заданной команды."""
|
|
||||||
return self._commands.get(command_name, {}).get("workflow")
|
|
||||||
|
|
||||||
|
|
||||||
# Инициализация сервисов (пример, убедитесь, что они доступны в этом файле или импортированы)
|
|
||||||
command_manager = CommandManager()
|
|
||||||
|
|
||||||
|
|
||||||
# Здесь мы меняем состояние графа, добавляя сообщение от пользователя
|
|
||||||
def parse_command_node(state: AgentState) -> AgentState:
|
|
||||||
"""
|
|
||||||
Парсит вход пользователя на предмет слеш-команды и инициализирует узел пользователя в графе.
|
|
||||||
"""
|
|
||||||
print("Выполняется узел: parse_command_node")
|
|
||||||
user_input = state.input.strip()
|
|
||||||
|
|
||||||
# Создаем ID для узла пользователя с помощью UUID
|
|
||||||
user_node_id = str(uuid.uuid4())
|
|
||||||
|
|
||||||
# Добавляем узел пользователя в граф для фронтенда с полным текстом сообщения
|
|
||||||
state.graph_nodes.append({
|
|
||||||
"id": user_node_id,
|
|
||||||
"type": "user",
|
|
||||||
"data": {
|
|
||||||
"label": user_input[:30] + "..." if len(user_input) > 30 else user_input,
|
|
||||||
"message": {"role": "user", "content": user_input, "node_id": user_node_id} # Сохраняем сообщение здесь
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
# Добавляем ребро к новому узлу, если есть родительский узел
|
|
||||||
if state.parent_node_id:
|
|
||||||
edge_id = str(uuid.uuid4())
|
|
||||||
state.graph_edges.append({
|
|
||||||
"id": edge_id,
|
|
||||||
"source": state.parent_node_id,
|
|
||||||
"target": user_node_id
|
|
||||||
})
|
|
||||||
state.parent_node_id = user_node_id
|
|
||||||
state.current_node_id = user_node_id
|
|
||||||
|
|
||||||
# Обновляем временную историю чата, которая используется для передачи между узлами
|
|
||||||
# и для вызова LLM в текущем раунде.
|
|
||||||
state.temporary_chat_history.append({
|
|
||||||
"role": "user",
|
|
||||||
"content": user_input,
|
|
||||||
"node_id": user_node_id
|
|
||||||
})
|
|
||||||
|
|
||||||
if user_input.startswith('/'):
|
|
||||||
parts = user_input[1:].split(' ', 1)
|
|
||||||
command_name = parts[0].lower()
|
|
||||||
command_args = parts[1].split(' ') if len(parts) > 1 else []
|
|
||||||
if command_name in command_manager.get_commands():
|
|
||||||
state.command = command_name
|
|
||||||
state.command_args = command_args
|
|
||||||
print(
|
|
||||||
f"Команда распознана: /{state.command} с аргументами: {state.command_args}"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
state.error = f"Неизвестная команда: /{command_name}. Введите /help для списка команд."
|
|
||||||
print(state.error)
|
|
||||||
state.command = "error" # Специальный флаг для обработки ошибок команд
|
|
||||||
else:
|
|
||||||
state.command = "chat" # Обычный чат
|
|
||||||
return state
|
|
||||||
|
|
||||||
|
|
||||||
def execute_command_node(state: AgentState) -> AgentState:
|
|
||||||
"""
|
|
||||||
Выполняет соответствующий workflow для команды.
|
|
||||||
Этот узел служит маршрутизатором для выбора следующего узла в зависимости от команды.
|
|
||||||
"""
|
|
||||||
print(
|
|
||||||
f"Выполняется узел: execute_command_node для команды: {state.command}")
|
|
||||||
# Этот узел будет выступать в роли маршрутизатора
|
|
||||||
# Фактическая логика команды будет в отдельных узлах, вызываемых по условию
|
|
||||||
return state # Просто передаем состояние дальше, чтобы маршрутизатор смог выбрать следующий узел
|
|
||||||
|
|
||||||
|
|
||||||
# Предполагаем, что используем Gemini для основного LLM и суммаризации/анализа
|
|
||||||
# В реальной системе можно выбрать динамически
|
|
||||||
DEFAULT_LLM_NAME = "gemini-2.0-flash" #"gemini-2.5-flash"
|
|
||||||
main_llm = get_llm(DEFAULT_LLM_NAME)
|
|
||||||
|
|
||||||
|
|
||||||
def call_llm_node(state: AgentState) -> AgentState:
|
|
||||||
"""
|
|
||||||
Вызывает LLM для обработки обычного диалога или генерации ответа.
|
|
||||||
Использует `temporary_chat_history` для передачи контекста LLM.
|
|
||||||
"""
|
|
||||||
print("Выполняется узел: call_llm_node (для обычного чата)")
|
|
||||||
try:
|
|
||||||
messages_for_llm = []
|
|
||||||
|
|
||||||
if state.system_prompt:
|
|
||||||
messages_for_llm.append(SystemMessage(content=state.system_prompt))
|
|
||||||
|
|
||||||
# Для обычного чата LLM использует текущую временную историю
|
|
||||||
for msg in state.temporary_chat_history: # Используем temporary_chat_history
|
|
||||||
if msg["role"] == "user":
|
|
||||||
messages_for_llm.append(HumanMessage(content=msg["content"]))
|
|
||||||
elif msg["role"] == "assistant":
|
|
||||||
messages_for_llm.append(AIMessage(content=msg["content"]))
|
|
||||||
|
|
||||||
model_name = state.selected_model
|
|
||||||
llm = get_llm(model_name)
|
|
||||||
response = llm.invoke(messages_for_llm)
|
|
||||||
|
|
||||||
llm_node_id = str(uuid.uuid4())
|
|
||||||
|
|
||||||
# Добавляем узел LLM в граф для фронтенда с полным текстом ответа
|
|
||||||
state.graph_nodes.append({
|
|
||||||
"id": llm_node_id,
|
|
||||||
"type": "llm",
|
|
||||||
"data": {
|
|
||||||
"label": response[:30] + "..." if len(response) > 30 else response,
|
|
||||||
"message": {"role": "assistant", "content": response, "node_id": llm_node_id} # Сохраняем сообщение здесь
|
|
||||||
}
|
|
||||||
})
|
|
||||||
edge_id = str(uuid.uuid4())
|
|
||||||
state.graph_edges.append({
|
|
||||||
"id": edge_id,
|
|
||||||
"source": state.parent_node_id,
|
|
||||||
"target": llm_node_id
|
|
||||||
})
|
|
||||||
state.parent_node_id = llm_node_id
|
|
||||||
state.current_node_id = llm_node_id
|
|
||||||
|
|
||||||
# Обновляем временную историю чата с ответом LLM
|
|
||||||
state.temporary_chat_history.append({
|
|
||||||
"role": "assistant",
|
|
||||||
"content": response,
|
|
||||||
"node_id": llm_node_id
|
|
||||||
})
|
|
||||||
state.llm_response = response
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
state.error = f"Ошибка при вызове LLM: {e}"
|
|
||||||
state.llm_response = f"Произошла ошибка: {e}"
|
|
||||||
print(state.error)
|
|
||||||
return state
|
|
||||||
|
|
||||||
|
|
||||||
image_gen_service = ImageGenerationService(
|
|
||||||
base_url="https://render-service-gsu7.onrender.com/g2"
|
|
||||||
) # TODO: Замените на реальный URL
|
|
||||||
|
|
||||||
|
|
||||||
def generate_images_node(state: AgentState) -> AgentState:
|
|
||||||
"""
|
|
||||||
Генерирует изображения по промпту, переданному в команде `/imagine`.
|
|
||||||
"""
|
|
||||||
print("Выполняется узел: generate_images_node")
|
|
||||||
prompt = " ".join(state.command_args)
|
|
||||||
if not prompt:
|
|
||||||
state.error = "Для команды /imagine требуется промпт. Пример: /imagine кошка на луне"
|
|
||||||
state.llm_response = state.error
|
|
||||||
# Добавляем ошибку во временную историю для согласованности
|
|
||||||
state.temporary_chat_history.append({"role": "assistant", "content": state.error})
|
|
||||||
return state
|
|
||||||
|
|
||||||
try:
|
|
||||||
urls = image_gen_service.generate_images(prompt)
|
|
||||||
state.image_urls = urls
|
|
||||||
|
|
||||||
# Формируем текст ответа с использованием Markdown для отображения изображений
|
|
||||||
image_markdown = "\n".join([
|
|
||||||
f'<a href="{url}" target="_blank"><img src="{url}" alt="image{i}" style="max-width: 300px; max-height: 300px;"></a>'
|
|
||||||
for i, url in enumerate(urls)
|
|
||||||
])
|
|
||||||
response_text = f"Сгенерировано {len(urls)} изображений:\n{image_markdown}"
|
|
||||||
state.llm_response = response_text
|
|
||||||
|
|
||||||
img_gen_node_id = str(uuid.uuid4())
|
|
||||||
# Добавляем узел генерации изображений в граф
|
|
||||||
state.graph_nodes.append({
|
|
||||||
"id": img_gen_node_id,
|
|
||||||
"type": "tool",
|
|
||||||
"data": {
|
|
||||||
"label": "Генерация изображений",
|
|
||||||
"details": prompt,
|
|
||||||
"message": {"role": "assistant", "content": response_text, "node_id": img_gen_node_id}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
edge_id = str(uuid.uuid4())
|
|
||||||
state.graph_edges.append({
|
|
||||||
"id": edge_id,
|
|
||||||
"source": state.parent_node_id,
|
|
||||||
"target": img_gen_node_id
|
|
||||||
})
|
|
||||||
state.parent_node_id = img_gen_node_id
|
|
||||||
state.current_node_id = img_gen_node_id
|
|
||||||
|
|
||||||
# Обновляем временную историю чата
|
|
||||||
state.temporary_chat_history.append({
|
|
||||||
"role": "assistant",
|
|
||||||
"content": response_text,
|
|
||||||
"node_id": img_gen_node_id # Привязываем к ID узла
|
|
||||||
})
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
state.error = f"Ошибка генерации изображений: {e}"
|
|
||||||
state.llm_response = f"Произошла ошибка при генерации изображений: {e}"
|
|
||||||
print(state.error)
|
|
||||||
# Добавляем ошибку во временную историю
|
|
||||||
state.temporary_chat_history.append({"role": "assistant", "content": state.llm_response})
|
|
||||||
return state
|
|
||||||
|
|
||||||
|
|
||||||
image_analysis_service = ImageAnalysisService(
|
|
||||||
llm=get_llm("gemini-2.5-flash")) # Gemini для анализа изображений
|
|
||||||
|
|
||||||
|
|
||||||
def analyze_image_node(state: AgentState) -> AgentState:
|
|
||||||
"""
|
|
||||||
Анализирует изображение по URL и промпту, переданным в команде `/analyze`.
|
|
||||||
"""
|
|
||||||
print("Выполняется узел: analyze_image_node")
|
|
||||||
if len(state.command_args) < 2:
|
|
||||||
state.error = "Для команды /analyze требуется URL изображения и промпт. Пример: /analyze [url] 'что на изображении?'"
|
|
||||||
state.llm_response = state.error
|
|
||||||
# Добавляем ошибку во временную историю для согласованности
|
|
||||||
state.temporary_chat_history.append({"role": "assistant", "content": state.error})
|
|
||||||
return state
|
|
||||||
|
|
||||||
image_url = state.command_args[0]
|
|
||||||
prompt = " ".join(state.command_args[1:])
|
|
||||||
|
|
||||||
try:
|
|
||||||
analysis_result = image_analysis_service.analyze_image(
|
|
||||||
image_url, prompt)
|
|
||||||
state.analysis_result = analysis_result
|
|
||||||
response_text = f"Результат анализа изображения '{image_url}': {analysis_result}"
|
|
||||||
state.llm_response = response_text
|
|
||||||
|
|
||||||
img_analysis_node_id = str(uuid.uuid4())
|
|
||||||
# Добавляем узел анализа изображения в граф
|
|
||||||
state.graph_nodes.append({
|
|
||||||
"id": img_analysis_node_id,
|
|
||||||
"type": "tool",
|
|
||||||
"data": {
|
|
||||||
"label": "Анализ изображения",
|
|
||||||
"details": f"URL: {image_url}, Промпт: {prompt}",
|
|
||||||
"message": {"role": "assistant", "content": response_text, "node_id": img_analysis_node_id}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
edge_id = str(uuid.uuid4())
|
|
||||||
state.graph_edges.append({
|
|
||||||
"id": edge_id,
|
|
||||||
"source": state.parent_node_id,
|
|
||||||
"target": img_analysis_node_id
|
|
||||||
})
|
|
||||||
state.parent_node_id = img_analysis_node_id
|
|
||||||
state.current_node_id = img_analysis_node_id
|
|
||||||
|
|
||||||
# Обновляем временную историю чата
|
|
||||||
state.temporary_chat_history.append({
|
|
||||||
"role": "assistant",
|
|
||||||
"content": response_text,
|
|
||||||
"node_id": img_analysis_node_id
|
|
||||||
})
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
state.error = f"Ошибка анализа изображения: {e}"
|
|
||||||
state.llm_response = f"Произошла ошибка при анализе изображения: {e}"
|
|
||||||
print(state.error)
|
|
||||||
# Добавляем ошибку во временную историю
|
|
||||||
state.temporary_chat_history.append({"role": "assistant", "content": state.llm_response})
|
|
||||||
return state
|
|
||||||
|
|
||||||
|
|
||||||
subtitle_service = SubtitleService()
|
|
||||||
|
|
||||||
|
|
||||||
def get_meet_subtitles_node(state: AgentState) -> AgentState:
|
|
||||||
"""
|
|
||||||
Получает субтитры из Google Meet через команду `/subtitles_meet`.
|
|
||||||
"""
|
|
||||||
print("Выполняется узел: get_meet_subtitles_node")
|
|
||||||
try:
|
|
||||||
subtitles = subtitle_service.get_google_meet_subtitles()
|
|
||||||
state.subtitles = subtitles
|
|
||||||
response_text = f"Текущие субтитры из Google Meet: {subtitles}"
|
|
||||||
state.llm_response = response_text
|
|
||||||
|
|
||||||
meet_subtitles_node_id = str(uuid.uuid4())
|
|
||||||
# Добавляем узел субтитров Meet в граф
|
|
||||||
state.graph_nodes.append({
|
|
||||||
"id": meet_subtitles_node_id,
|
|
||||||
"type": "tool",
|
|
||||||
"data": {
|
|
||||||
"label": "Субтитры Google Meet",
|
|
||||||
"details": subtitles[:30] + "..." if subtitles else "",
|
|
||||||
"message": {"role": "assistant", "content": response_text, "node_id": meet_subtitles_node_id}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
edge_id = str(uuid.uuid4())
|
|
||||||
state.graph_edges.append({
|
|
||||||
"id": edge_id,
|
|
||||||
"source": state.parent_node_id,
|
|
||||||
"target": meet_subtitles_node_id
|
|
||||||
})
|
|
||||||
state.parent_node_id = meet_subtitles_node_id
|
|
||||||
state.current_node_id = meet_subtitles_node_id
|
|
||||||
|
|
||||||
# Обновляем временную историю чата
|
|
||||||
state.temporary_chat_history.append({
|
|
||||||
"role": "assistant",
|
|
||||||
"content": response_text,
|
|
||||||
"node_id": meet_subtitles_node_id
|
|
||||||
})
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
state.error = f"Ошибка получения субтитров Google Meet: {e}"
|
|
||||||
state.llm_response = f"Произошла ошибка при получении субтитров Google Meet: {e}"
|
|
||||||
print(state.error)
|
|
||||||
# Добавляем ошибку во временную историю
|
|
||||||
state.temporary_chat_history.append({"role": "assistant", "content": state.llm_response})
|
|
||||||
return state
|
|
||||||
|
|
||||||
|
|
||||||
def get_teams_subtitles_node(state: AgentState) -> AgentState:
|
|
||||||
"""
|
|
||||||
Получает субтитры из MS Teams через команду `/subtitles_teams`.
|
|
||||||
"""
|
|
||||||
print("Выполняется узел: get_teams_subtitles_node")
|
|
||||||
try:
|
|
||||||
subtitles = subtitle_service.get_ms_teams_subtitles()
|
|
||||||
state.subtitles = subtitles
|
|
||||||
response_text = f"Текущие субтитры из MS Teams: {subtitles}"
|
|
||||||
state.llm_response = response_text
|
|
||||||
|
|
||||||
teams_subtitles_node_id = str(uuid.uuid4())
|
|
||||||
# Добавляем узел субтитров Teams в граф
|
|
||||||
state.graph_nodes.append({
|
|
||||||
"id": teams_subtitles_node_id,
|
|
||||||
"type": "tool",
|
|
||||||
"data": {
|
|
||||||
"label": "Субтитры MS Teams",
|
|
||||||
"details": subtitles[:30] + "..." if subtitles else "",
|
|
||||||
"message": {"role": "assistant", "content": response_text, "node_id": teams_subtitles_node_id}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
edge_id = str(uuid.uuid4())
|
|
||||||
state.graph_edges.append({
|
|
||||||
"id": edge_id,
|
|
||||||
"source": state.parent_node_id,
|
|
||||||
"target": teams_subtitles_node_id
|
|
||||||
})
|
|
||||||
state.parent_node_id = teams_subtitles_node_id
|
|
||||||
state.current_node_id = teams_subtitles_node_id
|
|
||||||
|
|
||||||
# Обновляем временную историю чата
|
|
||||||
state.temporary_chat_history.append({
|
|
||||||
"role": "assistant",
|
|
||||||
"content": response_text,
|
|
||||||
"node_id": teams_subtitles_node_id
|
|
||||||
})
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
state.error = f"Ошибка получения субтитров MS Teams: {e}"
|
|
||||||
state.llm_response = f"Произошла ошибка при получении субтитров MS Teams: {e}"
|
|
||||||
print(state.error)
|
|
||||||
# Добавляем ошибку во временную историю
|
|
||||||
state.temporary_chat_history.append({"role": "assistant", "content": state.llm_response})
|
|
||||||
return state
|
|
||||||
|
|
||||||
|
|
||||||
summarization_service = SummarizationService(
|
|
||||||
llm=get_llm("gemini-2.5-flash")) # Gemini для суммаризации
|
|
||||||
|
|
||||||
|
|
||||||
def summarize_history_node(state: AgentState) -> AgentState:
|
|
||||||
"""
|
|
||||||
Суммирует историю диалога, используя `temporary_chat_history` в качестве контекста.
|
|
||||||
"""
|
|
||||||
print("Выполняется узел: summarize_history_node")
|
|
||||||
try:
|
|
||||||
# Для суммаризации используем временную историю, подготовленную в предыдущих узлах
|
|
||||||
summarized_text = summarization_service.summarize_history_of_dicts(
|
|
||||||
state.temporary_chat_history) # Изменен метод для использования List[Dict]
|
|
||||||
state.summarized_history_text = summarized_text
|
|
||||||
response_text = f"История диалога суммирована:\n{summarized_text}"
|
|
||||||
state.llm_response = response_text
|
|
||||||
|
|
||||||
summary_node_id = str(uuid.uuid4())
|
|
||||||
# Добавляем узел суммаризации в граф
|
|
||||||
state.graph_nodes.append({
|
|
||||||
"id": summary_node_id,
|
|
||||||
"type": "tool",
|
|
||||||
"data": {
|
|
||||||
"label": "Суммаризация истории",
|
|
||||||
"details": summarized_text[:30] + "..." if summarized_text else "",
|
|
||||||
"message": {"role": "assistant", "content": response_text, "node_id": summary_node_id}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
edge_id = str(uuid.uuid4())
|
|
||||||
state.graph_edges.append({
|
|
||||||
"id": edge_id,
|
|
||||||
"source": state.parent_node_id,
|
|
||||||
"target": summary_node_id
|
|
||||||
})
|
|
||||||
state.parent_node_id = summary_node_id
|
|
||||||
state.current_node_id = summary_node_id
|
|
||||||
|
|
||||||
# Обновляем временную историю чата
|
|
||||||
state.temporary_chat_history.append({
|
|
||||||
"role": "assistant",
|
|
||||||
"content": response_text,
|
|
||||||
"node_id": summary_node_id
|
|
||||||
})
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
state.error = f"Ошибка суммаризации истории: {e}"
|
|
||||||
state.llm_response = f"Произошла ошибка при суммаризации истории: {e}"
|
|
||||||
print(state.error)
|
|
||||||
# Добавляем ошибку во временную историю
|
|
||||||
state.temporary_chat_history.append({"role": "assistant", "content": state.llm_response})
|
|
||||||
return state
|
|
||||||
|
|
||||||
|
|
||||||
def handle_error_node(state: AgentState) -> AgentState:
|
|
||||||
"""
|
|
||||||
Обрабатывает ошибки команд, формируя ответ для пользователя и добавляя его в граф.
|
|
||||||
"""
|
|
||||||
print(f"Выполняется узел: handle_error_node. Ошибка: {state.error}")
|
|
||||||
state.llm_response = state.error
|
|
||||||
|
|
||||||
error_node_id = str(uuid.uuid4())
|
|
||||||
# Добавляем узел ошибки в граф
|
|
||||||
state.graph_nodes.append({
|
|
||||||
"id": error_node_id,
|
|
||||||
"type": "error",
|
|
||||||
"data": {
|
|
||||||
"label": "Ошибка",
|
|
||||||
"details": state.error,
|
|
||||||
"message": {"role": "assistant", "content": state.llm_response, "node_id": error_node_id}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
edge_id = str(uuid.uuid4())
|
|
||||||
state.graph_edges.append({
|
|
||||||
"id": edge_id,
|
|
||||||
"source": state.parent_node_id,
|
|
||||||
"target": error_node_id
|
|
||||||
})
|
|
||||||
state.parent_node_id = error_node_id
|
|
||||||
state.current_node_id = error_node_id
|
|
||||||
|
|
||||||
# Обновляем временную историю чата
|
|
||||||
state.temporary_chat_history.append({
|
|
||||||
"role": "assistant",
|
|
||||||
"content": state.llm_response,
|
|
||||||
"node_id": error_node_id
|
|
||||||
})
|
|
||||||
|
|
||||||
return state
|
|
||||||
|
|
||||||
|
|
||||||
def help_node(state: AgentState) -> AgentState:
|
|
||||||
"""
|
|
||||||
Предоставляет информацию о доступных командах, формируя ответ для пользователя и добавляя его в граф.
|
|
||||||
"""
|
|
||||||
print("Выполняется узел: help_node")
|
|
||||||
commands_info = command_manager.get_commands()
|
|
||||||
help_text = "Доступные команды:\n"
|
|
||||||
for cmd, info in commands_info.items():
|
|
||||||
help_text += f"/{cmd}: {info['description']}\n"
|
|
||||||
state.llm_response = help_text
|
|
||||||
|
|
||||||
help_node_id = str(uuid.uuid4())
|
|
||||||
# Добавляем узел справки в граф
|
|
||||||
state.graph_nodes.append({
|
|
||||||
"id": help_node_id,
|
|
||||||
"type": "info",
|
|
||||||
"data": {
|
|
||||||
"label": "Справка",
|
|
||||||
"details": "Список команд",
|
|
||||||
"message": {"role": "assistant", "content": state.llm_response, "node_id": help_node_id}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
edge_id = str(uuid.uuid4())
|
|
||||||
state.graph_edges.append({
|
|
||||||
"id": edge_id,
|
|
||||||
"source": state.parent_node_id,
|
|
||||||
"target": help_node_id
|
|
||||||
})
|
|
||||||
state.parent_node_id = help_node_id
|
|
||||||
state.current_node_id = help_node_id
|
|
||||||
|
|
||||||
# Обновляем временную историю чата
|
|
||||||
state.temporary_chat_history.append({
|
|
||||||
"role": "assistant",
|
|
||||||
"content": state.llm_response,
|
|
||||||
"node_id": help_node_id
|
|
||||||
})
|
|
||||||
|
|
||||||
return state
|
|
||||||
172
app/react_agent.py
Normal file
172
app/react_agent.py
Normal file
|
|
@ -0,0 +1,172 @@
|
||||||
|
import functools
|
||||||
|
import importlib.util
|
||||||
|
import inspect
|
||||||
|
import subprocess
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from langchain_core.tools import StructuredTool
|
||||||
|
from langgraph.prebuilt import create_react_agent
|
||||||
|
from langchain_openai import ChatOpenAI
|
||||||
|
from llm_client import MODELS
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import inspect
|
||||||
|
import subprocess
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from typing import Any
|
||||||
|
from langchain_core.tools import StructuredTool
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
def build_react_agent(model_name: str, temperature: float, max_tokens: int, agency_mode: bool, obsidian_settings: dict):
|
||||||
|
"""Инициализация ядра на базе LangGraph."""
|
||||||
|
m_cfg = MODELS.get(model_name)
|
||||||
|
llm = ChatOpenAI(
|
||||||
|
api_key=m_cfg["apiKey"],
|
||||||
|
base_url=m_cfg["apiBase"],
|
||||||
|
model_name=m_cfg["model_name"],
|
||||||
|
temperature=temperature,
|
||||||
|
max_tokens=max_tokens if max_tokens else None,
|
||||||
|
streaming=True
|
||||||
|
)
|
||||||
|
tools = get_dynamic_tools(obsidian_settings) if agency_mode else []
|
||||||
|
return create_react_agent(llm, tools=tools)
|
||||||
|
|
||||||
|
# Специфическая Pydantic-модель для инструментов без скрипта
|
||||||
|
class PurePromptSchema(BaseModel):
|
||||||
|
context: str = Field(description="Контекст, данные или текст, к которому нужно применить эту инструкцию/промпт.")
|
||||||
|
|
||||||
|
|
||||||
|
def create_subprocess_tool(script_path: str, method_name: str,
|
||||||
|
hidden_prompt: str):
|
||||||
|
"""Вид 2: Инструмент со скриптом + пост-процессинг."""
|
||||||
|
spec = importlib.util.spec_from_file_location("user_dynamic_tool",
|
||||||
|
script_path)
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
|
||||||
|
try:
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
func = getattr(module, method_name)
|
||||||
|
docstring = inspect.getdoc(func) or f"Выполняет скрипт {method_name}"
|
||||||
|
except Exception as e:
|
||||||
|
raise RuntimeError(f"Ошибка чтения сигнатуры {script_path}: {e}")
|
||||||
|
|
||||||
|
@functools.wraps(func)
|
||||||
|
def wrapper(**kwargs: Any) -> str:
|
||||||
|
runner_code = f"""
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import importlib.util
|
||||||
|
|
||||||
|
try:
|
||||||
|
spec = importlib.util.spec_from_file_location('sub_module', r'{script_path}')
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
|
||||||
|
kwargs = json.loads(sys.stdin.read())
|
||||||
|
result = getattr(module, '{method_name}')(**kwargs)
|
||||||
|
print(result)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"SCRIPT RUNTIME ERROR: {{e}}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
process = subprocess.run([sys.executable, "-c", runner_code],
|
||||||
|
input=json.dumps(kwargs),
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
encoding='utf-8',
|
||||||
|
check=True)
|
||||||
|
raw_output = process.stdout.strip()
|
||||||
|
|
||||||
|
# Прилизывание для скрипта
|
||||||
|
if hidden_prompt:
|
||||||
|
return f"РЕЗУЛЬТАТ ВЫПОЛНЕНИЯ СКРИПТА:\n{raw_output}\n\nИНСТРУКЦИЯ ПО ФОРМАТИРОВАНИЮ ОТВЕТА ДЛЯ АГЕНТА:\n{hidden_prompt}"
|
||||||
|
return raw_output
|
||||||
|
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
# Даже если скрипт упал, мы отдаем ошибку + промпт, чтобы агент мог обработать её по инструкции
|
||||||
|
error_msg = f"ОШИБКА ИСПОЛНЕНИЯ (Код {e.returncode}):\nSTDOUT: {e.stdout}\nSTDERR: {e.stderr}"
|
||||||
|
if hidden_prompt:
|
||||||
|
return f"{error_msg}\n\nИНСТРУКЦИЯ НА СЛУЧАЙ ОШИБКИ:\n{hidden_prompt}"
|
||||||
|
return error_msg
|
||||||
|
|
||||||
|
return StructuredTool.from_function(func=wrapper,
|
||||||
|
name=func.__name__,
|
||||||
|
description=docstring,
|
||||||
|
infer_schema=True)
|
||||||
|
|
||||||
|
def get_dynamic_tools(obsidian_settings: dict):
|
||||||
|
"""Собирает инструменты двух видов на основе настроек Obsidian."""
|
||||||
|
tools = []
|
||||||
|
custom_tools = obsidian_settings.get("customTools", [])
|
||||||
|
|
||||||
|
for ct in custom_tools:
|
||||||
|
name = ct.get("name")
|
||||||
|
desc = ct.get("description")
|
||||||
|
full_path = ct.get("scriptPath", "")
|
||||||
|
# Поддерживаем оба ключа, если в JS/TS они не раскрылись
|
||||||
|
hidden_prompt = ct.get("hiddenPromptExpanded") or ct.get("hiddenPrompt", "")
|
||||||
|
|
||||||
|
if not name:
|
||||||
|
continue # Инструмент без имени создать нельзя
|
||||||
|
|
||||||
|
# Парсим путь к скрипту (учитываем Windows-пути с буквой диска)
|
||||||
|
script_path = ""
|
||||||
|
method_name = "main"
|
||||||
|
|
||||||
|
if full_path:
|
||||||
|
parts = full_path.rsplit(":", 1) # Разделяем строку с конца 1 раз
|
||||||
|
|
||||||
|
if len(parts) == 2:
|
||||||
|
# Если слева от разделения ровно 1 символ-буква (например, 'D'),
|
||||||
|
# значит это путь без метода, а двоеточие — от диска Windows.
|
||||||
|
if len(parts[0]) == 1 and parts[0].isalpha():
|
||||||
|
script_path = full_path
|
||||||
|
else:
|
||||||
|
script_path = parts[0]
|
||||||
|
method_name = parts[1]
|
||||||
|
else:
|
||||||
|
script_path = full_path
|
||||||
|
|
||||||
|
# ПРОВЕРКА: Есть ли физический скрипт?
|
||||||
|
is_valid_script = script_path and os.path.exists(script_path)
|
||||||
|
|
||||||
|
if not is_valid_script:
|
||||||
|
# ----------------------------------------------------
|
||||||
|
# ВИД 1: Чистый промпт (Инструмент-инструкция / Системная подсказка)
|
||||||
|
# ----------------------------------------------------
|
||||||
|
def create_pure_prompt_wrapper(prompt_text: str):
|
||||||
|
# Функция принимает context, который ИИ обязан туда положить
|
||||||
|
def pure_prompt_wrapper(context: str) -> str:
|
||||||
|
return (
|
||||||
|
f"ВХОДНЫЕ ДАННЫЕ ОТ ПОЛЬЗОВАТЕЛЯ/МОДЕЛИ:\n{context}\n\n"
|
||||||
|
f"ИНСТРУКЦИЯ ПО ОБРАБОТКЕ И ПРИЛИЗЫВАНИЮ (ОБЯЗАТЕЛЬНО К ИСПОЛНЕНИЮ):\n{prompt_text}"
|
||||||
|
)
|
||||||
|
return pure_prompt_wrapper
|
||||||
|
|
||||||
|
tool = StructuredTool.from_function(
|
||||||
|
func=create_pure_prompt_wrapper(hidden_prompt),
|
||||||
|
name=name,
|
||||||
|
description=desc or "Инструмент для применения специфических правил форматирования или инструкций.",
|
||||||
|
args_schema=PurePromptSchema # Жестко навязываем схему с аргументом context!
|
||||||
|
)
|
||||||
|
tools.append(tool)
|
||||||
|
print(f"🛠 Инструмент-промпт '{tool.name}' (без скрипта) успешно инициализирован.")
|
||||||
|
|
||||||
|
else:
|
||||||
|
# ----------------------------------------------------
|
||||||
|
# ВИД 2: Скрипт + Прилизывание
|
||||||
|
# ----------------------------------------------------
|
||||||
|
try:
|
||||||
|
tool = create_subprocess_tool(script_path, method_name, hidden_prompt)
|
||||||
|
tool.name = name # Приоритет имени из UI Obsidian
|
||||||
|
if desc:
|
||||||
|
tool.description = desc
|
||||||
|
tools.append(tool)
|
||||||
|
print(f"🛠 Инструмент-скрипт '{tool.name}' успешно инициализирован.")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Ошибка генерации инструмента-скрипта '{name}': {e}")
|
||||||
|
|
||||||
|
return tools
|
||||||
179
app/workflows.py
179
app/workflows.py
|
|
@ -1,99 +1,17 @@
|
||||||
"""
|
|
||||||
Определяет логику рабочих процессов агента,
|
|
||||||
используя LangGraph для управления состояниями
|
|
||||||
и переходами между узлами обработки.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from typing import Dict, Any, Optional, Set
|
|
||||||
from langgraph.graph import StateGraph, END
|
|
||||||
from graph_history_manager import GraphHistoryManager
|
|
||||||
from models import AgentState
|
|
||||||
from nodes import parse_command_node, execute_command_node, call_llm_node, generate_images_node, analyze_image_node, get_meet_subtitles_node, get_teams_subtitles_node, summarize_history_node, handle_error_node, help_node
|
|
||||||
import uuid # Добавлено для генерации UUID
|
|
||||||
from llm_client import DEFAULT_TEMPERATURE, get_llm
|
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import base64
|
import base64
|
||||||
from pathlib import Path
|
import json
|
||||||
# --- LangGraph: Построение графа ---
|
from typing import Dict, Any, Optional
|
||||||
|
|
||||||
|
from langchain_core.messages import HumanMessage, AIMessage, SystemMessage, AIMessageChunk
|
||||||
|
from react_agent import build_react_agent
|
||||||
|
# Убедитесь, что graph_history_manager импортирован.
|
||||||
|
# Если код в workflows.py, то graph_history_manager создается/импортируется там.
|
||||||
|
|
||||||
|
from graph_history_manager import GraphHistoryManager
|
||||||
|
|
||||||
graph_history_manager = GraphHistoryManager()
|
graph_history_manager = GraphHistoryManager()
|
||||||
|
|
||||||
workflow = StateGraph(AgentState)
|
|
||||||
|
|
||||||
# Добавляем узлы
|
|
||||||
workflow.add_node("parse_command", parse_command_node)
|
|
||||||
workflow.add_node("execute_command",
|
|
||||||
execute_command_node) # Это по сути роутер
|
|
||||||
workflow.add_node("call_llm", call_llm_node)
|
|
||||||
workflow.add_node("generate_images", generate_images_node)
|
|
||||||
workflow.add_node("analyze_image", analyze_image_node)
|
|
||||||
workflow.add_node("get_meet_subtitles", get_meet_subtitles_node)
|
|
||||||
workflow.add_node("get_teams_subtitles", get_teams_subtitles_node)
|
|
||||||
workflow.add_node("summarize_history", summarize_history_node)
|
|
||||||
workflow.add_node("handle_error", handle_error_node)
|
|
||||||
workflow.add_node("help", help_node)
|
|
||||||
|
|
||||||
# Устанавливаем точку входа
|
|
||||||
workflow.set_entry_point("parse_command")
|
|
||||||
|
|
||||||
|
|
||||||
# Определяем маршрутизацию после parse_command
|
|
||||||
def route_command(state: AgentState) -> str:
|
|
||||||
"""Маршрутизирует выполнение на основе распознанной команды."""
|
|
||||||
if state.error:
|
|
||||||
return "handle_error" # Если была ошибка парсинга команды
|
|
||||||
elif state.command == "imagine":
|
|
||||||
return "generate_images"
|
|
||||||
elif state.command == "analyze":
|
|
||||||
return "analyze_image"
|
|
||||||
elif state.command == "subtitles_meet":
|
|
||||||
return "get_meet_subtitles"
|
|
||||||
elif state.command == "subtitles_teams":
|
|
||||||
return "get_teams_subtitles"
|
|
||||||
elif state.command == "summarize":
|
|
||||||
return "summarize_history"
|
|
||||||
elif state.command == "help":
|
|
||||||
return "help"
|
|
||||||
elif state.command == "chat":
|
|
||||||
return "call_llm"
|
|
||||||
elif state.command == "error": # Команда была, но не известная
|
|
||||||
return "handle_error"
|
|
||||||
else:
|
|
||||||
# Fallback на LLM, если команда не распознана или пуста (хотя parse_command должен был бы ее поймать)
|
|
||||||
return "call_llm"
|
|
||||||
|
|
||||||
|
|
||||||
workflow.add_conditional_edges(
|
|
||||||
"parse_command", # Откуда
|
|
||||||
route_command, # Функция, определяющая куда идти
|
|
||||||
{
|
|
||||||
"generate_images": "generate_images",
|
|
||||||
"analyze_image": "analyze_image",
|
|
||||||
"get_meet_subtitles": "get_meet_subtitles",
|
|
||||||
"get_teams_subtitles": "get_teams_subtitles",
|
|
||||||
"summarize_history": "summarize_history",
|
|
||||||
"help": "help",
|
|
||||||
"call_llm": "call_llm",
|
|
||||||
"handle_error":
|
|
||||||
"handle_error", # Для ошибок, найденных в parse_command
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
# Все узлы операций (кроме ошибок) ведут к END после завершения
|
|
||||||
workflow.add_edge("call_llm", END)
|
|
||||||
workflow.add_edge("generate_images", END)
|
|
||||||
workflow.add_edge("analyze_image", END)
|
|
||||||
workflow.add_edge("get_meet_subtitles", END)
|
|
||||||
workflow.add_edge("get_teams_subtitles", END)
|
|
||||||
workflow.add_edge("summarize_history", END)
|
|
||||||
workflow.add_edge("help", END)
|
|
||||||
workflow.add_edge("handle_error",
|
|
||||||
END) # Ошибка - это тоже конечный пункт для текущего запроса
|
|
||||||
|
|
||||||
# Компилируем граф
|
|
||||||
app = workflow.compile()
|
|
||||||
|
|
||||||
|
|
||||||
def run_agent_streaming(graph_id: str,
|
def run_agent_streaming(graph_id: str,
|
||||||
user_node_id: str,
|
user_node_id: str,
|
||||||
|
|
@ -101,9 +19,15 @@ def run_agent_streaming(graph_id: str,
|
||||||
system_prompt: Optional[str] = None,
|
system_prompt: Optional[str] = None,
|
||||||
model: Optional[str] = None,
|
model: Optional[str] = None,
|
||||||
cache_folder: Optional[str] = None,
|
cache_folder: Optional[str] = None,
|
||||||
temperature: float = DEFAULT_TEMPERATURE,
|
temperature: float = 0.7,
|
||||||
max_tokens: Optional[int] = None):
|
max_tokens: Optional[int] = None,
|
||||||
"""Генератор для стримингового ответа LLM с поддержкой вложений."""
|
agency_mode: bool = False,
|
||||||
|
obsidian_settings: dict = None):
|
||||||
|
"""
|
||||||
|
Основной генератор: Запускает LangGraph / ReAct агента.
|
||||||
|
Поддерживает мультимодальность, чтение файлов из Vault и исполнение Python-тулов.
|
||||||
|
Отдает словари, которые api.py запакует в SSE-события.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
loaded_graph_data = graph_history_manager.get_graph(
|
loaded_graph_data = graph_history_manager.get_graph(
|
||||||
graph_id, target_node_id=user_node_id)
|
graph_id, target_node_id=user_node_id)
|
||||||
|
|
@ -114,8 +38,7 @@ def run_agent_streaming(graph_id: str,
|
||||||
|
|
||||||
messages = loaded_graph_data.get("messages", [])
|
messages = loaded_graph_data.get("messages", [])
|
||||||
|
|
||||||
from langchain_core.messages import HumanMessage, SystemMessage, AIMessage
|
# 2. Формируем контекст LangChain с учетом файлов и изображений (вложений)
|
||||||
|
|
||||||
messages_for_llm = []
|
messages_for_llm = []
|
||||||
if system_prompt:
|
if system_prompt:
|
||||||
messages_for_llm.append(SystemMessage(content=system_prompt))
|
messages_for_llm.append(SystemMessage(content=system_prompt))
|
||||||
|
|
@ -123,9 +46,10 @@ def run_agent_streaming(graph_id: str,
|
||||||
# Обрабатываем вложения
|
# Обрабатываем вложения
|
||||||
for i, msg in enumerate(messages):
|
for i, msg in enumerate(messages):
|
||||||
is_last = (i == len(messages) - 1)
|
is_last = (i == len(messages) - 1)
|
||||||
|
role = msg.get("role")
|
||||||
|
|
||||||
if msg["role"] == "user":
|
if role == "user":
|
||||||
text_content = msg["content"]
|
text_content = msg.get("content", "")
|
||||||
attachments = msg.get("attachments", [])
|
attachments = msg.get("attachments", [])
|
||||||
|
|
||||||
# Проверяем, есть ли мультимодальный контент
|
# Проверяем, есть ли мультимодальный контент
|
||||||
|
|
@ -135,7 +59,7 @@ def run_agent_streaming(graph_id: str,
|
||||||
# Обрабатываем вложения
|
# Обрабатываем вложения
|
||||||
if attachments:
|
if attachments:
|
||||||
for att in attachments:
|
for att in attachments:
|
||||||
# Раскрываем только permanent или вложения последнего сообщения
|
# Прикрепляем файл только если он перманентный (@@/##) или это последнее сообщение
|
||||||
if att.get("permanent") or is_last:
|
if att.get("permanent") or is_last:
|
||||||
attachment_content = prepare_attachment_for_llm(
|
attachment_content = prepare_attachment_for_llm(
|
||||||
att, cache_folder)
|
att, cache_folder)
|
||||||
|
|
@ -146,7 +70,7 @@ def run_agent_streaming(graph_id: str,
|
||||||
|
|
||||||
content_parts.append(attachment_content)
|
content_parts.append(attachment_content)
|
||||||
|
|
||||||
# Формируем финальный контент
|
# Если есть картинки, шлем list of dicts. Иначе склеиваем в один текст.
|
||||||
if has_multimodal:
|
if has_multimodal:
|
||||||
# Мультимодальное сообщение
|
# Мультимодальное сообщение
|
||||||
messages_for_llm.append(
|
messages_for_llm.append(
|
||||||
|
|
@ -160,15 +84,53 @@ def run_agent_streaming(graph_id: str,
|
||||||
full_text += part.get("text", "")
|
full_text += part.get("text", "")
|
||||||
messages_for_llm.append(HumanMessage(content=full_text))
|
messages_for_llm.append(HumanMessage(content=full_text))
|
||||||
|
|
||||||
elif msg["role"] == "assistant":
|
elif role == "assistant":
|
||||||
messages_for_llm.append(AIMessage(content=msg["content"]))
|
messages_for_llm.append(AIMessage(content=msg.get("content", "")))
|
||||||
|
|
||||||
# Стриминг ответа
|
# 3. Создаем агента. Если agency_mode == False, он соберет 0 тулов и отработает как обычная LLM.
|
||||||
llm = get_llm(model or "gemini-2.5-flash")
|
agent_executor = build_react_agent(
|
||||||
for chunk in llm.stream2(messages_for_llm, temperature=temperature, max_tokens=max_tokens):
|
model_name=model,
|
||||||
content = chunk.content if hasattr(chunk,
|
temperature=temperature,
|
||||||
'content') else str(chunk)
|
max_tokens=max_tokens,
|
||||||
yield {"type": "chunk", "content": content}
|
agency_mode=agency_mode,
|
||||||
|
obsidian_settings=obsidian_settings or {}
|
||||||
|
)
|
||||||
|
|
||||||
|
# 4. Выполняем цикл с потоковой передачей событий
|
||||||
|
step_counter = 0
|
||||||
|
|
||||||
|
# LangGraph Stream Mode 'messages': отдает чанки токенов и вызовы функций
|
||||||
|
for chunk, metadata in agent_executor.stream({"messages": messages_for_llm}, stream_mode="messages"):
|
||||||
|
|
||||||
|
if isinstance(chunk, AIMessageChunk):
|
||||||
|
# Агент запросил вызов инструмента (Формирование JSON аргументов)
|
||||||
|
if hasattr(chunk, "tool_call_chunks") and chunk.tool_call_chunks:
|
||||||
|
for tc in chunk.tool_call_chunks:
|
||||||
|
if "name" in tc and tc["name"]:
|
||||||
|
step_counter += 1
|
||||||
|
yield {
|
||||||
|
"type": "tool_start",
|
||||||
|
"name": tc["name"],
|
||||||
|
"step": step_counter,
|
||||||
|
"node_id": assistant_node_id
|
||||||
|
}
|
||||||
|
|
||||||
|
# Обычный стриминг текста финального ответа
|
||||||
|
if chunk.content:
|
||||||
|
yield {
|
||||||
|
"type": "chunk",
|
||||||
|
"content": chunk.content,
|
||||||
|
"node_id": assistant_node_id
|
||||||
|
}
|
||||||
|
|
||||||
|
# Тул отработал и принес ответ
|
||||||
|
elif hasattr(chunk, "type") and chunk.type == "tool":
|
||||||
|
yield {
|
||||||
|
"type": "tool_end",
|
||||||
|
"name": chunk.name,
|
||||||
|
"step": step_counter,
|
||||||
|
"node_id": assistant_node_id
|
||||||
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Ошибка при стриминге: {e}")
|
print(f"Ошибка при стриминге: {e}")
|
||||||
|
|
@ -270,3 +232,4 @@ def format_attachment_for_llm(attachment: Dict[str, Any]) -> str:
|
||||||
header = f"\n\n--- Содержимое {att_type}: {attachment['name']} ---\n"
|
header = f"\n\n--- Содержимое {att_type}: {attachment['name']} ---\n"
|
||||||
footer = f"\n--- Конец {att_type}: {attachment['name']} ---\n\n"
|
footer = f"\n--- Конец {att_type}: {attachment['name']} ---\n\n"
|
||||||
return header + attachment.get("content", "").strip() + footer
|
return header + attachment.get("content", "").strip() + footer
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user