Add agency

This commit is contained in:
dimitrievgs 2026-05-30 23:46:11 +03:00
parent 91daa5b2bd
commit d071d16bd2
5 changed files with 264 additions and 809 deletions

View File

@ -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,
if chunk_data.get("type") == "chunk": agency_mode, current_obsidian_settings):
accumulated_content += chunk_data.get("content", "")
# Прокидываем любые успешные этапы: куски текста или статусы работы тулов
if chunk_data.get("type") in ["chunk", "tool_start", "tool_end"]:
if chunk_data.get("type") == "chunk":
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:

View File

@ -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())

View File

@ -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
View 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

View File

@ -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