From d071d16bd2985322803e40e4be696a4ee84a87bd Mon Sep 17 00:00:00 2001 From: dimitrievgs Date: Sat, 30 May 2026 23:46:11 +0300 Subject: [PATCH] Add agency --- app/api.py | 31 ++- app/llm_client.py | 133 ----------- app/nodes.py | 558 --------------------------------------------- app/react_agent.py | 172 ++++++++++++++ app/workflows.py | 179 ++++++--------- 5 files changed, 264 insertions(+), 809 deletions(-) delete mode 100644 app/nodes.py create mode 100644 app/react_agent.py diff --git a/app/api.py b/app/api.py index e792c6f..c46b9b8 100644 --- a/app/api.py +++ b/app/api.py @@ -223,7 +223,7 @@ def send_user_message(): @api.route('/api/chat/stream', methods=['POST']) def chat_stream(): - """API endpoint для стриминга ответа LLM.""" + """API endpoint для стриминга ответа LLM и работы ReAct-агента.""" data = request.get_json() graph_id = data.get("graph_id") user_node_id = data.get("user_node_id") @@ -232,12 +232,16 @@ def chat_stream(): cache_folder = data.get("cache_folder") temperature = data.get("temperature", DEFAULT_TEMPERATURE) max_tokens = data.get("max_tokens") + agency_mode = data.get("agency_mode", False) # НОВОЕ: Перехватываем режим тумблера "Агент" # Необязательный параметр для существующего узла ассистента при регенерации existing_assistant_node_id = data.get("existing_assistant_node_id") if not graph_id or not user_node_id: return jsonify({"error": "Не указаны graph_id или user_node_id."}, 400) + # Подтягиваем глобальные настройки Obsidian (синхронизируются через /settings/sync) + global current_obsidian_settings + def generate(): assistant_node_id = None try: @@ -253,23 +257,28 @@ def chat_stream(): # Отправляем ID нового узла клиенту yield f"data: {json.dumps({'type': 'node_created', 'node_id': assistant_node_id, 'reused': False})}\n\n" - # Запускаем стриминг ответа от LLM + # Запускаем стриминг ответа от LLM (используем обновленный агент) accumulated_content = "" try: for chunk_data in run_agent_streaming(graph_id, user_node_id, assistant_node_id, system_prompt, model, - cache_folder, temperature, max_tokens): - if chunk_data.get("type") == "chunk": - accumulated_content += chunk_data.get("content", "") + cache_folder, temperature, max_tokens, + agency_mode, current_obsidian_settings): + + # Прокидываем любые успешные этапы: куски текста или статусы работы тулов + if chunk_data.get("type") in ["chunk", "tool_start", "tool_end"]: + if chunk_data.get("type") == "chunk": + accumulated_content += chunk_data.get("content", "") yield f"data: {json.dumps(chunk_data)}\n\n" + + # Обработка ошибки во время стриминга (крашим узел на фронте) elif chunk_data.get("type") == "error": if assistant_node_id: graph_history_manager.mark_node_as_error( graph_id, assistant_node_id, - chunk_data.get("error", - "Неизвестная ошибка при стриминге")) + chunk_data.get("error", "Неизвестная ошибка при стриминге")) if graph_history_manager.socketio: graph_history_manager.socketio.emit( 'node_type_changed', { @@ -279,22 +288,24 @@ def chat_stream(): }) yield f"data: {json.dumps(chunk_data)}\n\n" return + except GeneratorExit: - # НОВОЕ: Обработка прерывания генератора (клиент отключился) + # Обработка прерывания генератора (если клиент нажал Stop на фронте или разорвал коннект) print(f"⚠️ Клиент отменил стрим для узла {assistant_node_id}") - # После завершения стриминга обновляем узел полным контентом + # После завершения стриминга (успешного) обновляем узел полным контентом в базе SQLite if assistant_node_id: graph_history_manager.update_assistant_node_content( graph_id, assistant_node_id, accumulated_content) - # Инициируем генерацию заголовка + # Инициируем фоновую генерацию заголовка graph_history_manager.title_generator.add_node_to_queue_direct( graph_id, assistant_node_id) yield f"data: {json.dumps({'type': 'done', 'node_id': assistant_node_id})}\n\n" except Exception as e: + # Отлов непредсказуемых внешних ошибок в самом генераторе API print(f"Ошибка при стриминге ответа: {e}") error_message = str(e) if assistant_node_id: diff --git a/app/llm_client.py b/app/llm_client.py index aeaf161..6b49a99 100644 --- a/app/llm_client.py +++ b/app/llm_client.py @@ -348,134 +348,6 @@ class CustomLLM: raise 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: """ Возвращает экземпляр CustomLLM для заданной модели. @@ -484,8 +356,3 @@ def get_llm(name: str) -> CustomLLM: if not config: raise ValueError(f"Модель '{name}' не сконфигурирована.") return CustomLLM(config) - - -def get_available_models(): - """Возвращает список доступных моделей.""" - return list(MODELS.keys()) diff --git a/app/nodes.py b/app/nodes.py deleted file mode 100644 index a744514..0000000 --- a/app/nodes.py +++ /dev/null @@ -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'image{i}' - 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 \ No newline at end of file diff --git a/app/react_agent.py b/app/react_agent.py new file mode 100644 index 0000000..7267f32 --- /dev/null +++ b/app/react_agent.py @@ -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 diff --git a/app/workflows.py b/app/workflows.py index 3308060..0436011 100644 --- a/app/workflows.py +++ b/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 base64 -from pathlib import Path -# --- LangGraph: Построение графа --- +import json +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() -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, user_node_id: str, @@ -101,9 +19,15 @@ def run_agent_streaming(graph_id: str, system_prompt: Optional[str] = None, model: Optional[str] = None, cache_folder: Optional[str] = None, - temperature: float = DEFAULT_TEMPERATURE, - max_tokens: Optional[int] = None): - """Генератор для стримингового ответа LLM с поддержкой вложений.""" + temperature: float = 0.7, + max_tokens: Optional[int] = None, + agency_mode: bool = False, + obsidian_settings: dict = None): + """ + Основной генератор: Запускает LangGraph / ReAct агента. + Поддерживает мультимодальность, чтение файлов из Vault и исполнение Python-тулов. + Отдает словари, которые api.py запакует в SSE-события. + """ try: loaded_graph_data = graph_history_manager.get_graph( 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", []) - from langchain_core.messages import HumanMessage, SystemMessage, AIMessage - + # 2. Формируем контекст LangChain с учетом файлов и изображений (вложений) messages_for_llm = [] if 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): is_last = (i == len(messages) - 1) + role = msg.get("role") - if msg["role"] == "user": - text_content = msg["content"] + if role == "user": + text_content = msg.get("content", "") attachments = msg.get("attachments", []) # Проверяем, есть ли мультимодальный контент @@ -135,7 +59,7 @@ def run_agent_streaming(graph_id: str, # Обрабатываем вложения if attachments: for att in attachments: - # Раскрываем только permanent или вложения последнего сообщения + # Прикрепляем файл только если он перманентный (@@/##) или это последнее сообщение if att.get("permanent") or is_last: attachment_content = prepare_attachment_for_llm( att, cache_folder) @@ -146,7 +70,7 @@ def run_agent_streaming(graph_id: str, content_parts.append(attachment_content) - # Формируем финальный контент + # Если есть картинки, шлем list of dicts. Иначе склеиваем в один текст. if has_multimodal: # Мультимодальное сообщение messages_for_llm.append( @@ -160,15 +84,53 @@ def run_agent_streaming(graph_id: str, full_text += part.get("text", "") messages_for_llm.append(HumanMessage(content=full_text)) - elif msg["role"] == "assistant": - messages_for_llm.append(AIMessage(content=msg["content"])) + elif role == "assistant": + messages_for_llm.append(AIMessage(content=msg.get("content", ""))) - # Стриминг ответа - llm = get_llm(model or "gemini-2.5-flash") - for chunk in llm.stream2(messages_for_llm, temperature=temperature, max_tokens=max_tokens): - content = chunk.content if hasattr(chunk, - 'content') else str(chunk) - yield {"type": "chunk", "content": content} + # 3. Создаем агента. Если agency_mode == False, он соберет 0 тулов и отработает как обычная LLM. + agent_executor = build_react_agent( + model_name=model, + temperature=temperature, + max_tokens=max_tokens, + 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: 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" footer = f"\n--- Конец {att_type}: {attachment['name']} ---\n\n" return header + attachment.get("content", "").strip() + footer +