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:
parent
7b3d7375e6
commit
5114e0ae59
119
bot.py
119
bot.py
|
|
@ -168,40 +168,6 @@ async def handle_ai_task(update: Update, text: str):
|
|||
logger.info(f"Пользователь {user_id}: ИИ отключен, пропускаем обработку")
|
||||
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():
|
||||
|
|
@ -406,6 +372,7 @@ async def handle_ai_task(update: Update, text: str):
|
|||
AI_PRESET_GIGA_AUTO,
|
||||
AI_PRESET_GIGA_LITE,
|
||||
AI_PRESET_GIGA_PRO,
|
||||
AI_PRESET_OPENCODE,
|
||||
)
|
||||
|
||||
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})")
|
||||
return
|
||||
|
||||
if ai_preset == AI_PRESET_QWEN:
|
||||
current_provider = "qwen"
|
||||
# Проверяем какой провайдер выбран в настройках
|
||||
current_provider = state.current_ai_provider
|
||||
|
||||
if current_provider == "qwen":
|
||||
provider_display = "Qwen Code"
|
||||
elif ai_preset in [AI_PRESET_GIGA_AUTO, AI_PRESET_GIGA_LITE, AI_PRESET_GIGA_PRO]:
|
||||
current_provider = "gigachat"
|
||||
# Для GigaChat пресетов устанавливаем нужную модель
|
||||
elif current_provider == "opencode":
|
||||
provider_display = f"Opencode ({state.opencode_model})"
|
||||
elif current_provider == "gigachat":
|
||||
# Для GigaChat используем модель из состояния пользователя
|
||||
from bot.tools.gigachat_tool import GigaChatConfig
|
||||
if ai_preset == AI_PRESET_GIGA_LITE:
|
||||
# Принудительно Lite модель
|
||||
gigachat_model = state.gigachat_model
|
||||
|
||||
if gigachat_model == "lite":
|
||||
GigaChatConfig.model = GigaChatConfig.model_lite
|
||||
elif ai_preset == AI_PRESET_GIGA_PRO:
|
||||
# Принудительно Pro модель
|
||||
elif gigachat_model == "pro":
|
||||
GigaChatConfig.model = GigaChatConfig.model_pro
|
||||
# ai_preset == AI_PRESET_GIGA_AUTO использует авто-переключение в gigachat_tool.py
|
||||
provider_display = f"GigaChat ({ai_preset})"
|
||||
elif gigachat_model == "max":
|
||||
GigaChatConfig.model = GigaChatConfig.model_max
|
||||
else:
|
||||
# По умолчанию Lite
|
||||
GigaChatConfig.model = GigaChatConfig.model_lite
|
||||
|
||||
provider_display = f"GigaChat ({gigachat_model})"
|
||||
else:
|
||||
# По умолчанию 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', 'Неизвестная ошибка')}"
|
||||
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 != "⚠️ Не удалось получить ответ ИИ":
|
||||
state.ai_chat_history.append(f"Assistant: {full_output[:500]}")
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ class AIProvider(Enum):
|
|||
"""Доступные AI-провайдеры."""
|
||||
QWEN = "qwen"
|
||||
GIGACHAT = "gigachat"
|
||||
OPENCODE = "opencode"
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -68,6 +69,11 @@ class AIProviderManager:
|
|||
self._providers[AIProvider.GIGACHAT.value] = GigaChatProvider()
|
||||
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]):
|
||||
"""Установить реестр инструментов для всех провайдеров."""
|
||||
self._tools_registry = tools_registry
|
||||
|
|
@ -88,6 +94,13 @@ class AIProviderManager:
|
|||
else:
|
||||
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]:
|
||||
"""Получить список доступных провайдеров."""
|
||||
return [
|
||||
|
|
@ -116,15 +129,29 @@ class AIProviderManager:
|
|||
description="Sber GigaChat API — российская AI-модель от Сбера",
|
||||
available=self.is_provider_available(AIProvider.GIGACHAT.value),
|
||||
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]:
|
||||
"""Получить информацию обо всех провайдерах."""
|
||||
return [
|
||||
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]:
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ from bot.models.user_state import (
|
|||
AI_PRESET_GIGA_AUTO,
|
||||
AI_PRESET_GIGA_LITE,
|
||||
AI_PRESET_GIGA_PRO,
|
||||
AI_PRESET_GIGA_MAX,
|
||||
AI_PRESET_OPENCODE,
|
||||
)
|
||||
from bot.config import state_manager
|
||||
|
||||
|
|
@ -52,6 +54,16 @@ PRESET_DESCRIPTIONS = {
|
|||
"description": "Максимальное качество. Для сложных творческих задач.",
|
||||
"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)
|
||||
current_preset = state.ai_preset
|
||||
|
||||
# Формируем меню
|
||||
# Формируем меню - Opencode и GigaChat теперь открывают подменю выбора моделей
|
||||
keyboard = [
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
|
|
@ -87,14 +99,18 @@ async def ai_presets_command(update: Update, context):
|
|||
callback_data=f"ai_preset_{AI_PRESET_GIGA_AUTO}"
|
||||
)
|
||||
],
|
||||
# Кнопка GigaChat с подменю - убираем Lite и Pro из основного меню
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
f"{'✅' if current_preset == AI_PRESET_GIGA_LITE else '⬜'} {PRESET_DESCRIPTIONS[AI_PRESET_GIGA_LITE]['icon']} GigaChat Lite",
|
||||
callback_data=f"ai_preset_{AI_PRESET_GIGA_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="gigachat_submenu"
|
||||
)
|
||||
],
|
||||
# Кнопка Opencode с подменю
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
f"{'✅' if current_preset == AI_PRESET_GIGA_PRO else '⬜'} {PRESET_DESCRIPTIONS[AI_PRESET_GIGA_PRO]['icon']} GigaChat Pro",
|
||||
callback_data=f"ai_preset_{AI_PRESET_GIGA_PRO}"
|
||||
f"{'✅' if current_preset == AI_PRESET_OPENCODE else '⬜'} {PRESET_DESCRIPTIONS[AI_PRESET_OPENCODE]['icon']} Opencode ▶",
|
||||
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_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_LITE]['icon']} **GigaChat Lite** — {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_GIGA_LITE]['icon']} **GigaChat** — {PRESET_DESCRIPTIONS[AI_PRESET_GIGA_LITE]['description']}\n"
|
||||
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)
|
||||
|
||||
|
|
@ -142,6 +158,8 @@ async def ai_preset_callback(update: Update, context):
|
|||
# Для совместимости с существующим кодом
|
||||
if preset == AI_PRESET_QWEN:
|
||||
state.current_ai_provider = "qwen"
|
||||
elif preset == AI_PRESET_OPENCODE:
|
||||
state.current_ai_provider = "opencode"
|
||||
else: # Любой 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_DESCRIPTIONS[preset]['description']}"
|
||||
|
||||
# Обновляем инлайн-меню
|
||||
# Обновляем инлайн-меню - с подменю для Opencode и GigaChat
|
||||
keyboard = [
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
|
|
@ -172,12 +190,14 @@ async def ai_preset_callback(update: Update, context):
|
|||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
f"{'✅' if preset == AI_PRESET_GIGA_LITE else '⬜'} {PRESET_DESCRIPTIONS[AI_PRESET_GIGA_LITE]['icon']} GigaChat Lite",
|
||||
callback_data=f"ai_preset_{AI_PRESET_GIGA_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="gigachat_submenu"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
f"{'✅' if preset == AI_PRESET_GIGA_PRO else '⬜'} {PRESET_DESCRIPTIONS[AI_PRESET_GIGA_PRO]['icon']} GigaChat Pro",
|
||||
callback_data=f"ai_preset_{AI_PRESET_GIGA_PRO}"
|
||||
f"{'✅' if preset == AI_PRESET_OPENCODE else '⬜'} {PRESET_DESCRIPTIONS[AI_PRESET_OPENCODE]['icon']} Opencode ▶",
|
||||
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)
|
||||
|
||||
|
||||
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):
|
||||
"""Переключить пресет и показать уведомление."""
|
||||
user_id = update.effective_user.id
|
||||
|
|
@ -229,6 +259,8 @@ async def switch_preset(update: Update, preset: str):
|
|||
state.ai_chat_mode = True
|
||||
if preset == AI_PRESET_QWEN:
|
||||
state.current_ai_provider = "qwen"
|
||||
elif preset == AI_PRESET_OPENCODE:
|
||||
state.current_ai_provider = "opencode"
|
||||
else:
|
||||
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_lite", ai_giga_lite_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-пресетов зарегистрированы")
|
||||
|
|
|
|||
|
|
@ -8,6 +8,15 @@ from telegram.ext import ContextTypes
|
|||
from bot.config import config, state_manager, server_manager, menu_builder
|
||||
from bot.utils.decorators import check_access
|
||||
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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -47,11 +56,74 @@ async def menu_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
|||
# Вместо отправки сообщения редактируем callback
|
||||
await query.edit_message_text(text, parse_mode=parse_mode, reply_markup=reply_markup)
|
||||
return None
|
||||
|
||||
|
||||
fake_update = type('FakeUpdate', (), {'message': FakeMessage(), 'effective_user': query.from_user})()
|
||||
await ai_presets_command(fake_update, context)
|
||||
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_"):
|
||||
# Пользователь нажал "Продолжить"
|
||||
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")
|
||||
)
|
||||
|
||||
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":
|
||||
# Сброс состояния редактирования/добавления сервера
|
||||
state.waiting_for_input = False
|
||||
|
|
@ -577,21 +680,199 @@ async def menu_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
|||
else:
|
||||
await query.edit_message_text(
|
||||
"ℹ️ **Компактификация не требуется**\n\n"
|
||||
"_Недостаточно сообщений для сжатия или summary уже актуален._",
|
||||
"История пуста или уже компактная.",
|
||||
parse_mode="Markdown",
|
||||
reply_markup=menu_builder.get_keyboard("memory")
|
||||
)
|
||||
else:
|
||||
await query.edit_message_text(
|
||||
f"⚠️ **Ошибка компактификации:**\n`{result.error}`",
|
||||
f"❌ **Ошибка компактификации:**\n\n{result.error}",
|
||||
parse_mode="Markdown",
|
||||
reply_markup=menu_builder.get_keyboard("memory")
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception(f"Ошибка в memory_compact: {e}")
|
||||
await query.edit_message_text(
|
||||
f"⚠️ **Ошибка компактификации:**\n`{e}`",
|
||||
f"❌ **Ошибка:** {e}",
|
||||
parse_mode="Markdown",
|
||||
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)
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -122,10 +122,20 @@ def init_menus(menu_builder: MenuBuilder):
|
|||
MenuItem("🔍 Поиск", "search_menu", icon="🔍"),
|
||||
MenuItem("📊 Система", "system_menu", icon="📊"),
|
||||
MenuItem("🌐 Сеть", "network_menu", icon="🌐"),
|
||||
MenuItem("🤖 AI модели Opencode", "opencode_models_menu", icon="🤖"),
|
||||
MenuItem("⬅️ Назад", "main", icon="⬅️"),
|
||||
]
|
||||
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 = [
|
||||
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_icon", icon="🎨"),
|
||||
MenuItem("🧠 Память ИИ", "memory_menu", icon="🧠"),
|
||||
MenuItem("🤖 Выбор AI-провайдера", "ai_provider_selection_menu", icon="🤖"),
|
||||
MenuItem("⬅️ Назад", "main", icon="⬅️"),
|
||||
]
|
||||
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_provider_menu = [
|
||||
MenuItem("🔄 Переключить AI-провайдер", "ai_provider_toggle", icon="🔄"),
|
||||
|
|
|
|||
|
|
@ -11,6 +11,18 @@ AI_PRESET_QWEN = "qwen" # Qwen Code (бесплатно, локально)
|
|||
AI_PRESET_GIGA_AUTO = "giga_auto" # GigaChat авто-переключение (Lite/Pro)
|
||||
AI_PRESET_GIGA_LITE = "giga_lite" # GigaChat Lite (дешевле)
|
||||
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
|
||||
|
|
@ -29,6 +41,8 @@ class UserState:
|
|||
messages_since_fact_extract: int = 0 # Счётчик для извлечения фактов
|
||||
ai_preset: str = AI_PRESET_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 # Ожидание решения пользователя
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -37,6 +37,7 @@ class GigaChatConfig:
|
|||
model: str = "GigaChat-Pro" # Модель по умолчанию
|
||||
model_lite: str = "GigaChat" # Lite модель для простых запросов
|
||||
model_pro: str = "GigaChat-Pro" # Pro модель для сложных запросов
|
||||
model_max: str = "GigaChat-Max" # Max модель для самых сложных задач
|
||||
api_url: str = "https://gigachat.devices.sberbank.ru/api/v1"
|
||||
timeout: int = 60
|
||||
# Пороги для переключения моделей
|
||||
|
|
@ -82,6 +83,7 @@ class GigaChatTool:
|
|||
model=os.getenv("GIGACHAT_MODEL", "GigaChat-Pro"),
|
||||
model_lite=os.getenv("GIGACHAT_MODEL_LITE", "GigaChat"),
|
||||
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_keyword_threshold=int(os.getenv("GIGACHAT_KEYWORD_THRESHOLD", "2")),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ class SSHExecutorTool(BaseTool):
|
|||
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', '')
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -28,9 +28,7 @@ from bot.base_ai_provider import (
|
|||
|
||||
# Импортируем OAuth модуль и константы
|
||||
from bot.utils.qwen_oauth import (
|
||||
get_authorization_url,
|
||||
check_authorization_complete,
|
||||
is_authorized,
|
||||
get_access_token,
|
||||
clear_authorization,
|
||||
QWEN_OAUTH_CLIENT_ID,
|
||||
|
|
@ -364,15 +362,6 @@ class QwenCodeManager:
|
|||
session.output_buffer = ""
|
||||
|
||||
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["FORCE_COLOR"] = "0"
|
||||
|
||||
|
|
|
|||
|
|
@ -80,7 +80,7 @@ rss_reader(action="list", limit=10, undigested_only=True)
|
|||
- Упоминания утилит: systemctl, journalctl, top, htop, df, du, free, ps, netstat
|
||||
|
||||
**Доступные серверы:**
|
||||
- `home` — 192.168.1.54 (пользователь: mirivlad)
|
||||
- `home` — 192.168.1.51 (пользователь: mirivlad)
|
||||
|
||||
**Параметры:**
|
||||
- `command` (str): Команда для выполнения
|
||||
|
|
@ -234,7 +234,7 @@ file_system_tool(operation='copy', source='file.txt', destination='backup/file.t
|
|||
### При SSH-командах:
|
||||
|
||||
```
|
||||
🖥️ **SSH: home (192.168.1.54)**
|
||||
🖥️ **SSH: home (192.168.1.51)**
|
||||
**Команда:** `df -h`
|
||||
|
||||
**Вывод:**
|
||||
|
|
|
|||
Loading…
Reference in New Issue