- matrix-gateway: POST /internal/matrix/presence/online endpoint - usePresenceHeartbeat hook with activity tracking - Auto away after 5 min inactivity - Offline on page close/visibility change - Integrated in MatrixChatRoom component
312 lines
11 KiB
Python
312 lines
11 KiB
Python
"""
|
||
Router Multimodal Support - Обробка images/files для DAARION Router
|
||
Додати цей код до існуючого Router на NODE1
|
||
"""
|
||
|
||
from fastapi import FastAPI, HTTPException
|
||
from pydantic import BaseModel
|
||
from typing import List, Optional, Dict, Any
|
||
import base64
|
||
import io
|
||
from PIL import Image
|
||
import logging
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
class ContextPayload(BaseModel):
|
||
system_prompt: Optional[str] = None
|
||
images: Optional[List[str]] = None # base64 encoded images
|
||
files: Optional[List[Dict[str, str]]] = None # file metadata + base64 data
|
||
|
||
class RouteRequest(BaseModel):
|
||
agent: str
|
||
message: str
|
||
mode: str = "chat"
|
||
payload: Optional[Dict[str, Any]] = None
|
||
|
||
# Vision-підтримуючі агенти
|
||
VISION_AGENTS = {
|
||
'sofia': {
|
||
'model': 'grok-4.1',
|
||
'provider': 'xai',
|
||
'supports_vision': True,
|
||
'supports_files': True
|
||
},
|
||
'spectra': {
|
||
'model': 'qwen3-vl:latest',
|
||
'provider': 'ollama',
|
||
'supports_vision': True,
|
||
'supports_files': False
|
||
},
|
||
'daarwizz': {
|
||
'model': 'qwen3-8b',
|
||
'provider': 'ollama',
|
||
'supports_vision': False,
|
||
'supports_files': True
|
||
},
|
||
'solarius': {
|
||
'model': 'deepseek-r1:70b',
|
||
'provider': 'ollama',
|
||
'supports_vision': False,
|
||
'supports_files': True
|
||
}
|
||
}
|
||
|
||
def process_images(images: List[str]) -> List[Image.Image]:
|
||
"""
|
||
Конвертує base64 зображення в PIL Image об'єкти
|
||
|
||
Args:
|
||
images: List of base64 encoded images (with or without data:image/...;base64, prefix)
|
||
|
||
Returns:
|
||
List of PIL Image objects
|
||
"""
|
||
processed = []
|
||
|
||
for idx, img_data in enumerate(images):
|
||
try:
|
||
# Видалити data:image/...;base64, префікс
|
||
if ',' in img_data:
|
||
img_data = img_data.split(',')[1]
|
||
|
||
# Декодувати base64
|
||
img_bytes = base64.b64decode(img_data)
|
||
img = Image.open(io.BytesIO(img_bytes))
|
||
|
||
# Конвертувати в RGB якщо потрібно
|
||
if img.mode != 'RGB':
|
||
img = img.convert('RGB')
|
||
|
||
processed.append(img)
|
||
logger.info(f"✅ Processed image {idx + 1}: {img.size}, {img.mode}")
|
||
|
||
except Exception as e:
|
||
logger.error(f"❌ Failed to process image {idx + 1}: {e}")
|
||
continue
|
||
|
||
return processed
|
||
|
||
def process_files(files: List[Dict[str, str]]) -> List[Dict[str, Any]]:
|
||
"""
|
||
Обробляє файли (PDF, TXT, MD, тощо)
|
||
|
||
Args:
|
||
files: List of {name, type, data} dicts with base64 encoded data
|
||
|
||
Returns:
|
||
List of processed files with metadata
|
||
"""
|
||
processed = []
|
||
|
||
for idx, file_data in enumerate(files):
|
||
try:
|
||
name = file_data.get('name', f'file_{idx + 1}')
|
||
file_type = file_data.get('type', 'application/octet-stream')
|
||
data = file_data.get('data', '')
|
||
|
||
# Видалити data:...;base64, префікс
|
||
if ',' in data:
|
||
data = data.split(',')[1]
|
||
|
||
# Декодувати base64
|
||
file_bytes = base64.b64decode(data)
|
||
|
||
# Спробувати витягти текст з різних типів файлів
|
||
text_content = None
|
||
if file_type.startswith('text/') or name.endswith(('.txt', '.md', '.json')):
|
||
try:
|
||
text_content = file_bytes.decode('utf-8')
|
||
except:
|
||
text_content = file_bytes.decode('latin-1')
|
||
|
||
processed.append({
|
||
'name': name,
|
||
'type': file_type,
|
||
'content': file_bytes,
|
||
'text': text_content,
|
||
'size': len(file_bytes)
|
||
})
|
||
|
||
logger.info(f"✅ Processed file {idx + 1}: {name} ({len(file_bytes)} bytes)")
|
||
|
||
except Exception as e:
|
||
logger.error(f"❌ Failed to process file {idx + 1}: {e}")
|
||
continue
|
||
|
||
return processed
|
||
|
||
def img_to_base64(img: Image.Image) -> str:
|
||
"""
|
||
Конвертує PIL Image в base64 string
|
||
|
||
Args:
|
||
img: PIL Image object
|
||
|
||
Returns:
|
||
base64 encoded string
|
||
"""
|
||
buffered = io.BytesIO()
|
||
img.save(buffered, format="PNG")
|
||
return base64.b64encode(buffered.getvalue()).decode()
|
||
|
||
async def route_multimodal(request: RouteRequest) -> Dict[str, Any]:
|
||
"""
|
||
Обробляє multimodal запити з images/files
|
||
|
||
Додати цю логіку в існуючий /route endpoint
|
||
"""
|
||
try:
|
||
# Отримати payload
|
||
payload = request.payload or {}
|
||
context = payload.get('context', {})
|
||
|
||
# Визначити агента
|
||
agent_id = request.agent
|
||
agent_config = VISION_AGENTS.get(agent_id)
|
||
|
||
if not agent_config:
|
||
# Агент не знайдений в маппінгу - використати default
|
||
agent_config = {
|
||
'model': 'qwen3-8b',
|
||
'provider': 'ollama',
|
||
'supports_vision': False,
|
||
'supports_files': False
|
||
}
|
||
|
||
# Обробити зображення (якщо є)
|
||
images = None
|
||
if context.get('images'):
|
||
images = process_images(context['images'])
|
||
logger.info(f"📷 Processed {len(images)} images")
|
||
|
||
# Перевірити чи агент підтримує vision
|
||
if not agent_config['supports_vision']:
|
||
return {
|
||
"error": f"Агент {agent_id} не підтримує обробку зображень",
|
||
"suggestion": "Спробуйте sofia або spectra для vision tasks",
|
||
"available_vision_agents": [
|
||
k for k, v in VISION_AGENTS.items() if v['supports_vision']
|
||
]
|
||
}
|
||
|
||
# Обробити файли (якщо є)
|
||
files = None
|
||
if context.get('files'):
|
||
files = process_files(context['files'])
|
||
logger.info(f"📎 Processed {len(files)} files")
|
||
|
||
if not agent_config['supports_files']:
|
||
logger.warning(f"⚠️ Agent {agent_id} may not support files properly")
|
||
|
||
# Підготувати запит до LLM
|
||
llm_request = {
|
||
"model": agent_config['model'],
|
||
"provider": agent_config['provider'],
|
||
"messages": [
|
||
{
|
||
"role": "system",
|
||
"content": context.get('system_prompt', '')
|
||
},
|
||
{
|
||
"role": "user",
|
||
"content": request.message
|
||
}
|
||
]
|
||
}
|
||
|
||
# Додати зображення до запиту (для vision моделей)
|
||
if images and agent_config['supports_vision']:
|
||
if agent_config['provider'] == 'ollama':
|
||
# Ollama Qwen3-VL format
|
||
llm_request['images'] = [img_to_base64(img) for img in images]
|
||
elif agent_config['provider'] == 'xai':
|
||
# xAI grok-4.1 format
|
||
# TODO: Перевірити правильний формат для grok-4.1
|
||
llm_request['images'] = [img_to_base64(img) for img in images]
|
||
|
||
# Додати файли як контекст
|
||
if files:
|
||
files_context = "\n\n" + "="*50 + "\n"
|
||
files_context += "📎 Прикріплені файли:\n\n"
|
||
|
||
for f in files:
|
||
files_context += f"**{f['name']}** ({f['size']} bytes, {f['type']})\n"
|
||
if f['text']:
|
||
# Якщо файл містить текст - додати його
|
||
files_context += f"```\n{f['text'][:2000]}\n```\n"
|
||
if len(f['text']) > 2000:
|
||
files_context += f"... (ще {len(f['text']) - 2000} символів)\n"
|
||
files_context += "\n"
|
||
|
||
files_context += "="*50
|
||
|
||
# Додати до повідомлення користувача
|
||
llm_request['messages'][-1]['content'] += files_context
|
||
|
||
# Викликати LLM (інтеграція з існуючою логікою Router)
|
||
# TODO: Замінити це на реальний виклик LLM
|
||
response_text = await call_llm(llm_request)
|
||
|
||
return {
|
||
"data": {
|
||
"text": response_text,
|
||
"model": agent_config['model'],
|
||
"provider": agent_config['provider']
|
||
},
|
||
"metadata": {
|
||
"agent": agent_id,
|
||
"has_images": bool(images),
|
||
"has_files": bool(files),
|
||
"images_count": len(images) if images else 0,
|
||
"files_count": len(files) if files else 0
|
||
}
|
||
}
|
||
|
||
except Exception as e:
|
||
logger.error(f"❌ Multimodal routing error: {e}", exc_info=True)
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
async def call_llm(request: Dict[str, Any]) -> str:
|
||
"""
|
||
Викликає LLM (Ollama або xAI)
|
||
|
||
Інтегрувати з існуючою логікою Router
|
||
"""
|
||
# TODO: Реалізувати виклик LLM
|
||
# Це має бути інтегровано з існуючою логікою Router
|
||
pass
|
||
|
||
# Приклад використання в FastAPI
|
||
def add_multimodal_to_router(app: FastAPI):
|
||
"""
|
||
Додає multimodal endpoints до існуючого Router
|
||
"""
|
||
|
||
@app.post("/route")
|
||
async def route(request: RouteRequest):
|
||
"""
|
||
Оновлений /route endpoint з multimodal підтримкою
|
||
"""
|
||
return await route_multimodal(request)
|
||
|
||
@app.get("/agents/vision")
|
||
async def get_vision_agents():
|
||
"""
|
||
Повертає список агентів з vision підтримкою
|
||
"""
|
||
return {
|
||
"vision_agents": [
|
||
{
|
||
"id": k,
|
||
"model": v['model'],
|
||
"provider": v['provider'],
|
||
"supports_vision": v['supports_vision'],
|
||
"supports_files": v['supports_files']
|
||
}
|
||
for k, v in VISION_AGENTS.items()
|
||
if v['supports_vision']
|
||
]
|
||
}
|
||
|