v0.5.3: Улучшение инструментов (SSH, cron, RSS) и интеграции с Qwen
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
parent
f208ffecf7
commit
7c088e2051
24
.env.example
24
.env.example
|
|
@ -16,27 +16,25 @@ ALLOWED_USERS=
|
||||||
WORKING_DIRECTORY=/home/mirivlad
|
WORKING_DIRECTORY=/home/mirivlad
|
||||||
|
|
||||||
# ===========================================
|
# ===========================================
|
||||||
# Мульти-серверная конфигурация (v2.0)
|
# SSH Серверы для AI-агента
|
||||||
# ===========================================
|
# ===========================================
|
||||||
|
# Формат: name|host|port|user|tag|password
|
||||||
# SSH ключ для подключения к серверам
|
# name - имя сервера (используется в ответах бота)
|
||||||
SSH_KEY_PATH=/home/mirivlad/.ssh/id_ed25519
|
# host - IP адрес или домен
|
||||||
|
|
||||||
# Список серверов (формат: name|host|port|user|tags)
|
|
||||||
# name - отображаемое имя сервера
|
|
||||||
# host - IP или домен
|
|
||||||
# port - SSH порт (обычно 22)
|
# port - SSH порт (обычно 22)
|
||||||
# user - пользователь SSH
|
# user - пользователь SSH
|
||||||
# tags - теги через запятую для группировки (web,db,prod,dev)
|
# tag - тег для категоризации (web, db, prod, и т.д.)
|
||||||
|
# password - пароль SSH (или используйте SSH-ключи)
|
||||||
#
|
#
|
||||||
# Пример:
|
# Пример:
|
||||||
# SERVERS=web-prod|192.168.1.10|22|root|web,prod,db-prod|192.168.1.11|22|postgres|db,prod,local|localhost|22|mirivlad|local,dev
|
# SERVERS=tomas|192.168.1.54|22|mirivlad|web|moloko22
|
||||||
#
|
#
|
||||||
# Пустой список = только локальный сервер
|
# Для нескольких серверов используйте запятую:
|
||||||
|
# SERVERS=home|192.168.1.54|22|user|web|pass123,work|10.0.0.5|22|admin|db|pass456
|
||||||
SERVERS=
|
SERVERS=
|
||||||
|
|
||||||
# Сервер по умолчанию (имя из списка или "local")
|
# SSH ключ для подключения (альтернатива паролю)
|
||||||
DEFAULT_SERVER=local
|
# SSH_KEY_PATH=/home/user/.ssh/id_ed25519
|
||||||
|
|
||||||
# ===========================================
|
# ===========================================
|
||||||
# SOCKS5 Proxy (опционально)
|
# SOCKS5 Proxy (опционально)
|
||||||
|
|
|
||||||
231
bot.py
231
bot.py
|
|
@ -55,7 +55,7 @@ BASE_DIR = Path(__file__).parent
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||||
level=logging.INFO,
|
level=logging.DEBUG,
|
||||||
handlers=[
|
handlers=[
|
||||||
logging.FileHandler(BASE_DIR / "bot.log"),
|
logging.FileHandler(BASE_DIR / "bot.log"),
|
||||||
logging.StreamHandler()
|
logging.StreamHandler()
|
||||||
|
|
@ -183,7 +183,7 @@ async def handle_ai_task(update: Update, text: str):
|
||||||
|
|
||||||
if tool_result.success:
|
if tool_result.success:
|
||||||
# Формируем ответ с результатами инструмента
|
# Формируем ответ с результатами инструмента
|
||||||
full_output = format_tool_result(agent_decision.tool_name, tool_result)
|
full_output = await format_tool_result(agent_decision.tool_name, tool_result)
|
||||||
|
|
||||||
# Добавляем в историю
|
# Добавляем в историю
|
||||||
state.ai_chat_history.append(f"Assistant: {full_output[:500]}")
|
state.ai_chat_history.append(f"Assistant: {full_output[:500]}")
|
||||||
|
|
@ -201,13 +201,93 @@ async def handle_ai_task(update: Update, text: str):
|
||||||
# Продолжаем с обычным ИИ-ответом если инструмент не сработал
|
# Продолжаем с обычным ИИ-ответом если инструмент не сработал
|
||||||
|
|
||||||
# === ОБЫЧНЫЙ ИИ-ОТВЕТ через Qwen ===
|
# === ОБЫЧНЫЙ ИИ-ОТВЕТ через Qwen ===
|
||||||
output_buffer = []
|
output_buffer = [] # Буфер для потокового отображения
|
||||||
|
result_buffer = [] # Буфер для финального результата (без статусов)
|
||||||
|
stream_message = None # Сообщение для потокового вывода (статусы)
|
||||||
|
result_message = None # Финальное сообщение с результатом
|
||||||
|
current_status = "🤖 Думаю..." # Текущий статус для отображения
|
||||||
|
is_tool_output = False # Флаг: идёт ли вывод инструмента
|
||||||
|
|
||||||
def on_output(text: str):
|
def on_output(text: str):
|
||||||
output_buffer.append(text)
|
"""Callback для накопления полного вывода (не используется для streaming)."""
|
||||||
|
pass
|
||||||
|
|
||||||
def on_oauth_url(url: str):
|
def on_oauth_url(url: str):
|
||||||
pass # OAuth обрабатывается автоматически
|
pass
|
||||||
|
|
||||||
|
def on_event(event):
|
||||||
|
"""Обработка событий stream-json для обновления статуса."""
|
||||||
|
nonlocal current_status, is_tool_output
|
||||||
|
|
||||||
|
from qwen_integration import QwenEventType
|
||||||
|
|
||||||
|
if event.event_type == QwenEventType.SYSTEM:
|
||||||
|
if event.subtype == 'session_start':
|
||||||
|
current_status = "🤖 Запуск сессии..."
|
||||||
|
|
||||||
|
elif event.event_type == QwenEventType.ASSISTANT:
|
||||||
|
message = event.message or {}
|
||||||
|
content_list = message.get('content', [])
|
||||||
|
for content_item in content_list:
|
||||||
|
if isinstance(content_item, dict) and content_item.get('type') == 'tool_use':
|
||||||
|
tool_name = content_item.get('name', 'инструмент')
|
||||||
|
current_status = f"🔧 Использую {tool_name}..."
|
||||||
|
is_tool_output = True # Начинается вывод инструмента
|
||||||
|
break
|
||||||
|
|
||||||
|
elif event.event_type == QwenEventType.RESULT:
|
||||||
|
if event.is_error:
|
||||||
|
current_status = "❌ Ошибка"
|
||||||
|
else:
|
||||||
|
current_status = "✅ Готово"
|
||||||
|
|
||||||
|
logger.debug(f"Событие Qwen: {event.event_type.value}, статус: {current_status}")
|
||||||
|
|
||||||
|
async def on_chunk(chunk: str):
|
||||||
|
"""Потоковая отправка chunks в Telegram."""
|
||||||
|
nonlocal stream_message, current_status, is_tool_output
|
||||||
|
|
||||||
|
chunk_text = chunk
|
||||||
|
if not chunk_text or not chunk_text.strip():
|
||||||
|
return
|
||||||
|
|
||||||
|
# Логируем для отладки
|
||||||
|
logger.debug(f"on_chunk: {repr(chunk_text[:50])}...")
|
||||||
|
|
||||||
|
# Добавляем в потоковый буфер (всё)
|
||||||
|
output_buffer.append(chunk_text)
|
||||||
|
|
||||||
|
# В result_buffer добавляем ТОЛЬКО если это не статус инструмента
|
||||||
|
# Статусы инструментов начинаются с "\n🔧"
|
||||||
|
if not chunk_text.strip().startswith("🔧"):
|
||||||
|
result_buffer.append(chunk_text)
|
||||||
|
|
||||||
|
logger.debug(f"output_buffer: {len(output_buffer)}, result_buffer: {len(result_buffer)}")
|
||||||
|
|
||||||
|
# Если сообщение ещё не создано - создаём
|
||||||
|
if stream_message is None:
|
||||||
|
stream_message = await update.message.reply_text(
|
||||||
|
f"⏳ {current_status}",
|
||||||
|
parse_mode="Markdown"
|
||||||
|
)
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
|
# Формируем текущий вывод
|
||||||
|
current_output = "".join(output_buffer)
|
||||||
|
|
||||||
|
# Обрезаем до безопасного размера
|
||||||
|
if len(current_output) > 3500:
|
||||||
|
current_output = current_output[-3500:]
|
||||||
|
|
||||||
|
# Обновляем сообщение
|
||||||
|
try:
|
||||||
|
await stream_message.edit_text(
|
||||||
|
f"⏳ {current_status}\n\n{current_output}",
|
||||||
|
parse_mode="Markdown"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Ошибка редактирования: {e}")
|
||||||
|
await asyncio.sleep(0.3)
|
||||||
|
|
||||||
# Формируем контекст с историей + памятью + summary
|
# Формируем контекст с историей + памятью + summary
|
||||||
# Получаем summary и последние сообщения из ChromaDB
|
# Получаем summary и последние сообщения из ChromaDB
|
||||||
|
|
@ -260,21 +340,32 @@ async def handle_ai_task(update: Update, text: str):
|
||||||
f"{text}"
|
f"{text}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Выполняем задачу (системный промпт уже добавлен в full_task)
|
# Выполняем задачу с потоковым выводом
|
||||||
result = await qwen_manager.run_task(user_id, full_task, on_output, on_oauth_url, use_system_prompt=False)
|
result = await qwen_manager.run_task(
|
||||||
|
user_id, full_task, on_output, on_oauth_url,
|
||||||
|
use_system_prompt=False, on_chunk=on_chunk, on_event=on_event
|
||||||
|
)
|
||||||
|
|
||||||
# Показываем результат
|
# Формируем финальный результат ИЗ result_buffer (без статусов инструментов)
|
||||||
full_output = "".join(output_buffer).strip()
|
full_output = "".join(result_buffer).strip()
|
||||||
|
|
||||||
|
# Если result_buffer пустой — пробуем извлечь текст из result
|
||||||
if not full_output:
|
if not full_output:
|
||||||
full_output = result
|
logger.warning("result_buffer пустой, пробуем извлечь текст из result")
|
||||||
|
import re
|
||||||
|
text_matches = re.findall(r'"text":"([^"]+)"', result)
|
||||||
|
if text_matches:
|
||||||
|
full_output = " ".join(text_matches).replace("\\n", "\n")
|
||||||
|
else:
|
||||||
|
full_output = "⚠️ Не удалось получить ответ ИИ"
|
||||||
|
logger.error(f"Result: {result[:500]}...")
|
||||||
|
|
||||||
# Добавляем ответ ИИ в историю и память
|
# Добавляем ответ ИИ в историю и память
|
||||||
if full_output:
|
if full_output and full_output != "⚠️ Не удалось получить ответ ИИ":
|
||||||
state.ai_chat_history.append(f"Assistant: {full_output[:500]}")
|
state.ai_chat_history.append(f"Assistant: {full_output[:500]}")
|
||||||
save_message(user_id, "assistant", full_output)
|
save_message(user_id, "assistant", full_output)
|
||||||
|
|
||||||
# Обрезаем если слишком длинный (с запасом на контекст)
|
# Обрезаем если слишком длинный
|
||||||
if len(full_output) > 3500:
|
if len(full_output) > 3500:
|
||||||
full_output = full_output[:3500] + "\n... (вывод обрезан)"
|
full_output = full_output[:3500] + "\n... (вывод обрезан)"
|
||||||
|
|
||||||
|
|
@ -282,19 +373,68 @@ async def handle_ai_task(update: Update, text: str):
|
||||||
state.messages_since_fact_extract += 1
|
state.messages_since_fact_extract += 1
|
||||||
if state.messages_since_fact_extract >= 5:
|
if state.messages_since_fact_extract >= 5:
|
||||||
logger.info(f"Запуск извлечения фактов через ИИ для пользователя {user_id}")
|
logger.info(f"Запуск извлечения фактов через ИИ для пользователя {user_id}")
|
||||||
dialog_context = "\n".join(state.ai_chat_history[-10:]) # Последние 10 сообщений
|
dialog_context = "\n".join(state.ai_chat_history[-10:])
|
||||||
asyncio.create_task(hybrid_memory_manager.extract_facts_with_ai(user_id, dialog_context))
|
asyncio.create_task(hybrid_memory_manager.extract_facts_with_ai(user_id, dialog_context))
|
||||||
state.messages_since_fact_extract = 0
|
state.messages_since_fact_extract = 0
|
||||||
|
|
||||||
# Формируем сообщение с информацией о контексте (как в qwen-code)
|
# Формируем сообщение с информацией о контексте
|
||||||
context_info = f"📊 Контекст: {context_percent}%"
|
context_info = f"📊 Контекст: {context_percent}%"
|
||||||
|
|
||||||
|
# Отправляем результат ОТДЕЛЬНЫМ сообщением
|
||||||
response_text = f"{full_output}\n\n*{context_info}*"
|
response_text = f"{full_output}\n\n*{context_info}*"
|
||||||
|
|
||||||
# Отправляем ответ с разбивкой на части если нужно
|
# Отправляем новое сообщение с результатом
|
||||||
await send_long_message(update, response_text, parse_mode="Markdown")
|
await update.message.reply_text(
|
||||||
|
response_text,
|
||||||
|
parse_mode="Markdown"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Обновляем потоковое сообщение на финальный статус
|
||||||
|
if stream_message:
|
||||||
|
try:
|
||||||
|
await stream_message.edit_text(
|
||||||
|
f"✅ {current_status}",
|
||||||
|
parse_mode="Markdown"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Ошибка обновления статусного сообщения: {e}")
|
||||||
|
|
||||||
|
|
||||||
def format_tool_result(tool_name: str, result: 'ToolResult') -> str:
|
async def translate_title(title: str, max_length: int = 100) -> str:
|
||||||
|
"""
|
||||||
|
Краткий перевод заголовка на русский через ИИ.
|
||||||
|
Если перевод не удался — возвращает оригинал.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Быстрый промпт для перевода
|
||||||
|
prompt = f"Переведи на русский язык этот заголовок новости (максимум {max_length} символов, без кавычек и пояснений):\n{title[:200]}"
|
||||||
|
|
||||||
|
# Используем qwen_manager для перевода
|
||||||
|
from qwen_integration import qwen_manager
|
||||||
|
|
||||||
|
# Создаём временную сессию для перевода
|
||||||
|
import hashlib
|
||||||
|
temp_user_id = f"translator_{hashlib.md5(title.encode()).hexdigest()}"
|
||||||
|
|
||||||
|
result = await qwen_manager.run_task(temp_user_id, prompt, on_output=lambda x: None, on_oauth_url=lambda x: None, use_system_prompt=False)
|
||||||
|
|
||||||
|
# Извлекаем текст из результата
|
||||||
|
import re
|
||||||
|
text_matches = re.findall(r'"text":"([^"]+)"', result)
|
||||||
|
if text_matches:
|
||||||
|
translated = " ".join(text_matches).replace("\\n", " ").strip()
|
||||||
|
# Убираем кавычки если есть
|
||||||
|
translated = translated.strip('"\'')
|
||||||
|
if translated and len(translated) > 3:
|
||||||
|
return translated[:max_length]
|
||||||
|
|
||||||
|
return title[:max_length]
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Ошибка перевода заголовка: {e}")
|
||||||
|
return title[:max_length]
|
||||||
|
|
||||||
|
|
||||||
|
async def format_tool_result(tool_name: str, result: 'ToolResult') -> str:
|
||||||
"""Форматировать результат выполнения инструмента."""
|
"""Форматировать результат выполнения инструмента."""
|
||||||
from bot.tools import ToolResult
|
from bot.tools import ToolResult
|
||||||
|
|
||||||
|
|
@ -315,20 +455,55 @@ def format_tool_result(tool_name: str, result: 'ToolResult') -> str:
|
||||||
|
|
||||||
return output
|
return output
|
||||||
|
|
||||||
elif tool_name == 'rss_reader':
|
elif tool_name in ('rss_reader', 'rss_tool'):
|
||||||
action = result.metadata.get('action', 'list')
|
action = result.metadata.get('action', 'list')
|
||||||
|
|
||||||
if action == 'list' and result.data:
|
if action == 'list' and result.data:
|
||||||
|
# Помечаем новости как прочитанные (digest_flag=1)
|
||||||
|
from bot.tools.rss_tool import RSSTool
|
||||||
|
rss_tool_instance = RSSTool(db_path='rss.db')
|
||||||
|
for item in result.data:
|
||||||
|
news_id = item.get('id')
|
||||||
|
if news_id:
|
||||||
|
await rss_tool_instance.mark_digest(news_id)
|
||||||
|
logger.debug(f"Новость {news_id} помечена как прочитанная")
|
||||||
|
|
||||||
output = "📰 **Последние новости:**\n\n"
|
output = "📰 **Последние новости:**\n\n"
|
||||||
for i, item in enumerate(result.data[:10], 1):
|
# Берём не более 15 новостей для читаемости
|
||||||
|
news_count = min(len(result.data), 15)
|
||||||
|
|
||||||
|
for i in range(news_count):
|
||||||
|
item = result.data[i]
|
||||||
title = item.get('title', 'Без названия')
|
title = item.get('title', 'Без названия')
|
||||||
pub_date = item.get('pub_date', '')
|
pub_date = item.get('pub_date', '')
|
||||||
link = item.get('link', '')[:50]
|
link = item.get('link', '')
|
||||||
output += f"{i}. {title}\n"
|
|
||||||
|
# Переводим заголовок на русский
|
||||||
|
translated_title = await translate_title(title, max_length=100)
|
||||||
|
|
||||||
|
# Форматируем дату
|
||||||
|
date_str = ""
|
||||||
if pub_date:
|
if pub_date:
|
||||||
output += f" 📅 {pub_date}\n"
|
try:
|
||||||
|
# Преобразуем дату в более читаемый формат
|
||||||
|
dt = datetime.strptime(pub_date[:19], '%Y-%m-%d %H:%M:%S')
|
||||||
|
date_str = dt.strftime('%d.%m.%Y %H:%M')
|
||||||
|
except:
|
||||||
|
date_str = pub_date[:16]
|
||||||
|
|
||||||
|
# Обрезаем заголовок если слишком длинный
|
||||||
|
if len(translated_title) > 120:
|
||||||
|
translated_title = translated_title[:117] + "..."
|
||||||
|
|
||||||
|
output += f"**{i+1}. {translated_title}**\n"
|
||||||
|
if date_str:
|
||||||
|
output += f" 📅 {date_str}\n"
|
||||||
if link:
|
if link:
|
||||||
output += f" 🔗 {link}\n\n"
|
# Обрезаем ссылку для читаемости
|
||||||
|
short_link = link[:60] + "..." if len(link) > 63 else link
|
||||||
|
output += f" 🔗 {short_link}\n"
|
||||||
|
output += "\n"
|
||||||
|
|
||||||
return output
|
return output
|
||||||
|
|
||||||
elif action == 'fetch':
|
elif action == 'fetch':
|
||||||
|
|
@ -338,7 +513,15 @@ def format_tool_result(tool_name: str, result: 'ToolResult') -> str:
|
||||||
elif action == 'list_feeds' and result.data:
|
elif action == 'list_feeds' and result.data:
|
||||||
output = "📑 **Ваши RSS ленты:**\n\n"
|
output = "📑 **Ваши RSS ленты:**\n\n"
|
||||||
for feed in result.data:
|
for feed in result.data:
|
||||||
output += f"• {feed.get('title', feed.get('url', 'Unknown'))}\n"
|
title = feed.get('title', feed.get('url', 'Unknown'))
|
||||||
|
url = feed.get('url', '')
|
||||||
|
last_fetch = feed.get('last_fetched', '')
|
||||||
|
|
||||||
|
output += f"• **{title}**\n"
|
||||||
|
output += f" 🔗 {url}\n"
|
||||||
|
if last_fetch:
|
||||||
|
output += f" 🕐 Обновлено: {last_fetch[:16]}\n"
|
||||||
|
output += "\n"
|
||||||
return output
|
return output
|
||||||
|
|
||||||
return f"RSS: {result.data}"
|
return f"RSS: {result.data}"
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,9 @@
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
import pty
|
||||||
|
from datetime import datetime
|
||||||
from typing import Tuple
|
from typing import Tuple
|
||||||
|
|
||||||
import asyncssh
|
import asyncssh
|
||||||
|
|
@ -12,7 +15,8 @@ from bot.config import config, state_manager, server_manager
|
||||||
from bot.models.server import Server
|
from bot.models.server import Server
|
||||||
from bot.models.session import ssh_session_manager, local_session_manager
|
from bot.models.session import ssh_session_manager, local_session_manager
|
||||||
from bot.utils.ssh_readers import read_ssh_output, read_pty_output, detect_input_type
|
from bot.utils.ssh_readers import read_ssh_output, read_pty_output, detect_input_type
|
||||||
from bot.utils.formatters import format_long_output
|
from bot.utils.formatters import format_long_output, escape_markdown, send_long_message
|
||||||
|
from bot.utils.cleaners import clean_ansi_codes, normalize_output
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,50 @@ from bot.tools import BaseTool, ToolResult, register_tool
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def _translate_title(title: str, max_length: int = 100) -> str:
|
||||||
|
"""
|
||||||
|
Перевести заголовок на русский через Qwen.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
title: Заголовок для перевода
|
||||||
|
max_length: Максимальная длина
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Переведённый заголовок
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import subprocess
|
||||||
|
import json
|
||||||
|
|
||||||
|
# Создаём временный промпт для перевода
|
||||||
|
translate_prompt = f"Translate this news title to Russian. Keep it concise, natural, and informative. Maximum {max_length} characters. Return ONLY the translation, no quotes or explanations.\n\nTitle: {title[:200]}"
|
||||||
|
|
||||||
|
# Используем qwen-cli если доступен
|
||||||
|
result = subprocess.run(
|
||||||
|
['qwen', 'chat', '--json', '--prompt', translate_prompt],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=15
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode == 0:
|
||||||
|
# Парсим JSON ответ
|
||||||
|
try:
|
||||||
|
response = json.loads(result.stdout)
|
||||||
|
translated = response.get('content', response.get('response', title))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
translated = result.stdout.strip()
|
||||||
|
|
||||||
|
# Очищаем от кавычек
|
||||||
|
translated = translated.strip('"\'')
|
||||||
|
return translated[:max_length]
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Ошибка перевода заголовка: {e}")
|
||||||
|
|
||||||
|
# Fallback - обрезаем оригинал
|
||||||
|
return title[:max_length]
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class CronJob:
|
class CronJob:
|
||||||
"""
|
"""
|
||||||
|
|
@ -426,13 +470,18 @@ class CronTool(BaseTool):
|
||||||
result_data['tool_result'] = tool_result.to_dict() if hasattr(tool_result, 'to_dict') else str(tool_result)
|
result_data['tool_result'] = tool_result.to_dict() if hasattr(tool_result, 'to_dict') else str(tool_result)
|
||||||
result_data['success'] = tool_result.success
|
result_data['success'] = tool_result.success
|
||||||
|
|
||||||
# Формируем результат
|
# Формируем результат с красивым форматированием
|
||||||
result_text = f"Задача '{name}' выполнена.\n\n"
|
result_text = f"Задача '{name}' выполнена.\n\n"
|
||||||
result_text += f"Использован инструмент: {decision.tool_name}\n"
|
result_text += f"Использован инструмент: {decision.tool_name}\n\n"
|
||||||
|
|
||||||
|
# Форматируем результат инструмента в читаемый вид
|
||||||
if tool_result.success:
|
if tool_result.success:
|
||||||
result_text += f"Результат: {tool_result.data or 'Успешно'}"
|
formatted_result = await self._format_tool_result_for_cron(
|
||||||
|
decision.tool_name, tool_result.data, tool_result.error
|
||||||
|
)
|
||||||
|
result_text += formatted_result
|
||||||
else:
|
else:
|
||||||
result_text += f"Ошибка: {tool_result.error}"
|
result_text += f"❌ Ошибка: {tool_result.error}"
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# ИИ решил что инструмент не нужен - выполняем промпт напрямую
|
# ИИ решил что инструмент не нужен - выполняем промпт напрямую
|
||||||
|
|
@ -487,6 +536,104 @@ class CronTool(BaseTool):
|
||||||
data=result_data
|
data=result_data
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def _format_tool_result_for_cron(self, tool_name: str, data: Any, error: str = None) -> str:
|
||||||
|
"""
|
||||||
|
Отформатировать результат выполнения инструмента в читаемый вид.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tool_name: Название инструмента
|
||||||
|
data: Данные результата
|
||||||
|
error: Ошибка (если есть)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Отформатированная строка с результатом
|
||||||
|
"""
|
||||||
|
# Поддерживаем оба имени: 'rss_reader' (старое) и 'rss_tool' (новое)
|
||||||
|
if tool_name in ('rss_reader', 'rss_tool'):
|
||||||
|
if not data:
|
||||||
|
return "📰 Новостей не найдено."
|
||||||
|
|
||||||
|
output = "📰 **Последние новости:**\n\n"
|
||||||
|
# Берём не более 15 новостей для читаемости
|
||||||
|
news_count = min(len(data), 15)
|
||||||
|
|
||||||
|
for i in range(news_count):
|
||||||
|
item = data[i]
|
||||||
|
title = item.get('title', 'Без названия')
|
||||||
|
pub_date = item.get('pub_date', '')
|
||||||
|
link = item.get('link', '')
|
||||||
|
|
||||||
|
# Переводим заголовок на русский
|
||||||
|
translated_title = await _translate_title(title, max_length=100)
|
||||||
|
|
||||||
|
# Форматируем дату
|
||||||
|
date_str = ""
|
||||||
|
if pub_date:
|
||||||
|
try:
|
||||||
|
dt = datetime.strptime(pub_date[:19], '%Y-%m-%d %H:%M:%S')
|
||||||
|
date_str = dt.strftime('%d.%m.%Y %H:%M')
|
||||||
|
except:
|
||||||
|
date_str = pub_date[:16]
|
||||||
|
|
||||||
|
# Обрезаем заголовок если слишком длинный
|
||||||
|
if len(translated_title) > 120:
|
||||||
|
translated_title = translated_title[:117] + "..."
|
||||||
|
|
||||||
|
output += f"**{i+1}. {translated_title}**\n"
|
||||||
|
if date_str:
|
||||||
|
output += f" 📅 {date_str}\n"
|
||||||
|
if link:
|
||||||
|
short_link = link[:60] + "..." if len(link) > 63 else link
|
||||||
|
output += f" 🔗 {short_link}\n"
|
||||||
|
output += "\n"
|
||||||
|
|
||||||
|
return output
|
||||||
|
|
||||||
|
elif tool_name == 'ddgs_search':
|
||||||
|
if not data:
|
||||||
|
return "🔍 Ничего не найдено по вашему запросу."
|
||||||
|
|
||||||
|
output = "🔍 **Результаты поиска:**\n\n"
|
||||||
|
for i, item in enumerate(data[:5], 1):
|
||||||
|
title = item.get('title', 'Без названия')
|
||||||
|
href = item.get('href', '')
|
||||||
|
body = item.get('body', '')[:200]
|
||||||
|
output += f"{i}. **{title}**\n"
|
||||||
|
if href:
|
||||||
|
output += f" {href}\n"
|
||||||
|
if body:
|
||||||
|
output += f" {body}\n\n"
|
||||||
|
|
||||||
|
return output
|
||||||
|
|
||||||
|
elif tool_name == 'ssh_executor':
|
||||||
|
if not data:
|
||||||
|
return "❌ **Ошибка SSH:** Нет данных"
|
||||||
|
|
||||||
|
output = "🖥️ **SSH результат:**\n"
|
||||||
|
|
||||||
|
if isinstance(data, dict):
|
||||||
|
if data.get('stdout'):
|
||||||
|
output += f"**Вывод:**\n```\n{data['stdout']}\n```\n\n"
|
||||||
|
if data.get('stderr'):
|
||||||
|
output += f"**Ошибки:**\n```\n{data['stderr']}\n```\n\n"
|
||||||
|
if data.get('returncode') == 0:
|
||||||
|
output += "✅ **Успешно**"
|
||||||
|
else:
|
||||||
|
output += f"❌ **Код возврата:** {data.get('returncode', 'N/A')}"
|
||||||
|
else:
|
||||||
|
output += str(data)
|
||||||
|
|
||||||
|
return output
|
||||||
|
|
||||||
|
elif tool_name == 'cron_tool':
|
||||||
|
if isinstance(data, dict):
|
||||||
|
return f"✅ **Результат:**\n{data}"
|
||||||
|
return str(data)
|
||||||
|
|
||||||
|
# Fallback для неизвестных инструментов
|
||||||
|
return str(data) if data else "Выполнено"
|
||||||
|
|
||||||
def _save_to_log(self, job_id: int, job_name: str, prompt: str, result: str):
|
def _save_to_log(self, job_id: int, job_name: str, prompt: str, result: str):
|
||||||
"""Сохранить результат выполнения задачи в лог-файл."""
|
"""Сохранить результат выполнения задачи в лог-файл."""
|
||||||
log_file = self.log_dir / f"cron_job_{job_id}_{job_name}.log"
|
log_file = self.log_dir / f"cron_job_{job_id}_{job_name}.log"
|
||||||
|
|
|
||||||
|
|
@ -173,12 +173,18 @@ class RSSTool(BaseTool):
|
||||||
|
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
['curl', '-sL', '-m', '30', '-A', 'Mozilla/5.0', url],
|
['curl', '-sL', '-m', '30', '-A', 'Mozilla/5.0', url],
|
||||||
capture_output=True, text=True
|
capture_output=True
|
||||||
)
|
)
|
||||||
|
|
||||||
if result.returncode == 0 and result.stdout:
|
if result.returncode == 0 and result.stdout:
|
||||||
|
# Декодируем с обработкой ошибок кодировки
|
||||||
|
try:
|
||||||
|
content = result.stdout.decode('utf-8', errors='ignore')
|
||||||
|
except Exception:
|
||||||
|
content = result.stdout.decode('latin-1', errors='ignore')
|
||||||
|
|
||||||
count = 0
|
count = 0
|
||||||
for item in self._parse_feed(result.stdout):
|
for item in self._parse_feed(content):
|
||||||
self._insert_news(feed_id, item['title'], item['link'], item['guid'], item['pub'])
|
self._insert_news(feed_id, item['title'], item['link'], item['guid'], item['pub'])
|
||||||
count += 1
|
count += 1
|
||||||
|
|
||||||
|
|
@ -196,7 +202,7 @@ class RSSTool(BaseTool):
|
||||||
return ToolResult(
|
return ToolResult(
|
||||||
success=True,
|
success=True,
|
||||||
data={'total_new_items': total},
|
data={'total_new_items': total},
|
||||||
metadata={'status': 'completed'}
|
metadata={'status': 'completed', 'action': 'fetch'}
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
self.lock_file.unlink(missing_ok=True)
|
self.lock_file.unlink(missing_ok=True)
|
||||||
|
|
@ -223,7 +229,7 @@ class RSSTool(BaseTool):
|
||||||
query = f"""
|
query = f"""
|
||||||
SELECT id, feed_id, title, pub_date, link, digest_flag
|
SELECT id, feed_id, title, pub_date, link, digest_flag
|
||||||
FROM news WHERE {' AND '.join(conditions)}
|
FROM news WHERE {' AND '.join(conditions)}
|
||||||
ORDER BY pub_date DESC LIMIT ?
|
ORDER BY created_at DESC, id DESC LIMIT ?
|
||||||
"""
|
"""
|
||||||
params.append(limit)
|
params.append(limit)
|
||||||
|
|
||||||
|
|
@ -247,7 +253,7 @@ class RSSTool(BaseTool):
|
||||||
return ToolResult(
|
return ToolResult(
|
||||||
success=True,
|
success=True,
|
||||||
data=news_list,
|
data=news_list,
|
||||||
metadata={'count': len(news_list), 'limit': limit}
|
metadata={'count': len(news_list), 'limit': limit, 'action': 'list'}
|
||||||
)
|
)
|
||||||
|
|
||||||
async def add_feed(self, url: str, title: Optional[str] = None) -> ToolResult:
|
async def add_feed(self, url: str, title: Optional[str] = None) -> ToolResult:
|
||||||
|
|
@ -262,7 +268,7 @@ class RSSTool(BaseTool):
|
||||||
return ToolResult(
|
return ToolResult(
|
||||||
success=True,
|
success=True,
|
||||||
data={'url': url, 'title': title},
|
data={'url': url, 'title': title},
|
||||||
metadata={'status': 'added'}
|
metadata={'status': 'added', 'action': 'add_feed'}
|
||||||
)
|
)
|
||||||
except sqlite3.IntegrityError:
|
except sqlite3.IntegrityError:
|
||||||
return ToolResult(
|
return ToolResult(
|
||||||
|
|
@ -295,7 +301,7 @@ class RSSTool(BaseTool):
|
||||||
return ToolResult(
|
return ToolResult(
|
||||||
success=True,
|
success=True,
|
||||||
data=feeds,
|
data=feeds,
|
||||||
metadata={'count': len(feeds)}
|
metadata={'count': len(feeds), 'action': 'list_feeds'}
|
||||||
)
|
)
|
||||||
|
|
||||||
async def mark_digest(self, news_id: int) -> ToolResult:
|
async def mark_digest(self, news_id: int) -> ToolResult:
|
||||||
|
|
|
||||||
|
|
@ -4,20 +4,28 @@ SSH Executor Tool - инструмент для выполнения коман
|
||||||
|
|
||||||
Бот может использовать этот инструмент автономно для выполнения системных задач
|
Бот может использовать этот инструмент автономно для выполнения системных задач
|
||||||
на серверах пользователя.
|
на серверах пользователя.
|
||||||
|
|
||||||
|
Конфигурация серверов загружается из .env:
|
||||||
|
SERVERS=name|host|port|user|tag|password|...
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional, Dict, Any, List
|
from typing import Optional, Dict, Any, List
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
import asyncssh
|
import asyncssh
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
from bot.tools import BaseTool, ToolResult, register_tool
|
from bot.tools import BaseTool, ToolResult, register_tool
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Загрузка переменных окружения
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ServerConfig:
|
class ServerConfig:
|
||||||
|
|
@ -27,6 +35,7 @@ class ServerConfig:
|
||||||
username: str
|
username: str
|
||||||
password: Optional[str] = None
|
password: Optional[str] = None
|
||||||
client_keys: Optional[List[str]] = None
|
client_keys: Optional[List[str]] = None
|
||||||
|
tags: List[str] = None
|
||||||
|
|
||||||
|
|
||||||
class SSHExecutorTool(BaseTool):
|
class SSHExecutorTool(BaseTool):
|
||||||
|
|
@ -37,18 +46,47 @@ class SSHExecutorTool(BaseTool):
|
||||||
category = "system"
|
category = "system"
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
# Серверы по умолчанию (можно расширять)
|
# Загружаем серверы из .env
|
||||||
self.servers: Dict[str, ServerConfig] = {
|
self.servers: Dict[str, ServerConfig] = {}
|
||||||
'home': ServerConfig(
|
|
||||||
host='192.168.1.54',
|
|
||||||
port=22,
|
|
||||||
username='mirivlad',
|
|
||||||
password='moloko22'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
self._last_connection: Optional[asyncssh.SSHClientConnection] = None
|
self._last_connection: Optional[asyncssh.SSHClientConnection] = None
|
||||||
self._last_server: Optional[str] = None
|
self._last_server: Optional[str] = None
|
||||||
|
|
||||||
|
self._load_servers_from_env()
|
||||||
|
|
||||||
|
def _load_servers_from_env(self):
|
||||||
|
"""
|
||||||
|
Загрузить конфигурацию серверов из .env.
|
||||||
|
|
||||||
|
Формат в .env:
|
||||||
|
SERVERS=name|host|port|user|tag|password
|
||||||
|
|
||||||
|
Пример:
|
||||||
|
SERVERS=tomas|192.168.1.54|22|mirivlad|web|moloko22
|
||||||
|
"""
|
||||||
|
servers_str = os.getenv('SERVERS', '')
|
||||||
|
|
||||||
|
if not servers_str.strip():
|
||||||
|
logger.warning("SERVERS не найден в .env, SSH инструмент не будет работать")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Парсим формат: name|host|port|user|tag|password
|
||||||
|
parts = servers_str.strip().split('|')
|
||||||
|
|
||||||
|
if len(parts) >= 6:
|
||||||
|
name, host, port, user, tag, password = parts[:6]
|
||||||
|
|
||||||
|
self.servers[name.strip()] = ServerConfig(
|
||||||
|
host=host.strip(),
|
||||||
|
port=int(port.strip()),
|
||||||
|
username=user.strip(),
|
||||||
|
tags=[tag.strip()] if tag.strip() else [],
|
||||||
|
password=password.strip() if password.strip() else None
|
||||||
|
)
|
||||||
|
logger.info(f"✅ Загружен сервер: {name} ({host}:{port})")
|
||||||
|
else:
|
||||||
|
logger.error(f"Неверный формат SERVERS в .env: {servers_str}")
|
||||||
|
logger.error("Ожидался формат: name|host|port|user|tag|password")
|
||||||
|
|
||||||
async def _connect(self, server_name: str = 'home') -> asyncssh.SSHClientConnection:
|
async def _connect(self, server_name: str = 'home') -> asyncssh.SSHClientConnection:
|
||||||
"""Подключиться к серверу."""
|
"""Подключиться к серверу."""
|
||||||
if server_name not in self.servers:
|
if server_name not in self.servers:
|
||||||
|
|
@ -148,13 +186,13 @@ class SSHExecutorTool(BaseTool):
|
||||||
'command': command
|
'command': command
|
||||||
}
|
}
|
||||||
|
|
||||||
async def execute(self, command: str, server: str = 'home', timeout: int = 30) -> ToolResult:
|
async def execute(self, command: str, server: str = None, timeout: int = 30) -> ToolResult:
|
||||||
"""
|
"""
|
||||||
Выполнить SSH-команду.
|
Выполнить SSH-команду.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
command: Команда для выполнения
|
command: Команда для выполнения
|
||||||
server: Имя сервера (default: 'home')
|
server: Имя сервера (default: первый из .env)
|
||||||
timeout: Таймаут в секундах (default: 30)
|
timeout: Таймаут в секундах (default: 30)
|
||||||
"""
|
"""
|
||||||
if not command or not command.strip():
|
if not command or not command.strip():
|
||||||
|
|
@ -163,6 +201,16 @@ class SSHExecutorTool(BaseTool):
|
||||||
error="Пустая команда"
|
error="Пустая команда"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Если сервер не указан - используем первый из конфигурации
|
||||||
|
if server is None:
|
||||||
|
if not self.servers:
|
||||||
|
return ToolResult(
|
||||||
|
success=False,
|
||||||
|
error="Серверы не настроены. Проверьте SERVERS в .env"
|
||||||
|
)
|
||||||
|
server = list(self.servers.keys())[0]
|
||||||
|
logger.info(f"Сервер не указан, используем первый: {server}")
|
||||||
|
|
||||||
logger.info(f"SSH Executor: server={server}, command={command[:100]}")
|
logger.info(f"SSH Executor: server={server}, command={command[:100]}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -2,17 +2,20 @@
|
||||||
"""
|
"""
|
||||||
Интеграция с Qwen Code CLI.
|
Интеграция с Qwen Code CLI.
|
||||||
Запуск, управление сессиями, обработка OAuth.
|
Запуск, управление сессиями, обработка OAuth.
|
||||||
|
|
||||||
|
Использует stream-json формат для потокового вывода.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import asyncio
|
import asyncio
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import Optional, Dict, Callable, Any
|
from typing import Optional, Dict, Callable, Any, List, Union
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -27,6 +30,28 @@ class QwenSessionState(Enum):
|
||||||
ERROR = "error"
|
ERROR = "error"
|
||||||
|
|
||||||
|
|
||||||
|
class QwenEventType(Enum):
|
||||||
|
"""Типы событий в stream-json выводе Qwen."""
|
||||||
|
SYSTEM = "system"
|
||||||
|
ASSISTANT = "assistant"
|
||||||
|
USER = "user"
|
||||||
|
RESULT = "result"
|
||||||
|
TOOL_USE = "tool_use"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class QwenStreamEvent:
|
||||||
|
"""Событие из stream-json вывода Qwen."""
|
||||||
|
event_type: QwenEventType
|
||||||
|
subtype: Optional[str] = None
|
||||||
|
uuid: Optional[str] = None
|
||||||
|
session_id: Optional[str] = None
|
||||||
|
message: Optional[Dict] = None
|
||||||
|
content: Optional[str] = None
|
||||||
|
is_error: bool = False
|
||||||
|
data: Optional[Dict] = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class QwenSession:
|
class QwenSession:
|
||||||
"""Сессия Qwen Code."""
|
"""Сессия Qwen Code."""
|
||||||
|
|
@ -37,6 +62,7 @@ class QwenSession:
|
||||||
last_activity: datetime = field(default_factory=datetime.now)
|
last_activity: datetime = field(default_factory=datetime.now)
|
||||||
pending_task: Optional[str] = None
|
pending_task: Optional[str] = None
|
||||||
output_buffer: str = ""
|
output_buffer: str = ""
|
||||||
|
session_id: Optional[str] = None
|
||||||
|
|
||||||
SESSION_TIMEOUT = timedelta(minutes=30) # Таймаут неактивности
|
SESSION_TIMEOUT = timedelta(minutes=30) # Таймаут неактивности
|
||||||
|
|
||||||
|
|
@ -107,17 +133,20 @@ class QwenCodeManager:
|
||||||
async def run_task(self, user_id: int, task: str,
|
async def run_task(self, user_id: int, task: str,
|
||||||
on_output: Callable[[str], Any],
|
on_output: Callable[[str], Any],
|
||||||
on_oauth_url: Callable[[str], Any],
|
on_oauth_url: Callable[[str], Any],
|
||||||
use_system_prompt: bool = True) -> str:
|
use_system_prompt: bool = True,
|
||||||
|
on_chunk: Callable[[str], Any] = None,
|
||||||
|
on_event: Callable[[QwenStreamEvent], Any] = None) -> str:
|
||||||
"""
|
"""
|
||||||
Выполнить задачу в Qwen Code.
|
Выполнить задачу в Qwen Code с потоковым выводом.
|
||||||
Для простоты каждый раз запускаем новый процесс.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
user_id: ID пользователя
|
user_id: ID пользователя
|
||||||
task: Задача для выполнения
|
task: Задача для выполнения
|
||||||
on_output: Callback для вывода
|
on_output: Callback для вывода (накапливается)
|
||||||
on_oauth_url: Callback для OAuth URL
|
on_oauth_url: Callback для OAuth URL
|
||||||
use_system_prompt: Добавить системный промпт (default: True)
|
use_system_prompt: Добавить системный промпт (default: True)
|
||||||
|
on_chunk: Callback для потоковой отправки chunks (опционально)
|
||||||
|
on_event: Callback для событий stream-json (опционально)
|
||||||
"""
|
"""
|
||||||
# Создаём временную сессию для отслеживания
|
# Создаём временную сессию для отслеживания
|
||||||
session = self.get_session(user_id)
|
session = self.get_session(user_id)
|
||||||
|
|
@ -137,8 +166,8 @@ class QwenCodeManager:
|
||||||
else:
|
else:
|
||||||
full_task = task
|
full_task = task
|
||||||
|
|
||||||
# Просто выполняем задачу через -p флаг
|
# Выполняем задачу через -p флаг с stream-json выводом
|
||||||
return await self._execute_task(session, full_task, on_output)
|
return await self._execute_task(session, full_task, on_output, on_chunk, on_event)
|
||||||
|
|
||||||
async def _start_session(self, session: QwenSession,
|
async def _start_session(self, session: QwenSession,
|
||||||
on_output: Callable[[str], Any],
|
on_output: Callable[[str], Any],
|
||||||
|
|
@ -228,66 +257,93 @@ class QwenCodeManager:
|
||||||
|
|
||||||
async def _execute_task(self, session: QwenSession,
|
async def _execute_task(self, session: QwenSession,
|
||||||
task: str,
|
task: str,
|
||||||
on_output: Callable[[str], Any]) -> str:
|
on_output: Callable[[str], Any],
|
||||||
"""Выполнить задачу в активной сессии."""
|
on_chunk: Callable[[str], Any] = None,
|
||||||
|
on_event: Callable[[QwenStreamEvent], Any] = None) -> str:
|
||||||
|
"""
|
||||||
|
Выполнить задачу в активной сессии с потоковым stream-json выводом.
|
||||||
|
|
||||||
|
Формат stream-json возвращает JSON-объекты по одному на строку:
|
||||||
|
{"type":"system","subtype":"session_start","uuid":"...","session_id":"..."}
|
||||||
|
{"type":"assistant","uuid":"...","message":{"content":[...]}}
|
||||||
|
{"type":"result","subtype":"success","uuid":"...","result":"..."}
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Сессия Qwen
|
||||||
|
task: Задача для выполнения
|
||||||
|
on_output: Callback для полного вывода (накапливается)
|
||||||
|
on_chunk: Callback для потоковой отправки текстовых chunks
|
||||||
|
on_event: Callback для полных JSON событий
|
||||||
|
"""
|
||||||
session.state = QwenSessionState.BUSY
|
session.state = QwenSessionState.BUSY
|
||||||
session.output_buffer = ""
|
session.output_buffer = ""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Для неинтерактивного режима используем -p
|
|
||||||
env = os.environ.copy()
|
env = os.environ.copy()
|
||||||
env["FORCE_COLOR"] = "0"
|
env["FORCE_COLOR"] = "0"
|
||||||
|
|
||||||
cmd = [
|
cmd = [
|
||||||
self._qwen_command,
|
self._qwen_command,
|
||||||
"-p", task, # Передаём задачу через флаг -p
|
"-p", task,
|
||||||
"--output-format", "text", # Простой текстовый вывод
|
"--output-format", "stream-json", # Правильный streaming формат
|
||||||
"--yolo", # Автоматическое подтверждение всех действий
|
"--yolo", # Авто-подтверждение
|
||||||
]
|
]
|
||||||
|
|
||||||
logger.info(f"Выполнение задачи: {' '.join(cmd)}")
|
logger.info(f"Выполнение задачи (stream-json): {' '.join(cmd)}")
|
||||||
|
|
||||||
process = subprocess.Popen(
|
process = await asyncio.create_subprocess_exec(
|
||||||
cmd,
|
*cmd,
|
||||||
stdout=subprocess.PIPE,
|
stdout=asyncio.subprocess.PIPE,
|
||||||
stderr=subprocess.STDOUT,
|
stderr=asyncio.subprocess.STDOUT,
|
||||||
cwd=self._working_dir,
|
cwd=self._working_dir,
|
||||||
env=env,
|
env=env
|
||||||
text=True,
|
|
||||||
bufsize=1
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Читаем вывод
|
|
||||||
output = ""
|
output = ""
|
||||||
timeout = 300 # 5 минут на выполнение
|
chunk_timeout = 300 # 5 минут на выполнение
|
||||||
|
last_chunk_time = datetime.now()
|
||||||
start_time = datetime.now()
|
partial_content = "" # Для накопления partial messages
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
# Проверяем таймаут
|
# Проверяем общий таймаут
|
||||||
if (datetime.now() - start_time).total_seconds() > timeout:
|
if (datetime.now() - last_chunk_time).total_seconds() > chunk_timeout:
|
||||||
output += "\n\n⚠️ Таймаут выполнения (5 минут)"
|
output += "\n\n⚠️ Таймаут выполнения (5 минут)"
|
||||||
process.terminate()
|
process.terminate()
|
||||||
break
|
break
|
||||||
|
|
||||||
# Проверяем процесс
|
# Проверяем процесс
|
||||||
if process.poll() is not None:
|
if process.returncode is not None:
|
||||||
# Процесс завершился
|
# Процесс завершился - читаем остаток
|
||||||
remaining = process.stdout.read()
|
remaining = await process.stdout.read()
|
||||||
if remaining:
|
if remaining:
|
||||||
output += remaining
|
remaining_str = remaining.decode('utf-8', errors='replace')
|
||||||
on_output(remaining)
|
output += remaining_str
|
||||||
|
# Парсим оставшиеся JSON события (не отправляем сырой вывод!)
|
||||||
|
await self._process_stream_lines(
|
||||||
|
remaining_str, on_output, on_chunk, on_event, session
|
||||||
|
)
|
||||||
break
|
break
|
||||||
|
|
||||||
# Читаем вывод
|
# Читаем строку из stdout
|
||||||
line = process.stdout.readline()
|
try:
|
||||||
if line:
|
line = await asyncio.wait_for(process.stdout.readline(), timeout=1.0)
|
||||||
output += line
|
if line:
|
||||||
session.output_buffer += line
|
line_str = line.decode('utf-8', errors='replace')
|
||||||
on_output(line)
|
output += line_str
|
||||||
|
session.output_buffer += line_str
|
||||||
|
last_chunk_time = datetime.now()
|
||||||
|
|
||||||
# Небольшая пауза
|
# Парсим JSON событие и извлекаем текст
|
||||||
await asyncio.sleep(0.1)
|
await self._process_stream_lines(
|
||||||
|
line_str, on_output, on_chunk, on_event, session
|
||||||
|
)
|
||||||
|
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
if process.returncode is not None:
|
||||||
|
break
|
||||||
|
continue
|
||||||
|
|
||||||
|
await asyncio.sleep(0.01)
|
||||||
|
|
||||||
session.state = QwenSessionState.READY
|
session.state = QwenSessionState.READY
|
||||||
session.last_activity = datetime.now()
|
session.last_activity = datetime.now()
|
||||||
|
|
@ -299,6 +355,117 @@ class QwenCodeManager:
|
||||||
logger.error(f"Ошибка выполнения задачи: {e}")
|
logger.error(f"Ошибка выполнения задачи: {e}")
|
||||||
return f"❌ Ошибка: {str(e)}"
|
return f"❌ Ошибка: {str(e)}"
|
||||||
|
|
||||||
|
async def _process_stream_lines(self,
|
||||||
|
text: str,
|
||||||
|
on_output: Callable[[str], Any],
|
||||||
|
on_chunk: Callable[[str], Any] = None,
|
||||||
|
on_event: Callable[[QwenStreamEvent], Any] = None,
|
||||||
|
session: QwenSession = None) -> str:
|
||||||
|
"""
|
||||||
|
Распарсить stream-json строки и извлечь текстовый контент.
|
||||||
|
|
||||||
|
Формат JSON событий:
|
||||||
|
- {"type":"system","subtype":"session_start","session_id":"..."}
|
||||||
|
- {"type":"assistant","message":{"content":[{"type":"text","text":"..."}]}}
|
||||||
|
- {"type":"result","subtype":"success","result":"...","duration_ms":1234}
|
||||||
|
|
||||||
|
Возвращает только текстовый контент для отображения пользователю.
|
||||||
|
"""
|
||||||
|
extracted_text = ""
|
||||||
|
|
||||||
|
for line in text.split('\n'):
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Проверяем это JSON или обычный текст
|
||||||
|
if line.startswith('{'):
|
||||||
|
try:
|
||||||
|
event_data = json.loads(line)
|
||||||
|
event_type = event_data.get('type', 'unknown')
|
||||||
|
|
||||||
|
# Создаём объект события
|
||||||
|
stream_event = QwenStreamEvent(
|
||||||
|
event_type=QwenEventType(event_type) if event_type in ['system', 'assistant', 'user', 'result', 'tool_use'] else None,
|
||||||
|
subtype=event_data.get('subtype'),
|
||||||
|
uuid=event_data.get('uuid'),
|
||||||
|
session_id=event_data.get('session_id'),
|
||||||
|
message=event_data.get('message'),
|
||||||
|
is_error=event_data.get('is_error', False),
|
||||||
|
data=event_data
|
||||||
|
)
|
||||||
|
|
||||||
|
# Обновляем session_id из события
|
||||||
|
if stream_event.session_id and session:
|
||||||
|
session.session_id = stream_event.session_id
|
||||||
|
|
||||||
|
# Извлекаем текст из разных типов событий
|
||||||
|
if event_type == 'assistant':
|
||||||
|
message = event_data.get('message', {})
|
||||||
|
content_list = message.get('content', [])
|
||||||
|
|
||||||
|
# Логируем для отладки
|
||||||
|
logger.debug(f"Assistant event: content_type={type(content_list)}, content={content_list[:1] if isinstance(content_list, list) else content_list}")
|
||||||
|
|
||||||
|
# Обрабатываем только если content - это список (не thinking)
|
||||||
|
if isinstance(content_list, list):
|
||||||
|
for content_item in content_list:
|
||||||
|
if isinstance(content_item, dict):
|
||||||
|
if content_item.get('type') == 'text':
|
||||||
|
text_content = content_item.get('text', '')
|
||||||
|
logger.debug(f"Text chunk: {text_content[:50]}...")
|
||||||
|
extracted_text += text_content
|
||||||
|
# Отправляем ТОЛЬКО в on_chunk для streaming
|
||||||
|
if on_chunk:
|
||||||
|
await on_chunk(text_content)
|
||||||
|
elif content_item.get('type') == 'tool_use':
|
||||||
|
# Инструмент используется - можно показать статус
|
||||||
|
tool_name = content_item.get('name', 'unknown')
|
||||||
|
# Добавляем переносы строк для разделения блоков
|
||||||
|
status_text = f"\n🔧 Использую инструмент: {tool_name}...\n"
|
||||||
|
extracted_text += status_text
|
||||||
|
if on_chunk:
|
||||||
|
await on_chunk(status_text)
|
||||||
|
# Если content.type == 'thinking' - не отправляем пользователю
|
||||||
|
|
||||||
|
elif event_type == 'result':
|
||||||
|
result_text = event_data.get('result', '')
|
||||||
|
if result_text:
|
||||||
|
extracted_text += result_text
|
||||||
|
# НЕ отправляем result через on_chunk — он уже был отправлен через assistant chunks
|
||||||
|
logger.debug(f"Result event: {result_text[:50]}...")
|
||||||
|
|
||||||
|
# Проверяем на ошибку
|
||||||
|
if event_data.get('is_error'):
|
||||||
|
error_text = event_data.get('error', 'Неизвестная ошибка')
|
||||||
|
logger.error(f"Ошибка Qwen: {error_text}")
|
||||||
|
|
||||||
|
elif event_type == 'system':
|
||||||
|
subtype = event_data.get('subtype', '')
|
||||||
|
if subtype == 'session_start':
|
||||||
|
logger.info(f"Сессия Qwen запущена: {stream_event.session_id}")
|
||||||
|
elif subtype == 'init':
|
||||||
|
# Игнорируем init событие
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Вызываем callback события если есть
|
||||||
|
if on_event:
|
||||||
|
on_event(stream_event)
|
||||||
|
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
# Не JSON строка - возвращаем как текст
|
||||||
|
logger.debug(f"Не JSON строка: {line[:100]}...")
|
||||||
|
extracted_text += line + "\n"
|
||||||
|
if on_chunk:
|
||||||
|
await on_chunk(line + "\n")
|
||||||
|
else:
|
||||||
|
# Обычный текст (не JSON) - например, приветственное сообщение
|
||||||
|
extracted_text += line + "\n"
|
||||||
|
if on_chunk:
|
||||||
|
await on_chunk(line + "\n")
|
||||||
|
|
||||||
|
return extracted_text
|
||||||
|
|
||||||
def _parse_output(self, output: str) -> str:
|
def _parse_output(self, output: str) -> str:
|
||||||
"""
|
"""
|
||||||
Распарсить JSON вывод qwen-code.
|
Распарсить JSON вывод qwen-code.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue