Initial commit: Telegram CLI Bot with AI memory, tools and multi-provider support
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
"""
|
||||
AI Providers - адаптеры для различных AI-провайдеров.
|
||||
|
||||
Каждый провайдер реализует интерфейс BaseAIProvider для единой работы
|
||||
с инструментами и контекстом.
|
||||
"""
|
||||
|
||||
from bot.providers.qwen_provider import QwenCodeProvider
|
||||
from bot.providers.gigachat_provider import GigaChatProvider
|
||||
|
||||
__all__ = [
|
||||
"QwenCodeProvider",
|
||||
"GigaChatProvider",
|
||||
]
|
||||
@@ -0,0 +1,519 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
GigaChat AI Provider - адаптер GigaChat для работы с инструментами.
|
||||
|
||||
Реализует интерфейс BaseAIProvider для единой работы с инструментами
|
||||
независимо от AI-провайдера.
|
||||
|
||||
Использует нативный GigaChat Function Calling API:
|
||||
https://developers.sber.ru/docs/ru/gigachat/guides/functions/overview
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional, Dict, Any, Callable, List
|
||||
import json
|
||||
|
||||
from bot.base_ai_provider import (
|
||||
BaseAIProvider,
|
||||
ProviderResponse,
|
||||
AIMessage,
|
||||
ToolCall,
|
||||
ToolCallStatus,
|
||||
)
|
||||
from bot.tools.gigachat_tool import GigaChatTool, GigaChatMessage, GigaChatConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GigaChatProvider(BaseAIProvider):
|
||||
"""
|
||||
GigaChat AI Provider с нативной поддержкой function calling.
|
||||
|
||||
Использует официальный GigaChat Function Calling API вместо
|
||||
эмуляции через текстовые блоки.
|
||||
"""
|
||||
|
||||
def __init__(self, config: Optional[GigaChatConfig] = None):
|
||||
self._tool = GigaChatTool(config)
|
||||
self._available: Optional[bool] = None
|
||||
self._functions_state_id: Optional[str] = None
|
||||
|
||||
@property
|
||||
def provider_name(self) -> str:
|
||||
return "GigaChat"
|
||||
|
||||
@property
|
||||
def supports_tools(self) -> bool:
|
||||
# GigaChat поддерживает нативные function calls
|
||||
return True
|
||||
|
||||
@property
|
||||
def supports_streaming(self) -> bool:
|
||||
return False
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""Проверить доступность GigaChat."""
|
||||
if self._available is not None:
|
||||
return self._available
|
||||
|
||||
try:
|
||||
import os
|
||||
client_id = os.getenv("GIGACHAT_CLIENT_ID")
|
||||
client_secret = os.getenv("GIGACHAT_CLIENT_SECRET")
|
||||
|
||||
self._available = bool(client_id and client_secret)
|
||||
|
||||
if not self._available:
|
||||
logger.warning("GigaChat недоступен: не настроены GIGACHAT_CLIENT_ID или GIGACHAT_CLIENT_SECRET")
|
||||
else:
|
||||
logger.info("GigaChat доступен")
|
||||
except Exception as e:
|
||||
self._available = False
|
||||
logger.error(f"Ошибка проверки доступности GigaChat: {e}")
|
||||
|
||||
return self._available
|
||||
|
||||
def get_error(self) -> Optional[str]:
|
||||
"""Получить последнюю ошибку."""
|
||||
if self._available is False:
|
||||
return "GigaChat недоступен: проверьте GIGACHAT_CLIENT_ID и GIGACHAT_CLIENT_SECRET"
|
||||
return None
|
||||
|
||||
def get_functions_schema(self, tools_registry: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Получить схему функций для GigaChat API в правильном формате.
|
||||
|
||||
Формат GigaChat:
|
||||
{
|
||||
"name": "function_name",
|
||||
"description": "Описание функции",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {...},
|
||||
"required": [...]
|
||||
},
|
||||
"return_parameters": {...} # опционально
|
||||
}
|
||||
"""
|
||||
schema = []
|
||||
|
||||
if tools_registry is None:
|
||||
return schema
|
||||
|
||||
# Обрабатываем разные типы tools_registry
|
||||
items = []
|
||||
if hasattr(tools_registry, 'get_all') and callable(getattr(tools_registry, 'get_all')):
|
||||
items = list(tools_registry.get_all().items())
|
||||
elif isinstance(tools_registry, dict):
|
||||
items = list(tools_registry.items())
|
||||
elif hasattr(tools_registry, 'tools'):
|
||||
items = list(tools_registry.tools.items()) if isinstance(tools_registry.tools, dict) else []
|
||||
|
||||
for name, tool in items:
|
||||
if hasattr(tool, 'get_schema'):
|
||||
tool_schema = tool.get_schema()
|
||||
# Преобразуем в формат GigaChat с гарантией наличия properties
|
||||
parameters = tool_schema.get("parameters", {})
|
||||
if not parameters:
|
||||
parameters = {"type": "object", "properties": {}}
|
||||
elif "properties" not in parameters:
|
||||
parameters["properties"] = {}
|
||||
|
||||
giga_schema = {
|
||||
"name": name,
|
||||
"description": tool_schema.get("description", ""),
|
||||
"parameters": parameters
|
||||
}
|
||||
# Добавляем return_parameters если есть
|
||||
if hasattr(tool, 'get_return_schema'):
|
||||
giga_schema["return_parameters"] = tool.get_return_schema()
|
||||
schema.append(giga_schema)
|
||||
elif hasattr(tool, 'description'):
|
||||
schema.append({
|
||||
"name": name,
|
||||
"description": tool.description,
|
||||
"parameters": {"type": "object", "properties": {}} # Пустая но валидная схема
|
||||
})
|
||||
|
||||
logger.info(f"📋 GigaChat functions schema: {[f['name'] for f in schema]}")
|
||||
return schema
|
||||
|
||||
def _parse_function_call(self, function_call: Dict[str, Any]) -> ToolCall:
|
||||
"""
|
||||
Преобразовать function_call из ответа GigaChat в ToolCall.
|
||||
|
||||
GigaChat возвращает:
|
||||
{
|
||||
"name": "function_name",
|
||||
"arguments": {"arg1": "value1", ...}
|
||||
}
|
||||
"""
|
||||
try:
|
||||
# Аргументы могут быть строкой JSON или уже dict
|
||||
args = function_call.get("arguments", {})
|
||||
if isinstance(args, str):
|
||||
args = json.loads(args)
|
||||
except (json.JSONDecodeError, TypeError) as e:
|
||||
logger.warning(f"Ошибка парсинга аргументов function_call: {e}")
|
||||
args = {}
|
||||
|
||||
return ToolCall(
|
||||
tool_name=function_call.get("name", "unknown"),
|
||||
tool_args=args,
|
||||
tool_call_id=function_call.get("name", "fc_0") # Используем name как ID
|
||||
)
|
||||
|
||||
async def process_with_tools(
|
||||
self,
|
||||
prompt: str,
|
||||
system_prompt: Optional[str] = None,
|
||||
context: Optional[List[Dict[str, str]]] = None,
|
||||
tools_registry: Optional[Dict[str, Any]] = None,
|
||||
on_chunk: Optional[Callable[[str], Any]] = None,
|
||||
max_iterations: int = 5,
|
||||
**kwargs
|
||||
) -> ProviderResponse:
|
||||
"""
|
||||
Обработка запросов с function calling для GigaChat.
|
||||
|
||||
Использует нативный GigaChat Function Calling API:
|
||||
1. Отправляем запрос с functions массивом
|
||||
2. Получаем function_call из ответа
|
||||
3. Выполняем инструмент
|
||||
4. Отправляем результат с role: "function"
|
||||
5. Повторяем пока не будет финального ответа
|
||||
|
||||
Формат сообщений:
|
||||
- user: {"role": "user", "content": "..."}
|
||||
- assistant: {"role": "assistant", "function_call": {...}}
|
||||
- function: {"role": "function", "name": "...", "content": "..."}
|
||||
"""
|
||||
if not tools_registry:
|
||||
return await self.chat(
|
||||
prompt=prompt,
|
||||
system_prompt=system_prompt,
|
||||
context=context,
|
||||
on_chunk=on_chunk,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
# Формируем базовые сообщения
|
||||
messages = []
|
||||
|
||||
# Добавляем системный промпт если есть
|
||||
if system_prompt:
|
||||
messages.append({"role": "system", "content": system_prompt})
|
||||
|
||||
# Добавляем контекст (историю диалога)
|
||||
if context:
|
||||
for msg in context:
|
||||
role = msg.get("role")
|
||||
# Пропускаем system messages — они уже добавлены
|
||||
if role == "system":
|
||||
continue
|
||||
# Преобразуем tool messages в function messages
|
||||
if role == "tool":
|
||||
role = "function"
|
||||
if role in ("user", "assistant", "function"):
|
||||
messages.append({
|
||||
"role": role,
|
||||
"content": msg.get("content", ""),
|
||||
"name": msg.get("name") # Для function messages
|
||||
})
|
||||
|
||||
# Добавляем текущий запрос пользователя
|
||||
if prompt:
|
||||
messages.append({"role": "user", "content": prompt})
|
||||
|
||||
# Получаем схему функций
|
||||
functions = self.get_functions_schema(tools_registry) if self.supports_tools else None
|
||||
|
||||
logger.info(f"🔍 GigaChat process_with_tools: {len(messages)} сообщений, {len(functions) if functions else 0} функций")
|
||||
|
||||
for iteration in range(max_iterations):
|
||||
logger.info(f"🔄 Итерация {iteration + 1}/{max_iterations}")
|
||||
|
||||
# Логируем сообщения перед отправкой
|
||||
for i, msg in enumerate(messages[-3:]): # Последние 3 сообщения
|
||||
content_preview = msg.get("content", "")[:100]
|
||||
logger.info(f" 📨 [{i}] role={msg.get('role')}, content='{content_preview}...'")
|
||||
|
||||
# Отправляем запрос с functions
|
||||
response = await self._chat_with_functions(
|
||||
messages=messages,
|
||||
functions=functions,
|
||||
user_id=kwargs.get('user_id'),
|
||||
temperature=kwargs.get("temperature", 0.7),
|
||||
max_tokens=kwargs.get("max_tokens", 2000),
|
||||
)
|
||||
|
||||
if not response.get("success"):
|
||||
return ProviderResponse(
|
||||
success=False,
|
||||
error=response.get("error", "Неизвестная ошибка"),
|
||||
provider_name=self.provider_name
|
||||
)
|
||||
|
||||
# Проверяем наличие function_call
|
||||
function_call = response.get("function_call")
|
||||
content = response.get("content", "")
|
||||
|
||||
logger.info(f"📬 Ответ GigaChat: content_len={len(content) if content else 0}, function_call={function_call is not None}")
|
||||
|
||||
# Если нет function_call — возвращаем финальный ответ
|
||||
if not function_call:
|
||||
return ProviderResponse(
|
||||
success=True,
|
||||
message=AIMessage(
|
||||
content=content,
|
||||
tool_calls=[],
|
||||
metadata={
|
||||
"model": response.get("model", "GigaChat"),
|
||||
"usage": response.get("usage", {}),
|
||||
"functions_state_id": response.get("functions_state_id")
|
||||
}
|
||||
),
|
||||
provider_name=self.provider_name,
|
||||
usage=response.get("usage")
|
||||
)
|
||||
|
||||
# Есть function_call — парсим и выполняем инструмент
|
||||
tool_call = self._parse_function_call(function_call)
|
||||
logger.info(f"🛠️ Function call: {tool_call.tool_name}({tool_call.tool_args})")
|
||||
|
||||
# Выполняем инструмент
|
||||
if hasattr(tools_registry, 'get'):
|
||||
tool = tools_registry.get(tool_call.tool_name)
|
||||
elif isinstance(tools_registry, dict):
|
||||
tool = tools_registry.get(tool_call.tool_name)
|
||||
else:
|
||||
tool = None
|
||||
|
||||
if tool is not None:
|
||||
try:
|
||||
if hasattr(tool, 'execute'):
|
||||
result = await tool.execute(
|
||||
**tool_call.tool_args,
|
||||
user_id=kwargs.get('user_id')
|
||||
)
|
||||
elif hasattr(tool, '__call__'):
|
||||
result = await tool(**tool_call.tool_args)
|
||||
else:
|
||||
result = f"Инструмент {tool_call.tool_name} не имеет метода execute"
|
||||
|
||||
tool_call.result = result
|
||||
tool_call.status = ToolCallStatus.SUCCESS
|
||||
except Exception as e:
|
||||
logger.exception(f"Ошибка выполнения инструмента {tool_call.tool_name}: {e}")
|
||||
tool_call.error = str(e)
|
||||
tool_call.status = ToolCallStatus.ERROR
|
||||
result = {"error": str(e)}
|
||||
else:
|
||||
tool_call.error = f"Инструмент {tool_call.tool_name} не найден"
|
||||
tool_call.status = ToolCallStatus.ERROR
|
||||
result = {"error": tool_call.error}
|
||||
|
||||
# Сериализуем результат
|
||||
if hasattr(result, 'to_dict'):
|
||||
result_dict = result.to_dict()
|
||||
elif isinstance(result, dict):
|
||||
result_dict = result
|
||||
else:
|
||||
result_dict = {"result": str(result)}
|
||||
|
||||
result_json = json.dumps(result_dict, ensure_ascii=False)
|
||||
|
||||
# Добавляем assistant message с function_call
|
||||
messages.append({
|
||||
"role": "assistant",
|
||||
"content": "", # Пустой content при function_call
|
||||
"function_call": function_call
|
||||
})
|
||||
|
||||
# Добавляем function message с результатом
|
||||
messages.append({
|
||||
"role": "function",
|
||||
"name": tool_call.tool_name,
|
||||
"content": result_json
|
||||
})
|
||||
|
||||
logger.info(f"✅ Добавлен function result: {tool_call.tool_name}, result_len={len(result_json)}")
|
||||
|
||||
# Сохраняем functions_state_id для следующей итерации
|
||||
if response.get("functions_state_id"):
|
||||
self._functions_state_id = response["functions_state_id"]
|
||||
|
||||
# Достигли максимума итераций
|
||||
return ProviderResponse(
|
||||
success=True,
|
||||
message=AIMessage(
|
||||
content=content + "\n\n[Достигнут максимум итераций выполнения функций]",
|
||||
metadata={"iterations": max_iterations}
|
||||
),
|
||||
provider_name=self.provider_name,
|
||||
usage=response.get("usage")
|
||||
)
|
||||
|
||||
async def _chat_with_functions(
|
||||
self,
|
||||
messages: List[Dict[str, Any]],
|
||||
functions: Optional[List[Dict[str, Any]]] = None,
|
||||
user_id: Optional[int] = None,
|
||||
temperature: float = 0.7,
|
||||
max_tokens: int = 2000,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Отправить запрос в GigaChat API с поддержкой function calling.
|
||||
|
||||
Возвращает:
|
||||
{
|
||||
"success": bool,
|
||||
"content": str,
|
||||
"function_call": {"name": str, "arguments": dict} или None,
|
||||
"model": str,
|
||||
"usage": dict,
|
||||
"functions_state_id": str или None
|
||||
}
|
||||
"""
|
||||
try:
|
||||
# Формируем сообщения в формате GigaChat
|
||||
gc_messages = []
|
||||
for msg in messages:
|
||||
gc_msg = {"role": msg["role"], "content": msg.get("content", "")}
|
||||
if msg.get("name"):
|
||||
gc_msg["name"] = msg["name"]
|
||||
if msg.get("function_call"):
|
||||
gc_msg["function_call"] = msg["function_call"]
|
||||
gc_messages.append(gc_msg)
|
||||
|
||||
# Выполняем запрос через GigaChatTool
|
||||
result = await self._tool.chat_with_functions(
|
||||
messages=gc_messages,
|
||||
functions=functions,
|
||||
user_id=str(user_id) if user_id else None,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
)
|
||||
|
||||
# Извлекаем function_call из ответа
|
||||
function_call = None
|
||||
if result.get("choices"):
|
||||
choice = result["choices"][0]
|
||||
message = choice.get("message", {})
|
||||
function_call = message.get("function_call")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"content": result.get("content", ""),
|
||||
"function_call": function_call,
|
||||
"model": result.get("model", "GigaChat"),
|
||||
"usage": result.get("usage", {}),
|
||||
"functions_state_id": result.get("functions_state_id")
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Ошибка _chat_with_functions: {e}")
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"function_call": None
|
||||
}
|
||||
|
||||
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,
|
||||
**kwargs
|
||||
) -> ProviderResponse:
|
||||
"""
|
||||
Отправить запрос GigaChat (без function calling).
|
||||
|
||||
Используется когда tools не переданы.
|
||||
"""
|
||||
try:
|
||||
# Формируем сообщения
|
||||
messages = []
|
||||
|
||||
if system_prompt:
|
||||
messages.append(GigaChatMessage(role="system", content=system_prompt))
|
||||
|
||||
if context:
|
||||
for msg in context:
|
||||
role = msg.get("role", "user")
|
||||
content = msg.get("content", "")
|
||||
if role == "system":
|
||||
continue
|
||||
if role in ("user", "assistant"):
|
||||
messages.append(GigaChatMessage(role=role, content=content))
|
||||
|
||||
if prompt:
|
||||
messages.append(GigaChatMessage(role="user", content=prompt))
|
||||
|
||||
# Выполняем запрос
|
||||
result = await self._tool.chat(
|
||||
messages=messages,
|
||||
user_id=str(user_id) if user_id else None,
|
||||
temperature=kwargs.get("temperature", 0.7),
|
||||
max_tokens=kwargs.get("max_tokens", 2000),
|
||||
)
|
||||
|
||||
if not result.get("content"):
|
||||
if result.get("error"):
|
||||
return ProviderResponse(
|
||||
success=False,
|
||||
error=result["error"],
|
||||
provider_name=self.provider_name
|
||||
)
|
||||
else:
|
||||
return ProviderResponse(
|
||||
success=False,
|
||||
error="Пустой ответ от GigaChat",
|
||||
provider_name=self.provider_name
|
||||
)
|
||||
|
||||
content = result["content"]
|
||||
|
||||
return ProviderResponse(
|
||||
success=True,
|
||||
message=AIMessage(
|
||||
content=content,
|
||||
tool_calls=[],
|
||||
metadata={
|
||||
"model": result.get("model", "GigaChat"),
|
||||
"usage": result.get("usage", {})
|
||||
}
|
||||
),
|
||||
provider_name=self.provider_name,
|
||||
usage=result.get("usage")
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка GigaChat провайдера: {e}")
|
||||
return ProviderResponse(
|
||||
success=False,
|
||||
error=str(e),
|
||||
provider_name=self.provider_name
|
||||
)
|
||||
|
||||
async def execute_tool(
|
||||
self,
|
||||
tool_name: str,
|
||||
tool_args: Dict[str, Any],
|
||||
tool_call_id: Optional[str] = None,
|
||||
**kwargs
|
||||
) -> ToolCall:
|
||||
"""
|
||||
Выполнить инструмент (заглушка).
|
||||
|
||||
Инструменты выполняются через process_with_tools.
|
||||
"""
|
||||
return ToolCall(
|
||||
tool_name=tool_name,
|
||||
tool_args=tool_args,
|
||||
tool_call_id=tool_call_id,
|
||||
status=ToolCallStatus.PENDING
|
||||
)
|
||||
@@ -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()
|
||||
@@ -0,0 +1,268 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Qwen Code AI Provider - адаптер Qwen Code для работы с инструментами.
|
||||
|
||||
Реализует интерфейс BaseAIProvider для единой работы с инструментами
|
||||
независимо от AI-провайдера.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
import json
|
||||
from typing import Optional, Dict, Any, Callable, List
|
||||
|
||||
from bot.base_ai_provider import (
|
||||
BaseAIProvider,
|
||||
ProviderResponse,
|
||||
AIMessage,
|
||||
ToolCall,
|
||||
ToolCallStatus,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class QwenCodeProvider(BaseAIProvider):
|
||||
"""
|
||||
Qwen Code AI Provider с нативной поддержкой инструментов.
|
||||
|
||||
Использует Qwen Code CLI с потоковым выводом и парсингом tool calls.
|
||||
"""
|
||||
|
||||
def __init__(self, qwen_manager):
|
||||
self._qwen_manager = qwen_manager
|
||||
|
||||
@property
|
||||
def provider_name(self) -> str:
|
||||
return "Qwen Code"
|
||||
|
||||
@property
|
||||
def supports_tools(self) -> bool:
|
||||
return True
|
||||
|
||||
@property
|
||||
def supports_streaming(self) -> bool:
|
||||
return True
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""Qwen Code всегда доступен (локальный CLI)."""
|
||||
return True
|
||||
|
||||
# Список инструментов бота - только их пропускаем
|
||||
ALLOWED_TOOLS = {
|
||||
'ddgs_tool',
|
||||
'rss_tool',
|
||||
'ssh_tool',
|
||||
'cron_tool',
|
||||
'file_system_tool',
|
||||
'telegram_web_tool',
|
||||
}
|
||||
|
||||
def _parse_qwen_result(self, raw_result: str) -> tuple[str, List[ToolCall]]:
|
||||
"""
|
||||
Распарсить результат от Qwen Code.
|
||||
|
||||
Извлекает текст и вызовы инструментов из stream-json вывода.
|
||||
|
||||
Формат stream-json от Qwen Code:
|
||||
{"type":"assistant","message":{"content":[{"type":"text","text":"..."}]}}
|
||||
{"type":"assistant","message":{"content":[{"type":"tool_use","name":"ssh_tool","args":{...}}]}}
|
||||
{"type":"result","result":"...","duration_ms":1234}
|
||||
|
||||
Returns:
|
||||
(content, tool_calls)
|
||||
"""
|
||||
content_parts = []
|
||||
tool_calls = []
|
||||
|
||||
# Пытаемся распарсить JSON lines
|
||||
try:
|
||||
lines = raw_result.strip().split('\n')
|
||||
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
# Пробуем распарсить как JSON
|
||||
try:
|
||||
data = json.loads(line)
|
||||
|
||||
# Обрабатываем разные типы событий
|
||||
event_type = data.get('type')
|
||||
|
||||
if event_type == 'assistant':
|
||||
message = data.get('message', {})
|
||||
content_list = message.get('content', [])
|
||||
|
||||
# Обрабатываем только если content - это список
|
||||
if isinstance(content_list, list):
|
||||
for content_item in content_list:
|
||||
if isinstance(content_item, dict):
|
||||
if content_item.get('type') == 'text':
|
||||
text_content = content_item.get('text', '')
|
||||
logger.debug(f"Text chunk: {text_content[:50]}...")
|
||||
content_parts.append(text_content)
|
||||
|
||||
elif content_item.get('type') == 'tool_use':
|
||||
# Извлекаем tool call
|
||||
tool_name = content_item.get('name', '')
|
||||
tool_args = content_item.get('args', {})
|
||||
tool_call_id = content_item.get('id', None)
|
||||
|
||||
# 🔥 ФИЛЬТР: пропускаем только инструменты бота
|
||||
if tool_name not in self.ALLOWED_TOOLS:
|
||||
logger.warning(f"⚠️ Игнорируем MCP инструмент Qwen Code: {tool_name}")
|
||||
continue
|
||||
|
||||
logger.info(f"Обнаружен tool_use: {tool_name}")
|
||||
|
||||
tool_calls.append(ToolCall(
|
||||
tool_name=tool_name,
|
||||
tool_args=tool_args,
|
||||
tool_call_id=tool_call_id,
|
||||
status=ToolCallStatus.PENDING
|
||||
))
|
||||
|
||||
elif event_type == 'result':
|
||||
# Result event может содержать финальный текст
|
||||
result_text = data.get('result', '')
|
||||
if result_text:
|
||||
content_parts.append(result_text)
|
||||
|
||||
except json.JSONDecodeError:
|
||||
# Не JSON - считаем текстом
|
||||
if line.strip():
|
||||
content_parts.append(line)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Ошибка парсинга Qwen результата: {e}")
|
||||
|
||||
# Фоллбэк: ищем текст в кавычках
|
||||
text_matches = re.findall(r'"text":"([^"]+)"', raw_result)
|
||||
if text_matches:
|
||||
content_parts.extend([t.replace('\\n', '\n') for t in text_matches])
|
||||
|
||||
# Собираем контент
|
||||
content = ''.join(content_parts).strip()
|
||||
|
||||
return content, tool_calls
|
||||
|
||||
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,
|
||||
**kwargs
|
||||
) -> ProviderResponse:
|
||||
"""
|
||||
Отправить запрос Qwen Code.
|
||||
|
||||
Args:
|
||||
prompt: Запрос пользователя
|
||||
system_prompt: Системный промпт
|
||||
context: История диалога
|
||||
tools: Доступные инструменты (схема) - пока не используется
|
||||
on_chunk: Callback для потокового вывода
|
||||
user_id: ID пользователя
|
||||
**kwargs: Дополнительные параметры
|
||||
|
||||
Returns:
|
||||
ProviderResponse с ответом и возможными вызовами инструментов
|
||||
"""
|
||||
if not self._qwen_manager:
|
||||
return ProviderResponse(
|
||||
success=False,
|
||||
error="Qwen менеджер не инициализирован",
|
||||
provider_name=self.provider_name
|
||||
)
|
||||
|
||||
if user_id is None:
|
||||
return ProviderResponse(
|
||||
success=False,
|
||||
error="user_id обязателен для Qwen Code",
|
||||
provider_name=self.provider_name
|
||||
)
|
||||
|
||||
try:
|
||||
# Формируем полный промпт
|
||||
full_prompt = prompt or ""
|
||||
|
||||
if system_prompt and kwargs.get('use_system_prompt', True):
|
||||
full_prompt = f"{system_prompt}\n\n{full_prompt}"
|
||||
|
||||
# Добавляем контекст если есть
|
||||
if context:
|
||||
context_text = "\n".join([
|
||||
f"{msg.get('role', 'user')}: {msg.get('content', '')}"
|
||||
for msg in context
|
||||
])
|
||||
full_prompt = f"{context_text}\n\n{full_prompt}"
|
||||
|
||||
# Выполняем через Qwen Manager
|
||||
output_buffer = []
|
||||
|
||||
def on_output(text: str):
|
||||
output_buffer.append(text)
|
||||
|
||||
async def on_chunk_wrapper(text: str):
|
||||
if on_chunk:
|
||||
await on_chunk(text)
|
||||
|
||||
result = await self._qwen_manager.run_task(
|
||||
user_id=user_id,
|
||||
task=full_prompt,
|
||||
on_output=on_output,
|
||||
on_oauth_url=lambda x: None,
|
||||
use_system_prompt=False, # Уже добавили в full_prompt
|
||||
on_chunk=on_chunk_wrapper,
|
||||
on_event=None
|
||||
)
|
||||
|
||||
# Парсим результат
|
||||
content, tool_calls = self._parse_qwen_result(result)
|
||||
|
||||
if not content and not tool_calls:
|
||||
# Если ничего не распарсили, возвращаем сырой результат
|
||||
content = result
|
||||
|
||||
return ProviderResponse(
|
||||
success=True,
|
||||
message=AIMessage(
|
||||
content=content,
|
||||
tool_calls=tool_calls,
|
||||
metadata={"raw_result": result}
|
||||
),
|
||||
provider_name=self.provider_name
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка Qwen Code провайдера: {e}")
|
||||
return ProviderResponse(
|
||||
success=False,
|
||||
error=str(e),
|
||||
provider_name=self.provider_name
|
||||
)
|
||||
|
||||
async def execute_tool(
|
||||
self,
|
||||
tool_name: str,
|
||||
tool_args: Dict[str, Any],
|
||||
tool_call_id: Optional[str] = None,
|
||||
**kwargs
|
||||
) -> ToolCall:
|
||||
"""
|
||||
Выполнить инструмент (заглушка).
|
||||
|
||||
Qwen Code не выполняет инструменты напрямую - это делает
|
||||
AIProviderManager через process_with_tools.
|
||||
"""
|
||||
return ToolCall(
|
||||
tool_name=tool_name,
|
||||
tool_args=tool_args,
|
||||
tool_call_id=tool_call_id,
|
||||
status=ToolCallStatus.PENDING
|
||||
)
|
||||
Reference in New Issue
Block a user