Sync from production (~/telegram-bot): OAuth Qwen, IP 192.168.1.51, provider updates

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
Vladimir 2026-03-07 20:45:01 +08:00
parent 7b3d7375e6
commit 5114e0ae59
12 changed files with 1412 additions and 80 deletions

119
bot.py
View File

@ -168,40 +168,6 @@ async def handle_ai_task(update: Update, text: str):
logger.info(f"Пользователь {user_id}: ИИ отключен, пропускаем обработку") logger.info(f"Пользователь {user_id}: ИИ отключен, пропускаем обработку")
return return
# === ПРОВЕРКА: Авторизация Qwen ===
from bot.utils.qwen_oauth import is_authorized, get_authorization_url
if not await is_authorized():
logger.info(f"Пользователь {user_id} не авторизован в Qwen, получаем OAuth URL")
oauth_url = await get_authorization_url()
if oauth_url:
# Устанавливаем флаг ожидания
state.waiting_for_qwen_oauth = True
await update.message.reply_text(
"🔐 **Требуется авторизация Qwen Code**\n\n"
"Для работы с Qwen Code необходимо авторизоваться.\n\n"
f"🔗 **[Открыть ссылку для авторизации]({oauth_url})**\n\n"
"Или скопируй:\n"
f"`{oauth_url}`\n\n"
"📋 **Инструкция:**\n"
"1. Нажми на ссылку выше или скопируй её в браузер\n"
"2. Войди через Google или GitHub\n"
"3. Разрешите доступ\n"
"4. Вернись в Telegram и отправь любое сообщение\n\n"
"от автоматически продолжит работу после авторизации._",
parse_mode="Markdown",
disable_web_page_preview=True
)
else:
await update.message.reply_text(
"❌ **Ошибка получения OAuth URL**\n\n"
"Не удалось получить ссылку для авторизации. Попробуйте позже или используйте /qwen_auth",
parse_mode="Markdown"
)
return
# === ПРОВЕРКА: Нужна ли компактификация? === # === ПРОВЕРКА: Нужна ли компактификация? ===
# Проверяем порог заполненности контекста # Проверяем порог заполненности контекста
if compactor.check_compaction_needed(): if compactor.check_compaction_needed():
@ -406,6 +372,7 @@ async def handle_ai_task(update: Update, text: str):
AI_PRESET_GIGA_AUTO, AI_PRESET_GIGA_AUTO,
AI_PRESET_GIGA_LITE, AI_PRESET_GIGA_LITE,
AI_PRESET_GIGA_PRO, AI_PRESET_GIGA_PRO,
AI_PRESET_OPENCODE,
) )
if ai_preset == AI_PRESET_OFF: if ai_preset == AI_PRESET_OFF:
@ -413,21 +380,29 @@ async def handle_ai_task(update: Update, text: str):
logger.warning(f"Попытка обработки AI-запроса при отключенном ИИ (пресет={ai_preset})") logger.warning(f"Попытка обработки AI-запроса при отключенном ИИ (пресет={ai_preset})")
return return
if ai_preset == AI_PRESET_QWEN: # Проверяем какой провайдер выбран в настройках
current_provider = "qwen" current_provider = state.current_ai_provider
if current_provider == "qwen":
provider_display = "Qwen Code" provider_display = "Qwen Code"
elif ai_preset in [AI_PRESET_GIGA_AUTO, AI_PRESET_GIGA_LITE, AI_PRESET_GIGA_PRO]: elif current_provider == "opencode":
current_provider = "gigachat" provider_display = f"Opencode ({state.opencode_model})"
# Для GigaChat пресетов устанавливаем нужную модель elif current_provider == "gigachat":
# Для GigaChat используем модель из состояния пользователя
from bot.tools.gigachat_tool import GigaChatConfig from bot.tools.gigachat_tool import GigaChatConfig
if ai_preset == AI_PRESET_GIGA_LITE: gigachat_model = state.gigachat_model
# Принудительно Lite модель
if gigachat_model == "lite":
GigaChatConfig.model = GigaChatConfig.model_lite GigaChatConfig.model = GigaChatConfig.model_lite
elif ai_preset == AI_PRESET_GIGA_PRO: elif gigachat_model == "pro":
# Принудительно Pro модель
GigaChatConfig.model = GigaChatConfig.model_pro GigaChatConfig.model = GigaChatConfig.model_pro
# ai_preset == AI_PRESET_GIGA_AUTO использует авто-переключение в gigachat_tool.py elif gigachat_model == "max":
provider_display = f"GigaChat ({ai_preset})" GigaChatConfig.model = GigaChatConfig.model_max
else:
# По умолчанию Lite
GigaChatConfig.model = GigaChatConfig.model_lite
provider_display = f"GigaChat ({gigachat_model})"
else: else:
# По умолчанию Qwen # По умолчанию Qwen
current_provider = "qwen" current_provider = "qwen"
@ -548,6 +523,60 @@ async def handle_ai_task(update: Update, text: str):
full_output = f"❌ **Ошибка {provider_manager.get_provider_info(current_provider).name}:**\n{result.get('error', 'Неизвестная ошибка')}" full_output = f"❌ **Ошибка {provider_manager.get_provider_info(current_provider).name}:**\n{result.get('error', 'Неизвестная ошибка')}"
provider_name = "GigaChat" provider_name = "GigaChat"
elif current_provider == "opencode":
# Opencode - использует provider_manager
try:
await status_msg.edit_text(
"⏳ 🚀 **Opencode думает...**",
parse_mode="Markdown"
)
except Exception as e:
logger.debug(f"Ошибка обновления статуса для Opencode: {e}")
# Формируем контекст для Opencode
context_messages = []
if summary:
context_messages.append({
"role": "system",
"content": f"=== SUMMARY ДИАЛОГА ===\n{summary}"
})
if memory_context:
context_messages.append({
"role": "system",
"content": f"=== КОНТЕКСТ ПАМЯТИ ===\n{memory_context}"
})
if history_context:
for line in history_context.split("\n"):
if line.startswith("user:"):
context_messages.append({"role": "user", "content": line[5:].strip()})
elif line.startswith("assistant:"):
context_messages.append({"role": "assistant", "content": line[10:].strip()})
# Устанавливаем модель для пользователя (используем синглтон)
from bot.providers.opencode_provider import opencode_provider as global_opencode_provider
if hasattr(state, 'opencode_model'):
global_opencode_provider.set_model(user_id, state.opencode_model)
result = await provider_manager.execute_request(
provider_id=current_provider,
user_id=user_id,
prompt=text,
system_prompt=system_prompt,
context=context_messages,
on_chunk=None
)
if result.get("success"):
full_output = result.get("content", "")
model_name = result.get("metadata", {}).get("model", state.opencode_model if hasattr(state, 'opencode_model') else 'minimax')
provider_name = f"Opencode ({model_name})"
else:
full_output = f"❌ **Ошибка {provider_manager.get_provider_info(current_provider).name}:**\n{result.get('error', 'Неизвестная ошибка')}"
provider_name = "Opencode"
# Добавляем ответ ИИ в историю и память # Добавляем ответ ИИ в историю и память
if full_output and 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]}")

View File

@ -23,6 +23,7 @@ class AIProvider(Enum):
"""Доступные AI-провайдеры.""" """Доступные AI-провайдеры."""
QWEN = "qwen" QWEN = "qwen"
GIGACHAT = "gigachat" GIGACHAT = "gigachat"
OPENCODE = "opencode"
@dataclass @dataclass
@ -68,6 +69,11 @@ class AIProviderManager:
self._providers[AIProvider.GIGACHAT.value] = GigaChatProvider() self._providers[AIProvider.GIGACHAT.value] = GigaChatProvider()
logger.info("GigaChat Provider инициализирован") logger.info("GigaChat Provider инициализирован")
# Opencode Provider
from bot.providers.opencode_provider import OpencodeProvider
self._providers[AIProvider.OPENCODE.value] = OpencodeProvider()
logger.info("Opencode Provider инициализирован")
def set_tools_registry(self, tools_registry: Dict[str, Any]): def set_tools_registry(self, tools_registry: Dict[str, Any]):
"""Установить реестр инструментов для всех провайдеров.""" """Установить реестр инструментов для всех провайдеров."""
self._tools_registry = tools_registry self._tools_registry = tools_registry
@ -88,6 +94,13 @@ class AIProviderManager:
else: else:
self._provider_status[AIProvider.GIGACHAT.value] = False self._provider_status[AIProvider.GIGACHAT.value] = False
# Проверяем Opencode
opencode_provider = self._providers.get(AIProvider.OPENCODE.value)
if opencode_provider:
self._provider_status[AIProvider.OPENCODE.value] = opencode_provider.is_available()
else:
self._provider_status[AIProvider.OPENCODE.value] = False
def get_available_providers(self) -> List[str]: def get_available_providers(self) -> List[str]:
"""Получить список доступных провайдеров.""" """Получить список доступных провайдеров."""
return [ return [
@ -116,15 +129,29 @@ class AIProviderManager:
description="Sber GigaChat API — российская AI-модель от Сбера", description="Sber GigaChat API — российская AI-модель от Сбера",
available=self.is_provider_available(AIProvider.GIGACHAT.value), available=self.is_provider_available(AIProvider.GIGACHAT.value),
is_active=is_active is_active=is_active
),
AIProvider.OPENCODE.value: ProviderInfo(
id=AIProvider.OPENCODE.value,
name="Opencode",
description="Opencode AI — бесплатные модели (minimax, big-pickle, gpt-5-nano)",
available=self.is_provider_available(AIProvider.OPENCODE.value),
is_active=is_active
) )
} }
return providers.get(provider_id) return providers.get(provider_id, ProviderInfo(
id=provider_id,
name=provider_id,
description="Unknown provider",
available=False,
is_active=is_active
))
def get_all_providers_info(self, active_provider_id: str) -> List[ProviderInfo]: def get_all_providers_info(self, active_provider_id: str) -> List[ProviderInfo]:
"""Получить информацию обо всех провайдерах.""" """Получить информацию обо всех провайдерах."""
return [ return [
self.get_provider_info(AIProvider.QWEN.value, AIProvider.QWEN.value == active_provider_id), self.get_provider_info(AIProvider.QWEN.value, AIProvider.QWEN.value == active_provider_id),
self.get_provider_info(AIProvider.GIGACHAT.value, AIProvider.GIGACHAT.value == active_provider_id) self.get_provider_info(AIProvider.GIGACHAT.value, AIProvider.GIGACHAT.value == active_provider_id),
self.get_provider_info(AIProvider.OPENCODE.value, AIProvider.OPENCODE.value == active_provider_id)
] ]
def switch_provider(self, user_id: int, provider_id: str, state_manager) -> tuple[bool, str]: def switch_provider(self, user_id: int, provider_id: str, state_manager) -> tuple[bool, str]:

View File

@ -20,6 +20,8 @@ from bot.models.user_state import (
AI_PRESET_GIGA_AUTO, AI_PRESET_GIGA_AUTO,
AI_PRESET_GIGA_LITE, AI_PRESET_GIGA_LITE,
AI_PRESET_GIGA_PRO, AI_PRESET_GIGA_PRO,
AI_PRESET_GIGA_MAX,
AI_PRESET_OPENCODE,
) )
from bot.config import state_manager from bot.config import state_manager
@ -52,6 +54,16 @@ PRESET_DESCRIPTIONS = {
"description": "Максимальное качество. Для сложных творческих задач.", "description": "Максимальное качество. Для сложных творческих задач.",
"icon": "👑" "icon": "👑"
}, },
AI_PRESET_GIGA_MAX: {
"name": "💎 GigaChat Max",
"description": "Топовая модель для самых сложных задач.",
"icon": "💎"
},
AI_PRESET_OPENCODE: {
"name": "⚡ Opencode",
"description": "Бесплатные модели (minimax, big-pickle, gpt-5-nano).",
"icon": "🚀"
},
} }
@ -67,7 +79,7 @@ async def ai_presets_command(update: Update, context):
state = state_manager.get(user_id) state = state_manager.get(user_id)
current_preset = state.ai_preset current_preset = state.ai_preset
# Формируем меню # Формируем меню - Opencode и GigaChat теперь открывают подменю выбора моделей
keyboard = [ keyboard = [
[ [
InlineKeyboardButton( InlineKeyboardButton(
@ -87,14 +99,18 @@ async def ai_presets_command(update: Update, context):
callback_data=f"ai_preset_{AI_PRESET_GIGA_AUTO}" callback_data=f"ai_preset_{AI_PRESET_GIGA_AUTO}"
) )
], ],
# Кнопка GigaChat с подменю - убираем Lite и Pro из основного меню
[ [
InlineKeyboardButton( InlineKeyboardButton(
f"{'' if current_preset == AI_PRESET_GIGA_LITE else ''} {PRESET_DESCRIPTIONS[AI_PRESET_GIGA_LITE]['icon']} GigaChat Lite", f"{'' if current_preset in [AI_PRESET_GIGA_LITE, AI_PRESET_GIGA_PRO, AI_PRESET_GIGA_MAX] else ''} {PRESET_DESCRIPTIONS[AI_PRESET_GIGA_LITE]['icon']} GigaChat ▶",
callback_data=f"ai_preset_{AI_PRESET_GIGA_LITE}" callback_data="gigachat_submenu"
), )
],
# Кнопка Opencode с подменю
[
InlineKeyboardButton( InlineKeyboardButton(
f"{'' if current_preset == AI_PRESET_GIGA_PRO else ''} {PRESET_DESCRIPTIONS[AI_PRESET_GIGA_PRO]['icon']} GigaChat Pro", f"{'' if current_preset == AI_PRESET_OPENCODE else ''} {PRESET_DESCRIPTIONS[AI_PRESET_OPENCODE]['icon']} Opencode ▶",
callback_data=f"ai_preset_{AI_PRESET_GIGA_PRO}" callback_data="opencode_submenu"
) )
], ],
] ]
@ -110,8 +126,8 @@ async def ai_presets_command(update: Update, context):
output += f"{PRESET_DESCRIPTIONS[AI_PRESET_OFF]['icon']} **ИИ Отключен** — {PRESET_DESCRIPTIONS[AI_PRESET_OFF]['description']}\n" output += f"{PRESET_DESCRIPTIONS[AI_PRESET_OFF]['icon']} **ИИ Отключен** — {PRESET_DESCRIPTIONS[AI_PRESET_OFF]['description']}\n"
output += f"{PRESET_DESCRIPTIONS[AI_PRESET_QWEN]['icon']} **Qwen Code** — {PRESET_DESCRIPTIONS[AI_PRESET_QWEN]['description']}\n" output += f"{PRESET_DESCRIPTIONS[AI_PRESET_QWEN]['icon']} **Qwen Code** — {PRESET_DESCRIPTIONS[AI_PRESET_QWEN]['description']}\n"
output += f"{PRESET_DESCRIPTIONS[AI_PRESET_GIGA_AUTO]['icon']} **GigaChat Авто** — {PRESET_DESCRIPTIONS[AI_PRESET_GIGA_AUTO]['description']}\n" output += f"{PRESET_DESCRIPTIONS[AI_PRESET_GIGA_AUTO]['icon']} **GigaChat Авто** — {PRESET_DESCRIPTIONS[AI_PRESET_GIGA_AUTO]['description']}\n"
output += f"{PRESET_DESCRIPTIONS[AI_PRESET_GIGA_LITE]['icon']} **GigaChat Lite** — {PRESET_DESCRIPTIONS[AI_PRESET_GIGA_LITE]['description']}\n" output += f"{PRESET_DESCRIPTIONS[AI_PRESET_GIGA_LITE]['icon']} **GigaChat** — {PRESET_DESCRIPTIONS[AI_PRESET_GIGA_LITE]['description']}\n"
output += f"{PRESET_DESCRIPTIONS[AI_PRESET_GIGA_PRO]['icon']} **GigaChat Pro** — {PRESET_DESCRIPTIONS[AI_PRESET_GIGA_PRO]['description']}" output += f"{PRESET_DESCRIPTIONS[AI_PRESET_OPENCODE]['icon']} **Opencode** — {PRESET_DESCRIPTIONS[AI_PRESET_OPENCODE]['description']}"
await update.message.reply_text(output, parse_mode="Markdown", reply_markup=reply_markup) await update.message.reply_text(output, parse_mode="Markdown", reply_markup=reply_markup)
@ -142,6 +158,8 @@ async def ai_preset_callback(update: Update, context):
# Для совместимости с существующим кодом # Для совместимости с существующим кодом
if preset == AI_PRESET_QWEN: if preset == AI_PRESET_QWEN:
state.current_ai_provider = "qwen" state.current_ai_provider = "qwen"
elif preset == AI_PRESET_OPENCODE:
state.current_ai_provider = "opencode"
else: # Любой GigaChat else: # Любой GigaChat
state.current_ai_provider = "gigachat" state.current_ai_provider = "gigachat"
@ -150,7 +168,7 @@ async def ai_preset_callback(update: Update, context):
output = f"✅ **Переключено на:** {preset_name}\n\n" output = f"✅ **Переключено на:** {preset_name}\n\n"
output += f"{PRESET_DESCRIPTIONS[preset]['description']}" output += f"{PRESET_DESCRIPTIONS[preset]['description']}"
# Обновляем инлайн-меню # Обновляем инлайн-меню - с подменю для Opencode и GigaChat
keyboard = [ keyboard = [
[ [
InlineKeyboardButton( InlineKeyboardButton(
@ -172,12 +190,14 @@ async def ai_preset_callback(update: Update, context):
], ],
[ [
InlineKeyboardButton( InlineKeyboardButton(
f"{'' if preset == AI_PRESET_GIGA_LITE else ''} {PRESET_DESCRIPTIONS[AI_PRESET_GIGA_LITE]['icon']} GigaChat Lite", f"{'' if preset in [AI_PRESET_GIGA_LITE, AI_PRESET_GIGA_PRO, AI_PRESET_GIGA_MAX] else ''} {PRESET_DESCRIPTIONS[AI_PRESET_GIGA_LITE]['icon']} GigaChat ▶",
callback_data=f"ai_preset_{AI_PRESET_GIGA_LITE}" callback_data="gigachat_submenu"
), )
],
[
InlineKeyboardButton( InlineKeyboardButton(
f"{'' if preset == AI_PRESET_GIGA_PRO else ''} {PRESET_DESCRIPTIONS[AI_PRESET_GIGA_PRO]['icon']} GigaChat Pro", f"{'' if preset == AI_PRESET_OPENCODE else ''} {PRESET_DESCRIPTIONS[AI_PRESET_OPENCODE]['icon']} Opencode ▶",
callback_data=f"ai_preset_{AI_PRESET_GIGA_PRO}" callback_data="opencode_submenu"
) )
], ],
] ]
@ -214,6 +234,16 @@ async def ai_giga_pro_command(update: Update, context):
await switch_preset(update, AI_PRESET_GIGA_PRO) await switch_preset(update, AI_PRESET_GIGA_PRO)
async def ai_giga_max_command(update: Update, context):
"""Быстрое переключение на GigaChat Max."""
await switch_preset(update, AI_PRESET_GIGA_MAX)
async def ai_opencode_command(update: Update, context):
"""Быстрое переключение на Opencode."""
await switch_preset(update, AI_PRESET_OPENCODE)
async def switch_preset(update: Update, preset: str): async def switch_preset(update: Update, preset: str):
"""Переключить пресет и показать уведомление.""" """Переключить пресет и показать уведомление."""
user_id = update.effective_user.id user_id = update.effective_user.id
@ -229,6 +259,8 @@ async def switch_preset(update: Update, preset: str):
state.ai_chat_mode = True state.ai_chat_mode = True
if preset == AI_PRESET_QWEN: if preset == AI_PRESET_QWEN:
state.current_ai_provider = "qwen" state.current_ai_provider = "qwen"
elif preset == AI_PRESET_OPENCODE:
state.current_ai_provider = "opencode"
else: else:
state.current_ai_provider = "gigachat" state.current_ai_provider = "gigachat"
@ -260,5 +292,7 @@ def register_ai_preset_handlers(dispatcher):
dispatcher.add_handler(CommandHandler("ai_giga_auto", ai_giga_auto_command)) dispatcher.add_handler(CommandHandler("ai_giga_auto", ai_giga_auto_command))
dispatcher.add_handler(CommandHandler("ai_giga_lite", ai_giga_lite_command)) dispatcher.add_handler(CommandHandler("ai_giga_lite", ai_giga_lite_command))
dispatcher.add_handler(CommandHandler("ai_giga_pro", ai_giga_pro_command)) dispatcher.add_handler(CommandHandler("ai_giga_pro", ai_giga_pro_command))
dispatcher.add_handler(CommandHandler("ai_giga_max", ai_giga_max_command))
dispatcher.add_handler(CommandHandler("ai_opencode", ai_opencode_command))
logger.info("Обработчики AI-пресетов зарегистрированы") logger.info("Обработчики AI-пресетов зарегистрированы")

View File

@ -8,6 +8,15 @@ from telegram.ext import ContextTypes
from bot.config import config, state_manager, server_manager, menu_builder from bot.config import config, state_manager, server_manager, menu_builder
from bot.utils.decorators import check_access from bot.utils.decorators import check_access
from bot.services.command_executor import execute_cli_command from bot.services.command_executor import execute_cli_command
from bot.models.user_state import (
AI_PRESET_OFF,
AI_PRESET_QWEN,
AI_PRESET_GIGA_AUTO,
AI_PRESET_GIGA_LITE,
AI_PRESET_GIGA_PRO,
AI_PRESET_GIGA_MAX,
AI_PRESET_OPENCODE,
)
from memory_system import memory_manager, get_user_profile_summary from memory_system import memory_manager, get_user_profile_summary
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -47,11 +56,74 @@ async def menu_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
# Вместо отправки сообщения редактируем callback # Вместо отправки сообщения редактируем callback
await query.edit_message_text(text, parse_mode=parse_mode, reply_markup=reply_markup) await query.edit_message_text(text, parse_mode=parse_mode, reply_markup=reply_markup)
return None return None
fake_update = type('FakeUpdate', (), {'message': FakeMessage(), 'effective_user': query.from_user})() fake_update = type('FakeUpdate', (), {'message': FakeMessage(), 'effective_user': query.from_user})()
await ai_presets_command(fake_update, context) await ai_presets_command(fake_update, context)
return return
# Обработчики подменю AI-пресетов
elif callback == "opencode_submenu":
# Подменю выбора моделей Opencode из AI-пресетов
state = state_manager.get(user_id)
current_model = state.opencode_model
state.ai_preset = AI_PRESET_OPENCODE
state.current_ai_provider = "opencode"
keyboard = [
[InlineKeyboardButton(f"{'' if current_model == 'minimax' else ''} ⚡ minimax", callback_data="opencode_model_minimax")],
[InlineKeyboardButton(f"{'' if current_model == 'big_pickle' else ''} 🗃️ big-pickle", callback_data="opencode_model_big_pickle")],
[InlineKeyboardButton(f"{'' if current_model == 'gpt5' else ''} 🔬 gpt-5-nano", callback_data="opencode_model_gpt5")],
[InlineKeyboardButton("⬅️ Назад к AI-пресетам", callback_data="ai_presets")],
]
model_descriptions = {
"minimax": "Быстрая, бесплатная модель. Хорошо справляется с простыми задачами.",
"big_pickle": "Большая бесплатная модель. Лучше для сложных задач.",
"gpt5": "Самая мощная бесплатная модель. Требует больше времени."
}
await query.edit_message_text(
f"📡 **Выбор модели Opencode**\n\n"
f"Текущая модель: **{current_model}**\n\n"
f" Описание моделей:\n"
f"• ⚡ **minimax** — {model_descriptions['minimax']}\n"
f"• 🗃️ **big-pickle** — {model_descriptions['big_pickle']}\n"
f"• 🔬 **gpt-5-nano** — {model_descriptions['gpt5']}\n\n"
f"Выберите модель для использования:",
parse_mode="Markdown",
reply_markup=InlineKeyboardMarkup(keyboard)
)
elif callback == "gigachat_submenu":
# Подменю выбора моделей GigaChat из AI-пресетов
state = state_manager.get(user_id)
current_model = state.gigachat_model
keyboard = [
[InlineKeyboardButton(f"{'' if current_model == 'lite' else ''} 📱 GigaChat Lite", callback_data="ai_preset_giga_lite")],
[InlineKeyboardButton(f"{'' if current_model == 'pro' else ''} 🚀 GigaChat Pro", callback_data="ai_preset_giga_pro")],
[InlineKeyboardButton(f"{'' if current_model == 'max' else ''} 💎 GigaChat Max", callback_data="ai_preset_giga_max")],
[InlineKeyboardButton("⬅️ Назад к AI-пресетам", callback_data="ai_presets")],
]
model_descriptions = {
"lite": "Быстрая и экономичная модель для простых задач",
"pro": "Баланс скорости и качества для большинства задач",
"max": "Самая мощная модель для сложных задач"
}
await query.edit_message_text(
f"🧠 **Выбор модели GigaChat**\n\n"
f"Текущая модель: **{current_model.upper()}**\n\n"
f" Описание моделей:\n"
f"• 📱 **Lite** — {model_descriptions['lite']}\n"
f"• 🚀 **Pro** — {model_descriptions['pro']}\n"
f"• 💎 **Max** — {model_descriptions['max']}\n\n"
f"Выберите модель для использования:",
parse_mode="Markdown",
reply_markup=InlineKeyboardMarkup(keyboard)
)
elif callback.startswith("continue_output_"): elif callback.startswith("continue_output_"):
# Пользователь нажал "Продолжить" # Пользователь нажал "Продолжить"
parts = callback.replace("continue_output_", "").split("_") parts = callback.replace("continue_output_", "").split("_")
@ -165,6 +237,37 @@ async def menu_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
reply_markup=menu_builder.get_keyboard("network") reply_markup=menu_builder.get_keyboard("network")
) )
elif callback == "opencode_models_menu":
# Меню выбора моделей Opencode в предустановленных командах
state = state_manager.get(user_id)
current_model = state.opencode_model
state.current_ai_provider = "opencode"
keyboard = [
[InlineKeyboardButton(f"{'' if current_model == 'minimax' else ''} ⚡ minimax", callback_data="opencode_model_minimax")],
[InlineKeyboardButton(f"{'' if current_model == 'big_pickle' else ''} 🗃️ big-pickle", callback_data="opencode_model_big_pickle")],
[InlineKeyboardButton(f"{'' if current_model == 'gpt5' else ''} 🔬 gpt-5-nano", callback_data="opencode_model_gpt5")],
[InlineKeyboardButton("⬅️ Назад", callback_data="preset_menu")],
]
model_descriptions = {
"minimax": "Быстрая, бесплатная модель",
"big_pickle": "Большая бесплатная модель",
"gpt5": "Самая мощная бесплатная модель"
}
await query.edit_message_text(
f"🤖 **AI модели Opencode**\n\n"
f"Текущая модель: **{current_model}**\n\n"
f" Описание:\n"
f"• ⚡ **minimax** — {model_descriptions['minimax']}\n"
f"• 🗃️ **big-pickle** — {model_descriptions['big_pickle']}\n"
f"• 🔬 **gpt-5-nano** — {model_descriptions['gpt5']}\n\n"
f"Выберите модель:",
parse_mode="Markdown",
reply_markup=InlineKeyboardMarkup(keyboard)
)
elif callback == "server_menu": elif callback == "server_menu":
# Сброс состояния редактирования/добавления сервера # Сброс состояния редактирования/добавления сервера
state.waiting_for_input = False state.waiting_for_input = False
@ -577,21 +680,199 @@ async def menu_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
else: else:
await query.edit_message_text( await query.edit_message_text(
" **Компактификация не требуется**\n\n" " **Компактификация не требуется**\n\n"
"_Недостаточно сообщений для сжатия или summary уже актуален._", "История пуста или уже компактная.",
parse_mode="Markdown", parse_mode="Markdown",
reply_markup=menu_builder.get_keyboard("memory") reply_markup=menu_builder.get_keyboard("memory")
) )
else: else:
await query.edit_message_text( await query.edit_message_text(
f"⚠️ **Ошибка компактификации:**\n`{result.error}`", f"❌ **Ошибка компактификации:**\n\n{result.error}",
parse_mode="Markdown", parse_mode="Markdown",
reply_markup=menu_builder.get_keyboard("memory") reply_markup=menu_builder.get_keyboard("memory")
) )
except Exception as e: except Exception as e:
logger.exception(f"Ошибка в memory_compact: {e}")
await query.edit_message_text( await query.edit_message_text(
f"⚠️ **Ошибка компактификации:**\n`{e}`", f"❌ **Ошибка:** {e}",
parse_mode="Markdown", parse_mode="Markdown",
reply_markup=menu_builder.get_keyboard("memory") reply_markup=menu_builder.get_keyboard("memory")
) )
elif callback == "opencode_model_menu":
state = state_manager.get(user_id)
current_model = state.opencode_model
state.current_ai_provider = "opencode"
keyboard = [
[InlineKeyboardButton(f"{'' if current_model == 'minimax' else ''} ⚡ minimax", callback_data="opencode_model_minimax")],
[InlineKeyboardButton(f"{'' if current_model == 'big_pickle' else ''} 🗃️ big-pickle", callback_data="opencode_model_big_pickle")],
[InlineKeyboardButton(f"{'' if current_model == 'gpt5' else ''} 🔬 gpt-5-nano", callback_data="opencode_model_gpt5")],
[InlineKeyboardButton("⬅️ Назад к AI-пресетам", callback_data="ai_presets")],
]
model_descriptions = {
"minimax": "Быстрая, бесплатная модель. Хорошо справляется с простыми задачами.",
"big_pickle": "Большая бесплатная модель. Лучше для сложных задач.",
"gpt5": "Самая мощная бесплатная модель. Требует больше времени."
}
await query.edit_message_text(
f"📡 **Выбор модели Opencode**\n\n"
f"Текущая модель: **{current_model}**\n\n"
f" Описание моделей:\n"
f"• ⚡ **minimax** — {model_descriptions['minimax']}\n"
f"• 🗃️ **big-pickle** — {model_descriptions['big_pickle']}\n"
f"• 🔬 **gpt-5-nano** — {model_descriptions['gpt5']}",
parse_mode="Markdown",
reply_markup=InlineKeyboardMarkup(keyboard)
)
elif callback.startswith("opencode_model_"):
model = callback.replace("opencode_model_", "")
state = state_manager.get(user_id)
state.opencode_model = model
# Обновляем модель в OpencodeProvider
from bot.providers.opencode_provider import OpencodeProvider
provider = OpencodeProvider()
provider.set_model(user_id, model)
model_names = {
"minimax": "⚡ minimax",
"big_pickle": "🗃️ big-pickle",
"gpt5": "🔬 gpt-5-nano"
}
await query.answer(f"✅ Модель изменена на {model_names.get(model, model)}")
# Показываем меню снова
keyboard = [
[InlineKeyboardButton(f"{'' if model == 'minimax' else ''} ⚡ minimax", callback_data="opencode_model_minimax")],
[InlineKeyboardButton(f"{'' if model == 'big_pickle' else ''} 🗃️ big-pickle", callback_data="opencode_model_big_pickle")],
[InlineKeyboardButton(f"{'' if model == 'gpt5' else ''} 🔬 gpt-5-nano", callback_data="opencode_model_gpt5")],
[InlineKeyboardButton("⬅️ Назад к AI-пресетам", callback_data="ai_presets")],
]
await query.edit_message_text(
f"📡 **Выбор модели Opencode**\n\n"
f"Текущая модель: **{model_names.get(model, model)}**\n\n"
f"✅ *Модель изменена!*\n\n"
f"Выберите модель или вернитесь назад:",
parse_mode="Markdown",
reply_markup=InlineKeyboardMarkup(keyboard)
)
# --- Обработчики меню выбора AI-провайдера ---
elif callback == "ai_provider_selection_menu":
state = state_manager.get(user_id)
current_provider = state.current_ai_provider
keyboard = [
[InlineKeyboardButton(f"{'' if current_provider == 'qwen' else ''} 🔄 Qwen Code", callback_data="ai_provider_qwen")],
[InlineKeyboardButton(f"{'' if current_provider == 'opencode' else ''} 📡 Opencode ▶", callback_data="opencode_model_menu")],
[InlineKeyboardButton(f"{'' if current_provider == 'gigachat' else ''} 🧠 GigaChat ▶", callback_data="gigachat_model_menu")],
[InlineKeyboardButton("⬅️ Назад", callback_data="settings")],
]
provider_descriptions = {
"qwen": "Бесплатный локальный AI от Alibaba",
"opencode": "Бесплатные модели (minimax, big-pickle, gpt-5-nano)",
"gigachat": "Российский AI от Сбера (Lite, Pro, Max)"
}
await query.edit_message_text(
f"🤖 **Выбор AI-провайдера**\n\n"
f"Текущий провайдер: **{current_provider.upper()}**\n\n"
f" Описание провайдеров:\n"
f"• 🔄 **Qwen Code** — {provider_descriptions['qwen']}\n"
f"• 📡 **Opencode** — {provider_descriptions['opencode']}\n"
f"• 🧠 **GigaChat** — {provider_descriptions['gigachat']}",
parse_mode="Markdown",
reply_markup=InlineKeyboardMarkup(keyboard)
)
elif callback == "ai_provider_qwen":
state = state_manager.get(user_id)
state.current_ai_provider = "qwen"
await query.answer("✅ Переключено на Qwen Code")
current_provider = "qwen"
keyboard = [
[InlineKeyboardButton(f"{'' if current_provider == 'qwen' else ''} 🔄 Qwen Code", callback_data="ai_provider_qwen")],
[InlineKeyboardButton(f"{'' if current_provider == 'opencode' else ''} 📡 Opencode ▶", callback_data="opencode_model_menu")],
[InlineKeyboardButton(f"{'' if current_provider == 'gigachat' else ''} 🧠 GigaChat ▶", callback_data="gigachat_model_menu")],
[InlineKeyboardButton("⬅️ Назад", callback_data="settings")],
]
await query.edit_message_text(
f"🤖 **Выбор AI-провайдера**\n\n"
f"Текущий провайдер: **QWEN CODE**\n\n"
f"✅ *Провайдер изменён!*\n\n"
f" Описание провайдеров:\n"
f"• 🔄 **Qwen Code** — Бесплатный локальный AI от Alibaba\n"
f"• 📡 **Opencode** — Бесплатные модели (minimax, big-pickle, gpt-5-nano)\n"
f"• 🧠 **GigaChat** — Российский AI от Сбера (Lite, Pro, Max)",
parse_mode="Markdown",
reply_markup=InlineKeyboardMarkup(keyboard)
)
elif callback == "gigachat_model_menu":
state = state_manager.get(user_id)
current_model = state.gigachat_model
state.current_ai_provider = "gigachat"
keyboard = [
[InlineKeyboardButton(f"{'' if current_model == 'lite' else ''} 📱 GigaChat Lite", callback_data="gigachat_model_lite")],
[InlineKeyboardButton(f"{'' if current_model == 'pro' else ''} 🚀 GigaChat Pro", callback_data="gigachat_model_pro")],
[InlineKeyboardButton(f"{'' if current_model == 'max' else ''} 💎 GigaChat Max", callback_data="gigachat_model_max")],
[InlineKeyboardButton("⬅️ Назад", callback_data="ai_provider_selection_menu")],
]
model_descriptions = {
"lite": "Быстрая и экономичная модель для простых задач",
"pro": "Баланс скорости и качества для большинства задач",
"max": "Самая мощная модель для сложных задач"
}
await query.edit_message_text(
f"🧠 **Выбор модели GigaChat**\n\n"
f"Текущая модель: **{current_model.upper()}**\n\n"
f" Описание моделей:\n"
f"• 📱 **Lite** — {model_descriptions['lite']}\n"
f"• 🚀 **Pro** — {model_descriptions['pro']}\n"
f"• 💎 **Max** — {model_descriptions['max']}",
parse_mode="Markdown",
reply_markup=InlineKeyboardMarkup(keyboard)
)
elif callback.startswith("gigachat_model_"):
model = callback.replace("gigachat_model_", "")
state = state_manager.get(user_id)
state.gigachat_model = model
state.current_ai_provider = "gigachat"
model_names = {
"lite": "📱 GigaChat Lite",
"pro": "🚀 GigaChat Pro",
"max": "💎 GigaChat Max"
}
await query.answer(f"✅ Модель изменена на {model_names.get(model, model)}")
current_model = model
keyboard = [
[InlineKeyboardButton(f"{'' if current_model == 'lite' else ''} 📱 GigaChat Lite", callback_data="gigachat_model_lite")],
[InlineKeyboardButton(f"{'' if current_model == 'pro' else ''} 🚀 GigaChat Pro", callback_data="gigachat_model_pro")],
[InlineKeyboardButton(f"{'' if current_model == 'max' else ''} 💎 GigaChat Max", callback_data="gigachat_model_max")],
[InlineKeyboardButton("⬅️ Назад", callback_data="ai_provider_selection_menu")],
]
await query.edit_message_text(
f"🧠 **Выбор модели GigaChat**\n\n"
f"Текущая модель: **{model_names.get(model, model)}**\n\n"
f"✅ *Модель изменена!*\n\n"
f"Теперь используется GigaChat с выбранной моделью.",
parse_mode="Markdown",
reply_markup=InlineKeyboardMarkup(keyboard)
)

View File

@ -122,10 +122,20 @@ def init_menus(menu_builder: MenuBuilder):
MenuItem("🔍 Поиск", "search_menu", icon="🔍"), MenuItem("🔍 Поиск", "search_menu", icon="🔍"),
MenuItem("📊 Система", "system_menu", icon="📊"), MenuItem("📊 Система", "system_menu", icon="📊"),
MenuItem("🌐 Сеть", "network_menu", icon="🌐"), MenuItem("🌐 Сеть", "network_menu", icon="🌐"),
MenuItem("🤖 AI модели Opencode", "opencode_models_menu", icon="🤖"),
MenuItem("⬅️ Назад", "main", icon="⬅️"), MenuItem("⬅️ Назад", "main", icon="⬅️"),
] ]
menu_builder.add_menu("preset", preset_menu) menu_builder.add_menu("preset", preset_menu)
# Меню моделей Opencode
opencode_models_menu = [
MenuItem("⚡ minimax (по умолчанию)", "opencode_model_minimax", icon=""),
MenuItem("🗃️ big-pickle", "opencode_model_big_pickle", icon="🗃️"),
MenuItem("🔬 gpt-5-nano", "opencode_model_gpt5", icon="🔬"),
MenuItem("⬅️ Назад", "preset", icon="⬅️"),
]
menu_builder.add_menu("opencode_models", opencode_models_menu)
# Файловая система # Файловая система
fs_menu = [ fs_menu = [
MenuItem("ls -la", "cmd_ls_la", command="ls -la", icon="📄"), MenuItem("ls -la", "cmd_ls_la", command="ls -la", icon="📄"),
@ -172,10 +182,38 @@ def init_menus(menu_builder: MenuBuilder):
MenuItem("📄 Изменить описание", "set_description", icon="📄"), MenuItem("📄 Изменить описание", "set_description", icon="📄"),
MenuItem("🎨 Изменить иконку", "set_icon", icon="🎨"), MenuItem("🎨 Изменить иконку", "set_icon", icon="🎨"),
MenuItem("🧠 Память ИИ", "memory_menu", icon="🧠"), MenuItem("🧠 Память ИИ", "memory_menu", icon="🧠"),
MenuItem("🤖 Выбор AI-провайдера", "ai_provider_selection_menu", icon="🤖"),
MenuItem("⬅️ Назад", "main", icon="⬅️"), MenuItem("⬅️ Назад", "main", icon="⬅️"),
] ]
menu_builder.add_menu("settings", settings_menu) menu_builder.add_menu("settings", settings_menu)
# Меню выбора AI-провайдера
ai_provider_selection_menu = [
MenuItem("🔄 Qwen Code", "ai_provider_qwen", icon="🔄"),
MenuItem("📡 Opencode ▶", "opencode_model_menu", icon="📡"),
MenuItem("🧠 GigaChat ▶", "gigachat_model_menu", icon="🧠"),
MenuItem("⬅️ Назад", "settings", icon="⬅️"),
]
menu_builder.add_menu("ai_provider_selection", ai_provider_selection_menu)
# Модели Opencode
opencode_model_menu = [
MenuItem("⚡ minimax (по умолчанию)", "opencode_model_minimax", icon=""),
MenuItem("🗃️ big-pickle", "opencode_model_big_pickle", icon="🗃️"),
MenuItem("🔬 gpt-5-nano", "opencode_model_gpt5", icon="🔬"),
MenuItem("⬅️ Назад", "ai_provider_selection", icon="⬅️"),
]
menu_builder.add_menu("opencode_model", opencode_model_menu)
# Модели GigaChat
gigachat_model_menu = [
MenuItem("📱 GigaChat Lite (по умолчанию)", "gigachat_model_lite", icon="📱"),
MenuItem("🚀 GigaChat Pro", "gigachat_model_pro", icon="🚀"),
MenuItem("💎 GigaChat Max", "gigachat_model_max", icon="💎"),
MenuItem("⬅️ Назад", "ai_provider_selection", icon="⬅️"),
]
menu_builder.add_menu("gigachat_model", gigachat_model_menu)
# Меню AI-провайдера # Меню AI-провайдера
ai_provider_menu = [ ai_provider_menu = [
MenuItem("🔄 Переключить AI-провайдер", "ai_provider_toggle", icon="🔄"), MenuItem("🔄 Переключить AI-провайдер", "ai_provider_toggle", icon="🔄"),

View File

@ -11,6 +11,18 @@ AI_PRESET_QWEN = "qwen" # Qwen Code (бесплатно, локально)
AI_PRESET_GIGA_AUTO = "giga_auto" # GigaChat авто-переключение (Lite/Pro) AI_PRESET_GIGA_AUTO = "giga_auto" # GigaChat авто-переключение (Lite/Pro)
AI_PRESET_GIGA_LITE = "giga_lite" # GigaChat Lite (дешевле) AI_PRESET_GIGA_LITE = "giga_lite" # GigaChat Lite (дешевле)
AI_PRESET_GIGA_PRO = "giga_pro" # GigaChat Pro (максимальное качество) AI_PRESET_GIGA_PRO = "giga_pro" # GigaChat Pro (максимальное качество)
AI_PRESET_GIGA_MAX = "giga_max" # GigaChat Max (топовая модель)
AI_PRESET_OPENCODE = "opencode" # Opencode (бесплатно, локально)
# Модели Opencode
OPENCODE_MODEL_MINIMAX = "minimax"
OPENCODE_MODEL_BIG_PICKLE = "big_pickle"
OPENCODE_MODEL_GPT5 = "gpt5"
# Модели GigaChat
GIGACHAT_MODEL_LITE = "lite"
GIGACHAT_MODEL_PRO = "pro"
GIGACHAT_MODEL_MAX = "max"
@dataclass @dataclass
@ -29,6 +41,8 @@ class UserState:
messages_since_fact_extract: int = 0 # Счётчик для извлечения фактов messages_since_fact_extract: int = 0 # Счётчик для извлечения фактов
ai_preset: str = AI_PRESET_QWEN # Текущий AI-пресет ai_preset: str = AI_PRESET_QWEN # Текущий AI-пресет
current_ai_provider: str = "qwen" # Текущий AI-провайдер (для совместимости) current_ai_provider: str = "qwen" # Текущий AI-провайдер (для совместимости)
opencode_model: str = OPENCODE_MODEL_MINIMAX # Модель Opencode
gigachat_model: str = GIGACHAT_MODEL_LITE # Модель GigaChat
# Для управления длинным выводом # Для управления длинным выводом
waiting_for_output_control: bool = False # Ожидание решения пользователя waiting_for_output_control: bool = False # Ожидание решения пользователя

View File

@ -0,0 +1,346 @@
#!/usr/bin/env python3
"""
Opencode AI Provider - интеграция с opencode CLI.
Использует opencode run для выполнения задач с бесплатными моделями:
- opencode/minimax-m2.5-free
- opencode/big-pickle
- opencode/gpt-5-nano
Поддерживает RAG через память бота.
"""
import os
import re
import asyncio
import logging
import subprocess
from pathlib import Path
from typing import Optional, Dict, Any, Callable, List
from dataclasses import dataclass, field
from bot.base_ai_provider import (
BaseAIProvider,
ProviderResponse,
AIMessage,
ToolCall,
ToolCallStatus,
)
logger = logging.getLogger(__name__)
OPENCODE_BIN = os.environ.get("OPENCODE_BIN", "/home/mirivlad/.opencode/bin/opencode")
AVAILABLE_MODELS = {
"minimax": "opencode/minimax-m2.5-free",
"big_pickle": "opencode/big-pickle",
"gpt5": "opencode/gpt-5-nano",
}
DEFAULT_MODEL = "minimax"
@dataclass
class OpencodeSession:
"""Сессия пользователя с opencode."""
user_id: int
model: str = DEFAULT_MODEL
history: List[Dict[str, str]] = field(default_factory=list)
class OpencodeProvider(BaseAIProvider):
"""
Opencode AI Provider.
Использует opencode CLI для генерации ответов.
Поддерживает несколько бесплатных моделей.
"""
def __init__(self):
self._sessions: Dict[int, OpencodeSession] = {}
self._default_model = DEFAULT_MODEL
self._initialized = False
@property
def provider_name(self) -> str:
return "Opencode"
@property
def supports_tools(self) -> bool:
return True
@property
def supports_streaming(self) -> bool:
return False
def is_available(self) -> bool:
"""Проверка доступности opencode CLI."""
return Path(OPENCODE_BIN).exists()
def get_session(self, user_id: int) -> OpencodeSession:
"""Получить или создать сессию пользователя."""
if user_id not in self._sessions:
self._sessions[user_id] = OpencodeSession(
user_id=user_id,
model=self._default_model
)
return self._sessions[user_id]
def set_model(self, user_id: int, model_key: str):
"""Установить модель для пользователя."""
session = self.get_session(user_id)
if model_key in AVAILABLE_MODELS:
session.model = AVAILABLE_MODELS[model_key]
logger.info(f"User {user_id} switched to model: {session.model}")
def get_model(self, user_id: int) -> str:
"""Получить текущую модель пользователя (полное имя)."""
session = self.get_session(user_id)
# Возвращаем полное имя модели из AVAILABLE_MODELS
return AVAILABLE_MODELS.get(session.model, session.model)
def get_available_models(self) -> Dict[str, str]:
"""Получить список доступных моделей."""
return AVAILABLE_MODELS.copy()
def _build_context(
self,
system_prompt: Optional[str],
context: Optional[List[Dict[str, str]]],
memory_context: str = ""
) -> str:
"""Собрать полный контекст для opencode."""
parts = []
if system_prompt:
parts.append(f"=== SYSTEM PROMPT ===\n{system_prompt}")
if memory_context:
parts.append(f"=== MEMORY CONTEXT ===\n{memory_context}")
if context:
history_text = "\n".join([
f"{msg.get('role', 'user')}: {msg.get('content', '')}"
for msg in context
if msg.get('role') != 'system'
])
if history_text:
parts.append(f"=== CONVERSATION HISTORY ===\n{history_text}")
return "\n\n".join(parts)
async def _run_opencode(
self,
prompt: str,
model: str,
on_chunk: Optional[Callable[[str], Any]] = None
) -> str:
"""
Выполнить запрос через opencode CLI.
Args:
prompt: Запрос пользователя
model: Модель для использования
on_chunk: Callback для потокового вывода (не используется)
Returns:
Ответ от opencode
"""
try:
logger.info(f"Opencode _run_opencode: model={model}, prompt_len={len(prompt) if prompt else 0}")
# Используем stdin для передачи промпта
cmd = [
OPENCODE_BIN,
"run",
"-m", model
]
logger.info(f"Running opencode cmd: {cmd}")
# Кодируем промпт для stdin
prompt_bytes = prompt.encode('utf-8') if prompt else b''
process = await asyncio.create_subprocess_exec(
*cmd,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
cwd=str(Path.home()),
)
# Отправляем промпт в stdin
stdout, _ = await asyncio.wait_for(
process.communicate(input=prompt_bytes),
timeout=120.0
)
full_output = stdout.decode('utf-8', errors='replace')
# Очищаем от ANSI кодов и служебных символов
full_output = self._clean_output(full_output)
return full_output
except asyncio.TimeoutError:
logger.error("Opencode timeout")
return "⏱️ Таймаут выполнения (2 минуты)"
except Exception as e:
logger.error(f"Opencode error: {e}")
return f"❌ Ошибка opencode: {str(e)}"
def _clean_output(self, output: str) -> str:
"""Очистить вывод от служебных символов."""
# Убираем ANSI escape последовательности
ansi_escape = re.compile(r'\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])')
output = ansi_escape.sub('', output)
# Убираем служебные строки
lines = output.split('\n')
cleaned_lines = []
for line in lines:
# Пропускаем служебные строки
if any(x in line.lower() for x in ['build', 'minimax', 'gpt', 'elapsed', 'rss', 'bun v']):
continue
if line.startswith('>'):
continue
if not line.strip():
continue
cleaned_lines.append(line)
return "\n".join(cleaned_lines).strip()
async def chat(
self,
prompt: str,
system_prompt: Optional[str] = None,
context: Optional[List[Dict[str, str]]] = None,
tools: Optional[List[Dict[str, Any]]] = None,
on_chunk: Optional[Callable[[str], Any]] = None,
user_id: Optional[int] = None,
memory_context: Optional[str] = None,
**kwargs
) -> ProviderResponse:
"""
Отправить запрос к Opencode.
Args:
prompt: Запрос пользователя
system_prompt: Системный промпт
context: История диалога
tools: Доступные инструменты (схема) - пока не используется
on_chunk: Callback для потокового вывода
user_id: ID пользователя
memory_context: Контекст из памяти бота
Returns:
ProviderResponse с ответом
"""
if not self.is_available():
return ProviderResponse(
success=False,
error="Opencode CLI не найден",
provider_name=self.provider_name
)
if user_id is None:
return ProviderResponse(
success=False,
error="user_id обязателен для Opencode",
provider_name=self.provider_name
)
try:
# Получаем текущую модель
model = self.get_model(user_id)
logger.info(f"Opencode: user_id={user_id}, model={model}, session={self._sessions.get(user_id)}")
# Собираем контекст
full_context = self._build_context(
system_prompt=system_prompt,
context=context,
memory_context=memory_context or ""
)
# Формируем полный промпт
# Когда prompt=None (из process_with_tools), используем контекст напрямую
if prompt is None:
full_prompt = full_context if full_context else ""
elif full_context:
full_prompt = f"{full_context}\n\n=== CURRENT REQUEST ===\n{prompt}"
else:
full_prompt = prompt
# Добавляем информацию об инструментах если есть
if tools:
tools_info = self._format_tools_for_prompt(tools)
full_prompt = f"{full_prompt}\n\n=== AVAILABLE TOOLS ===\n{tools_info}"
logger.info(f"Opencode request (model={model}): {str(prompt)[:50] if prompt else 'from context'}...")
# Выполняем запрос
result = await self._run_opencode(
prompt=full_prompt,
model=model,
on_chunk=on_chunk
)
if not result:
result = "⚠️ Пустой ответ от Opencode"
return ProviderResponse(
success=True,
message=AIMessage(
content=result,
metadata={"model": model}
),
provider_name=self.provider_name
)
except Exception as e:
logger.error(f"Opencode provider error: {e}")
return ProviderResponse(
success=False,
error=str(e),
provider_name=self.provider_name
)
def _format_tools_for_prompt(self, tools: List[Dict[str, Any]]) -> str:
"""Форматировать инструменты для промпта."""
if not tools:
return ""
lines = ["У тебя есть следующие инструменты:\n"]
for tool in tools:
name = tool.get('name', 'unknown')
desc = tool.get('description', 'Нет описания')
params = tool.get('parameters', {})
lines.append(f"- {name}: {desc}")
if params:
props = params.get('properties', {})
if props:
lines.append(f" Параметры: {', '.join(props.keys())}")
return "\n".join(lines)
async def execute_tool(
self,
tool_name: str,
tool_args: Dict[str, Any],
tool_call_id: Optional[str] = None,
**kwargs
) -> ToolCall:
"""Выполнить инструмент (заглушка)."""
return ToolCall(
tool_name=tool_name,
tool_args=tool_args,
tool_call_id=tool_call_id,
status=ToolCallStatus.PENDING
)
# Глобальный экземпляр
opencode_provider = OpencodeProvider()

View File

@ -37,6 +37,7 @@ class GigaChatConfig:
model: str = "GigaChat-Pro" # Модель по умолчанию model: str = "GigaChat-Pro" # Модель по умолчанию
model_lite: str = "GigaChat" # Lite модель для простых запросов model_lite: str = "GigaChat" # Lite модель для простых запросов
model_pro: str = "GigaChat-Pro" # Pro модель для сложных запросов model_pro: str = "GigaChat-Pro" # Pro модель для сложных запросов
model_max: str = "GigaChat-Max" # Max модель для самых сложных задач
api_url: str = "https://gigachat.devices.sberbank.ru/api/v1" api_url: str = "https://gigachat.devices.sberbank.ru/api/v1"
timeout: int = 60 timeout: int = 60
# Пороги для переключения моделей # Пороги для переключения моделей
@ -82,6 +83,7 @@ class GigaChatTool:
model=os.getenv("GIGACHAT_MODEL", "GigaChat-Pro"), model=os.getenv("GIGACHAT_MODEL", "GigaChat-Pro"),
model_lite=os.getenv("GIGACHAT_MODEL_LITE", "GigaChat"), model_lite=os.getenv("GIGACHAT_MODEL_LITE", "GigaChat"),
model_pro=os.getenv("GIGACHAT_MODEL_PRO", "GigaChat-Pro"), model_pro=os.getenv("GIGACHAT_MODEL_PRO", "GigaChat-Pro"),
model_max=os.getenv("GIGACHAT_MODEL_MAX", "GigaChat-Max"),
complexity_token_threshold=int(os.getenv("GIGACHAT_TOKEN_THRESHOLD", "50")), complexity_token_threshold=int(os.getenv("GIGACHAT_TOKEN_THRESHOLD", "50")),
complexity_keyword_threshold=int(os.getenv("GIGACHAT_KEYWORD_THRESHOLD", "2")), complexity_keyword_threshold=int(os.getenv("GIGACHAT_KEYWORD_THRESHOLD", "2")),
) )

View File

@ -61,7 +61,7 @@ class SSHExecutorTool(BaseTool):
SERVERS=name|host|port|user|tag|password SERVERS=name|host|port|user|tag|password
Пример: Пример:
SERVERS=tomas|192.168.1.54|22|mirivlad|web|moloko22 SERVERS=tomas|192.168.1.51|22|mirivlad|web|moloko22
""" """
servers_str = os.getenv('SERVERS', '') servers_str = os.getenv('SERVERS', '')

572
bot/utils/qwen_oauth.py.bak Normal file
View File

@ -0,0 +1,572 @@
#!/usr/bin/env python3
"""
Qwen OAuth 2.0 Device Flow клиент.
Реализует авторизацию через Device Authorization Grant (RFC 8628).
"""
import os
import json
import hashlib
import secrets
import time
import aiohttp
import logging
from pathlib import Path
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from typing import Optional, Dict, Any
logger = logging.getLogger(__name__)
# Qwen OAuth константы
QWEN_OAUTH_BASE_URL = 'https://chat.qwen.ai'
QWEN_OAUTH_DEVICE_CODE_ENDPOINT = f'{QWEN_OAUTH_BASE_URL}/api/v1/oauth2/device/code'
QWEN_OAUTH_TOKEN_ENDPOINT = f'{QWEN_OAUTH_BASE_URL}/api/v1/oauth2/token'
QWEN_OAUTH_CLIENT_ID = 'f0304373b74a44d2b584a3fb70ca9e56'
QWEN_OAUTH_SCOPE = 'openid profile email model.completion'
# Пути для хранения токенов (как в qwen-code CLI)
QWEN_CONFIG_DIR = Path.home() / '.qwen'
QWEN_CREDENTIALS_FILE = QWEN_CONFIG_DIR / 'oauth_creds.json'
@dataclass
class QwenCredentials:
"""OAuth токены Qwen."""
access_token: str = ''
refresh_token: str = ''
token_type: str = 'Bearer'
expiry_date: int = 0 # Unix timestamp в миллисекундах
resource_url: str = 'portal.qwen.ai'
def is_expired(self, buffer_minutes: int = 5) -> bool:
"""Проверка истечения токена с буфером."""
if not self.expiry_date:
return True
expiry_ms = self.expiry_date - (buffer_minutes * 60 * 1000)
return int(datetime.now().timestamp() * 1000) >= expiry_ms
@dataclass
class DeviceAuthorizationResponse:
"""Ответ устройства авторизации."""
device_code: str = ''
user_code: str = ''
verification_uri: str = ''
verification_uri_complete: str = ''
expires_in: int = 0
interval: int = 5 # Polling interval в секундах
@property
def authorization_url(self) -> str:
"""Полная ссылка для авторизации."""
return self.verification_uri_complete
@property
def is_expired(self) -> bool:
"""Истёк ли срок действия device code."""
return time.time() > (self.expires_in - 30) # 30 сек буфер
class QwenOAuthClient:
"""Qwen OAuth 2.0 клиент."""
def __init__(self, credentials_path: Optional[Path] = None):
self.credentials_path = credentials_path or QWEN_CREDENTIALS_FILE
self._credentials: Optional[QwenCredentials] = None
self._load_credentials()
def _load_credentials(self) -> None:
"""Загрузить токены из файла."""
if self.credentials_path.exists():
try:
with open(self.credentials_path, 'r') as f:
data = json.load(f)
self._credentials = QwenCredentials(**data)
logger.info(f"Токены загружены из {self.credentials_path}")
except Exception as e:
logger.error(f"Ошибка загрузки токенов: {e}")
self._credentials = None
else:
logger.debug("Файл с токенами не найден")
self._credentials = None
# Загружаем code_verifier из device_code.json если есть
device_code_file = QWEN_CONFIG_DIR / 'device_code.json'
if device_code_file.exists():
try:
with open(device_code_file, 'r') as f:
data = json.load(f)
code_verifier = data.get('code_verifier', '')
if code_verifier:
self._code_verifier = code_verifier
logger.info(f"Code verifier загружен из {device_code_file}")
except Exception as e:
logger.debug(f"Ошибка загрузки code_verifier: {e}")
def _save_credentials(self) -> None:
"""Сохранить токены в файл."""
if self._credentials:
self.credentials_path.parent.mkdir(parents=True, exist_ok=True)
with open(self.credentials_path, 'w') as f:
json.dump(self._credentials.__dict__, f, indent=2)
# Устанавливаем права 600 (только владелец)
os.chmod(self.credentials_path, 0o600)
logger.info(f"Токены сохранены в {self.credentials_path}")
def has_valid_token(self) -> bool:
"""Проверка наличия валидного токена."""
return self._credentials is not None and not self._credentials.is_expired()
async def get_access_token(self) -> Optional[str]:
"""Получить access token (обновляет если истёк)."""
if self.has_valid_token():
return self._credentials.access_token
if self._credentials and self._credentials.refresh_token:
# Пробуем обновить токен
if await self._refresh_token():
return self._credentials.access_token
return None
async def request_device_authorization(self) -> DeviceAuthorizationResponse:
"""
Запросить Device Authorization.
Returns:
DeviceAuthorizationResponse с данными для авторизации
"""
# Проверяем есть ли сохранённый code_verifier
if not hasattr(self, '_code_verifier') or not self._code_verifier:
# Генерируем PKCE code verifier и challenge
self._code_verifier = secrets.token_urlsafe(32)
code_challenge = hashlib.sha256(self._code_verifier.encode()).hexdigest()
payload = {
'client_id': QWEN_OAUTH_CLIENT_ID,
'scope': QWEN_OAUTH_SCOPE,
'code_challenge': code_challenge,
'code_challenge_method': 'S256'
}
headers = {
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json',
'x-request-id': secrets.token_hex(16),
'User-Agent': 'qwen-code-cli/0.11.0'
}
form_data = '&'.join(f'{k}={v}' for k, v in payload.items())
async with aiohttp.ClientSession() as session:
async with session.post(
QWEN_OAUTH_DEVICE_CODE_ENDPOINT,
data=form_data,
headers=headers
) as resp:
if resp.status != 200:
text = await resp.text()
raise Exception(f"Device authorization failed: {resp.status} - {text}")
data = await resp.json()
return DeviceAuthorizationResponse(
device_code=data.get('device_code', ''),
user_code=data.get('user_code', ''),
verification_uri=data.get('verification_uri', ''),
verification_uri_complete=data.get('verification_uri_complete', ''),
expires_in=data.get('expires_in', 900),
interval=data.get('interval', 5)
)
async def poll_for_token(self, device_code: str, timeout_seconds: int = 900) -> bool:
"""
Опрос сервера для получения токена после авторизации пользователем.
Args:
device_code: Device code из request_device_authorization
timeout_seconds: Максимальное время ожидания
Returns:
True если авторизация успешна
"""
if not hasattr(self, '_code_verifier'):
raise Exception("Code verifier not set. Call request_device_authorization first.")
start_time = time.time()
interval = 5 # Начальный интервал
while time.time() - start_time < timeout_seconds:
payload = {
'grant_type': 'urn:ietf:params:oauth:grant-type:device_code',
'device_code': device_code,
'code_verifier': self._code_verifier,
'client_id': QWEN_OAUTH_CLIENT_ID
}
headers = {
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json',
'User-Agent': 'qwen-code-cli/0.11.0'
}
form_data = '&'.join(f'{k}={v}' for k, v in payload.items())
logger.debug(f"Polling payload: grant_type={payload['grant_type']}, device_code={device_code[:20]}..., code_verifier={self._code_verifier[:20]}...")
async with aiohttp.ClientSession() as session:
async with session.post(
QWEN_OAUTH_TOKEN_ENDPOINT,
data=form_data,
headers=headers
) as resp:
data = await resp.json()
logger.info(f"Polling response: status={resp.status}, data={data}")
if resp.status == 200:
# Успех! Сохраняем токены
self._credentials = QwenCredentials(
access_token=data.get('access_token', ''),
refresh_token=data.get('refresh_token', ''),
token_type=data.get('token_type', 'Bearer'),
expiry_date=int(datetime.now().timestamp() * 1000) + (data.get('expires_in', 3600) * 1000),
resource_url=data.get('resource_url', 'portal.qwen.ai')
)
self._save_credentials()
logger.info("Авторизация успешна!")
return True
error = data.get('error', '')
if error == 'authorization_pending':
# Пользователь ещё не авторизовался
logger.debug("Ожидание авторизации пользователя...")
await asyncio.sleep(interval)
continue
elif error == 'slow_down':
# Сервер просит увеличить интервал
interval += 5
logger.debug(f"Увеличиваем интервал до {interval} сек")
await asyncio.sleep(interval)
continue
elif error == 'expired_token':
logger.error("Device code истёк")
return False
elif error == 'access_denied':
logger.error("Пользователь отклонил авторизацию")
return False
else:
logger.error(f"Неизвестная ошибка: {error}")
return False
logger.error("Таймаут авторизации")
return False
async def _refresh_token(self) -> bool:
"""Обновить access token используя refresh token."""
if not self._credentials or not self._credentials.refresh_token:
return False
payload = {
'grant_type': 'refresh_token',
'refresh_token': self._credentials.refresh_token,
'client_id': QWEN_OAUTH_CLIENT_ID
}
headers = {
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json',
'User-Agent': 'qwen-code-cli/0.11.0'
}
form_data = '&'.join(f'{k}={v}' for k, v in payload.items())
try:
async with aiohttp.ClientSession() as session:
async with session.post(
QWEN_OAUTH_TOKEN_ENDPOINT,
data=form_data,
headers=headers
) as resp:
if resp.status == 200:
data = await resp.json()
self._credentials.access_token = data.get('access_token', '')
self._credentials.refresh_token = data.get('refresh_token', self._credentials.refresh_token)
self._credentials.expiry_date = int(datetime.now().timestamp() * 1000) + (data.get('expires_in', 3600) * 1000)
self._save_credentials()
logger.info("Токен обновлён")
return True
else:
logger.error(f"Ошибка обновления токена: {resp.status}")
self._credentials = None # Очищаем неверные токены
return False
except Exception as e:
logger.error(f"Ошибка обновления токена: {e}")
return False
def clear_credentials(self) -> None:
"""Очистить сохранённые токены."""
self._credentials = None
if self.credentials_path.exists():
self.credentials_path.unlink()
logger.info("Токены очищены")
# Глобальный клиент (singleton)
_oauth_client: Optional[QwenOAuthClient] = None
def get_oauth_client() -> QwenOAuthClient:
"""Получить OAuth клиент (singleton)."""
global _oauth_client
if _oauth_client is None:
_oauth_client = QwenOAuthClient()
return _oauth_client
async def get_authorization_url() -> Optional[str]:
"""
Получить URL для авторизации.
Returns:
URL для авторизации или None если ошибка
"""
try:
# Проверяем есть ли активный device code
device_code_file = QWEN_CONFIG_DIR / 'device_code.json'
if device_code_file.exists():
with open(device_code_file, 'r') as f:
data = json.load(f)
start_time = data.get('start_time', 0)
expires_in = data.get('expires_in', 900)
code_verifier = data.get('code_verifier', '')
auth_url = data.get('authorization_url', '')
device_code = data.get('device_code', '')
# Если device code ещё активен (900 сек = 15 мин) — используем его
# Проверяем что прошло меньше половины времени жизни для надёжности
if time.time() - start_time < expires_in / 2 and code_verifier and auth_url and device_code:
logger.info(f"Используем существующий device code (осталось {expires_in - (time.time() - start_time):.0f} сек)")
return auth_url
else:
logger.info("Device code истёк или скоро истечёт, запрашиваем новый")
# Удаляем старый файл
device_code_file.unlink()
# Генерируем PKCE пару как в qwen-code CLI
import base64
import hashlib
code_verifier = base64.urlsafe_b64encode(os.urandom(32)).decode('utf-8').rstrip('=')
code_challenge = base64.urlsafe_b64encode(hashlib.sha256(code_verifier.encode()).digest()).decode('utf-8').rstrip('=')
# Запрос device authorization
payload = {
'client_id': QWEN_OAUTH_CLIENT_ID,
'scope': QWEN_OAUTH_SCOPE,
'code_challenge': code_challenge,
'code_challenge_method': 'S256'
}
headers = {
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json',
'x-request-id': secrets.token_hex(16),
'User-Agent': 'qwen-code-cli/0.11.0'
}
form_data = '&'.join(f'{k}={v}' for k, v in payload.items())
async with aiohttp.ClientSession() as session:
async with session.post(
QWEN_OAUTH_DEVICE_CODE_ENDPOINT,
data=form_data,
headers=headers
) as resp:
if resp.status != 200:
text = await resp.text()
logger.error(f"Device authorization failed: {resp.status} - {text[:200]}")
raise Exception(f"Device authorization failed: {resp.status}")
# Проверяем Content-Type
content_type = resp.headers.get('Content-Type', '')
if 'application/json' not in content_type:
logger.error(f"Unexpected content type: {content_type}")
raise Exception(f"Expected JSON response, got {content_type}")
data = await resp.json()
# Извлекаем данные из ответа
auth_url = data.get('verification_uri_complete', '')
device_code = data.get('device_code', '')
expires_in = data.get('expires_in', 900)
if not auth_url or not device_code:
logger.error(f"Missing auth_url or device_code in response: {data}")
raise Exception("Invalid OAuth response")
logger.info(f"Получен OAuth URL: {auth_url}")
# Сохраняем device code и code verifier для polling
device_code_file.parent.mkdir(parents=True, exist_ok=True)
with open(device_code_file, 'w') as f:
json.dump({
'device_code': device_code,
'code_verifier': code_verifier, # Сохраняем тот же code_verifier!
'expires_in': expires_in,
'start_time': time.time(),
'authorization_url': auth_url
}, f, indent=2)
# Устанавливаем права 600 (только владелец)
os.chmod(device_code_file, 0o600)
logger.info(f"Device code сохранён: {device_code[:20]}..., expires in {expires_in}s")
return auth_url
except aiohttp.ContentTypeError as e:
logger.error(f"Content-Type error: {e}")
return None
except Exception as e:
logger.error(f"Ошибка получения URL авторизации: {e}", exc_info=True)
return None
async def check_authorization_complete() -> bool:
"""
Проверить завершение авторизации (polling).
Returns:
True если авторизация завершена успешно
"""
try:
# Читаем device code
device_code_file = QWEN_CONFIG_DIR / 'device_code.json'
if not device_code_file.exists():
logger.debug("device_code.json не найден")
return False
with open(device_code_file, 'r') as f:
data = json.load(f)
device_code = data.get('device_code', '')
code_verifier = data.get('code_verifier', '') # Получаем code_verifier из файла
start_time = data.get('start_time', time.time())
expires_in = data.get('expires_in', 900)
logger.info(f"Device code: {device_code[:20]}..., code_verifier: {code_verifier[:20]}...")
# Проверяем не истёк ли timeout
if time.time() - start_time > expires_in:
logger.warning("Device code истёк")
device_code_file.unlink()
return False
# Polling с code_verifier из файла
logger.info("Запуск polling для получения токена...")
payload = {
'grant_type': 'urn:ietf:params:oauth:grant-type:device_code',
'device_code': device_code,
'code_verifier': code_verifier,
'client_id': QWEN_OAUTH_CLIENT_ID
}
headers = {
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json',
'User-Agent': 'qwen-code-cli/0.11.0'
}
form_data = '&'.join(f'{k}={v}' for k, v in payload.items())
async with aiohttp.ClientSession() as session:
async with session.post(
QWEN_OAUTH_TOKEN_ENDPOINT,
data=form_data,
headers=headers
) as resp:
data = await resp.json()
logger.info(f"Polling response: status={resp.status}, data={data}")
if resp.status == 200:
# Успех! Сохраняем токены
credentials = {
'access_token': data.get('access_token', ''),
'refresh_token': data.get('refresh_token', ''),
'token_type': data.get('token_type', 'Bearer'),
'expiry_date': int(time.time() * 1000) + (data.get('expires_in', 3600) * 1000),
'resource_url': data.get('resource_url', 'portal.qwen.ai')
}
# Сохраняем токены в файл
QWEN_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
with open(QWEN_CREDENTIALS_FILE, 'w') as f:
json.dump(credentials, f, indent=2)
os.chmod(QWEN_CREDENTIALS_FILE, 0o600)
device_code_file.unlink()
logger.info("Авторизация успешна! Токены сохранены.")
return True
error = data.get('error', '')
if error == 'authorization_pending':
logger.debug("Ожидание авторизации пользователя...")
return False
elif error == 'slow_down':
logger.debug("Сервер просит увеличить интервал")
await asyncio.sleep(5)
return False
elif error == 'expired_token':
logger.error("Device code истёк")
device_code_file.unlink()
return False
elif error == 'access_denied':
logger.error("Пользователь отклонил авторизацию")
device_code_file.unlink()
return False
elif error == 'invalid_request':
error_desc = data.get('error_description', '')
logger.error(f"Invalid request: {error_desc}")
# Проверяем не истёк ли code_verifier
if 'code_verifier' in error_desc.lower():
logger.error("Code verifier не совпадает — удаляем device_code.json")
device_code_file.unlink()
return False
else:
logger.error(f"Неизвестная ошибка: {error}")
return False
except Exception as e:
logger.error(f"Ошибка проверки авторизации: {e}", exc_info=True)
return False
async def is_authorized() -> bool:
"""Проверить авторизован ли пользователь."""
client = get_oauth_client()
return client.has_valid_token()
async def get_access_token() -> Optional[str]:
"""Получить access token."""
client = get_oauth_client()
return await client.get_access_token()
def clear_authorization() -> None:
"""Очистить авторизацию."""
client = get_oauth_client()
client.clear_credentials()

View File

@ -28,9 +28,7 @@ from bot.base_ai_provider import (
# Импортируем OAuth модуль и константы # Импортируем OAuth модуль и константы
from bot.utils.qwen_oauth import ( from bot.utils.qwen_oauth import (
get_authorization_url,
check_authorization_complete, check_authorization_complete,
is_authorized,
get_access_token, get_access_token,
clear_authorization, clear_authorization,
QWEN_OAUTH_CLIENT_ID, QWEN_OAUTH_CLIENT_ID,
@ -364,15 +362,6 @@ class QwenCodeManager:
session.output_buffer = "" session.output_buffer = ""
try: try:
# ПРОВЕРКА: Проверяем авторизацию ПЕРЕД запуском qwen-code
# Это предотвращает ошибку "No auth type is selected"
if not is_authorized():
logger.warning("Пользователь не авторизован в Qwen, получаем OAuth URL")
oauth_url = await get_authorization_url()
if oauth_url and session.on_oauth_url:
await session.on_oauth_url(oauth_url)
return "🔐 Требуется авторизация Qwen. Пожалуйста, пройдите по ссылке и вернитесь."
env = os.environ.copy() env = os.environ.copy()
env["FORCE_COLOR"] = "0" env["FORCE_COLOR"] = "0"

View File

@ -80,7 +80,7 @@ rss_reader(action="list", limit=10, undigested_only=True)
- Упоминания утилит: systemctl, journalctl, top, htop, df, du, free, ps, netstat - Упоминания утилит: systemctl, journalctl, top, htop, df, du, free, ps, netstat
**Доступные серверы:** **Доступные серверы:**
- `home` — 192.168.1.54 (пользователь: mirivlad) - `home` — 192.168.1.51 (пользователь: mirivlad)
**Параметры:** **Параметры:**
- `command` (str): Команда для выполнения - `command` (str): Команда для выполнения
@ -234,7 +234,7 @@ file_system_tool(operation='copy', source='file.txt', destination='backup/file.t
### При SSH-командах: ### При SSH-командах:
``` ```
🖥️ **SSH: home (192.168.1.54)** 🖥️ **SSH: home (192.168.1.51)**
**Команда:** `df -h` **Команда:** `df -h`
**Вывод:** **Вывод:**